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:
2026-05-02 11:49:07 -04:00
commit 0265afa054
252 changed files with 37574 additions and 0 deletions
+474
View File
@@ -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);
+497
View File
@@ -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();
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# test-content.sh — Run content validation from CLI
#
# Usage:
# bash tools/dev/test-content.sh
# bash tools/dev/test-content.sh --verbose
# bash tools/dev/test-content.sh --quests-only
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
if ! command -v node &>/dev/null; then
echo "ERROR: node not found. Install Node.js to run content validation."
exit 1
fi
exec node "$PROJECT_ROOT/tools/content/validate-content.js" "$@"
+54
View File
@@ -0,0 +1,54 @@
#!/usr/bin/env bash
# Install config management for Sysadmin Chronicles.
# Config lives at ~/.config/sysadmin-chronicles/config (survives game dir moves).
# Source this file; do not execute directly.
SC_CONFIG_DIR="${SC_CONFIG_DIR:-$HOME/.config/sysadmin-chronicles}"
SC_CONFIG_FILE="$SC_CONFIG_DIR/config"
config_read() {
[ -f "$SC_CONFIG_FILE" ] && source "$SC_CONFIG_FILE" || true
}
config_write() {
local key="$1"
local value="$2"
mkdir -p "$SC_CONFIG_DIR"
local tmp
tmp="$(mktemp "$SC_CONFIG_DIR/config.XXXXXX")"
if [ -f "$SC_CONFIG_FILE" ]; then
awk -v key="$key" -v value="$value" '
BEGIN { found = 0 }
index($0, key "=") == 1 {
print key "=" value
found = 1
next
}
{ print }
END {
if (!found) {
print key "=" value
}
}
' "$SC_CONFIG_FILE" > "$tmp"
else
printf '%s=%s\n' "$key" "$value" > "$tmp"
fi
mv "$tmp" "$SC_CONFIG_FILE"
}
config_show() {
if [ ! -f "$SC_CONFIG_FILE" ]; then
echo " (no config file at $SC_CONFIG_FILE)"
return
fi
echo " Config: $SC_CONFIG_FILE"
local line key value
while IFS= read -r line; do
[[ "$line" =~ ^# ]] && continue
[[ -z "$line" ]] && continue
key="${line%%=*}"
value="${line#*=}"
printf ' %-28s %s\n' "$key" "$value"
done < "$SC_CONFIG_FILE"
}
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
# Dependency detection and installation for Sysadmin Chronicles.
# Source this file; do not execute directly.
SC_DISTRO=""
detect_distro() {
if [ -f /etc/arch-release ]; then
SC_DISTRO=arch
elif grep -qi ubuntu /etc/os-release 2>/dev/null; then
SC_DISTRO=ubuntu
elif [ -f /etc/debian_version ]; then
SC_DISTRO=debian
elif [ -f /etc/fedora-release ]; then
SC_DISTRO=fedora
elif grep -qi opensuse /etc/os-release 2>/dev/null; then
SC_DISTRO=opensuse
else
SC_DISTRO=unknown
fi
}
# Per-distro package names for canonical dep names.
# Format: canonical:arch:debian:ubuntu:fedora:opensuse
# Empty field = not applicable / same as above / handled specially.
_SC_DEP_MAP=(
"libvirt:libvirt:libvirt-daemon-system:libvirt-daemon-system:libvirt:libvirt"
"qemu-system:qemu-system-x86:qemu-system-x86:qemu-kvm:qemu-kvm:qemu-kvm"
"qemu-img:qemu-img:qemu-utils:qemu-utils:qemu-img:qemu-tools"
"virt-install:virt-install:virtinst:virtinst:virt-install:virt-install"
"virt-viewer:virt-viewer:virt-viewer:virt-viewer:virt-viewer:virt-viewer"
"cloud-localds:cloud-image-utils:cloud-image-utils:cloud-image-utils:cloud-utils:cloud-utils"
"genisoimage:cdrtools:genisoimage:genisoimage:genisoimage:genisoimage"
"xorriso:libisoburn:xorriso:xorriso:xorriso:xorriso"
"nodejs:nodejs:nodejs:nodejs:nodejs:nodejs"
"openssh:openssh:openssh-client:openssh-client:openssh-clients:openssh-clients"
)
# Arch-only QEMU SPICE/display extras installed alongside qemu-system
_SC_ARCH_QEMU_EXTRAS=(
qemu-hw-display-qxl
qemu-hw-display-virtio-gpu
qemu-ui-spice-core
qemu-chardev-spice
qemu-audio-spice
)
map_package() {
local dep="$1"
local distro="${2:-$SC_DISTRO}"
local entry
for entry in "${_SC_DEP_MAP[@]}"; do
IFS=':' read -r name arch debian ubuntu fedora opensuse <<< "$entry"
if [ "$name" = "$dep" ]; then
case "$distro" in
arch) printf '%s' "$arch" ;;
debian) printf '%s' "$debian" ;;
ubuntu) printf '%s' "$ubuntu" ;;
fedora) printf '%s' "$fedora" ;;
opensuse) printf '%s' "$opensuse" ;;
esac
return
fi
done
}
# Outputs canonical dep names that are not yet installed (one per line)
check_deps() {
local missing=()
command -v virsh >/dev/null 2>&1 || missing+=(libvirt)
command -v qemu-system-x86_64 >/dev/null 2>&1 || missing+=(qemu-system)
command -v qemu-img >/dev/null 2>&1 || missing+=(qemu-img)
command -v virt-install >/dev/null 2>&1 || missing+=(virt-install)
command -v remote-viewer >/dev/null 2>&1 || missing+=(virt-viewer)
command -v node >/dev/null 2>&1 || missing+=(nodejs)
command -v ssh >/dev/null 2>&1 || missing+=(openssh)
# Need at least one cloud-init ISO tool
if ! command -v cloud-localds >/dev/null 2>&1 \
&& ! command -v genisoimage >/dev/null 2>&1 \
&& ! command -v mkisofs >/dev/null 2>&1 \
&& ! command -v xorriso >/dev/null 2>&1; then
missing+=(cloud-localds genisoimage xorriso)
fi
[ "${#missing[@]}" -gt 0 ] && printf '%s\n' "${missing[@]}" || true
}
# Install a list of canonical dep names. Logs to SC_INSTALL_LOG if set.
install_deps() {
local deps=("$@")
local pkgs=()
for dep in "${deps[@]}"; do
local pkg
pkg="$(map_package "$dep")"
[ -n "$pkg" ] && pkgs+=("$pkg")
done
if [ "$SC_DISTRO" = "arch" ]; then
pkgs+=("${_SC_ARCH_QEMU_EXTRAS[@]}")
fi
[ "${#pkgs[@]}" -eq 0 ] && return 0
local -a pm_cmd
case "$SC_DISTRO" in
arch) pm_cmd=(pacman -S --noconfirm --needed) ;;
debian|ubuntu) pm_cmd=(apt-get install -y) ;;
fedora) pm_cmd=(dnf install -y) ;;
opensuse) pm_cmd=(zypper install -y) ;;
*)
echo " Unsupported distro '$SC_DISTRO' — install these manually:"
printf ' %s\n' "${pkgs[@]}"
return 1
;;
esac
sudo "${pm_cmd[@]}" "${pkgs[@]}"
if [ -n "${SC_INSTALL_LOG:-}" ] && [ "$SC_INSTALL_LOG" != "/dev/null" ]; then
mkdir -p "$(dirname "$SC_INSTALL_LOG")"
local distro_label="$SC_DISTRO"
for pkg in "${pkgs[@]}"; do
local ver=""
case "$SC_DISTRO" in
arch) ver="$(pacman -Q "$pkg" 2>/dev/null | awk '{print $2}' || true)" ;;
debian|ubuntu) ver="$(dpkg -l "$pkg" 2>/dev/null | awk '/^ii/{print $3}' | head -1 || true)" ;;
fedora) ver="$(rpm -q --queryformat '%{VERSION}' "$pkg" 2>/dev/null || true)" ;;
esac
printf '[INSTALLED] %-36s %-14s via %s\n' "$pkg" "${ver:-}" "$distro_label" \
>> "$SC_INSTALL_LOG"
done
fi
}
# Outputs already-installed canonical deps (for log completeness)
log_present_deps() {
local log_file="${SC_INSTALL_LOG:-}"
[ -z "$log_file" ] || [ "$log_file" = "/dev/null" ] && return
local distro_label="$SC_DISTRO"
for dep in libvirt qemu-system qemu-img virt-install virt-viewer nodejs openssh; do
local bin
case "$dep" in
libvirt) bin=virsh ;;
qemu-system) bin=qemu-system-x86_64 ;;
qemu-img) bin=qemu-img ;;
virt-install) bin=virt-install ;;
virt-viewer) bin=remote-viewer ;;
nodejs) bin=node ;;
openssh) bin=ssh ;;
esac
if command -v "$bin" >/dev/null 2>&1; then
printf '[SKIPPED] %-36s already installed\n' "$dep" >> "$log_file"
fi
done
}
dep_label() {
case "$1" in
libvirt) echo "Virtual machine manager (libvirt)" ;;
qemu-system) echo "KVM virtualization support (QEMU)" ;;
qemu-img) echo "VM disk image tools (qemu-img)" ;;
virt-install) echo "VM installer (virt-install)" ;;
virt-viewer) echo "SPICE display viewer (virt-viewer)" ;;
cloud-localds|genisoimage|xorriso) echo "Cloud image tools" ;;
nodejs) echo "Node.js runtime" ;;
openssh) echo "SSH client" ;;
*) echo "$1" ;;
esac
}
+101
View File
@@ -0,0 +1,101 @@
#!/usr/bin/env bash
# Shared internal HTTPS/URL helpers for Sysadmin Chronicles launch and VM build scripts.
# Source this file; do not execute directly.
sc_internal_port() {
printf '%s\n' "${PORT:-3000}"
}
sc_cert_dir() {
printf '%s\n' "${SC_CERT_DIR:-$HOME/.local/share/sysadmin-chronicles/certs}"
}
sc_tls_cert() {
printf '%s/server.crt\n' "$(sc_cert_dir)"
}
sc_tls_key() {
printf '%s/server.key\n' "$(sc_cert_dir)"
}
sc_ca_cert() {
printf '%s/ca.crt\n' "$(sc_cert_dir)"
}
sc_hud_url() {
printf '%s\n' "${SC_HUD_URL:-https://portal.axiomworks.internal:$(sc_internal_port)}"
}
sc_sage_url() {
printf '%s\n' "${SC_SAGE_URL:-https://sage.axiomworks.internal:$(sc_internal_port)/sage/}"
}
sc_company_url() {
printf '%s\n' "${SC_COMPANY_URL:-https://www.axiomworks.corp/}"
}
sc_have_internal_certs() {
[[ -f "$(sc_tls_cert)" && -f "$(sc_tls_key)" && -f "$(sc_ca_cert)" ]]
}
sc_ensure_internal_certs() {
local project_root="$1"
if sc_have_internal_certs; then
return 0
fi
bash "$project_root/tools/setup/generate-certs.sh"
}
sc_export_internal_https_env() {
export SC_CERT_DIR="$(sc_cert_dir)"
export SC_TLS_CERT="$(sc_tls_cert)"
export SC_TLS_KEY="$(sc_tls_key)"
export SC_HUD_URL="$(sc_hud_url)"
export SC_SAGE_URL="$(sc_sage_url)"
export SC_COMPANY_URL="$(sc_company_url)"
}
sc_listen_pids() {
local port="$1"
if command -v lsof >/dev/null 2>&1; then
lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u
return 0
fi
ss -H -ltnp "sport = :$port" 2>/dev/null \
| sed -n 's/.*pid=\([0-9][0-9]*\).*/\1/p' \
| sort -u
}
sc_pid_is_repo_server() {
local pid="$1"
local project_root="$2"
local server_dir="$project_root/server"
local cwd=""
local cmdline=""
[[ -r "/proc/$pid/cmdline" ]] || return 1
cwd="$(readlink -f "/proc/$pid/cwd" 2>/dev/null || true)"
cmdline="$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null || true)"
[[ "$cwd" == "$server_dir" ]] || return 1
[[ "$cmdline" == *"node"* && "$cmdline" == *"src/index.js"* ]]
}
sc_pid_has_internal_tls() {
local pid="$1"
[[ -r "/proc/$pid/environ" ]] || return 1
tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
| grep -q '^SC_TLS_CERT=.*server\.crt$' \
&& tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null \
| grep -q '^SC_TLS_KEY=.*server\.key$'
}
sc_stop_pid() {
local pid="$1"
kill "$pid" 2>/dev/null || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
kill -0 "$pid" 2>/dev/null || return 0
sleep 0.2
done
kill -TERM "$pid" 2>/dev/null || true
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# libvirt wrappers for Sysadmin Chronicles.
# Source this file; do not execute directly.
# Expects LIBVIRT_DEFAULT_URI to be set by the caller.
#
# SC_VIRSH_SUDO=true — prefix all virsh calls with sudo.
# Set this in install.sh when the current session doesn't yet have
# the libvirt group active (e.g., right after usermod -aG libvirt).
_virsh() {
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
sudo virsh "$@"
else
virsh "$@"
fi
}
ensure_network() {
local name="$1"
local xml_path="$2"
if _virsh net-list --all 2>/dev/null | grep -q "\\b${name}\\b"; then
if ! _virsh net-info "$name" 2>/dev/null | grep -q "Active:.*yes"; then
_virsh net-start "$name" >/dev/null 2>&1
fi
return 0
fi
_virsh net-define "$xml_path" >/dev/null 2>&1
_virsh net-autostart "$name" >/dev/null 2>&1
_virsh net-start "$name" >/dev/null 2>&1
}
ensure_pool() {
local name="$1"
local path="$2"
if _virsh pool-list --all 2>/dev/null | grep -q "\\b${name}\\b"; then
if ! _virsh pool-info "$name" 2>/dev/null | grep -q "State:.*running"; then
_virsh pool-start "$name" >/dev/null 2>&1
fi
return 0
fi
mkdir -p "$path"
_virsh pool-define-as "$name" dir --target "$path" >/dev/null 2>&1
_virsh pool-autostart "$name" >/dev/null 2>&1
_virsh pool-start "$name" >/dev/null 2>&1
}
pool_path() {
local name="$1"
_virsh pool-dumpxml "$name" 2>/dev/null \
| sed -n 's:.*<path>\(.*\)</path>.*:\1:p' \
| head -n1
}
domain_exists() {
_virsh dominfo "$1" >/dev/null 2>&1
}
domain_state() {
_virsh domstate "$1" 2>/dev/null | tr -d ' \n'
}
network_active() {
_virsh net-info "$1" 2>/dev/null | grep -q "Active:.*yes"
}
ensure_network_active() {
local name="$1"
_virsh net-list --all 2>/dev/null | grep -q "\\b${name}\\b" || return 1
network_active "$name" || _virsh net-start "$name" >/dev/null 2>&1
}
snapshot_exists() {
_virsh snapshot-info "$1" "$2" >/dev/null 2>&1
}
snapshot_create() {
local domain="$1"
local name="$2"
local desc="${3:-}"
_virsh snapshot-delete "$domain" "$name" >/dev/null 2>&1 || true
_virsh snapshot-create-as "$domain" "$name" --description "$desc" --atomic
}
snapshot_revert() {
_virsh snapshot-revert "$1" "$2" --running
}
snapshot_delete() {
_virsh snapshot-delete "$1" "$2"
}
snapshot_list_names() {
_virsh snapshot-list "$1" --name 2>/dev/null || true
}
# Returns approximate qcow2 disk usage for a domain in human-readable form
domain_disk_usage() {
local domain="$1"
local total=0
local disk
for disk in $(_virsh domblklist "$domain" --details 2>/dev/null | awk '/disk/ && $4 != "-" {print $4}' || true); do
[ -f "$disk" ] || continue
local sz
sz="$(du -sb "$disk" 2>/dev/null | awk '{print $1}' || echo 0)"
total=$(( total + sz ))
done
if [ "$total" -gt 0 ]; then
numfmt --to=iec-i --suffix=B "$total" 2>/dev/null || echo "${total}B"
else
echo "0B"
fi
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Save slot management for Sysadmin Chronicles.
# Source this file; do not execute directly.
SC_SAVE_DIR="${SC_SAVE_DIR:-$HOME/.local/share/sysadmin-chronicles/saves}"
_save_path() { printf '%s/%s.json' "$SC_SAVE_DIR" "$1"; }
_save_active_slot() {
local slot=""
if [ -f "$SC_SAVE_DIR/.active" ]; then
slot="$(cat "$SC_SAVE_DIR/.active")"
fi
printf '%s' "${slot:-autosave}"
}
_save_valid_slot() {
case "$1" in
autosave|slot-1|slot-2|slot-3) return 0 ;;
*) echo " ✗ Invalid slot name: $1 (use autosave, slot-1, slot-2, or slot-3)"; return 1 ;;
esac
}
_new_game_json() {
local slot="$1"
printf '{"slot":"%s","day":1,"trust":50,"questsCompleted":0,"quests":{},"flags":{},"inbox":[],"clock":{"shift":1,"day":1}}\n' \
"$slot"
}
save_list() {
mkdir -p "$SC_SAVE_DIR"
local active
active="$(_save_active_slot)"
printf ' %-14s %-10s %-10s %-10s\n' "Slot" "Day" "Trust" "Quests"
printf ' %-14s %-10s %-10s %-10s\n' "──────────────" "──────────" "──────────" "──────────"
local slot
for slot in autosave slot-1 slot-2 slot-3; do
local path
path="$(_save_path "$slot")"
if [ -f "$path" ]; then
local day trust quests marker=""
day="$( grep -o '"day":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
trust="$( grep -o '"trust":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
quests="$( grep -o '"questsCompleted":[0-9]*' "$path" 2>/dev/null | head -1 | cut -d: -f2 || echo '?')"
[ "$slot" = "$active" ] && marker=" [active]"
printf ' %-14s %-10s %-10s %-10s%s\n' "$slot" "Day $day" "T:$trust" "Q:$quests" "$marker"
else
printf ' %-14s %s\n' "$slot" "—empty—"
fi
done
}
save_switch() {
local slot="$1"
_save_valid_slot "$slot" || return 1
[ -f "$(_save_path "$slot")" ] || { echo " ✗ No save in slot: $slot"; return 1; }
printf '%s' "$slot" > "$SC_SAVE_DIR/.active"
echo " ✓ Switched to $slot"
}
save_new() {
local slot="$1"
_save_valid_slot "$slot" || return 1
mkdir -p "$SC_SAVE_DIR"
_new_game_json "$slot" > "$(_save_path "$slot")"
echo " ✓ Created new save: $slot"
}
save_reset() {
local slot="${1:-$(_save_active_slot)}"
_save_valid_slot "$slot" || return 1
mkdir -p "$SC_SAVE_DIR"
_new_game_json "$slot" > "$(_save_path "$slot")"
echo " ✓ Reset $slot to new game state"
}
save_export() {
local slot="$1"
local dest="$2"
_save_valid_slot "$slot" || return 1
[ -f "$(_save_path "$slot")" ] || { echo " ✗ No save in slot: $slot"; return 1; }
[ -n "$dest" ] || { echo " ✗ No destination path given"; return 1; }
cp "$(_save_path "$slot")" "$dest"
echo " ✓ Exported $slot$dest"
}
save_import() {
local src="$1"
local slot="$2"
_save_valid_slot "$slot" || return 1
[ -f "$src" ] || { echo " ✗ File not found: $src"; return 1; }
# Basic JSON sanity check
grep -q '^{' "$src" 2>/dev/null || { echo " ✗ File does not look like a save: $src"; return 1; }
mkdir -p "$SC_SAVE_DIR"
cp "$src" "$(_save_path "$slot")"
echo " ✓ Imported $src$slot"
}
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# Shared UI helpers for Sysadmin Chronicles scripts.
# Source this file; do not execute directly.
_SC_STEP_N=0
# Colors — disabled if stdout is not a terminal or NO_COLOR is set
if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then
_C_RESET='\033[0m'
_C_BOLD='\033[1m'
_C_GREEN='\033[0;32m'
_C_YELLOW='\033[0;33m'
_C_RED='\033[0;31m'
_C_CYAN='\033[0;36m'
_C_DIM='\033[2m'
else
_C_RESET='' _C_BOLD='' _C_GREEN='' _C_YELLOW='' _C_RED='' _C_CYAN='' _C_DIM=''
fi
sc_header() {
local title="${1:-Sysadmin Chronicles}"
echo ""
printf "${_C_CYAN}${_C_BOLD}"
echo "╔══════════════════════════════════════════╗"
printf "║ %-40s║\n" "$title"
echo "╚══════════════════════════════════════════╝"
printf "${_C_RESET}"
echo ""
}
sc_done_banner() {
echo ""
printf "${_C_GREEN}${_C_BOLD}"
echo "╔══════════════════════════════════════════╗"
printf "║ %-40s║\n" "SETUP COMPLETE!"
echo "╚══════════════════════════════════════════╝"
printf "${_C_RESET}"
echo ""
}
sc_section() {
echo ""
printf "${_C_BOLD}── %s ${_C_DIM}─────────────────────────────────${_C_RESET}\n" "$*"
}
sc_step() {
(( _SC_STEP_N++ )) || true
echo ""
printf "${_C_BOLD}── Step %d: %s${_C_RESET}\n" "$_SC_STEP_N" "$*"
}
sc_ok() { printf " ${_C_GREEN}${_C_RESET} %s\n" "$*"; }
sc_warn() { printf " ${_C_YELLOW}${_C_RESET} %s\n" "$*"; }
sc_err() { printf " ${_C_RED}${_C_RESET} %s\n" "$*" >&2; }
sc_fail() { sc_err "$*"; exit 1; }
sc_info() { printf " ${_C_DIM}${_C_RESET} %s\n" "$*"; }
# Prompt — writes question to /dev/tty, returns answer on stdout
sc_prompt() {
local question="$1"
local default="${2:-}"
if [ -n "$default" ]; then
printf " %s [%s] > " "$question" "$default" >/dev/tty
else
printf " %s > " "$question" >/dev/tty
fi
local answer
read -r answer </dev/tty
printf '%s' "${answer:-$default}"
}
# Confirm — returns 0 for yes, 1 for no
sc_confirm() {
local question="$1"
local default="${2:-Y}"
local prompt
if [ "${default^^}" = "Y" ]; then
prompt="[Y/n]"
else
prompt="[y/N]"
fi
printf " %s %s " "$question" "$prompt" >/dev/tty
local answer
read -r answer </dev/tty
answer="${answer:-$default}"
case "${answer,,}" in
y|yes) return 0 ;;
*) return 1 ;;
esac
}
sc_progress() {
local label="$1"
local current="$2"
local total="$3"
printf " %-38s %d/%d\n" "$label" "$current" "$total"
}
_SC_SPINNER_PID=""
sc_spinner() {
local label="${1:-Working...}"
printf " %s " "$label" >/dev/tty
(
local frames=('⠋' '⠙' '⠹' '⠸' '⠼' '⠴' '⠦' '⠧' '⠇' '⠏')
local i=0
while true; do
printf "\r %s %s " "$label" "${frames[$((i % ${#frames[@]}))]}" >/dev/tty
sleep 0.12
(( i++ )) || true
done
) &
_SC_SPINNER_PID=$!
disown "$_SC_SPINNER_PID" 2>/dev/null || true
}
sc_spinner_stop() {
if [ -n "${_SC_SPINNER_PID:-}" ]; then
kill "$_SC_SPINNER_PID" 2>/dev/null || true
wait "$_SC_SPINNER_PID" 2>/dev/null || true
_SC_SPINNER_PID=""
printf "\r%-60s\r" "" >/dev/tty
fi
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# VM operations for Sysadmin Chronicles.
# Source this file; requires lib/libvirt.sh and PROJECT_ROOT set.
SC_VM_TOOLS="${SC_VM_TOOLS:-${PROJECT_ROOT:-}/tools/vm}"
_sc_validate_snapshot_name() {
[[ "$1" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]*$ ]]
}
_sc_protected_snapshot() {
[[ "$1" == baseline.* ]] || [[ "$1" == checkpoint.* ]]
}
vm_build() {
local profile="$1"
shift
local dry_run=false force=false
for arg in "$@"; do
case "$arg" in
--dry-run) dry_run=true ;;
--force) force=true ;;
esac
done
local script="$SC_VM_TOOLS/build-${profile}.sh"
[ -f "$script" ] || { echo " ✗ No build script for profile: $profile"; return 1; }
local args=()
[ "$dry_run" = true ] && args+=(--dry-run)
[ "$force" = true ] && args+=(--force)
bash "$script" "${args[@]}"
}
vm_rebuild() {
local profile="$1"
shift
local dry_run=false
for arg in "$@"; do [ "$arg" = "--dry-run" ] && dry_run=true; done
local domain="sc-${profile}"
if domain_exists "$domain" && [ "$dry_run" = false ]; then
virsh destroy "$domain" >/dev/null 2>&1 || true
virsh undefine "$domain" --nvram --snapshots-metadata >/dev/null 2>&1 \
|| virsh undefine "$domain" --snapshots-metadata >/dev/null 2>&1 \
|| virsh undefine "$domain" >/dev/null 2>&1 || true
fi
local extra_args=()
[ "$dry_run" = true ] && extra_args+=(--dry-run)
vm_build "$profile" "${extra_args[@]}"
}
vm_revert() {
snapshot_revert "$1" "$2"
}
vm_status() {
local vm_id="$1"
domain_exists "$vm_id" && domain_state "$vm_id" || printf 'missing'
}
vm_start() {
virsh start "$1" >/dev/null 2>&1
}
vm_stop() {
virsh shutdown "$1" >/dev/null 2>&1 || virsh destroy "$1" >/dev/null 2>&1 || true
}
vm_snapshot_create() {
local vm_id="$1"
local name="$2"
_sc_validate_snapshot_name "$name" \
|| { echo " ✗ Invalid name (letters, numbers, hyphens only): $name"; return 1; }
snapshot_create "$vm_id" "$name" "User snapshot — $(date '+%Y-%m-%d %H:%M')"
}
vm_snapshot_list() {
local vm_id="$1"
virsh snapshot-list "$vm_id" 2>/dev/null || true
}
vm_snapshot_revert() {
local vm_id="$1"
local name="$2"
snapshot_exists "$vm_id" "$name" \
|| { echo " ✗ Snapshot not found: $name"; return 1; }
snapshot_revert "$vm_id" "$name"
}
vm_snapshot_delete() {
local vm_id="$1"
local name="$2"
if _sc_protected_snapshot "$name"; then
echo " ✗ Cannot delete protected snapshot: $name"
return 1
fi
snapshot_delete "$vm_id" "$name"
}
+238
View File
@@ -0,0 +1,238 @@
#!/usr/bin/env bash
# Save slot and VM snapshot management for Sysadmin Chronicles.
#
# Usage:
# manage-saves.sh Interactive menu
# manage-saves.sh --list List save slots (non-interactive)
# manage-saves.sh --reset Reset current save
# manage-saves.sh --reset slot-1 Reset a specific slot
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$PROJECT_ROOT/tools/lib/ui.sh"
source "$PROJECT_ROOT/tools/lib/config.sh"
source "$PROJECT_ROOT/tools/lib/save.sh"
source "$PROJECT_ROOT/tools/lib/libvirt.sh"
source "$PROJECT_ROOT/tools/lib/vm.sh"
config_read || true
export LIBVIRT_DEFAULT_URI="${SC_LIBVIRT_URI:-${LIBVIRT_DEFAULT_URI:-qemu:///system}}"
# Non-interactive flags
for arg in "$@"; do
case "$arg" in
--list)
save_list
exit 0
;;
esac
done
if [[ "${1:-}" == "--reset" ]]; then
slot="${2:-}"
if [ -n "$slot" ]; then
save_reset "$slot"
else
save_reset
fi
exit 0
fi
# ---------------------------------------------------------------------------
# Interactive menu
# ---------------------------------------------------------------------------
declare -A VM_LABEL=(
[sc-workstation]="workstation (ares)"
[sc-web-server]="web server (hermes)"
[sc-build-machine]="build server (vulcan)"
)
ALL_VMS=(sc-workstation sc-web-server sc-build-machine)
sc_header "SYSADMIN CHRONICLES — SAVE MANAGEMENT"
_main_menu() {
while true; do
echo ""
save_list
echo ""
echo " ── Save Actions ─────────────────────────────"
echo " s) Switch active save slot"
echo " n) New save slot"
echo " r) Reset a save slot"
echo " e) Export save to file"
echo " i) Import save from file"
echo ""
echo " ── VM Snapshots ─────────────────────────────"
echo " v) View and manage VM snapshots"
echo ""
echo " q) Quit"
echo ""
printf " > " >/dev/tty
read -r choice </dev/tty
echo ""
case "$choice" in
s|S) _switch_slot ;;
n|N) _new_slot ;;
r|R) _reset_slot ;;
e|E) _export_slot ;;
i|I) _import_slot ;;
v|V) _vm_snapshots ;;
q|Q) break ;;
*) sc_warn "Unknown choice." ;;
esac
done
}
_switch_slot() {
echo " Switch to which slot? (autosave / slot-1 / slot-2 / slot-3)"
printf " > " >/dev/tty
read -r slot </dev/tty
echo ""
save_switch "$slot" || true
# Warn if VM state may not match
local active_day
active_day="$(grep -o '"day":[0-9]*' "$SC_SAVE_DIR/${slot}.json" 2>/dev/null | head -1 | cut -d: -f2 || echo '')"
if [ -n "$active_day" ] && [ "$active_day" -gt 1 ] 2>/dev/null; then
echo ""
sc_warn "This save is on Day $active_day. Your VMs may not match this slot's expected state."
sc_info "If things look wrong, use 'tools/vm/rebuild-vms.sh' to revert VMs."
fi
}
_new_slot() {
echo " Create in which slot? (slot-1 / slot-2 / slot-3)"
printf " > " >/dev/tty
read -r slot </dev/tty
echo ""
save_new "$slot" || true
}
_reset_slot() {
echo " Reset which slot? (autosave / slot-1 / slot-2 / slot-3)"
printf " > " >/dev/tty
read -r slot </dev/tty
echo ""
if sc_confirm " Reset $slot to new game state? This cannot be undone." "N"; then
save_reset "$slot"
fi
}
_export_slot() {
echo " Export which slot? (autosave / slot-1 / slot-2 / slot-3)"
printf " > " >/dev/tty
read -r slot </dev/tty
echo ""
echo " Save to (full path, e.g. ~/Desktop/my-save.json):"
printf " > " >/dev/tty
read -r dest </dev/tty
dest="${dest/#\~/$HOME}"
echo ""
save_export "$slot" "$dest" || true
}
_import_slot() {
echo " Path to save file to import:"
printf " > " >/dev/tty
read -r src </dev/tty
src="${src/#\~/$HOME}"
echo ""
echo " Import into which slot? (autosave / slot-1 / slot-2 / slot-3)"
printf " > " >/dev/tty
read -r slot </dev/tty
echo ""
save_import "$src" "$slot" || true
}
_vm_snapshots() {
while true; do
echo ""
echo " VM Snapshots"
echo ""
local vm label snaps snap ts prot
for vm in "${ALL_VMS[@]}"; do
label="${VM_LABEL[$vm]:-$vm}"
echo " $label"
if domain_exists "$vm" 2>/dev/null; then
snaps="$(virsh snapshot-list "$vm" --name 2>/dev/null | grep -v '^$' || true)"
if [ -n "$snaps" ]; then
while IFS= read -r snap; do
ts="$(virsh snapshot-info "$vm" "$snap" 2>/dev/null | grep 'Creation Time' | awk '{print $3}' || true)"
if [[ "$snap" == baseline.* ]] || [[ "$snap" == checkpoint.* ]]; then
prot=" [protected]"
else
prot=""
fi
printf " %-36s %-12s%s\n" "$snap" "$ts" "$prot"
done <<< "$snaps"
else
echo " (no snapshots)"
fi
else
echo " (VM not found)"
fi
echo ""
done
echo " Actions: [t]ake snapshot [r]evert [d]elete [q]uit"
echo ""
printf " > " >/dev/tty
local snap_action
read -r snap_action </dev/tty
echo ""
case "$snap_action" in
t|T)
_pick_vm vm_id " Snapshot which VM? (workstation / web-server / build-machine)"
echo " Snapshot name (letters, numbers, hyphens):"
printf " > " >/dev/tty
read -r snap_name </dev/tty
echo ""
vm_snapshot_create "sc-${vm_id}" "$snap_name" \
&& sc_ok "Snapshot taken: $snap_name on $vm_id" \
|| sc_warn "Snapshot failed."
;;
r|R)
_pick_vm vm_id " Revert which VM?"
echo " Snapshot name:"
printf " > " >/dev/tty
read -r snap_name </dev/tty
echo ""
if sc_confirm " Revert $vm_id to '$snap_name'?" "N"; then
vm_snapshot_revert "sc-${vm_id}" "$snap_name" \
&& sc_ok "Reverted to $snap_name" \
|| sc_warn "Revert failed."
fi
;;
d|D)
_pick_vm vm_id " Delete snapshot from which VM?"
echo " Snapshot name:"
printf " > " >/dev/tty
read -r snap_name </dev/tty
echo ""
vm_snapshot_delete "sc-${vm_id}" "$snap_name" \
&& sc_ok "Deleted $snap_name from $vm_id" \
|| true
;;
q|Q) break ;;
*) sc_warn "Unknown action." ;;
esac
done
}
_pick_vm() {
local -n _result_ref="$1"
local _prompt="$2"
echo " $_prompt (workstation / web-server / build-machine)"
printf " > " >/dev/tty
read -r _result_ref </dev/tty
echo ""
}
_main_menu
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env bash
# check-host.sh — Sysadmin Chronicles host prerequisite checker
#
# Run this before first-run-setup.sh to see what's missing.
# Safe to run any number of times — read-only checks only.
#
# Exit code 0 = all required dependencies present.
# Exit code 1 = one or more required dependencies missing.
set -euo pipefail
OWNER_USER="${SUDO_USER:-$USER}"
OWNER_HOME="$(getent passwd "$OWNER_USER" | cut -d: -f6)"
OWNER_HOME="${OWNER_HOME:-$HOME}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
PASS="✓"
FAIL="✗"
WARN="⚠"
errors=0
warnings=0
run_virsh_quick() {
timeout 5 virsh "$@" >/dev/null 2>&1
}
check_cmd() {
local cmd="$1"
local label="${2:-$cmd}"
if command -v "$cmd" &>/dev/null; then
echo " $PASS $label"
else
echo " $FAIL $label — NOT FOUND"
((errors+=1))
fi
}
check_file() {
local path="$1"
local label="$2"
local required="${3:-true}"
if [ -e "$path" ]; then
echo " $PASS $label ($path)"
else
if [ "$required" = "true" ]; then
echo " $FAIL $label — NOT FOUND ($path)"
((errors+=1))
else
echo " $WARN $label — not found ($path) [optional]"
((warnings+=1))
fi
fi
}
check_group() {
local group="$1"
if id -nG "$OWNER_USER" | grep -qw "$group"; then
echo " $PASS User is in group: $group"
else
echo " $WARN Not in group '$group' — libvirt access may require sudo or group add"
((warnings+=1))
fi
}
check_kvm_access() {
if id -nG "$OWNER_USER" | grep -qw kvm; then
echo " $PASS User is in group: kvm"
elif [ -r /dev/kvm ] && [ -w /dev/kvm ]; then
echo " $PASS /dev/kvm is accessible to this session"
else
echo " $WARN Not in group 'kvm' — KVM acceleration may require sudo or group add"
((warnings+=1))
fi
}
libvirt_ready=false
socket_ready=false
if run_virsh_quick -q list --all; then
libvirt_ready=true
fi
if systemctl is-active --quiet libvirtd.socket 2>/dev/null || \
systemctl is-active --quiet virtqemud.socket 2>/dev/null; then
socket_ready=true
fi
# ---------------------------------------------------------------------------
echo ""
echo "══════════════════════════════════════════════════"
echo " Sysadmin Chronicles — Host Prerequisite Check"
echo "══════════════════════════════════════════════════"
echo ""
echo "── Virtualization ─────────────────────────────────"
check_file "/dev/kvm" "KVM device node"
check_cmd "virsh" "virsh (libvirt CLI)"
check_cmd "qemu-system-x86_64" "QEMU (x86_64)"
# libvirt runtime
if [ "$libvirt_ready" = "true" ]; then
echo " $PASS libvirt responds to virsh"
elif [ "$socket_ready" = "true" ]; then
echo " $WARN libvirt socket activation is available, but this user cannot reach $LIBVIRT_DEFAULT_URI"
echo " Add yourself to the libvirt group or use sudo for setup."
((warnings+=1))
else
echo " $FAIL libvirt is not reachable — start socket activation or the daemon"
echo " Example: sudo systemctl enable --now libvirtd.socket"
((errors+=1))
fi
check_group "libvirt"
check_kvm_access
echo ""
echo "── Storage ────────────────────────────────────────"
check_cmd "qemu-img" "qemu-img (disk image tool)"
# Storage pool exists check
if run_virsh_quick pool-info sc-images; then
echo " $PASS sc-images storage pool exists"
else
echo " $WARN sc-images storage pool not yet created (run first-run-setup.sh)"
((warnings+=1))
fi
echo ""
echo "── Networking ─────────────────────────────────────"
if run_virsh_quick net-info sc-internal; then
echo " $PASS sc-internal network exists"
if timeout 5 virsh net-dumpxml sc-internal 2>/dev/null | grep -q "<forward mode=['\"]nat['\"]"; then
echo " $PASS sc-internal has NAT egress for VM provisioning"
else
echo " $WARN sc-internal is missing NAT egress — rebuilds may fail package installation"
echo " Run: sudo bash tools/setup/first-run-setup.sh"
((warnings+=1))
fi
else
echo " $WARN sc-internal network not yet created (run first-run-setup.sh)"
((warnings+=1))
fi
echo ""
echo "── SSH Keys ───────────────────────────────────────"
check_file "$OWNER_HOME/.ssh/sc_host_key" "Host→Guest SSH key" "false"
check_file "$OWNER_HOME/.ssh/sc_host_key.pub" "Host→Guest SSH public key" "false"
echo ""
echo "── Runtime Tools ──────────────────────────────────"
check_cmd "ssh" "ssh"
check_cmd "node" "node (Node.js, for content validation)"
check_cmd "godot" "godot (Godot 4 engine binary)" || true # warn only
echo ""
echo "── VM Images ──────────────────────────────────────"
for vm in sc-workstation sc-web-server sc-build-machine; do
if run_virsh_quick dominfo "$vm"; then
echo " $PASS Domain exists: $vm"
else
echo " $WARN Domain not yet created: $vm (run seed-vms.sh)"
((warnings+=1))
fi
done
echo ""
echo "══════════════════════════════════════════════════"
if [ "$errors" -eq 0 ] && [ "$warnings" -eq 0 ]; then
echo " ✅ All checks passed. Ready to play."
elif [ "$errors" -eq 0 ]; then
echo "$warnings warning(s). Run first-run-setup.sh if this is a fresh install."
else
echo "$errors error(s), $warnings warning(s). Run first-run-setup.sh to resolve."
fi
echo "══════════════════════════════════════════════════"
echo ""
exit $([ "$errors" -eq 0 ] && echo 0 || echo 1)
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env bash
# first-run-setup.sh — Sysadmin Chronicles one-time host setup
#
# This script creates the libvirt resources (networks, storage pool) and SSH
# keys required for the game. It is safe to re-run — all actions are idempotent.
#
# What this does:
# 1. Creates sc-internal libvirt network (private NAT, game-only)
# 2. Creates sc-images storage pool for qcow2 VM images
# 3. Generates the host→guest SSH key pair (sc_host_key)
# 4. Checks libvirtd is running and user has access
#
# What this does NOT do:
# - Build VM images (that's seed-vms.sh)
# - Modify /etc/libvirt or system-wide config beyond the named libvirt resources
# - Require broad sudo during normal gameplay
#
# AGENT RULES: Never run provisioning scripts against VMs from here.
# This script only creates host infrastructure.
set -euo pipefail
OWNER_USER="${SUDO_USER:-$USER}"
OWNER_HOME="$(getent passwd "$OWNER_USER" | cut -d: -f6)"
OWNER_HOME="${OWNER_HOME:-$HOME}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DRY_RUN=false
if [[ "${1:-}" == "--dry-run" ]]; then
DRY_RUN=true
echo "[DRY-RUN] No changes will be made."
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
run() {
if [ "$DRY_RUN" = "true" ]; then
echo " [would run] $*"
else
"$@"
fi
}
step() { echo ""; echo "── $* ───────────────────────"; }
ok() { echo "$*"; }
info() { echo "$*"; }
libvirt_reachable() {
virsh -q list --all >/dev/null 2>&1
}
libvirt_socket_available() {
systemctl is-active --quiet libvirtd.socket 2>/dev/null || \
systemctl is-active --quiet virtqemud.socket 2>/dev/null
}
echo ""
echo "══════════════════════════════════════════════════"
echo " Sysadmin Chronicles — First-Run Setup"
echo "══════════════════════════════════════════════════"
# Sanity: check libvirt access
step "Checking libvirt access"
if libvirt_reachable; then
ok "libvirt responds to virsh"
elif libvirt_socket_available; then
ok "libvirt socket activation is available"
else
echo " ERROR: libvirt is not reachable."
echo " Run: sudo systemctl enable --now libvirtd.socket"
exit 1
fi
# ---------------------------------------------------------------------------
step "Creating sc-internal libvirt network"
# ---------------------------------------------------------------------------
NETWORK_XML="$SCRIPT_DIR/../vm/network-sc-internal.xml"
write_network_xml() {
local target="$1"
cat > "$target" << 'EOF'
<network>
<name>sc-internal</name>
<forward mode='nat'/>
<bridge name='sc-br0' stp='on' delay='0'/>
<ip address='10.42.0.1' netmask='255.255.255.0'>
<dhcp>
<range start='10.42.0.10' end='10.42.0.50'/>
</dhcp>
</ip>
</network>
EOF
}
network_has_nat() {
virsh net-dumpxml sc-internal 2>/dev/null | grep -q "<forward mode=['\"]nat['\"]"
}
define_network() {
local label="$1"
local tmpxml
tmpxml=$(mktemp /tmp/sc-internal-XXXXXX.xml)
if [ -f "$NETWORK_XML" ]; then
cp "$NETWORK_XML" "$tmpxml"
else
write_network_xml "$tmpxml"
fi
run virsh net-define "$tmpxml"
run virsh net-autostart sc-internal
run virsh net-start sc-internal
rm -f "$tmpxml"
ok "$label"
}
if virsh net-list --all | grep -q "sc-internal"; then
if network_has_nat; then
ok "sc-internal network already exists with NAT"
else
info "Recreating sc-internal with NAT egress for guest provisioning..."
run virsh net-destroy sc-internal >/dev/null 2>&1 || true
run virsh net-undefine sc-internal
define_network "sc-internal network recreated and started"
fi
else
info "Creating sc-internal (private NAT, game-scoped network)..."
define_network "sc-internal network created and started"
fi
# ---------------------------------------------------------------------------
step "Creating sc-images storage pool"
# ---------------------------------------------------------------------------
if [ "${LIBVIRT_DEFAULT_URI}" = "qemu:///system" ]; then
IMAGES_DIR="/var/lib/libvirt/images/sysadmin-chronicles"
else
IMAGES_DIR="$OWNER_HOME/.local/share/sysadmin-chronicles/images"
fi
if virsh pool-list --all | grep -q "sc-images"; then
CURRENT_POOL_PATH="$(virsh pool-dumpxml sc-images 2>/dev/null | sed -n 's:.*<path>\(.*\)</path>.*:\1:p' | head -n1)"
if [ "$CURRENT_POOL_PATH" = "$IMAGES_DIR" ]; then
ok "sc-images pool already exists"
else
info "Recreating sc-images pool at $IMAGES_DIR (was $CURRENT_POOL_PATH)..."
run mkdir -p "$IMAGES_DIR"
run virsh pool-destroy sc-images >/dev/null 2>&1 || true
run virsh pool-undefine sc-images
run virsh pool-define-as sc-images dir --target "$IMAGES_DIR"
run virsh pool-autostart sc-images
run virsh pool-start sc-images
ok "sc-images pool recreated"
fi
else
info "Creating sc-images pool at $IMAGES_DIR..."
run mkdir -p "$IMAGES_DIR"
run virsh pool-define-as sc-images dir --target "$IMAGES_DIR"
run virsh pool-autostart sc-images
run virsh pool-start sc-images
ok "sc-images pool created"
fi
# ---------------------------------------------------------------------------
step "Generating SSH key pair"
# ---------------------------------------------------------------------------
KEY_PATH="$OWNER_HOME/.ssh/sc_host_key"
if [ -f "$KEY_PATH" ]; then
run chown "$OWNER_USER:$OWNER_USER" "$KEY_PATH" "${KEY_PATH}.pub" >/dev/null 2>&1 || true
run chmod 600 "$KEY_PATH" >/dev/null 2>&1 || true
run chmod 644 "${KEY_PATH}.pub" >/dev/null 2>&1 || true
ok "SSH key already exists: $KEY_PATH"
else
info "Generating ed25519 key: $KEY_PATH"
run mkdir -p "$(dirname "$KEY_PATH")"
run ssh-keygen -t ed25519 -N "" -C "sysadmin-chronicles-host" -f "$KEY_PATH"
run chmod 600 "$KEY_PATH"
run chmod 644 "${KEY_PATH}.pub"
run chown "$OWNER_USER:$OWNER_USER" "$KEY_PATH" "${KEY_PATH}.pub"
ok "SSH key generated"
info "Public key (add to VM authorized_keys during build):"
if [ "$DRY_RUN" = "false" ]; then
cat "${KEY_PATH}.pub"
fi
fi
# ---------------------------------------------------------------------------
step "Verifying group membership"
# ---------------------------------------------------------------------------
if id -nG | grep -qw "libvirt"; then
ok "User is in libvirt group"
else
info "Consider adding yourself to the libvirt group for passwordless VM control:"
info " sudo usermod -aG libvirt \$USER && newgrp libvirt"
fi
# ---------------------------------------------------------------------------
echo ""
echo "══════════════════════════════════════════════════"
echo " Setup complete."
echo " Next step: bash tools/setup/seed-vms.sh"
echo "══════════════════════════════════════════════════"
echo ""
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Generates a self-signed CA and server certificate for Sysadmin Chronicles TLS.
# Idempotent — skips if certs already exist.
# Run this before building VMs. Called by install.sh automatically.
set -euo pipefail
SC_CERT_DIR="${SC_CERT_DIR:-$HOME/.local/share/sysadmin-chronicles/certs}"
mkdir -p "$SC_CERT_DIR"
chmod 700 "$SC_CERT_DIR"
if [[ -f "$SC_CERT_DIR/server.crt" && -f "$SC_CERT_DIR/server.key" && -f "$SC_CERT_DIR/ca.crt" ]]; then
echo "TLS certs already exist at $SC_CERT_DIR — skipping."
exit 0
fi
echo "Generating Axiom Works internal CA..."
openssl genrsa -out "$SC_CERT_DIR/ca.key" 4096 2>/dev/null
openssl req -new -x509 -days 3650 \
-key "$SC_CERT_DIR/ca.key" \
-out "$SC_CERT_DIR/ca.crt" \
-subj "/CN=Axiom Works Internal CA/O=Axiom Works" 2>/dev/null
echo "Generating server certificate..."
openssl genrsa -out "$SC_CERT_DIR/server.key" 4096 2>/dev/null
openssl req -new \
-key "$SC_CERT_DIR/server.key" \
-out "$SC_CERT_DIR/server.csr" \
-subj "/CN=portal.axiomworks.internal/O=Axiom Works" 2>/dev/null
cat > "$SC_CERT_DIR/server.ext" <<'EXTEOF'
subjectAltName=DNS:portal.axiomworks.internal,DNS:sage.axiomworks.internal,DNS:axiomworks.corp,DNS:www.axiomworks.corp,DNS:*.axiomworks.internal,DNS:*.axiomworks.corp
EXTEOF
openssl x509 -req -days 3650 \
-in "$SC_CERT_DIR/server.csr" \
-CA "$SC_CERT_DIR/ca.crt" \
-CAkey "$SC_CERT_DIR/ca.key" \
-CAcreateserial \
-out "$SC_CERT_DIR/server.crt" \
-extfile "$SC_CERT_DIR/server.ext" 2>/dev/null
chmod 600 "$SC_CERT_DIR/ca.key" "$SC_CERT_DIR/server.key"
rm -f "$SC_CERT_DIR/server.csr" "$SC_CERT_DIR/server.ext"
echo "TLS certs generated at $SC_CERT_DIR"
echo " CA cert: $SC_CERT_DIR/ca.crt"
echo " Server cert: $SC_CERT_DIR/server.crt"
echo " Server key: $SC_CERT_DIR/server.key"
+327
View File
@@ -0,0 +1,327 @@
#!/usr/bin/env bash
# seed-vms.sh — Build all game VMs and create baseline snapshots.
#
# This script orchestrates the full VM provisioning pipeline:
# 1. Build base VM images (cloud-init or manual install)
# 2. Install guest helper binaries
# 3. Run quest-prep scripts for each Tier 1 quest
# 4. Take named baseline snapshots
#
# Prerequisites: Run first-run-setup.sh first (creates networks + pool).
#
# Usage:
# bash tools/setup/seed-vms.sh # Build all VMs
# bash tools/setup/seed-vms.sh --dry-run # Preview only
# bash tools/setup/seed-vms.sh --vm workstation # One VM only
# bash tools/setup/seed-vms.sh --skip-build # Prep scripts + snapshots only
#
# AGENT RULES:
# - Never run quest-prep scripts against live player VMs.
# - All prep scripts must be idempotent (safe to run twice).
# - Snapshots are only taken after prep scripts complete successfully.
set -euo pipefail
OWNER_USER="${SUDO_USER:-$USER}"
OWNER_HOME="$(getent passwd "$OWNER_USER" | cut -d: -f6)"
OWNER_HOME="${OWNER_HOME:-$HOME}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
export SC_OWNER_USER="$OWNER_USER"
export SC_OWNER_HOME="$OWNER_HOME"
export SC_SSH_KEY="${SC_SSH_KEY:-$OWNER_HOME/.ssh/sc_host_key}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VM_TOOLS="$PROJECT_ROOT/tools/vm"
QUEST_PREP="$VM_TOOLS/quest-prep"
source "$PROJECT_ROOT/tools/lib/config.sh"
config_read || true
normalize_dir_path() {
local path="${1:-}"
while [[ "$path" == *//* ]]; do
path="${path//\/\//\/}"
done
while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
path="${path%/}"
done
printf '%s\n' "$path"
}
if [ -n "${SC_IMAGES_DIR:-}" ]; then
SC_IMAGES_DIR="$(normalize_dir_path "$SC_IMAGES_DIR")"
export SC_IMAGE_ROOT="$SC_IMAGES_DIR"
fi
export SC_POOL_NAME="${SC_POOL_NAME:-sc-images}"
export SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}"
DRY_RUN=false
SKIP_BUILD=false
SINGLE_VM=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--skip-build) SKIP_BUILD=true; shift ;;
--vm) SINGLE_VM="$2"; shift 2 ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
done
run() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN] $*"
else
"$@"
fi
}
step() { echo ""; echo "── $* ───────────────────────────────────────"; }
ok() { echo "$*"; }
info() { echo "$*"; }
fail() { echo "$*"; exit 1; }
vm_selected() {
local key="$1"
[ -z "$SINGLE_VM" ] || [ "$SINGLE_VM" = "$key" ]
}
domain_selected() {
local domain="$1"
case "$domain" in
sc-workstation) vm_selected "workstation" ;;
sc-web-server) vm_selected "web_server" ;;
sc-build-machine) vm_selected "build_machine" ;;
*) return 1 ;;
esac
}
require_file() {
local path="$1"
local label="$2"
if [ ! -f "$path" ]; then
fail "$label is missing: $path"
fi
}
echo ""
echo "══════════════════════════════════════════════════"
echo " Sysadmin Chronicles — VM Seed Pipeline"
echo "══════════════════════════════════════════════════"
[ "$DRY_RUN" = "true" ] && echo " [DRY-RUN mode]"
echo ""
step "Validating provisioning toolchain"
require_file "$QUEST_PREP/Q001-prep.sh" "Q001 prep script"
require_file "$QUEST_PREP/Q002-prep.sh" "Q002 prep script"
require_file "$QUEST_PREP/Q003-prep.sh" "Q003 prep script"
require_file "$QUEST_PREP/Q004-prep.sh" "Q004 prep script"
require_file "$QUEST_PREP/Q006-prep.sh" "Q006 prep script"
require_file "$QUEST_PREP/Q006-post-clean.sh" "Q006 post-clean script"
if [ "$SKIP_BUILD" = "false" ]; then
missing_scripts=()
for script in \
"$VM_TOOLS/build-workstation.sh" \
"$VM_TOOLS/build-web-server.sh" \
"$VM_TOOLS/build-build-machine.sh" \
"$VM_TOOLS/install-guest-helper.sh" \
"$VM_TOOLS/suppress-maintenance-noise.sh"
do
if [ ! -f "$script" ]; then
missing_scripts+=("$script")
fi
done
if [ "${#missing_scripts[@]}" -gt 0 ]; then
echo " ✗ VM provisioning pipeline is incomplete in this repo checkout."
echo " Missing files:"
for script in "${missing_scripts[@]}"; do
echo " - $script"
done
echo ""
echo " Current state:"
echo " - The Godot game and authored content are present."
echo " - The VM image build/provision helper scripts are not."
echo ""
echo " Real VM seeding cannot complete until those scripts are added."
exit 1
fi
fi
# ---------------------------------------------------------------------------
# STEP 1 — Build base images
# ---------------------------------------------------------------------------
if [ "$SKIP_BUILD" = "false" ]; then
step "Building VM base images"
info "NOTE: VM image builds require cloud-init ISO or manual install."
info " See docs/ARCHITECTURE.md §5.3.1 for workstation profile guidance."
info " See tools/vm/build-*.sh scripts for per-VM build details."
echo ""
if vm_selected "workstation"; then
info "Building workstation (ares) — Debian XFCE desktop..."
run bash "$VM_TOOLS/build-workstation.sh" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
fi
if vm_selected "web_server"; then
info "Building web_server (hermes) — headless Debian..."
run bash "$VM_TOOLS/build-web-server.sh" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
fi
if vm_selected "build_machine"; then
info "Building build_machine (vulcan) — headless Arch..."
run bash "$VM_TOOLS/build-build-machine.sh" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
fi
fi
# ---------------------------------------------------------------------------
# STEP 1b — Verify baseline connectivity
# ---------------------------------------------------------------------------
if [ "$SKIP_BUILD" = "false" ] && [ "$DRY_RUN" = "false" ]; then
step "Verifying baseline connectivity"
for dom_host in "sc-web-server:hermes" "sc-build-machine:vulcan"; do
dom="${dom_host%%:*}"
host="${dom_host##*:}"
addr="$(virsh domifaddr "$dom" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | head -n1 || true)"
if [ -z "$addr" ]; then
info "$dom: no IP yet — skipping connectivity check"
continue
fi
result="$(ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 -i "$SC_SSH_KEY" "player@$addr" hostname 2>/dev/null || echo FAIL)"
if [ "$result" = "FAIL" ] || [ -z "$result" ]; then
fail "$dom ($host): 'hostname' failed — check inetutils and shell PATH provisioning"
fi
ok "$dom ($host): hostname=$result"
done
fi
# ---------------------------------------------------------------------------
# STEP 2 — Suppress guest maintenance noise
# ---------------------------------------------------------------------------
step "Suppressing guest maintenance noise"
info "Tuning base images to suppress package manager notices..."
for dom in sc-workstation sc-web-server sc-build-machine; do
domain_selected "$dom" || continue
if virsh dominfo "$dom" &>/dev/null 2>&1 || [ "$DRY_RUN" = "true" ]; then
run bash "$VM_TOOLS/suppress-maintenance-noise.sh" "$dom" \
$([ "$DRY_RUN" = "true" ] && echo "--dry-run")
ok "$dom: maintenance noise suppressed"
fi
done
# ---------------------------------------------------------------------------
# STEP 3 — Install guest helpers
# ---------------------------------------------------------------------------
step "Installing guest helpers"
info "Guest helpers are non-authoritative — advisory signals only."
for dom in sc-workstation sc-web-server sc-build-machine; do
domain_selected "$dom" || continue
if virsh dominfo "$dom" &>/dev/null 2>&1 || [ "$DRY_RUN" = "true" ]; then
run bash "$VM_TOOLS/install-guest-helper.sh" "$dom" \
$([ "$DRY_RUN" = "true" ] && echo "--dry-run")
ok "$dom: guest helper installed"
fi
done
# ---------------------------------------------------------------------------
# STEP 4 — Run quest-prep scripts and snapshot
# ---------------------------------------------------------------------------
step "Running quest-prep scripts and snapshotting"
run_prep_and_snapshot() {
local quest_id="$1"
local domain="$2"
local snapshot_name="$3"
local prep_script="$QUEST_PREP/${quest_id}-prep.sh"
if [ ! -f "$prep_script" ]; then
echo " ⚠ No prep script found for $quest_id — skipping"
return
fi
info "Running $quest_id prep on $domain..."
run bash "$prep_script" "$domain" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
info "Taking snapshot '$snapshot_name' on $domain..."
run virsh snapshot-delete "$domain" "$snapshot_name" >/dev/null 2>&1 || true
run virsh snapshot-create-as "$domain" "$snapshot_name" \
--description "${quest_id} baseline — created by seed-vms.sh" \
--atomic
ok "$domain$snapshot_name"
}
run_post_clean_and_snapshot() {
local quest_id="$1"
local domain="$2"
local snapshot_name="$3"
local clean_script="$QUEST_PREP/${quest_id}-post-clean.sh"
if [ ! -f "$clean_script" ]; then
echo " ⚠ No post-clean script found for $quest_id — skipping"
return
fi
info "Applying ${quest_id} clean branch state on $domain..."
run bash "$clean_script" "$domain" $([ "$DRY_RUN" = "true" ] && echo "--dry-run")
info "Taking snapshot '$snapshot_name' on $domain..."
run virsh snapshot-delete "$domain" "$snapshot_name" >/dev/null 2>&1 || true
run virsh snapshot-create-as "$domain" "$snapshot_name" \
--description "${quest_id} clean branch baseline — created by seed-vms.sh" \
--atomic
ok "$domain$snapshot_name"
}
# Q001: workstation day-one state
if vm_selected "workstation"; then
run_prep_and_snapshot "Q001" "sc-workstation" "baseline.day-one"
fi
# Q002Q004 share hermes clean baseline; prep scripts layer on top
if vm_selected "web_server"; then
run_prep_and_snapshot "Q002" "sc-web-server" "baseline.clean"
run_prep_and_snapshot "Q003" "sc-web-server" "baseline.post-q002"
run_prep_and_snapshot "Q004" "sc-web-server" "baseline.post-q003"
fi
# Q005 and Q007 use post-q004 baseline
if vm_selected "web_server"; then
info "Creating baseline.post-q004 snapshot (used by Q005, Q007)..."
run virsh snapshot-delete "sc-web-server" "baseline.post-q004" >/dev/null 2>&1 || true
run virsh snapshot-create-as "sc-web-server" "baseline.post-q004" \
--description "Post-Q004 baseline" --atomic || true
fi
# Q006: build machine broken baseline, then authored clean handoff for later quests
if vm_selected "build_machine"; then
run_prep_and_snapshot "Q006" "sc-build-machine" "baseline.clean"
run_post_clean_and_snapshot "Q006" "sc-build-machine" "baseline.post-q006"
fi
info "Q008 remains a multi-VM authored-state gap and is not provisioned by seed-vms.sh yet."
# Take recovery snapshots (always available as fallback)
step "Creating recovery snapshots"
for dom in sc-workstation sc-web-server sc-build-machine; do
domain_selected "$dom" || continue
info "Creating baseline.recovery on $dom..."
run virsh snapshot-delete "$dom" "baseline.recovery" >/dev/null 2>&1 || true
run virsh snapshot-create-as "$dom" "baseline.recovery" \
--description "Recovery fallback — created by seed-vms.sh" \
--atomic || true
ok "$dom → baseline.recovery"
done
# ---------------------------------------------------------------------------
echo ""
echo "══════════════════════════════════════════════════"
echo " Seed pipeline complete."
echo " Verify with: bash tools/setup/check-host.sh"
echo " Run content validation: bash tools/dev/test-content.sh"
echo "══════════════════════════════════════════════════"
echo ""
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env bash
# uninstall.sh — Remove all Sysadmin Chronicles game-owned host resources.
#
# Removes:
# - sc- prefixed libvirt VM domains (after confirmation)
# - sc- prefixed libvirt networks
# - sc-images storage pool and its directory
# - SSH key pair (~/.ssh/sc_host_key)
#
# Does NOT remove:
# - The game directory itself (remove manually if desired)
# - Any system-wide libvirt config
# - Any resources not prefixed with sc-
#
# Usage:
# bash tools/setup/uninstall.sh
# bash tools/setup/uninstall.sh --dry-run
# bash tools/setup/uninstall.sh --yes (skip confirmation)
set -euo pipefail
DRY_RUN=false
ASSUME_YES=false
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
--yes) ASSUME_YES=true ;;
esac
done
run() {
if [ "$DRY_RUN" = "true" ]; then echo " [DRY-RUN] $*"; else "$@"; fi
}
echo ""
echo "══════════════════════════════════════════════════"
echo " Sysadmin Chronicles — Uninstall"
echo "══════════════════════════════════════════════════"
[ "$DRY_RUN" = "true" ] && echo " [DRY-RUN mode — no changes]"
echo ""
echo " This will PERMANENTLY remove all sc- prefixed VMs,"
echo " networks, storage, and SSH keys."
echo ""
if [ "$ASSUME_YES" = "false" ] && [ "$DRY_RUN" = "false" ]; then
read -rp " Type YES to confirm uninstall: " confirm
if [ "$confirm" != "YES" ]; then
echo " Aborted."
exit 0
fi
fi
# ---------------------------------------------------------------------------
# Remove VMs
# ---------------------------------------------------------------------------
echo ""
echo "── Removing VM domains ──────────────────────────"
for domain in $(virsh list --all --name 2>/dev/null | grep "^sc-" || true); do
echo " Removing $domain..."
# Stop if running
if virsh domstate "$domain" 2>/dev/null | grep -q "running"; then
run virsh destroy "$domain"
fi
# Remove all snapshots
for snap in $(virsh snapshot-list "$domain" --name 2>/dev/null || true); do
run virsh snapshot-delete "$domain" "$snap"
done
run virsh undefine "$domain" --remove-all-storage
echo "$domain removed"
done
# ---------------------------------------------------------------------------
# Remove networks
# ---------------------------------------------------------------------------
echo ""
echo "── Removing sc- networks ─────────────────────────"
for net in $(virsh net-list --all --name 2>/dev/null | grep "^sc-" || true); do
echo " Removing network $net..."
if virsh net-info "$net" 2>/dev/null | grep -q "Active:.*yes"; then
run virsh net-destroy "$net"
fi
run virsh net-undefine "$net"
echo "$net removed"
done
# ---------------------------------------------------------------------------
# Remove storage pool
# ---------------------------------------------------------------------------
echo ""
echo "── Removing sc-images storage pool ──────────────"
if virsh pool-list --all | grep -q "sc-images"; then
POOL_PATH=$(virsh pool-dumpxml sc-images 2>/dev/null | grep -oP '(?<=<path>)[^<]+' || echo "")
if virsh pool-info sc-images 2>/dev/null | grep -q "State:.*running"; then
run virsh pool-destroy sc-images
fi
run virsh pool-undefine sc-images
if [ -n "$POOL_PATH" ] && [ -d "$POOL_PATH" ]; then
run rm -rf "$POOL_PATH"
fi
echo " ✓ sc-images pool removed"
else
echo " (sc-images pool not found — skipping)"
fi
# ---------------------------------------------------------------------------
# Remove SSH keys
# ---------------------------------------------------------------------------
echo ""
echo "── Removing SSH keys ─────────────────────────────"
KEY="$HOME/.ssh/sc_host_key"
if [ -f "$KEY" ]; then
run rm -f "$KEY" "${KEY}.pub"
echo " ✓ SSH keys removed"
else
echo " (No sc_host_key found — skipping)"
fi
echo ""
echo "══════════════════════════════════════════════════"
echo " Uninstall complete."
echo " Game files (this directory) were not removed."
echo "══════════════════════════════════════════════════"
echo ""
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Wrapper — delegates to the modular build-vm.sh driver.
exec "$(dirname "$0")/build-vm.sh" "$(dirname "$0")/profiles/build-machine.sh" "$@"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# build-vm.sh — Modular VM builder. Sources a profile file that declares VM
# variables and a generate_user_data() function, then runs the common build
# pipeline against it.
#
# Usage:
# ./build-vm.sh <profile> [--dry-run] [--force]
#
# Example:
# ./build-vm.sh profiles/web-server.sh --force
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
if [[ $# -lt 1 ]]; then
echo "Usage: $0 <profile> [--dry-run] [--force]"
exit 1
fi
PROFILE_ARG="$1"; shift
DRY_RUN=false
FORCE=false
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--force) FORCE=true; shift ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
done
source "$PROJECT_ROOT/tools/lib/config.sh"
config_read || true
if [ -n "${SC_IMAGES_DIR:-}" ]; then
SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-$SC_IMAGES_DIR}"
export SC_IMAGE_ROOT
fi
if [ -n "${SC_LIBVIRT_URI:-}" ]; then
LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-$SC_LIBVIRT_URI}"
export LIBVIRT_DEFAULT_URI
fi
source "$SCRIPT_DIR/lib/common.sh"
# Resolve profile path: bare name (e.g. "web-server") or explicit path.
if [[ -f "$PROFILE_ARG" ]]; then
PROFILE="$PROFILE_ARG"
elif [[ -f "$SCRIPT_DIR/profiles/${PROFILE_ARG}.sh" ]]; then
PROFILE="$SCRIPT_DIR/profiles/${PROFILE_ARG}.sh"
elif [[ -f "$SCRIPT_DIR/profiles/${PROFILE_ARG}" ]]; then
PROFILE="$SCRIPT_DIR/profiles/${PROFILE_ARG}"
else
echo "Profile not found: $PROFILE_ARG"
echo "Available profiles:"
ls "$SCRIPT_DIR/profiles/"
exit 1
fi
source "$PROFILE"
# Validate required profile variables.
for var in DOMAIN HOSTNAME RAM_MB VCPUS DISK_SIZE GRAPHICS BASE_URL BASE_IMAGE; do
[[ -n "${!var:-}" ]] || { echo "Profile must set $var"; exit 1; }
done
declare -f generate_user_data >/dev/null || { echo "Profile must define generate_user_data()"; exit 1; }
GAME_HOST_IP="${SC_GAME_HOST_IP:-10.42.0.1}"
POOL_DIR="$(pool_path)"
DISK_PATH="$POOL_DIR/${DOMAIN}.qcow2"
SEED_ISO="$SC_SEED_DIR/${DOMAIN}-seed.iso"
PUBKEY="$(<"${SC_SSH_KEY}.pub")"
export DOMAIN HOSTNAME RAM_MB VCPUS DISK_SIZE GRAPHICS BASE_URL BASE_IMAGE
export GAME_HOST_IP POOL_DIR DISK_PATH SEED_ISO PUBKEY
ensure_vm_tooling
echo ""
echo "══════════════════════════════════════════════════"
echo " Building VM: $DOMAIN ($HOSTNAME)"
echo " Profile: $(basename "$PROFILE")"
echo " RAM: ${RAM_MB} MB vCPUs: ${VCPUS} Disk: ${DISK_SIZE}"
echo "══════════════════════════════════════════════════"
if domain_exists "$DOMAIN" && [ "$FORCE" = "false" ]; then
ok "$DOMAIN already exists. Use --force to rebuild it."
exit 0
fi
step "Preparing base image"
download_if_missing "$BASE_URL" "$BASE_IMAGE"
step "Preparing cloud-init seed"
tmpdir="$(mktemp -d)"
trap 'rm -rf "$tmpdir"' EXIT
generate_user_data > "$tmpdir/user-data"
cat > "$tmpdir/meta-data" <<EOF
instance-id: ${DOMAIN}
local-hostname: ${HOSTNAME}
EOF
create_seed_iso "$tmpdir/user-data" "$tmpdir/meta-data" "$SEED_ISO"
step "Building domain"
destroy_domain "$DOMAIN"
create_backing_disk "$BASE_IMAGE" "$DISK_PATH" "$DISK_SIZE"
build_import_domain "$DOMAIN" "$DISK_PATH" "$SEED_ISO" "$RAM_MB" "$VCPUS" "$GRAPHICS"
step "Waiting for guest networking"
guest_addr=""
if [ "$DRY_RUN" = "false" ]; then
if guest_addr="$(wait_for_agent_ip "$DOMAIN" 300)"; then
ok "$DOMAIN is reachable at $guest_addr"
else
info "Guest IP not available yet. First boot may still be running cloud-init."
fi
fi
if [ "$DRY_RUN" = "false" ] && [ -n "${READY_COMMAND:-}" ]; then
step "Waiting for guest readiness"
if [ -n "$guest_addr" ]; then
if [ -n "${READY_WATCH_TEMPLATE:-}" ]; then
watch_command="${READY_WATCH_TEMPLATE//\{ADDR\}/$guest_addr}"
info "Watch live progress in another terminal:"
info "$watch_command"
fi
fi
if wait_for_guest_command "$DOMAIN" "${READY_TIMEOUT:-600}" "$READY_COMMAND" "${READY_PROGRESS_COMMAND:-}" "${READY_PROGRESS_EVERY_SEC:-30}"; then
ok "$DOMAIN passed readiness check"
detach_seed_iso "$DOMAIN" "$SEED_ISO"
else
fail "$DOMAIN did not pass readiness check within ${READY_TIMEOUT:-600}s"
fi
fi
ok "$DOMAIN build complete"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Wrapper — delegates to the modular build-vm.sh driver.
exec "$(dirname "$0")/build-vm.sh" "$(dirname "$0")/profiles/web-server.sh" "$@"
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# Wrapper — delegates to the modular build-vm.sh driver.
exec "$(dirname "$0")/build-vm.sh" "$(dirname "$0")/profiles/workstation.sh" "$@"
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# install-guest-helper.sh — Install the advisory guest helper onto a VM.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=false
DOMAIN="${1:-}"
if [ -z "$DOMAIN" ]; then
echo "Usage: bash tools/vm/install-guest-helper.sh <domain> [--dry-run]"
exit 1
fi
if [[ "${2:-}" == "--dry-run" ]]; then
DRY_RUN=true
fi
source "$SCRIPT_DIR/lib/common.sh"
helper_name=""
case "$DOMAIN" in
sc-workstation) helper_name="atlas-index" ;;
sc-web-server) helper_name="yardd" ;;
sc-build-machine) helper_name="ops-telemetry-cache" ;;
*) echo "Unknown domain: $DOMAIN"; exit 1 ;;
esac
ensure_vm_tooling
tmp_script="$(mktemp)"
cat > "$tmp_script" <<EOF
cat > /usr/local/bin/${helper_name} <<'HELPER'
#!/usr/bin/env bash
set -euo pipefail
printf '{"helper":"%s","hostname":"%s","timestamp":"%s"}\n' \
"${helper_name}" \
"\$(hostname)" \
"\$(date -Iseconds)"
HELPER
chmod 755 /usr/local/bin/${helper_name}
EOF
info "Installing guest helper ${helper_name} on ${DOMAIN}"
guest_run_sudo_script "$DOMAIN" "$tmp_script"
rm -f "$tmp_script"
ok "${DOMAIN}: helper ${helper_name} installed"
+427
View File
@@ -0,0 +1,427 @@
#!/usr/bin/env bash
set -euo pipefail
COMMON_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VM_TOOLS_DIR="$(cd "$COMMON_DIR/.." && pwd)"
PROJECT_ROOT="$(cd "$VM_TOOLS_DIR/../.." && pwd)"
SC_OWNER_USER="${SC_OWNER_USER:-${SUDO_USER:-$USER}}"
SC_OWNER_HOME="${SC_OWNER_HOME:-$(getent passwd "$SC_OWNER_USER" | cut -d: -f6)}"
SC_OWNER_HOME="${SC_OWNER_HOME:-$HOME}"
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
if [ "${LIBVIRT_DEFAULT_URI}" = "qemu:///system" ]; then
SC_HOME="${SC_HOME:-/var/lib/libvirt/sysadmin-chronicles}"
SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-/var/lib/libvirt/images/sysadmin-chronicles}"
else
SC_HOME="${SC_HOME:-$SC_OWNER_HOME/.local/share/sysadmin-chronicles}"
SC_IMAGE_ROOT="${SC_IMAGE_ROOT:-$SC_HOME/images}"
fi
SC_BASE_DIR="$SC_IMAGE_ROOT/base"
SC_SEED_DIR="$SC_IMAGE_ROOT/seed"
SC_POOL_NAME="${SC_POOL_NAME:-sc-images}"
SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}"
SC_SSH_KEY="${SC_SSH_KEY:-$SC_OWNER_HOME/.ssh/sc_host_key}"
DRY_RUN="${DRY_RUN:-false}"
_virsh() {
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
sudo virsh "$@"
else
virsh "$@"
fi
}
_virt_install() {
if [ "${SC_VIRSH_SUDO:-false}" = true ]; then
sudo virt-install "$@"
else
virt-install "$@"
fi
}
step() { echo ""; echo "── $* ───────────────────────────────────────"; }
ok() { echo "$*"; }
info() { echo "$*"; }
fail() { echo "$*"; exit 1; }
ssh_login_user() {
local domain="${1:-}"
case "$domain" in
sc-workstation) printf '%s\n' "opsbridge" ;;
*) printf '%s\n' "player" ;;
esac
}
run() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN] $*"
else
"$@"
fi
}
require_cmd() {
local cmd="$1"
command -v "$cmd" >/dev/null 2>&1 || fail "Required command not found: $cmd"
}
ensure_vm_tooling() {
require_cmd virsh
require_cmd qemu-img
require_cmd curl
require_cmd virt-install
if ! command -v cloud-localds >/dev/null 2>&1 \
&& ! command -v genisoimage >/dev/null 2>&1 \
&& ! command -v mkisofs >/dev/null 2>&1 \
&& ! command -v xorriso >/dev/null 2>&1; then
fail "Need cloud-localds, genisoimage, mkisofs, or xorriso to build NoCloud seed images"
fi
[ -f "$SC_SSH_KEY" ] || fail "Missing SSH private key: $SC_SSH_KEY"
[ -f "${SC_SSH_KEY}.pub" ] || fail "Missing SSH public key: ${SC_SSH_KEY}.pub"
run mkdir -p "$SC_BASE_DIR" "$SC_SEED_DIR"
_virsh pool-info "$SC_POOL_NAME" >/dev/null 2>&1 || fail "Missing libvirt pool: $SC_POOL_NAME"
_virsh net-info "$SC_NETWORK_NAME" >/dev/null 2>&1 || fail "Missing libvirt network: $SC_NETWORK_NAME"
}
pool_path() {
local path
path="$(_virsh pool-dumpxml "$SC_POOL_NAME" | sed -n 's:.*<path>\(.*\)</path>.*:\1:p' | head -n1)"
[ -n "$path" ] || fail "Could not determine pool path for $SC_POOL_NAME"
printf '%s\n' "$path"
}
domain_exists() {
local domain="$1"
_virsh dominfo "$domain" >/dev/null 2>&1
}
download_if_missing() {
local url="$1"
local dest="$2"
if [ -f "$dest" ]; then
ok "Using cached base image: $(basename "$dest")"
return
fi
info "Downloading $(basename "$dest")"
run curl -L --fail --output "$dest" "$url"
}
create_backing_disk() {
local base_image="$1"
local target_disk="$2"
local disk_size="${3:-}"
run mkdir -p "$(dirname "$target_disk")"
run rm -f "$target_disk"
if [ -n "$disk_size" ]; then
run qemu-img create -f qcow2 -F qcow2 -b "$base_image" "$target_disk" "$disk_size"
else
run qemu-img create -f qcow2 -F qcow2 -b "$base_image" "$target_disk"
fi
}
create_seed_iso() {
local user_data="$1"
local meta_data="$2"
local output_iso="$3"
local seed_dir
seed_dir="$(mktemp -d)"
cp "$user_data" "$seed_dir/user-data"
cp "$meta_data" "$seed_dir/meta-data"
run rm -f "$output_iso"
if command -v cloud-localds >/dev/null 2>&1; then
run cloud-localds "$output_iso" "$seed_dir/user-data" "$seed_dir/meta-data"
elif command -v genisoimage >/dev/null 2>&1; then
run genisoimage -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data"
elif command -v mkisofs >/dev/null 2>&1; then
run mkisofs -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data"
else
run xorriso -as mkisofs -quiet -output "$output_iso" -volid cidata -joliet -rock "$seed_dir/user-data" "$seed_dir/meta-data"
fi
rm -rf "$seed_dir"
}
destroy_domain() {
local domain="$1"
if ! domain_exists "$domain"; then
return
fi
info "Removing existing domain definition: $domain"
run _virsh destroy "$domain" >/dev/null 2>&1 || true
run _virsh undefine "$domain" --nvram --snapshots-metadata >/dev/null 2>&1 \
|| run _virsh undefine "$domain" --snapshots-metadata >/dev/null 2>&1 \
|| run _virsh undefine "$domain" --nvram >/dev/null 2>&1 \
|| run _virsh undefine "$domain" >/dev/null 2>&1 \
|| true
}
build_import_domain() {
local domain="$1"
local disk_path="$2"
local seed_iso="$3"
local ram_mb="$4"
local vcpus="$5"
local graphics_mode="$6"
local args=(
--name "$domain"
--memory "$ram_mb"
--vcpus "$vcpus"
--import
--disk "path=$disk_path,format=qcow2,bus=virtio"
--disk "path=$seed_iso,device=cdrom"
--network "network=$SC_NETWORK_NAME,model=virtio"
--channel "unix,target_type=virtio,name=org.qemu.guest_agent.0"
--rng /dev/urandom
--osinfo detect=on,require=off
--noautoconsole
)
case "$graphics_mode" in
none)
args+=(--graphics none --console pty,target_type=serial)
;;
vnc)
args+=(--graphics vnc,listen=127.0.0.1)
;;
spice)
args+=(
--graphics spice,listen=127.0.0.1
--video virtio
--channel "spicevmc,target_type=virtio,name=com.redhat.spice.0"
)
;;
spice-qxl)
args+=(
--graphics spice,listen=127.0.0.1
--video qxl
--channel "spicevmc,target_type=virtio,name=com.redhat.spice.0"
)
;;
*)
fail "Unknown graphics mode: $graphics_mode"
;;
esac
run _virt_install "${args[@]}"
run _virsh autostart "$domain"
}
seed_cdrom_target() {
local domain="$1"
local seed_iso="$2"
_virsh dumpxml "$domain" 2>/dev/null \
| awk -v seed="$seed_iso" '
/<disk / { in_disk=1; target="" }
in_disk && /<source file=/ && index($0, seed) { matched=1 }
in_disk && /<target dev=/ {
line=$0
sub(/.*<target dev=.?/, "", line)
sub(/[ '\'"'"'"].*/, "", line)
target=line
}
in_disk && /<\/disk>/ {
if (matched && target != "") {
print target
exit
}
in_disk=0
matched=0
target=""
}
'
}
detach_seed_iso() {
local domain="$1"
local seed_iso="$2"
local target
target="$(seed_cdrom_target "$domain" "$seed_iso" || true)"
if [ -z "$target" ]; then
info "No cloud-init seed ISO attached to $domain"
return 0
fi
info "Detaching cloud-init seed ISO from $domain ($target)"
if _virsh domstate "$domain" 2>/dev/null | grep -qi running; then
run _virsh detach-disk "$domain" "$target" --config >/dev/null 2>&1 || true
run _virsh detach-disk "$domain" "$target" --live >/dev/null 2>&1 || true
if seed_cdrom_target "$domain" "$seed_iso" >/dev/null 2>&1; then
info "Restarting $domain to apply cloud-init seed ISO detach"
run _virsh shutdown "$domain" >/dev/null 2>&1 || true
local waited=0
while [ "$waited" -lt 60 ] && _virsh domstate "$domain" 2>/dev/null | grep -qi running; do
sleep 2
waited=$((waited + 2))
done
if _virsh domstate "$domain" 2>/dev/null | grep -qi running; then
run _virsh destroy "$domain" >/dev/null 2>&1 || true
fi
run _virsh start "$domain" >/dev/null 2>&1
wait_for_agent_ip "$domain" 180 >/dev/null || true
fi
else
run _virsh detach-disk "$domain" "$target" --config >/dev/null 2>&1 || true
fi
}
domain_mac() {
local domain="$1"
_virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\([^']*\)'.*/\1/p" | head -n1
}
dhcp_lease_ip() {
local domain="$1"
local mac
mac="$(domain_mac "$domain")"
[ -n "$mac" ] || return 1
_virsh net-dhcp-leases "$SC_NETWORK_NAME" 2>/dev/null \
| awk -v mac="$mac" '$0 ~ mac {print $5}' \
| cut -d/ -f1 \
| head -n1
}
valid_guest_ip() {
local addr="${1:-}"
[[ -n "$addr" ]] || return 1
[[ "$addr" != 127.* ]] || return 1
[[ "$addr" != "0.0.0.0" ]] || return 1
return 0
}
wait_for_agent_ip() {
local domain="$1"
local timeout_sec="${2:-300}"
local waited=0
while [ "$waited" -lt "$timeout_sec" ]; do
local addr
addr="$(_virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | head -n1 || true)"
if ! valid_guest_ip "$addr"; then
addr=""
fi
if [ -z "$addr" ]; then
addr="$(dhcp_lease_ip "$domain" || true)"
fi
if valid_guest_ip "$addr"; then
printf '%s\n' "$addr"
return 0
fi
sleep 5
waited=$((waited + 5))
done
return 1
}
wait_for_ssh() {
local domain="$1"
local timeout_sec="${2:-180}"
local waited=0
local login_user
login_user="$(ssh_login_user "$domain")"
while [ "$waited" -lt "$timeout_sec" ]; do
local addr
addr="$(wait_for_agent_ip "$domain" 10 || true)"
if [ -n "$addr" ]; then
if ssh_base_args "$addr" "$login_user" true >/dev/null 2>&1; then
return 0
fi
fi
sleep 5
waited=$((waited + 5))
done
return 1
}
wait_for_guest_command() {
local domain="$1"
local timeout_sec="$2"
local command="$3"
local progress_command="${4:-}"
local progress_every_sec="${5:-30}"
local waited=0
local last_progress=-9999
local login_user
login_user="$(ssh_login_user "$domain")"
while [ "$waited" -lt "$timeout_sec" ]; do
local addr
addr="$(wait_for_agent_ip "$domain" 10 || true)"
if [ -n "$addr" ]; then
if ssh_base_args "$addr" "$login_user" "$command" >/dev/null 2>&1; then
return 0
fi
if [ -n "$progress_command" ] && [ $((waited - last_progress)) -ge "$progress_every_sec" ]; then
last_progress="$waited"
info "Guest progress for $domain:"
ssh_base_args "$addr" "$login_user" "$progress_command" 2>/dev/null || true
fi
fi
sleep 5
waited=$((waited + 5))
done
return 1
}
ssh_base_args() {
local host="$1"
local login_user="${2:-player}"
shift
shift || true
ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o BatchMode=yes \
-o ConnectTimeout=5 \
-o LogLevel=ERROR \
-i "$SC_SSH_KEY" \
"${login_user}@${host}" \
"$@"
}
guest_run() {
local domain="$1"
shift
wait_for_ssh "$domain" 180 >/dev/null || fail "SSH did not become ready for $domain"
local addr
addr="$(wait_for_agent_ip "$domain" 120)" || fail "Could not resolve IP for $domain"
local login_user
login_user="$(ssh_login_user "$domain")"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN][SSH $domain@$addr] $*"
return 0
fi
ssh_base_args "$addr" "$login_user" "$@"
}
guest_run_sudo_script() {
local domain="$1"
local script_file="$2"
wait_for_ssh "$domain" 180 >/dev/null || fail "SSH did not become ready for $domain"
local addr
addr="$(wait_for_agent_ip "$domain" 120)" || fail "Could not resolve IP for $domain"
local login_user
login_user="$(ssh_login_user "$domain")"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN][SSH $domain@$addr] sudo bash -s < $script_file"
return 0
fi
ssh \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
-o BatchMode=yes \
-o ConnectTimeout=5 \
-o LogLevel=ERROR \
-i "$SC_SSH_KEY" \
"${login_user}@${addr}" "sudo bash -s" < "$script_file"
}
+14
View File
@@ -0,0 +1,14 @@
<network>
<name>sc-internal</name>
<forward mode='nat'/>
<bridge name='sc-br0' stp='on' delay='0'/>
<ip address='10.42.0.1' netmask='255.255.255.0'>
<dhcp>
<range start='10.42.0.10' end='10.42.0.50'/>
<!-- Fixed reservations — must match /etc/hosts in each VM profile -->
<host mac='52:54:00:49:9b:64' name='hermes' ip='10.42.0.40'/>
<host mac='52:54:00:5e:9f:b9' name='vulcan' ip='10.42.0.24'/>
<host mac='52:54:00:bd:aa:29' name='ares' ip='10.42.0.36'/>
</dhcp>
</ip>
</network>
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Profile: sc-build-machine (vulcan)
# Role: Arch Linux build machine — compiles AxiomFlow artifacts, runs scheduled
# jobs, deploys to hermes. Intentionally different distro from Debian servers.
# Distro: Arch Linux cloud image
DOMAIN="sc-build-machine"
HOSTNAME="vulcan"
RAM_MB=768
VCPUS=2
DISK_SIZE="10G"
GRAPHICS="vnc"
BASE_URL="https://geo.mirror.pkgbuild.com/images/latest/Arch-Linux-x86_64-cloudimg.qcow2"
BASE_IMAGE="$SC_BASE_DIR/Arch-Linux-x86_64-cloudimg.qcow2"
generate_user_data() {
cat <<EOF
#cloud-config
hostname: ${HOSTNAME}
fqdn: ${HOSTNAME}.axiomworks.internal
manage_etc_hosts: false
ssh_pwauth: false
users:
- default
- name: player
gecos: Axiom Works Builder
groups: [wheel]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys:
- ${PUBKEY}
write_files:
- path: /etc/hosts
owner: root:root
permissions: '0644'
content: |
127.0.0.1 localhost
127.0.1.1 vulcan vulcan.axiomworks.internal
${GAME_HOST_IP} axiomworks.internal portal.axiomworks.internal
10.42.0.40 hermes hermes.axiomworks.internal
- path: /etc/sudoers.d/99-player
owner: root:root
permissions: '0440'
content: |
player ALL=(ALL) NOPASSWD:ALL
- path: /etc/sysctl.d/99-sc-vulcan.conf
owner: root:root
permissions: '0644'
content: |
vm.swappiness=10
vm.vfs_cache_pressure=50
vm.dirty_ratio=25
vm.dirty_background_ratio=5
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
- path: /home/player/.bashrc
owner: root:root
permissions: '0644'
content: |
[ -z "\$PS1" ] && return
export PATH=/usr/local/sbin:/usr/local/bin:/usr/bin:/usr/sbin:/sbin:/bin
export TERM=xterm-256color
export EDITOR=vim
PS1='\[\e[0;35m\]\u@\h\[\e[0m\]:\[\e[0;34m\]\w\[\e[0m\]\$ '
HISTSIZE=5000
HISTFILESIZE=10000
HISTCONTROL=ignoredups:erasedups
shopt -s histappend
alias ll='ls -lh --color=auto'
alias la='ls -lha --color=auto'
alias grep='grep --color=auto'
alias ..='cd ..'
alias pacs='pacman -Ss'
alias paci='sudo pacman -S'
alias pacq='pacman -Qi'
if [ -f /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion
fi
- path: /home/player/.bash_profile
owner: root:root
permissions: '0644'
content: |
[[ -f ~/.bashrc ]] && . ~/.bashrc
runcmd:
- pacman -Sy --noconfirm archlinux-keyring
- pacman -Su --noconfirm
- pacman -S --noconfirm --needed sudo openssh qemu-guest-agent base-devel git inetutils iproute2 curl wget rsync vim nano htop python python-pip jq less tree unzip tcpdump lsof strace bind-tools openbsd-netcat bash-completion
- systemctl enable qemu-guest-agent sshd
- systemctl start qemu-guest-agent sshd
- mkdir -p /srv/repo /srv/builds /var/log/axiomworks
- printf 'vulcan — AxiomFlow build machine\n' > /srv/repo/README.txt
- dd if=/dev/zero of=/swapfile bs=1M count=1024 status=progress
- chmod 600 /swapfile
- mkswap /swapfile
- swapon /swapfile
- echo '/swapfile none swap sw 0 0' >> /etc/fstab
- sysctl -p /etc/sysctl.d/99-sc-vulcan.conf
- chown -R player:player /home/player /srv/repo /srv/builds
- systemctl disable ModemManager || true
- systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
final_message: "Vulcan build machine is ready."
EOF
}
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
# Profile: sc-web-server (hermes)
# Role: nginx web/app server — staging and demo environment for AxiomFlow.
# Distro: Debian 12 (bookworm) cloud image
DOMAIN="sc-web-server"
HOSTNAME="hermes"
RAM_MB=512
VCPUS=1
DISK_SIZE="8G"
GRAPHICS="vnc"
BASE_URL="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
BASE_IMAGE="$SC_BASE_DIR/debian-12-genericcloud-amd64.qcow2"
generate_user_data() {
cat <<EOF
#cloud-config
hostname: ${HOSTNAME}
fqdn: ${HOSTNAME}.axiomworks.internal
manage_etc_hosts: false
ssh_pwauth: false
package_update: true
package_upgrade: false
packages:
- qemu-guest-agent
- openssh-server
- sudo
- nginx
- logrotate
- rsync
- curl
- wget
- git
- python3
- jq
- vim
- nano
- htop
- procps
- psmisc
- iproute2
- iputils-ping
- dnsutils
- netcat-openbsd
- tcpdump
- lsof
- strace
- less
- tree
- unzip
- bash-completion
users:
- default
- name: player
gecos: Axiom Works Operator
groups: [sudo]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys:
- ${PUBKEY}
write_files:
- path: /etc/hosts
owner: root:root
permissions: '0644'
content: |
127.0.0.1 localhost
127.0.1.1 hermes hermes.axiomworks.internal
${GAME_HOST_IP} axiomworks.internal portal.axiomworks.internal
- path: /etc/sudoers.d/99-player
owner: root:root
permissions: '0440'
content: |
player ALL=(ALL) NOPASSWD:ALL
- path: /etc/nginx/sites-available/axiomworks.conf
owner: root:root
permissions: '0644'
content: |
server {
listen 80;
server_name hermes hermes.axiomworks.internal _;
root /var/www/axiomworks;
index index.html;
access_log /var/log/nginx/axiomworks.access.log;
error_log /var/log/nginx/axiomworks.error.log;
location / {
try_files \$uri \$uri/ =404;
}
}
- path: /var/www/axiomworks/index.html
owner: root:root
permissions: '0644'
content: |
<!doctype html>
<html><head><title>AxiomFlow</title></head>
<body><h1>AxiomFlow Staging</h1><p>Build not yet deployed.</p></body>
</html>
- path: /opt/deploy/deploy.sh
owner: root:root
permissions: '0755'
content: |
#!/usr/bin/env bash
set -euo pipefail
SRC="\${1:-/home/player/build/dist}"
rsync -av --delete "\$SRC/" /var/www/axiomworks/
echo "\$(date) Deploy from \$SRC complete." >> /var/log/axiomworks/deploy.log
- path: /home/player/.bashrc
owner: root:root
permissions: '0644'
content: |
[ -z "\$PS1" ] && return
export TERM=xterm-256color
export EDITOR=vim
PS1='\[\e[0;33m\]\u@\h\[\e[0m\]:\[\e[0;34m\]\w\[\e[0m\]\$ '
HISTSIZE=5000
HISTFILESIZE=10000
HISTCONTROL=ignoredups:erasedups
shopt -s histappend
alias ll='ls -lh --color=auto'
alias la='ls -lha --color=auto'
alias grep='grep --color=auto'
alias ..='cd ..'
alias nginx-test='nginx -t'
alias nginx-reload='systemctl reload nginx'
alias logs='journalctl -f'
if [ -f /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion
fi
- path: /etc/sysctl.d/99-sc-hermes.conf
owner: root:root
permissions: '0644'
content: |
vm.swappiness=10
vm.vfs_cache_pressure=50
vm.dirty_ratio=15
vm.dirty_background_ratio=3
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
runcmd:
- ln -sf /etc/nginx/sites-available/axiomworks.conf /etc/nginx/sites-enabled/axiomworks.conf
- rm -f /etc/nginx/sites-enabled/default
- mkdir -p /var/www/axiomworks /var/log/axiomworks /opt/deploy
- chown -R www-data:www-data /var/www/axiomworks
- touch /var/log/axiomworks/deploy.log
- chown www-data:www-data /var/log/axiomworks/deploy.log
- chown -R player:player /home/player
- fallocate -l 512M /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile && echo '/swapfile none swap sw 0 0' >> /etc/fstab
- sysctl -p /etc/sysctl.d/99-sc-hermes.conf
- systemctl enable --now qemu-guest-agent ssh nginx
- systemctl disable --now unattended-upgrades || true
- systemctl disable --now apt-daily.timer apt-daily-upgrade.timer || true
- systemctl disable --now ModemManager || true
- systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
final_message: "Hermes web server is ready."
EOF
}
+734
View File
@@ -0,0 +1,734 @@
#!/usr/bin/env bash
# Profile: sc-workstation (ares)
# Role: XFCE desktop workstation — where the player works.
# Distro: Debian 12 (bookworm) cloud image
DOMAIN="sc-workstation"
HOSTNAME="ares"
RAM_MB=2048
VCPUS=2
DISK_SIZE="20G"
GRAPHICS="${SC_WORKSTATION_GRAPHICS:-spice}"
BASE_URL="https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
BASE_IMAGE="$SC_BASE_DIR/debian-12-genericcloud-amd64.qcow2"
READY_TIMEOUT=1200
READY_COMMAND='cloud-init status 2>/dev/null | grep -q "status: done" && ! uname -r | grep -q cloud && test -e /dev/dri/card0 && systemctl is-active --quiet lightdm'
READY_PROGRESS_COMMAND='cloud-init status --long; echo "---"; tail -n 12 /var/log/cloud-init-output.log'
READY_WATCH_TEMPLATE='ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=5 -o LogLevel=ERROR -i ~/.ssh/sc_host_key opsbridge@{ADDR} "sudo tail -f /var/log/cloud-init-output.log"'
# Extra variables used in user-data
WALLPAPER_PATH="${SC_WALLPAPER_PATH:-$PROJECT_ROOT/server/public/wallpaper.png}"
WALLPAPER_B64=""
if [ -f "$WALLPAPER_PATH" ]; then
WALLPAPER_B64="$(base64 -w 0 "$WALLPAPER_PATH")"
fi
WALLPAPER_B64_INDENT="$(printf '%s\n' "$WALLPAPER_B64" | fold -w 76 | sed 's/^/ /')"
PRIVKEY_INDENT="$(sed 's/^/ /' "$SC_SSH_KEY")"
source "$PROJECT_ROOT/tools/lib/internal-https.sh"
sc_ensure_internal_certs "$PROJECT_ROOT"
sc_export_internal_https_env
SC_CERT_DIR="$(sc_cert_dir)"
HUD_URL="$(sc_hud_url)"
SAGE_URL="$(sc_sage_url)"
COMPANY_URL="$(sc_company_url)"
_SC_CA_CERT_PEM=""
_SC_SERVER_CERT_PEM=""
_SC_SERVER_KEY_PEM=""
if [[ -f "$(sc_ca_cert)" && -f "$(sc_tls_cert)" && -f "$(sc_tls_key)" ]]; then
_SC_CA_CERT_PEM="$(cat "$SC_CERT_DIR/ca.crt")"
_SC_SERVER_CERT_PEM="$(cat "$SC_CERT_DIR/server.crt")"
_SC_SERVER_KEY_PEM="$(cat "$SC_CERT_DIR/server.key")"
fi
_SC_CA_CERT_INDENT="$(printf '%s\n' "$_SC_CA_CERT_PEM" | sed 's/^/ /')"
_SC_SERVER_CERT_INDENT="$(printf '%s\n' "$_SC_SERVER_CERT_PEM" | sed 's/^/ /')"
_SC_SERVER_KEY_INDENT="$(printf '%s\n' "$_SC_SERVER_KEY_PEM" | sed 's/^/ /')"
_SC_CA_CERT_JSON="$(printf '%s' "$_SC_CA_CERT_PEM" | tr '\n' '|' | sed 's/|/\\n/g')"
PLAYER_SSH_CONFIG="$(cat <<'EOF'
Host hermes
HostName 10.42.0.40
User player
IdentityFile ~/.ssh/sc_host_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
BatchMode yes
ConnectTimeout 5
LogLevel ERROR
Host vulcan
HostName 10.42.0.24
User player
IdentityFile ~/.ssh/sc_host_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
BatchMode yes
ConnectTimeout 5
LogLevel ERROR
Host 10.42.0.*
User player
IdentityFile ~/.ssh/sc_host_key
StrictHostKeyChecking no
UserKnownHostsFile /dev/null
BatchMode yes
ConnectTimeout 5
LogLevel ERROR
EOF
)"
PLAYER_SSH_CONFIG_INDENT="$(printf '%s\n' "$PLAYER_SSH_CONFIG" | sed 's/^/ /')"
_nginx_config() {
printf '%s\n' \
' server {' \
' listen 443 ssl;' \
' server_name axiomworks.corp www.axiomworks.corp;' \
' ssl_certificate /etc/nginx/certs/server.crt;' \
' ssl_certificate_key /etc/nginx/certs/server.key;' \
' location / {' \
" proxy_pass https://${GAME_HOST_IP}:3000/company/;" \
' proxy_ssl_verify off;' \
' proxy_set_header Host $host;' \
' proxy_set_header X-Real-IP $remote_addr;' \
' }' \
' }' \
' server {' \
' listen 80;' \
' server_name axiomworks.corp www.axiomworks.corp;' \
' return 301 https://$host$request_uri;' \
' }'
}
_cert_write_files() {
if [[ -z "$_SC_CA_CERT_PEM" ]]; then return; fi
printf '%s\n' ' - path: /usr/local/share/ca-certificates/axiomworks-ca.crt'
printf '%s\n' ' owner: root:root'
printf '%s\n' " permissions: '0644'"
printf '%s\n' ' content: |'
printf '%s\n' "$_SC_CA_CERT_INDENT"
printf '%s\n' ' - path: /etc/nginx/certs/server.crt'
printf '%s\n' ' owner: root:root'
printf '%s\n' " permissions: '0644'"
printf '%s\n' ' content: |'
printf '%s\n' "$_SC_SERVER_CERT_INDENT"
printf '%s\n' ' - path: /etc/nginx/certs/server.key'
printf '%s\n' ' owner: root:root'
printf '%s\n' " permissions: '0600'"
printf '%s\n' ' content: |'
printf '%s\n' "$_SC_SERVER_KEY_INDENT"
printf '%s\n' ' - path: /etc/chromium/policies/managed/axiomworks-ca.json'
printf '%s\n' ' owner: root:root'
printf '%s\n' " permissions: '0644'"
printf '%s\n' ' content: |'
printf '%s\n' ' {'
printf '%s\n' ' "AdditionalTrustAnchors": ['
printf '%s\n' " \"$_SC_CA_CERT_JSON\""
printf '%s\n' ' ]'
printf '%s\n' ' }'
}
generate_user_data() {
cat <<EOF
#cloud-config
hostname: ${HOSTNAME}
fqdn: ${HOSTNAME}.internal
manage_etc_hosts: false
ssh_pwauth: false
package_update: true
package_upgrade: false
packages:
- qemu-guest-agent
- spice-vdagent
- accountsservice
- openssh-server
- sudo
- bash-completion
- xfce4
- xfce4-goodies
- lightdm
- lightdm-gtk-greeter
- tilix
- chromium
- thunar
- gvfs
- libglib2.0-bin
- libnss3-tools
- dbus-x11
- geany
- meld
- fonts-hack
- fonts-firacode
- vim
- nano
- htop
- tmux
- curl
- wget
- rsync
- git
- jq
- python3
- openssh-client
- nmap
- netcat-openbsd
- dnsutils
- traceroute
- mtr
- tcpdump
- strace
- lsof
- openssl
- whois
- iperf3
- logwatch
- gnome-themes-extra
- avahi-daemon
- libnss-mdns
- nginx
users:
- default
- name: opsbridge
gecos: Axiom Works Ops Bridge
groups: [sudo]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys:
- ${PUBKEY}
- name: player
gecos: Axiom Works Player
groups: [sudo]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys:
- ${PUBKEY}
write_files:
- path: /etc/hosts
owner: root:root
permissions: '0644'
content: |
127.0.0.1 localhost axiomworks.corp www.axiomworks.corp
127.0.1.1 ares ares.axiomworks.internal
${GAME_HOST_IP} axiomworks.internal portal.axiomworks.internal sage.axiomworks.internal www.axiomworks.internal
10.42.0.40 hermes hermes.axiomworks.internal
10.42.0.24 vulcan vulcan.axiomworks.internal
- path: /etc/axiom/onboarding
owner: root:root
permissions: '0644'
content: |
Welcome to Axiom Works.
Hostname: ares
User: player
Portal: ${HUD_URL}
Knowledge base: ${SAGE_URL}
SSH targets: hermes.axiomworks.internal vulcan.axiomworks.internal
Open Tilix for terminal work. Use ssh hermes or ssh vulcan once your SSH key is configured.
- path: /etc/sudoers.d/99-player
owner: root:root
permissions: '0440'
content: |
player ALL=(ALL) NOPASSWD:ALL
- path: /etc/sudoers.d/99-opsbridge
owner: root:root
permissions: '0440'
content: |
opsbridge ALL=(ALL) NOPASSWD:ALL
- path: /etc/lightdm/lightdm.conf.d/50-autologin.conf
owner: root:root
permissions: '0644'
content: |
[Seat:*]
autologin-user=player
autologin-user-timeout=0
- path: /home/player/.config/chromium/Default/Bookmarks
owner: root:root
permissions: '0644'
content: |
{
"checksum": "",
"roots": {
"bookmark_bar": {
"children": [
{
"date_added": "13369637600000000",
"guid": "1a2b3c4d-0001-0001-0001-000000000001",
"id": "2",
"name": "Axiom Works Portal",
"type": "url",
"url": "${HUD_URL}"
},
{
"date_added": "13369637600000000",
"guid": "1a2b3c4d-0001-0001-0001-000000000002",
"id": "3",
"name": "Sage (KB)",
"type": "url",
"url": "${SAGE_URL}"
},
{
"date_added": "13369637600000000",
"guid": "1a2b3c4d-0001-0001-0001-000000000003",
"id": "6",
"name": "Axiom Works Website",
"type": "url",
"url": "${COMPANY_URL}"
}
],
"date_added": "13369637600000000",
"date_modified": "13369637600000000",
"guid": "0bc5d13f-2cba-48a8-9801-375a6731a4b8",
"id": "1",
"name": "Bookmarks bar",
"type": "folder"
},
"other": {
"children": [],
"date_added": "13369637600000000",
"date_modified": "0",
"guid": "82b081ec-3dd3-493c-b8d3-c1c01c3ce438",
"id": "4",
"name": "Other bookmarks",
"type": "folder"
},
"synced": {
"children": [],
"date_added": "13369637600000000",
"date_modified": "0",
"guid": "4cf2e351-0e85-532b-bb37-df045d8f8d0f",
"id": "5",
"name": "Mobile bookmarks",
"type": "folder"
}
},
"version": 1
}
- path: /usr/local/bin/open-portal
owner: root:root
permissions: '0755'
content: |
#!/bin/bash
# Wait for game server before opening Chromium — avoids "site can't be reached" on fast VM boot.
until curl -sf --max-time 2 "${HUD_URL}" >/dev/null 2>&1; do sleep 2; done
exec chromium --no-first-run --no-default-browser-check --new-window "${HUD_URL}"
- path: /usr/local/share/axiomworks-wallpaper.png
owner: root:root
permissions: '0644'
encoding: b64
content: |
${WALLPAPER_B64_INDENT}
- path: /usr/share/backgrounds/wallpaper.png
owner: root:root
permissions: '0644'
encoding: b64
content: |
${WALLPAPER_B64_INDENT}
- path: /home/player/Desktop/Portal.desktop
owner: root:root
permissions: '0755'
content: |
[Desktop Entry]
Type=Application
Name=Axiom Works Portal
Exec=chromium --no-first-run --no-default-browser-check --new-window ${HUD_URL}
Icon=chromium
Terminal=false
- path: /home/player/Desktop/Terminal.desktop
owner: root:root
permissions: '0755'
content: |
[Desktop Entry]
Type=Application
Name=Terminal
Exec=tilix
Icon=utilities-terminal
Terminal=false
Path=/home/player
- path: /usr/local/bin/trust-desktop-launchers
owner: root:root
permissions: '0755'
content: |
#!/bin/bash
# Trust every player desktop launcher from the real player login session.
set -u
PATH=/usr/local/bin:/usr/bin:/bin
player_uid="\$(id -u player)"
desktop_dir=/home/player/Desktop
export HOME=/home/player
export USER=player
export LOGNAME=player
export DISPLAY="\${DISPLAY:-:0}"
export XAUTHORITY="\${XAUTHORITY:-/home/player/.Xauthority}"
export XDG_RUNTIME_DIR="/run/user/\$player_uid"
if [ -S "\$XDG_RUNTIME_DIR/bus" ]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=\$XDG_RUNTIME_DIR/bus"
fi
metadata_daemon=""
for candidate in /usr/libexec/gvfsd-metadata /usr/lib/gvfs/gvfsd-metadata /usr/lib/x86_64-linux-gnu/gvfs/gvfsd-metadata; do
if [ -x "\$candidate" ]; then
metadata_daemon="\$candidate"
break
fi
done
if [ -n "\$metadata_daemon" ] && ! /usr/bin/pgrep -u "\$player_uid" -x gvfsd-metadata >/dev/null 2>&1; then
"\$metadata_daemon" >/dev/null 2>&1 &
sleep 1
fi
for i in \$(/usr/bin/seq 1 20); do
trusted_any=false
failed=false
for launcher in "\$desktop_dir"/*.desktop; do
[ -e "\$launcher" ] || continue
chmod 0755 "\$launcher" 2>/dev/null || true
checksum="\$(/usr/bin/sha256sum "\$launcher" | /usr/bin/awk '{print \$1}')" || {
failed=true
continue
}
if /usr/bin/gio set -t string "\$launcher" metadata::xfce-exe-checksum "\$checksum" 2>/dev/null; then
actual_checksum="\$(/usr/bin/gio info -a metadata::xfce-exe-checksum "\$launcher" 2>/dev/null | /usr/bin/awk -F': ' '/metadata::xfce-exe-checksum:/ {print \$2; exit}')"
owner_mode="\$(/usr/bin/stat -c '%U:%G %a' "\$launcher" 2>/dev/null || true)"
if [ "\$actual_checksum" != "\$checksum" ] || [ "\$owner_mode" != "player:player 755" ]; then
failed=true
continue
fi
trusted_any=true
else
failed=true
fi
done
if [ "\$trusted_any" = true ] && [ "\$failed" = false ]; then
/usr/bin/xfdesktop --reload >/dev/null 2>&1 || /usr/bin/pkill -HUP xfdesktop 2>/dev/null || true
rm -f /home/player/.config/autostart/trust-launchers.desktop
exit 0
fi
sleep 1
done
# gvfsd not ready — will retry next login
exit 1
- path: /home/player/.local/bin/trust-desktop-launchers.sh
owner: root:root
permissions: '0755'
content: |
#!/bin/bash
exec /usr/local/bin/trust-desktop-launchers
- path: /home/player/.config/autostart/trust-launchers.desktop
owner: root:root
permissions: '0644'
content: |
[Desktop Entry]
Type=Application
Name=Trust Desktop Launchers
Exec=/usr/local/bin/trust-desktop-launchers
Terminal=false
X-GNOME-Autostart-enabled=true
Hidden=false
NoDisplay=true
- path: /home/player/Desktop/VIEWER_HELP.txt
owner: root:root
permissions: '0644'
content: |
Workstation Viewer — Quick Reference
=====================================
Toggle fullscreen: F11
Release mouse/kb: Shift+F12 (or Ctrl+Alt on some builds)
Scale display: View → Zoom (or Ctrl+scroll)
Copy from guest: Select text, then right-click → Copy
Paste to guest: Right-click input field → Paste
Switch USB redirect: Input → USB Device Redirection
- path: /home/player/.config/xfce4/desktop/icons.screen0-1264x757.rc
owner: root:root
permissions: '0644'
content: |
[xfdesktop-version-4.10.3+-rcfile_format]
4.10.3+=true
[/home/player/Desktop/VIEWER_HELP.txt]
row=6
col=0
[/home/player/Desktop/Terminal.desktop]
row=0
col=6
[/home/player/Desktop/Portal.desktop]
row=0
col=7
[Trash]
row=6
col=11
[/]
row=0
col=4
[/home/player]
row=0
col=5
- path: /home/player/.config/xfce4/desktop/icons.screen.latest.rc
owner: root:root
permissions: '0644'
content: |
[xfdesktop-version-4.10.3+-rcfile_format]
4.10.3+=true
[/home/player/Desktop/VIEWER_HELP.txt]
row=6
col=0
[/home/player/Desktop/Terminal.desktop]
row=0
col=6
[/home/player/Desktop/Portal.desktop]
row=0
col=7
[Trash]
row=6
col=11
[/]
row=0
col=4
[/home/player]
row=0
col=5
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xfwm4.xml
owner: root:root
permissions: '0644'
content: |
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfwm4" version="1.0">
<property name="general" type="empty">
<property name="use_compositing" type="bool" value="false"/>
</property>
</channel>
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-screensaver.xml
owner: root:root
permissions: '0644'
content: |
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-screensaver" version="1.0">
<property name="saver" type="empty">
<property name="enabled" type="bool" value="false"/>
</property>
<property name="lock" type="empty">
<property name="enabled" type="bool" value="false"/>
</property>
</channel>
- path: /home/player/.ssh/sc_host_key
owner: root:root
permissions: '0600'
content: |
${PRIVKEY_INDENT}
- path: /home/player/.ssh/config
owner: root:root
permissions: '0600'
content: |
${PLAYER_SSH_CONFIG_INDENT}
- path: /home/player/.config/chromium/Default/Preferences
owner: root:root
permissions: '0644'
content: |
{
"bookmark_bar": { "show_on_all_tabs": true },
"browser": {
"check_default_browser": false,
"show_home_button": false
},
"background_mode": { "enabled": false },
"signin": { "allowed": false },
"metrics": { "reporting_enabled": false },
"safebrowsing": { "enabled": false },
"translate": { "enabled": false }
}
- path: /home/player/.config/chromium/First Run
owner: root:root
permissions: '0644'
content: ''
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xsettings.xml
owner: root:root
permissions: '0644'
content: |
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xsettings" version="1.0">
<property name="Net" type="empty">
<property name="ThemeName" type="string" value="Adwaita-dark"/>
<property name="IconThemeName" type="string" value="Adwaita"/>
</property>
<property name="Gtk" type="empty">
<property name="CursorThemeName" type="string" value="Adwaita"/>
</property>
</channel>
- path: /home/player/.config/xfce4/helpers.rc
owner: root:root
permissions: '0644'
content: |
TerminalEmulator=tilix
- path: /home/player/.config/xfce4/xfconf/xfce-perchannel-xml/xfce4-desktop.xml
owner: root:root
permissions: '0644'
content: |
<?xml version="1.0" encoding="UTF-8"?>
<channel name="xfce4-desktop" version="1.0">
<property name="backdrop" type="empty">
<property name="screen0" type="empty">
<property name="monitorVirtual-0" type="empty">
<property name="workspace0" type="empty">
<property name="color-style" type="int" value="0"/>
<property name="rgba1" type="array">
<value type="uint" value="16"/>
<value type="uint" value="22"/>
<value type="uint" value="30"/>
<value type="uint" value="255"/>
</property>
<property name="image-style" type="int" value="2"/>
<property name="last-image" type="string" value="/usr/local/share/axiomworks-wallpaper.png"/>
</property>
</property>
<property name="monitorVirtual-1" type="empty">
<property name="workspace0" type="empty">
<property name="color-style" type="int" value="1"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
</property>
<property name="workspace1" type="empty">
<property name="color-style" type="int" value="1"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
</property>
<property name="workspace2" type="empty">
<property name="color-style" type="int" value="1"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
</property>
<property name="workspace3" type="empty">
<property name="color-style" type="int" value="1"/>
<property name="image-style" type="int" value="5"/>
<property name="last-image" type="string" value="/usr/share/backgrounds/wallpaper.png"/>
</property>
</property>
</property>
</property>
<property name="desktop-icons" type="empty">
<property name="show-removable" type="bool" value="false"/>
<property name="show-device-icons" type="bool" value="false"/>
<property name="show-network-removable" type="bool" value="false"/>
<property name="show-trash" type="bool" value="true"/>
<property name="show-home" type="bool" value="true"/>
<property name="show-filesystem" type="bool" value="true"/>
</property>
</channel>
- path: /home/player/.bashrc
owner: root:root
permissions: '0644'
content: |
[ -z "\$PS1" ] && return
export TERM=xterm-256color
export EDITOR=nano
PS1='\[\e[0;32m\]\u@\h\[\e[0m\]:\[\e[0;34m\]\w\[\e[0m\]\$ '
HISTSIZE=5000
HISTFILESIZE=10000
HISTCONTROL=ignoredups:erasedups
shopt -s histappend
alias ll='ls -lh --color=auto'
alias la='ls -lha --color=auto'
alias l='ls -CF --color=auto'
alias grep='grep --color=auto'
alias ..='cd ..'
alias ...='cd ../..'
export LS_COLORS='di=0;34:ln=0;36:ex=0;32:'
if [ -f /usr/share/bash-completion/bash_completion ]; then
. /usr/share/bash-completion/bash_completion
fi
- path: /etc/sysctl.d/99-sc-workstation.conf
owner: root:root
permissions: '0644'
content: |
vm.swappiness=10
vm.vfs_cache_pressure=50
vm.dirty_ratio=20
vm.dirty_background_ratio=5
net.ipv6.conf.all.disable_ipv6=1
net.ipv6.conf.default.disable_ipv6=1
- path: /etc/udev/rules.d/90-sysadmin-chronicles-hide-system-disk.rules
owner: root:root
permissions: '0644'
content: |
# Hide the internal VirtIO system disk from desktop file-manager device lists.
KERNEL=="vd[a-z]", ENV{UDISKS_IGNORE}="1"
KERNEL=="vd[a-z][0-9]*", ENV{UDISKS_IGNORE}="1"
- path: /etc/nginx/sites-available/axiomworks
owner: root:root
permissions: '0644'
content: |
$(_nginx_config)
$(_cert_write_files)
runcmd:
- mkdir -p /home/player/Desktop /home/player/projects /home/player/.ssh /home/player/.config/autostart /home/player/.config/xfce4/desktop /home/player/.config/xfce4/xfconf/xfce-perchannel-xml /home/player/.config/chromium/Default /home/opsbridge/.ssh /home/player/.local/bin
- chown -R player:player /home/player
- chown -R opsbridge:opsbridge /home/opsbridge
- passwd -d player
- chmod 700 /home/player/.ssh
- chmod 700 /home/opsbridge/.ssh
- touch /home/player/.ssh/authorized_keys
- touch /home/opsbridge/.ssh/authorized_keys
- chown player:player /home/player/.ssh/authorized_keys
- chown opsbridge:opsbridge /home/opsbridge/.ssh/authorized_keys
- chmod 600 /home/player/.ssh/authorized_keys
- chmod 600 /home/opsbridge/.ssh/authorized_keys
- printf '%s\n' 'Axiom Works workstation ready.' > /home/player/notes.txt
- chown player:player /home/player/notes.txt
- mkdir -p /var/lib/lightdm/data
- chown lightdm:lightdm /var/lib/lightdm/data || chown 108:114 /var/lib/lightdm/data || true
- test -f /swapfile || fallocate -l 1G /swapfile
- chmod 600 /swapfile
- mkswap -f /swapfile
- swapon /swapfile || true
- grep -q '^/swapfile ' /etc/fstab || echo '/swapfile none swap sw 0 0' >> /etc/fstab
- ln -sf /etc/nginx/sites-available/axiomworks /etc/nginx/sites-enabled/axiomworks
- mkdir -p /etc/nginx/certs
- test -f /usr/local/share/ca-certificates/axiomworks-ca.crt && update-ca-certificates || true
- mkdir -p /etc/chromium/policies/managed
- |
if [ -f /usr/local/share/ca-certificates/axiomworks-ca.crt ]; then
mkdir -p /home/player/.pki/nssdb
certutil -d sql:/home/player/.pki/nssdb -N --empty-password 2>/dev/null || true
certutil -d sql:/home/player/.pki/nssdb -A -t "CT,," -n "Axiom Works CA" -i /usr/local/share/ca-certificates/axiomworks-ca.crt 2>/dev/null || true
chown -R player:player /home/player/.pki
fi
- rm -f /etc/nginx/sites-enabled/default
- systemctl enable --now nginx
- systemctl enable --now qemu-guest-agent ssh spice-vdagent
- systemctl enable lightdm
- systemctl set-default graphical.target
- DEBIAN_FRONTEND=noninteractive apt-get purge -y plymouth plymouth-label || true
- DEBIAN_FRONTEND=noninteractive apt-get install -y linux-image-amd64
- cloud_kernels="\$(dpkg-query -W -f='\${Package}\\n' 'linux-image-*-cloud-amd64' 2>/dev/null | tr '\\n' ' ')"; if [ -n "\$cloud_kernels" ]; then DEBIAN_FRONTEND=noninteractive apt-get purge -y linux-image-cloud-amd64 \$cloud_kernels; fi
- update-grub || true
- update-alternatives --set x-www-browser /usr/bin/chromium || true
- update-alternatives --set x-terminal-emulator /usr/bin/tilix || true
- sysctl -p /etc/sysctl.d/99-sc-workstation.conf
- udevadm control --reload-rules || true
- udevadm trigger --subsystem-match=block || true
- systemctl enable --now avahi-daemon
- "sed -i 's/^hosts:.*/hosts: files mdns4_minimal [NOTFOUND=return] dns/' /etc/nsswitch.conf"
- systemctl disable --now unattended-upgrades || true
- systemctl disable --now apt-daily.timer apt-daily-upgrade.timer || true
- systemctl disable --now ModemManager || true
- systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target
- rm -f /home/player/.config/autostart/game-hud.desktop
- rm -f /home/player/.Xauthority /home/player/.ICEauthority
- find /home/player/Desktop -maxdepth 1 -type f -name '*.desktop' -exec chmod 0755 {} +
- chown -R player:player /home/player
power_state:
mode: reboot
timeout: 30
condition: true
final_message: "Ares XFCE workstation is ready."
EOF
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Q001-prep.sh — Workstation baseline: SSH key missing
#
# Prepares the workstation VM for Q001 "Welcome Aboard".
# The player's SSH key was never added during provisioning.
#
# What this does:
# - Ensures the player account exists
# - Removes /home/player/.ssh/authorized_keys (key not provisioned)
# - Leaves /var/log/auth.log with a "Permission denied (publickey)" entry
#
# Idempotent: safe to run multiple times.
# AGENT RULES: Never run against a live player session.
set -euo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DOMAIN="${1:-sc-workstation}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
SSH_USER="${SSH_USER:-opsbridge}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
get_vm_ip() {
local domain="$1"
local addr=""
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
if [ -n "$addr" ]; then
printf '%s\n' "$addr"
return 0
fi
local mac=""
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
[ -n "$mac" ] || return 1
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
[ -n "$addr" ] || return 1
printf '%s\n' "$addr"
}
VM_IP="$(get_vm_ip "$DOMAIN")"
SSH="ssh $SSH_OPTS $SSH_USER@$VM_IP"
run_in_vm() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN in $DOMAIN] $*"
else
$SSH "sudo $*"
fi
}
echo "Q001-prep: Preparing $DOMAIN for 'Welcome Aboard'..."
run_in_vm "bash -lc 'mkdir -p /home/player/.ssh; touch /var/log/auth.log; ts=\$(date +\"%b %d %H:%M:%S\"); echo \"\$ts ares sshd[1234]: Failed publickey for player from 10.42.0.1 port 22 ssh2\" >> /var/log/auth.log; rm -f /home/player/.ssh/authorized_keys; echo Q001-prep: authorized_keys removed'"
echo "Q001-prep: Done."
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env bash
# Q002-prep.sh — hermes baseline: nginx config syntax error
#
# Prepares sc-web-server for Q002 "Syntax Error in Aisle Four".
# Introduces a deliberate nginx config syntax error that breaks the service.
#
# What this does:
# - Installs nginx if not present
# - Writes a broken /etc/nginx/sites-enabled/axiomworks.conf
# (missing semicolon on the server_name line)
# - Stops nginx so the player finds it down
# - Adds error log evidence
#
# Idempotent: safe to run multiple times.
set -euo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DOMAIN="${1:-sc-web-server}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
get_vm_ip() {
local domain="$1"
local addr=""
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
if [ -n "$addr" ]; then
printf '%s\n' "$addr"
return 0
fi
local mac=""
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
[ -n "$mac" ] || return 1
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
[ -n "$addr" ] || return 1
printf '%s\n' "$addr"
}
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
VM_IP=$(get_vm_ip "$DOMAIN")
SSH="ssh $SSH_OPTS player@$VM_IP"
run_in_vm() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN in $DOMAIN] $*"
else
printf '%s\n' "$*" | $SSH "sudo bash -se"
fi
}
echo "Q002-prep: Preparing $DOMAIN for 'Syntax Error in Aisle Four'..."
run_in_vm "mkdir -p /etc/nginx/sites-enabled /etc/nginx/sites-available"
# Write broken nginx config (missing semicolon after server_name)
run_in_vm "cat > /etc/nginx/sites-enabled/axiomworks.conf <<'NGINX_CONF'
server {
listen 80;
server_name axiomworks.internal # <-- MISSING SEMICOLON: this is the bug
root /var/www/axiomworks;
index index.html;
location / {
try_files \$uri \$uri/ =404;
}
}
NGINX_CONF"
# Disable the default site to make this the only relevant config
run_in_vm "rm -f /etc/nginx/sites-enabled/default"
# Stop nginx (it fails to start with bad config)
run_in_vm "systemctl stop nginx || true"
# Populate nginx error log with the kind of evidence a player would find
run_in_vm "mkdir -p /var/log/nginx && echo '[emerg] unexpected \";\" in /etc/nginx/sites-enabled/axiomworks.conf:3' >> /var/log/nginx/error.log"
# Create the web root (nginx would serve from here if config were valid)
run_in_vm "mkdir -p /var/www/axiomworks && echo '<h1>Axiom Works</h1>' > /var/www/axiomworks/index.html"
echo "Q002-prep: Done. nginx is stopped with broken config on $DOMAIN."
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# Q003-prep.sh — hermes baseline: logrotate missing, nginx access log ballooning
#
# Prepares sc-web-server for Q003 "The Log That Ate the Disk".
# Assumes Q002 is already resolved (nginx is running, config is clean).
#
# What this does:
# - Removes /etc/logrotate.d/nginx (log rotation not configured)
# - Grows /var/log/nginx/access.log to ~80% disk pressure
# - Disk usage should read >85% on /var so player sees the pressure
#
# Idempotent: safe to run multiple times.
set -euo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DOMAIN="${1:-sc-web-server}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
get_vm_ip() {
local domain="$1"
local addr=""
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
if [ -n "$addr" ]; then
printf '%s\n' "$addr"
return 0
fi
local mac=""
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
[ -n "$mac" ] || return 1
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
[ -n "$addr" ] || return 1
printf '%s\n' "$addr"
}
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
VM_IP=$(get_vm_ip "$DOMAIN")
SSH="ssh $SSH_OPTS player@$VM_IP"
run_in_vm() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN in $DOMAIN] $*"
else
printf '%s\n' "$*" | $SSH "sudo bash -se"
fi
}
echo "Q003-prep: Preparing $DOMAIN for 'The Log That Ate the Disk'..."
# Remove logrotate config for nginx
run_in_vm "rm -f /etc/logrotate.d/nginx"
# Generate a large access log (~500MB of fake log entries, enough to fill a 6GB VM)
# Use truncate for speed rather than generating real content
run_in_vm "mkdir -p /var/log/nginx"
run_in_vm "truncate -s 500M /var/log/nginx/access.log"
# Write real-looking last few lines so tail shows something plausible
run_in_vm "echo '10.42.0.1 - - [\$(date +\"%d/%b/%Y:%H:%M:%S +0000\")] \"GET / HTTP/1.1\" 200 612 \"-\" \"Mozilla/5.0\"' >> /var/log/nginx/access.log"
echo "Q003-prep: Done. /var/log/nginx/access.log inflated on $DOMAIN."
echo " Check disk pressure with: df -h (on the VM)"
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Q004-prep.sh — hermes baseline: web root owned by root, deploy script in place
#
# Prepares sc-web-server for Q004 "Not My Files".
# A bad deploy re-ran as root and chowned the web root to root.
# The deploy script itself is in /opt/deploy/deploy.sh.
#
# What this does:
# - Chowns /var/www/axiomworks and all contents to root:root
# - Places a deploy script at /opt/deploy/deploy.sh (chowned player:player)
# - Ensures nginx is running (deploy will fail but nginx serves stale content)
#
# Idempotent: safe to run multiple times.
set -euo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DOMAIN="${1:-sc-web-server}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
get_vm_ip() {
local domain="$1"
local addr=""
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
if [ -n "$addr" ]; then
printf '%s\n' "$addr"
return 0
fi
local mac=""
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
[ -n "$mac" ] || return 1
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
[ -n "$addr" ] || return 1
printf '%s\n' "$addr"
}
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
VM_IP=$(get_vm_ip "$DOMAIN")
SSH="ssh $SSH_OPTS player@$VM_IP"
run_in_vm() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN in $DOMAIN] $*"
else
printf '%s\n' "$*" | $SSH "sudo bash -se"
fi
}
echo "Q004-prep: Preparing $DOMAIN for 'Not My Files'..."
# Ensure web root exists and is owned by root (the bug)
run_in_vm "mkdir -p /var/www/axiomworks && chown -R root:root /var/www/axiomworks"
# Create the deploy script as player:player (this is correct — player runs it)
run_in_vm "mkdir -p /opt/deploy"
run_in_vm "cat > /opt/deploy/deploy.sh <<'DEPLOY_SCRIPT'
#!/usr/bin/env bash
# deploy.sh — Axiom Works web deploy
# Copies build artifacts to /var/www/axiomworks/
set -e
SRC=\"\${1:-/home/player/build/dist}\"
rsync -av \"\$SRC/\" /var/www/axiomworks/
echo 'Deploy complete.'
DEPLOY_SCRIPT"
run_in_vm "chown player:player /opt/deploy/deploy.sh && chmod 755 /opt/deploy/deploy.sh"
# Ensure nginx is running (serves stale content with root-owned files)
run_in_vm "systemctl start nginx || true"
echo "Q004-prep: Done. /var/www/axiomworks is owned by root on $DOMAIN."
echo " Player must: sudo chown -R player:player /var/www/axiomworks"
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Q006-post-clean.sh — vulcan clean branch state after Q006
#
# Applies the authored clean outcome of Q006 so seed-vms.sh can materialize
# baseline.post-q006 for later quests.
#
# What this does:
# - Enables and starts systemd-timesyncd
# - Verifies archlinux-keyring is installed
# - Replaces pacman.log failure evidence with a healthy update trail
#
# Idempotent: safe to run multiple times.
set -euo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DOMAIN="${1:-sc-build-machine}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
get_vm_ip() {
local domain="$1"
local addr=""
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
if [ -n "$addr" ]; then
printf '%s\n' "$addr"
return 0
fi
local mac=""
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
[ -n "$mac" ] || return 1
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
[ -n "$addr" ] || return 1
printf '%s\n' "$addr"
}
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
VM_IP="$(get_vm_ip "$DOMAIN")"
SSH="ssh $SSH_OPTS player@$VM_IP"
run_in_vm() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN in $DOMAIN] $*"
else
printf '%s\n' "$*" | $SSH "sudo bash -se"
fi
}
echo "Q006-post-clean: Applying clean Q006 outcome on $DOMAIN..."
run_in_vm "pacman -Q archlinux-keyring >/dev/null"
run_in_vm "timedatectl set-ntp true || true"
run_in_vm "systemctl enable --now systemd-timesyncd"
run_in_vm "cat > /var/log/pacman.log <<'PACMAN_LOG'
[2026-04-23T09:02:14-0400] [PACMAN] synchronizing package lists
[2026-04-23T09:02:19-0400] [ALPM] transaction started
[2026-04-23T09:02:19-0400] [ALPM] upgraded archlinux-keyring (20260401-1 -> 20260420-1)
[2026-04-23T09:02:20-0400] [ALPM] transaction completed
PACMAN_LOG"
run_in_vm "cat > /var/log/axiomworks/time-drift.note <<'NOTE'
Time sync restored.
systemd-timesyncd is enabled and active.
archlinux-keyring is present and package operations are healthy.
NOTE"
echo "Q006-post-clean: Done. systemd-timesyncd is active and baseline.post-q006 is ready on $DOMAIN."
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Q006-prep.sh — vulcan baseline: time sync disabled, pacman signature errors logged
#
# Prepares sc-build-machine for Q006 "Time Is A Flat Circle".
# The machine clock is drifting because time sync was disabled, which surfaces
# as pacman signature verification failures.
#
# What this does:
# - Disables and stops common NTP services
# - Seeds pacman.log with realistic signature failure evidence
# - Leaves a small operator note pointing at time drift symptoms
#
# Idempotent: safe to run multiple times.
set -euo pipefail
export LIBVIRT_DEFAULT_URI="${LIBVIRT_DEFAULT_URI:-qemu:///system}"
DOMAIN="${1:-sc-build-machine}"
DRY_RUN=false
[[ "${2:-}" == "--dry-run" ]] && DRY_RUN=true
get_vm_ip() {
local domain="$1"
local addr=""
addr="$(virsh domifaddr "$domain" --source agent 2>/dev/null | awk '/ipv4/ {print $4}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
if [ -n "$addr" ]; then
printf '%s\n' "$addr"
return 0
fi
local mac=""
mac="$(virsh dumpxml "$domain" 2>/dev/null | sed -n "s/.*<mac address='\\([^']*\\)'.*/\\1/p" | head -n1)"
[ -n "$mac" ] || return 1
addr="$(virsh net-dhcp-leases sc-internal 2>/dev/null | awk -v mac="$mac" '$0 ~ mac {print $5}' | cut -d/ -f1 | grep -v '^127\.' | head -n1 || true)"
[ -n "$addr" ] || return 1
printf '%s\n' "$addr"
}
SC_SSH_KEY="${SC_SSH_KEY:-${HOME}/.ssh/sc_host_key}"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o ConnectTimeout=10 -o LogLevel=ERROR -i $SC_SSH_KEY"
VM_IP="$(get_vm_ip "$DOMAIN")"
SSH="ssh $SSH_OPTS player@$VM_IP"
run_in_vm() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN in $DOMAIN] $*"
else
printf '%s\n' "$*" | $SSH "sudo bash -se"
fi
}
echo "Q006-prep: Preparing $DOMAIN for 'Time Is A Flat Circle'..."
run_in_vm "timedatectl set-ntp false || true"
run_in_vm "systemctl stop systemd-timesyncd ntpd chronyd 2>/dev/null || true"
run_in_vm "systemctl disable systemd-timesyncd ntpd chronyd 2>/dev/null || true"
run_in_vm "mkdir -p /var/log/axiomworks /srv/repo /srv/builds"
run_in_vm "cat > /var/log/pacman.log <<'PACMAN_LOG'
[2026-04-23T08:10:51-0400] [PACMAN] synchronizing package lists
[2026-04-23T08:10:57-0400] [ALPM] transaction started
[2026-04-23T08:10:58-0400] [ALPM] warning: Public keyring not found; have you run 'pacman-key --init'?
[2026-04-23T08:10:58-0400] [ALPM] error: archlinux-keyring: signature from \"Arch Linux Master Key\" is invalid
[2026-04-23T08:10:58-0400] [ALPM] error: failed to commit transaction (invalid or corrupted package (PGP signature))
[2026-04-23T08:10:58-0400] [ALPM] transaction failed
PACMAN_LOG"
run_in_vm "cat > /var/log/axiomworks/time-drift.note <<'NOTE'
Builds started failing after the machine clock fell behind.
Symptoms:
- pacman reports invalid or corrupted package (PGP signature)
- signed packages appear to come from the future
- timedatectl shows NTP inactive
NOTE"
echo "Q006-prep: Done. NTP is disabled and pacman signature failures are seeded on $DOMAIN."
+311
View File
@@ -0,0 +1,311 @@
#!/usr/bin/env bash
# Rebuild or revert game virtual machines.
#
# Usage:
# rebuild-vms.sh Interactive menu
# rebuild-vms.sh --vm workstation Rebuild a single VM (interactive)
# rebuild-vms.sh --revert Revert all VMs to baseline snapshot
# rebuild-vms.sh --revert --vm workstation
# rebuild-vms.sh --snapshot --vm workstation --name before-risky-thing
# rebuild-vms.sh --snapshot --all --name pre-shift-4
# rebuild-vms.sh --revert --name before-risky-thing --vm workstation
# rebuild-vms.sh --dry-run [other flags]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
source "$PROJECT_ROOT/tools/lib/ui.sh"
source "$PROJECT_ROOT/tools/lib/config.sh"
source "$PROJECT_ROOT/tools/lib/libvirt.sh"
source "$PROJECT_ROOT/tools/lib/vm.sh"
config_read || true
_normalize_dir_path() {
local path="${1:-}"
while [[ "$path" == *//* ]]; do
path="${path//\/\//\/}"
done
while [ "$path" != "/" ] && [ "${path%/}" != "$path" ]; do
path="${path%/}"
done
printf '%s\n' "$path"
}
if [ -n "${SC_GAME_DIR:-}" ]; then
normalized_game_dir="$(_normalize_dir_path "$SC_GAME_DIR")"
if [ "$normalized_game_dir" != "$SC_GAME_DIR" ]; then
SC_GAME_DIR="$normalized_game_dir"
config_write SC_GAME_DIR "$SC_GAME_DIR"
fi
fi
if [ -n "${SC_IMAGES_DIR:-}" ]; then
normalized_images_dir="$(_normalize_dir_path "$SC_IMAGES_DIR")"
elif [ -n "${SC_GAME_DIR:-}" ]; then
normalized_images_dir="$SC_GAME_DIR/images"
else
normalized_images_dir=""
fi
if [ -n "$normalized_images_dir" ]; then
if [ "${SC_IMAGES_DIR:-}" != "$normalized_images_dir" ]; then
SC_IMAGES_DIR="$normalized_images_dir"
config_write SC_IMAGES_DIR "$SC_IMAGES_DIR"
fi
export SC_IMAGE_ROOT="$SC_IMAGES_DIR"
fi
export LIBVIRT_DEFAULT_URI="${SC_LIBVIRT_URI:-${LIBVIRT_DEFAULT_URI:-qemu:///system}}"
export SC_POOL_NAME="${SC_POOL_NAME:-sc-images}"
export SC_NETWORK_NAME="${SC_NETWORK_NAME:-sc-internal}"
# VM display names
declare -A VM_LABEL=(
[sc-workstation]="workstation"
[sc-web-server]="web server"
[sc-build-machine]="build server"
)
declare -A VM_PROFILE=(
[sc-workstation]=workstation
[sc-web-server]=web-server
[sc-build-machine]=build-machine
)
ALL_VMS=(sc-workstation sc-web-server sc-build-machine)
DRY_RUN=false
MODE=""
SINGLE_VM=""
SNAP_NAME=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=true; shift ;;
--revert) MODE=revert; shift ;;
--snapshot) MODE=snapshot; shift ;;
--vm) SINGLE_VM="sc-$2"; shift 2 ;;
--name) SNAP_NAME="$2"; shift 2 ;;
--all) SINGLE_VM=""; shift ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
done
run() {
if [ "$DRY_RUN" = true ]; then
echo " [dry-run] $*"
else
"$@"
fi
}
# Revert one VM to its newest baseline snapshot; prints result.
_revert_to_baseline() {
local vm="$1"
local label="${VM_LABEL[$vm]:-$vm}"
local candidate local_snap=""
for candidate in "baseline.recovery" "baseline.day-one" "baseline.clean"; do
if snapshot_exists "$vm" "$candidate" 2>/dev/null; then
local_snap="$candidate"
break
fi
done
if [ -n "$local_snap" ]; then
sc_info "Reverting $label to $local_snap..."
run snapshot_revert "$vm" "$local_snap"
sc_ok "$label reverted to $local_snap"
else
sc_warn "No baseline snapshot found for $vm — skipping"
fi
}
_target_vms() {
if [ -n "$SINGLE_VM" ]; then
echo "$SINGLE_VM"
else
printf '%s\n' "${ALL_VMS[@]}"
fi
}
# ---------------------------------------------------------------------------
# Non-interactive flag modes
# ---------------------------------------------------------------------------
if [ "$MODE" = "revert" ] && [ -n "$SNAP_NAME" ]; then
sc_header "REVERTING TO SNAPSHOT: $SNAP_NAME"
while IFS= read -r vm; do
label="${VM_LABEL[$vm]:-$vm}"
sc_info "Reverting $label..."
if snapshot_exists "$vm" "$SNAP_NAME"; then
run snapshot_revert "$vm" "$SNAP_NAME"
sc_ok "$label reverted to $SNAP_NAME"
else
sc_warn "Snapshot '$SNAP_NAME' not found on $vm — skipping"
fi
done < <(_target_vms)
exit 0
fi
if [ "$MODE" = "snapshot" ]; then
[ -n "$SNAP_NAME" ] || { echo " --snapshot requires --name"; exit 1; }
while IFS= read -r vm; do
label="${VM_LABEL[$vm]:-$vm}"
sc_info "Snapshotting $label as '$SNAP_NAME'..."
run vm_snapshot_create "$vm" "$SNAP_NAME"
sc_ok "$label$SNAP_NAME"
done < <(_target_vms)
exit 0
fi
if [ "$MODE" = "revert" ]; then
sc_header "REVERTING TO BASELINE"
while IFS= read -r vm; do
_revert_to_baseline "$vm"
done < <(_target_vms)
exit 0
fi
# ---------------------------------------------------------------------------
# Interactive menu
# ---------------------------------------------------------------------------
sc_header "SYSADMIN CHRONICLES — VM TOOLS"
while true; do
echo " What would you like to do?"
echo ""
echo " 1) Revert all VMs to last known good (fast — ~30s)"
echo " 2) Rebuild workstation (~8 min)"
echo " 3) Rebuild web server (~4 min)"
echo " 4) Rebuild build server (~5 min)"
echo " 5) Rebuild everything (~20 min)"
echo " 6) Take a snapshot"
echo " 7) Revert to a named snapshot"
echo ""
echo " q) Cancel"
echo ""
printf " > " >/dev/tty
read -r choice </dev/tty
echo ""
case "$choice" in
q|Q)
echo " Cancelled."
exit 0
;;
1)
sc_section "Reverting all VMs to baseline"
for vm in "${ALL_VMS[@]}"; do
_revert_to_baseline "$vm"
done
break
;;
2|3|4|5)
case "$choice" in
2) targets=(sc-workstation) ;;
3) targets=(sc-web-server) ;;
4) targets=(sc-build-machine) ;;
5) targets=("${ALL_VMS[@]}") ;;
esac
overall_status=0
for vm in "${targets[@]}"; do
label="${VM_LABEL[$vm]:-$vm}"
profile="${VM_PROFILE[$vm]}"
echo ""
sc_warn "This will permanently rebuild $label."
sc_warn "Quest progress on this VM will be lost."
echo ""
if sc_confirm "Back up save data first?" "Y"; then
BACKUP="$HOME/.local/share/sysadmin-chronicles/saves/pre-rebuild-$(date +%Y%m%d-%H%M%S).json"
[ -f "$HOME/.local/share/sysadmin-chronicles/saves/autosave.json" ] \
&& cp "$HOME/.local/share/sysadmin-chronicles/saves/autosave.json" "$BACKUP" \
&& sc_ok "Save backed up to $BACKUP" || sc_info "(no autosave found)"
fi
if ! sc_confirm "Rebuild $label now?" "N"; then
sc_info "Skipping $label."
continue
fi
sc_section "Rebuilding $label"
logfile="$HOME/.local/share/sysadmin-chronicles/rebuild-${profile}.log"
printf " Rebuilding %-18s " "$label"
start_ts="$(date +%s)"
if run vm_rebuild "$profile" $( [ "$DRY_RUN" = true ] && echo "--dry-run" ) \
> "$logfile" 2>&1; then
elapsed=$(( $(date +%s) - start_ts ))
printf "✓ %dm %02ds\n" $(( elapsed / 60 )) $(( elapsed % 60 ))
else
printf "✗\n"
sc_warn "Rebuild failed — see $logfile"
overall_status=1
continue
fi
# Re-run quest prep and re-snapshot
sc_info "Re-running quest prep for $vm..."
if run bash "$PROJECT_ROOT/tools/setup/seed-vms.sh" --skip-build --vm "${profile//-/_}" \
>> "$logfile" 2>&1; then
sc_ok "$label rebuild complete"
else
sc_warn "Quest prep had errors — see $logfile"
overall_status=1
fi
done
[ "$overall_status" -eq 0 ] || exit "$overall_status"
break
;;
6)
echo " Take a snapshot"
echo ""
echo " Which VM?"
for i in "${!ALL_VMS[@]}"; do
vm="${ALL_VMS[$i]}"
printf " %d) %s\n" $(( i + 1 )) "${VM_LABEL[$vm]:-$vm}"
done
printf " > " >/dev/tty
read -r vm_choice </dev/tty
echo ""
vm="${ALL_VMS[$(( vm_choice - 1 ))]:-}"
[ -n "$vm" ] || { sc_warn "Invalid choice"; continue; }
printf " Snapshot name (letters, numbers, hyphens): " >/dev/tty
read -r snap_name </dev/tty
echo ""
run vm_snapshot_create "$vm" "$snap_name" \
&& sc_ok "Snapshot created: $snap_name on ${VM_LABEL[$vm]:-$vm}" \
|| sc_warn "Snapshot failed."
break
;;
7)
echo " Revert to a named snapshot"
echo ""
echo " Which VM?"
for i in "${!ALL_VMS[@]}"; do
vm="${ALL_VMS[$i]}"
printf " %d) %s\n" $(( i + 1 )) "${VM_LABEL[$vm]:-$vm}"
done
printf " > " >/dev/tty
read -r vm_choice </dev/tty
echo ""
vm="${ALL_VMS[$(( vm_choice - 1 ))]:-}"
[ -n "$vm" ] || { sc_warn "Invalid choice"; continue; }
echo " Available snapshots on ${VM_LABEL[$vm]:-$vm}:"
virsh snapshot-list "$vm" --name 2>/dev/null | grep -v '^$' | sed 's/^/ /' || true
echo ""
printf " Snapshot name to revert to: " >/dev/tty
read -r snap_name </dev/tty
echo ""
snap_date="$(virsh snapshot-info "$vm" "$snap_name" 2>/dev/null | grep 'Creation Time' | awk '{print $3, $4}' || echo "")"
[ -n "$snap_date" ] && sc_info "Snapshot date: $snap_date"
if sc_confirm "Revert ${VM_LABEL[$vm]:-$vm} to '$snap_name'?" "N"; then
run vm_snapshot_revert "$vm" "$snap_name" \
&& sc_ok "Reverted to $snap_name" \
|| sc_warn "Revert failed."
fi
break
;;
*)
sc_warn "Invalid choice — enter 17 or q."
;;
esac
done
echo ""
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Repair trusted desktop launcher metadata in an existing sc-workstation VM.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/lib/common.sh"
DOMAIN="${SC_WORKSTATION_DOMAIN:-sc-workstation}"
tmp_script="$(mktemp)"
trap 'rm -f "$tmp_script"' EXIT
cat > "$tmp_script" <<'GUESTEOF'
set -euo pipefail
install -d -o player -g player /home/player/Desktop /home/player/.local/bin /home/player/.config/autostart
find /home/player/Desktop -maxdepth 1 -type f -name '*.desktop' -exec chown player:player {} +
find /home/player/Desktop -maxdepth 1 -type f -name '*.desktop' -exec chmod 0755 {} +
if [ -f /home/player/.config/chromium/Default/Bookmarks ]; then
sudo -u player sed -i 's#http://www\.axiomworks\.corp/#https://www.axiomworks.corp/#g' /home/player/.config/chromium/Default/Bookmarks
fi
cat > /usr/local/bin/trust-desktop-launchers <<'SCRIPTEOF'
#!/bin/bash
set -u
PATH=/usr/local/bin:/usr/bin:/bin
player_uid="$(id -u player)"
desktop_dir=/home/player/Desktop
export HOME=/home/player
export USER=player
export LOGNAME=player
export DISPLAY="${DISPLAY:-:0}"
export XAUTHORITY="${XAUTHORITY:-/home/player/.Xauthority}"
export XDG_RUNTIME_DIR="/run/user/$player_uid"
if [ -S "$XDG_RUNTIME_DIR/bus" ]; then
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
fi
metadata_daemon=""
for candidate in /usr/libexec/gvfsd-metadata /usr/lib/gvfs/gvfsd-metadata /usr/lib/x86_64-linux-gnu/gvfs/gvfsd-metadata; do
if [ -x "$candidate" ]; then
metadata_daemon="$candidate"
break
fi
done
if [ -n "$metadata_daemon" ] && ! /usr/bin/pgrep -u "$player_uid" -x gvfsd-metadata >/dev/null 2>&1; then
"$metadata_daemon" >/dev/null 2>&1 &
sleep 1
fi
for i in $(/usr/bin/seq 1 20); do
trusted_any=false
failed=false
for launcher in "$desktop_dir"/*.desktop; do
[ -e "$launcher" ] || continue
chmod 0755 "$launcher" 2>/dev/null || true
checksum="$(/usr/bin/sha256sum "$launcher" | /usr/bin/awk '{print $1}')" || {
failed=true
continue
}
if /usr/bin/gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" 2>/dev/null; then
actual_checksum="$(/usr/bin/gio info -a metadata::xfce-exe-checksum "$launcher" 2>/dev/null | /usr/bin/awk -F': ' '/metadata::xfce-exe-checksum:/ {print $2; exit}')"
owner_mode="$(/usr/bin/stat -c '%U:%G %a' "$launcher" 2>/dev/null || true)"
if [ "$actual_checksum" != "$checksum" ] || [ "$owner_mode" != "player:player 755" ]; then
failed=true
continue
fi
trusted_any=true
else
failed=true
fi
done
if [ "$trusted_any" = true ] && [ "$failed" = false ]; then
/usr/bin/xfdesktop --reload >/dev/null 2>&1 || /usr/bin/pkill -HUP xfdesktop 2>/dev/null || true
rm -f /home/player/.config/autostart/trust-launchers.desktop
exit 0
fi
sleep 1
done
exit 1
SCRIPTEOF
chmod 0755 /usr/local/bin/trust-desktop-launchers
cat > /home/player/.local/bin/trust-desktop-launchers.sh <<'SCRIPTEOF'
#!/bin/bash
exec /usr/local/bin/trust-desktop-launchers
SCRIPTEOF
chown player:player /home/player/.local/bin/trust-desktop-launchers.sh
chmod 0755 /home/player/.local/bin/trust-desktop-launchers.sh
cat > /home/player/.config/autostart/trust-launchers.desktop <<'DESKTOPEOF'
[Desktop Entry]
Type=Application
Name=Trust Desktop Launchers
Exec=/usr/local/bin/trust-desktop-launchers
Terminal=false
X-GNOME-Autostart-enabled=true
Hidden=false
NoDisplay=true
DESKTOPEOF
chown player:player /home/player/.config/autostart/trust-launchers.desktop
chmod 0644 /home/player/.config/autostart/trust-launchers.desktop
if [ -S "/run/user/$(id -u player)/bus" ]; then
sudo -u player env HOME=/home/player /usr/local/bin/trust-desktop-launchers
else
echo "Player DBus session is not active; repair will retry on next graphical login." >&2
fi
GUESTEOF
guest_run_sudo_script "$DOMAIN" "$tmp_script"
ok "Desktop launcher repair applied to $DOMAIN"
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
# snapshot-all.sh — Snapshot or revert all game VMs at once.
#
# Usage:
# bash tools/vm/snapshot-all.sh --snapshot <name> Create named snapshot on all VMs
# bash tools/vm/snapshot-all.sh --revert-to <name> Revert all VMs to named snapshot
# bash tools/vm/snapshot-all.sh --list List all snapshots per VM
# bash tools/vm/snapshot-all.sh --dry-run --revert-to ... Dry run (no state changes)
#
# SAFETY:
# - Only operates on sc- prefixed domains.
# - Always prints a summary before modifying state.
# - --revert-to requires explicit confirmation (skipped with --yes flag).
# - This script is for developer use only. It is NOT available in-game.
#
# AGENT RULES:
# - Never run --revert-to without explicit user instruction.
# - Never run against domains that don't start with sc-.
set -euo pipefail
VMS=("sc-workstation" "sc-web-server" "sc-build-machine")
DRY_RUN=false
ASSUME_YES=false
SNAPSHOT_NAME=""
REVERT_NAME=""
LIST_MODE=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--snapshot) SNAPSHOT_NAME="$2"; shift 2 ;;
--revert-to) REVERT_NAME="$2"; shift 2 ;;
--list) LIST_MODE=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
--yes) ASSUME_YES=true; shift ;;
*) echo "Unknown argument: $1"; exit 1 ;;
esac
done
run() {
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY-RUN] $*"
else
"$@"
fi
}
guard_prefix() {
local dom="$1"
if [[ "$dom" != sc-* ]]; then
echo "SAFETY: refusing to operate on non-game domain: $dom"
exit 1
fi
}
# ---------------------------------------------------------------------------
if [ "$LIST_MODE" = "true" ]; then
echo ""
echo "── Snapshots per VM ─────────────────────────────"
for dom in "${VMS[@]}"; do
echo ""
echo " $dom:"
if virsh dominfo "$dom" &>/dev/null 2>&1; then
virsh snapshot-list "$dom" --name 2>/dev/null | sed 's/^/ /' || echo " (none)"
else
echo " (domain does not exist)"
fi
done
echo ""
exit 0
fi
# ---------------------------------------------------------------------------
# SNAPSHOT
# ---------------------------------------------------------------------------
if [ -n "$SNAPSHOT_NAME" ]; then
echo ""
echo "Creating snapshot '$SNAPSHOT_NAME' on all game VMs..."
[ "$DRY_RUN" = "true" ] && echo "[DRY-RUN mode]"
echo ""
for dom in "${VMS[@]}"; do
guard_prefix "$dom"
if virsh dominfo "$dom" &>/dev/null 2>&1; then
echo " Snapshotting $dom..."
run virsh snapshot-create-as "$dom" "$SNAPSHOT_NAME" \
--description "Created by snapshot-all.sh" \
--atomic
echo "$dom$SNAPSHOT_NAME"
else
echo "$dom not found — skipping"
fi
done
echo ""
echo "Done."
exit 0
fi
# ---------------------------------------------------------------------------
# REVERT
# ---------------------------------------------------------------------------
if [ -n "$REVERT_NAME" ]; then
echo ""
echo "══════════════════════════════════════════════════"
echo " REVERT ALL VMs TO: $REVERT_NAME"
echo "══════════════════════════════════════════════════"
echo " VMs: ${VMS[*]}"
echo " This will DISCARD all unsaved VM state."
[ "$DRY_RUN" = "true" ] && echo " [DRY-RUN mode — no changes will be made]"
echo ""
if [ "$ASSUME_YES" = "false" ] && [ "$DRY_RUN" = "false" ]; then
read -rp " Type YES to confirm revert: " confirm
if [ "$confirm" != "YES" ]; then
echo " Aborted."
exit 0
fi
fi
for dom in "${VMS[@]}"; do
guard_prefix "$dom"
if virsh dominfo "$dom" &>/dev/null 2>&1; then
echo " Reverting $dom..."
# Stop VM first if running
if virsh domstate "$dom" 2>/dev/null | grep -q "running"; then
run virsh destroy "$dom"
fi
run virsh snapshot-revert "$dom" "$REVERT_NAME" --running
echo "$dom$REVERT_NAME"
else
echo "$dom not found — skipping"
fi
done
echo ""
echo "Revert complete."
exit 0
fi
# No mode selected
echo "Usage:"
echo " bash snapshot-all.sh --snapshot <name>"
echo " bash snapshot-all.sh --revert-to <name>"
echo " bash snapshot-all.sh --list"
echo " Add --dry-run to preview without changes."
exit 1
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# suppress-maintenance-noise.sh — Reduce guest maintenance output noise.
#
# Suppresses on Debian/Ubuntu guests:
# - APT periodic background updates
# - MOTD dynamic scripts (package counts, landscape-sysinfo, news)
# - PAM motd modules (dynamic MOTD printed at login)
# - "X updates can be applied immediately" login banner
#
# Suppresses on Arch guests:
# - pkgfile update timer (if present)
# - quiet-mode marker for game to detect
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DRY_RUN=false
DOMAIN="${1:-}"
if [ -z "$DOMAIN" ]; then
echo "Usage: bash tools/vm/suppress-maintenance-noise.sh <domain> [--dry-run]"
exit 1
fi
if [[ "${2:-}" == "--dry-run" ]]; then
DRY_RUN=true
fi
source "$SCRIPT_DIR/lib/common.sh"
ensure_vm_tooling
tmp_script="$(mktemp)"
cat > "$tmp_script" <<'EOF'
# --- Debian/Ubuntu ---
if command -v apt-get >/dev/null 2>&1; then
# Disable APT background periodic tasks
mkdir -p /etc/apt/apt.conf.d
cat > /etc/apt/apt.conf.d/99sysadmin-chronicles-quiet <<'APT'
APT::Periodic::Enable "0";
APT::Periodic::Update-Package-Lists "0";
APT::Periodic::Unattended-Upgrade "0";
APT::Periodic::Download-Upgradeable-Packages "0";
Acquire::Languages "none";
APT
# Disable dynamic MOTD scripts that show update counts, ads, news
if [ -d /etc/update-motd.d ]; then
chmod -x /etc/update-motd.d/* 2>/dev/null || true
# Preserve a minimal placeholder so PAM doesn't error
printf '#!/bin/sh\n' > /etc/update-motd.d/00-sysadmin-chronicles
chmod +x /etc/update-motd.d/00-sysadmin-chronicles
fi
# Remove static /etc/motd content if present
if [ -f /etc/motd ]; then
printf '' > /etc/motd
fi
# Disable PAM dynamic motd in sshd PAM config (suppresses update counts at login)
for pam_file in /etc/pam.d/sshd /etc/pam.d/login; do
if [ -f "$pam_file" ]; then
sed -i 's/^session\s\+optional\s\+pam_motd\.so/#&/' "$pam_file"
fi
done
# Suppress "X updates can be applied" from landscape-sysinfo / update-notifier
if [ -f /etc/landscape/client.conf ]; then
sed -i '/sysinfo/d' /etc/landscape/client.conf 2>/dev/null || true
fi
# Disable landscape-sysinfo if installed
if command -v landscape-sysinfo >/dev/null 2>&1; then
if [ -f /etc/landscape/client.conf ]; then
grep -q 'include_sysinfo_plugins' /etc/landscape/client.conf || \
printf '[sysinfo]\ninclude_sysinfo_plugins =\n' >> /etc/landscape/client.conf
else
mkdir -p /etc/landscape
printf '[sysinfo]\ninclude_sysinfo_plugins =\n' > /etc/landscape/client.conf
fi
fi
# Disable update-notifier login hint (Debian/Ubuntu)
if [ -d /etc/profile.d ]; then
for f in /etc/profile.d/update-notifier.sh /etc/profile.d/motd-news.sh; do
[ -f "$f" ] && chmod -x "$f" 2>/dev/null || true
done
fi
fi
# --- Arch Linux ---
if command -v pacman >/dev/null 2>&1; then
# Disable pkgfile update timer if present (produces periodic output)
if systemctl list-unit-files pkgfile-update.timer &>/dev/null; then
systemctl disable --now pkgfile-update.timer 2>/dev/null || true
fi
# Quiet-mode marker for game to detect
mkdir -p /etc/sysadmin-chronicles
printf 'managed=true\n' > /etc/sysadmin-chronicles/quiet-mode.conf
fi
EOF
info "Suppressing maintenance noise on ${DOMAIN}"
guest_run_sudo_script "$DOMAIN" "$tmp_script"
rm -f "$tmp_script"
ok "${DOMAIN}: maintenance noise suppressed"