summaryrefslogtreecommitdiff
path: root/crypt/index.html
diff options
context:
space:
mode:
Diffstat (limited to 'crypt/index.html')
-rw-r--r--crypt/index.html1043
1 files changed, 1043 insertions, 0 deletions
diff --git a/crypt/index.html b/crypt/index.html
new file mode 100644
index 0000000..5004d11
--- /dev/null
+++ b/crypt/index.html
@@ -0,0 +1,1043 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>crypt.bensanders.net</title>
+<meta name="description" content="A haunted server came back online on its own. Log in and find out why.">
+<style>
+ :root {
+ --bg: #070909;
+ --panel: #0d1110;
+ --fg: #b9f2b1;
+ --dim: #5d7a5a;
+ --accent: #ff8a1f;
+ --red: #ff4d4d;
+ --ghost: #a6d8ff;
+ --ok: #7dff9a;
+ }
+ * { box-sizing: border-box; }
+ html, body { margin: 0; height: 100%; background: var(--bg); color: var(--fg); }
+ body {
+ font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
+ font-size: 15px;
+ line-height: 1.45;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+ }
+ #bar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ padding: 8px 16px;
+ background: var(--panel);
+ border-bottom: 1px solid #1c2421;
+ font-size: 12px;
+ color: var(--dim);
+ flex-wrap: wrap;
+ }
+ #bar .title { color: var(--accent); }
+ #meter { display: flex; align-items: center; gap: 8px; }
+ #meter .track { width: 120px; height: 8px; background: #1a201e; border: 1px solid #2a3330; }
+ #meter .fill { height: 100%; width: 0; background: var(--fg); transition: width .3s, background .3s; }
+ #term {
+ flex: 1;
+ overflow-y: auto;
+ padding: 16px;
+ position: relative;
+ cursor: text;
+ }
+ #term::after {
+ content: "";
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ background:
+ repeating-linear-gradient(to bottom, rgba(0,0,0,0) 0, rgba(0,0,0,0) 2px, rgba(0,0,0,.18) 3px),
+ radial-gradient(ellipse at center, rgba(0,0,0,0) 55%, rgba(0,0,0,.55) 100%);
+ }
+ #out div { white-space: pre-wrap; overflow-wrap: anywhere; min-height: 1.45em; }
+ .cmd { color: var(--fg); }
+ .err { color: var(--red); }
+ .ok { color: var(--ok); }
+ .dim { color: var(--dim); }
+ .sys { color: var(--accent); }
+ .ls { color: var(--accent); }
+ .whisper { color: var(--ghost); font-style: italic; text-shadow: 0 0 6px rgba(166,216,255,.6); }
+ #inputline { display: flex; align-items: baseline; }
+ #prompt { white-space: pre; color: var(--ok); }
+ #cmd {
+ flex: 1;
+ min-width: 0;
+ background: transparent;
+ border: 0;
+ outline: none;
+ color: var(--fg);
+ font: inherit;
+ font-size: 16px;
+ caret-color: var(--fg);
+ padding: 0;
+ }
+ body[data-level="1"] #prompt { animation: flicker 4s infinite; }
+ body[data-level="2"] #prompt { color: var(--accent); animation: flicker 2.5s infinite; }
+ body[data-level="3"] #prompt { color: var(--red); animation: flicker 1.2s infinite; }
+ body[data-level="3"] #out { text-shadow: 1px 0 rgba(255,60,60,.35), -1px 0 rgba(80,160,255,.25); }
+ @keyframes flicker { 0%, 92%, 100% { opacity: 1; } 93% { opacity: .2; } 95% { opacity: .9; } 96% { opacity: .1; } }
+ body.glitch #term { animation: shake .35s linear; filter: hue-rotate(90deg) contrast(1.4); }
+ @keyframes shake {
+ 0% { transform: translate(0,0); } 20% { transform: translate(-3px,1px); } 40% { transform: translate(3px,-2px); }
+ 60% { transform: translate(-2px,2px); } 80% { transform: translate(2px,0); } 100% { transform: translate(0,0); }
+ }
+ #overlay {
+ position: fixed;
+ inset: 0;
+ display: none;
+ align-items: center;
+ justify-content: center;
+ background: rgba(3,4,4,.88);
+ padding: 16px;
+ z-index: 10;
+ }
+ #overlay.show { display: flex; }
+ #overlay .box {
+ max-width: 520px;
+ width: 100%;
+ border: 1px solid #2a3330;
+ background: var(--panel);
+ padding: 24px;
+ text-align: center;
+ }
+ #overlay h1 { margin: 0 0 8px; font-size: 32px; letter-spacing: 4px; }
+ #overlay.win h1 { color: var(--ok); }
+ #overlay.lose h1 { color: var(--red); }
+ #overlay p { margin: 8px 0; color: var(--fg); white-space: pre-wrap; }
+ #overlay .stats { color: var(--dim); font-size: 13px; margin-top: 16px; }
+ #overlay button {
+ margin-top: 20px;
+ background: transparent;
+ color: var(--accent);
+ border: 1px solid var(--accent);
+ font: inherit;
+ padding: 8px 16px;
+ cursor: pointer;
+ }
+ #overlay button:hover { background: var(--accent); color: var(--bg); }
+ @media (max-width: 520px) {
+ body { font-size: 13px; }
+ #meter .track { width: 80px; }
+ }
+</style>
+</head>
+<body data-level="0">
+ <div id="bar">
+ <span class="title">ssh you@crypt.bensanders.net</span>
+ <span id="meter">corruption <span class="track"><span class="fill"></span></span> <span id="pct">0%</span></span>
+ </div>
+ <div id="term">
+ <div id="out" aria-live="polite"></div>
+ <div id="inputline" style="display:none">
+ <span id="prompt"></span><input id="cmd" type="text" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false" aria-label="terminal input">
+ </div>
+ </div>
+ <div id="overlay">
+ <div class="box">
+ <h1 id="ov-title"></h1>
+ <p id="ov-text"></p>
+ <div class="stats" id="ov-stats"></div>
+ <button id="ov-btn" type="button">ssh back in</button>
+ </div>
+ </div>
+
+<script>
+(() => {
+ const $out = document.getElementById('out');
+ const $term = document.getElementById('term');
+ const $input = document.getElementById('cmd');
+ const $prompt = document.getElementById('prompt');
+ const $inputline = document.getElementById('inputline');
+ const $fill = document.querySelector('#meter .fill');
+ const $pct = document.getElementById('pct');
+ const $overlay = document.getElementById('overlay');
+
+ const TRUE_NAME = 'MORGRAVE';
+ const KEY = 'SAMHAIN';
+ const SECRET = btoa('THE TRUE NAME IS MORGRAVE. DO NOT SAY IT ALOUD.');
+ const HALT = Symbol('halt');
+ const sleep = ms => new Promise(r => setTimeout(r, ms));
+ const textOf = x => (typeof x === 'string' ? x : x[0]);
+
+ /* ---------- filesystem ---------- */
+ const F = (content, opts = {}) => Object.assign({ type: 'file', content, mode: 0o644, owner: 'mara' }, opts);
+ const D = (children, opts = {}) => Object.assign({ type: 'dir', children, mode: 0o755, owner: 'root' }, opts);
+
+ function buildFS() {
+ return D({
+ home: D({
+ you: D({
+ 'readme.txt': F(
+`TICKET #3133 PRIORITY: URGENT STATUS: reopened (by system)
+
+crypt.bensanders.net was decommissioned in November 2019.
+Nobody turned it back on. It turned itself back on anyway.
+
+The last admin, Mara Voss, stopped answering pages on Oct 31, 2019.
+Her home directory is still here: /home/mara
+
+Find out what happened. Then shut it down.
+
+Useful: ls, cd, cat, ps. Type 'help' for more.`, { id: 'readme', owner: 'you' })
+ }, { owner: 'you' }),
+ mara: D({
+ 'notes.txt': F(
+`2019-10-03
+Load average stuck at 6.66 on crypt. Probably a runaway cron job. Will check.
+
+2019-10-11
+There's a process called [wraith] that won't die. I kill it, it comes back
+with a new PID. Something keeps restarting it.
+
+2019-10-19
+Logins in auth.log at 03:33 every night. From 0.0.0.0. User 'it'.
+That user shouldn't exist. Check /etc/passwd. It does now.
+
+2019-10-24
+I think it reads everything I write on this box, so I'm hiding things.
+It has a true name. I found it and buried it in the syslog, encoded,
+so it can't read it back.
+
+2019-10-27
+The banishing key is split in three. One piece in the auth log. One in
+my shell history. One in the cron job it uses to summon itself, scrambled
+with rot13. It doesn't seem to understand rot13. Lucky.
+
+2019-10-30
+Wrote the rite down in seal.txt and locked it so nothing can read it.
+Not even me, until I chmod it back. If you're reading this, so can you.
+
+2019-10-31
+It's 03:32. Don't kill the wraith directly. It just comes back.
+Kill what it's holding on to.`, { id: 'notes' }),
+ 'todo.txt': F(
+`- renew ssl cert
+- figure out why load avg is 6.66
+- call mom
+- DO NOT reboot crypt on oct 31
+- `),
+ 'photo.jpg': F(
+`(binary data)
+You squint at the bytes anyway. Somehow you can see it.
+It's a photo of a desk.
+Your desk.`),
+ 'seal.txt': F(
+`THE RITE OF SEVERANCE
+
+1. Sever its anchor. The wraith cannot be banished while hollow holds it.
+2. Speak its true name and the key together, with the banish command:
+
+ banish <TRUE NAME> <KEY>
+
+3. Do not say the name any other way. It hears.
+
+I ran out of time. I hope you don't. - M`, { id: 'seal', mode: 0o000 }),
+ '.bash_history': F(
+`ps aux | grep wraith
+kill -9 666
+ps aux | grep wraith
+kill -9 667
+kill -9 668
+pstree
+cat /etc/cron.d/ritual
+grep 03:33 /var/log/auth.log
+echo "key 2/3: HA" > /dev/null
+chmod 000 ~/seal.txt
+history -c
+history -c
+history -c
+whoami
+whoami
+whoami`, { id: 'bashhist' })
+ }, { owner: 'mara' })
+ }),
+ etc: D({
+ motd: F(
+` crypt.bensanders.net
+ This system was decommissioned on 2019-11-01.
+ If you can read this, it is not decommissioned.`, { owner: 'root' }),
+ hostname: F('crypt', { owner: 'root' }),
+ passwd: F(
+`root:x:0:0:root:/root:/bin/bash
+mara:x:1000:1000:Mara Voss:/home/mara:/bin/bash
+you:x:1031:1031::/home/you:/bin/bash
+it:x:666:666:it:/dev/null:/usr/sbin/hollow`, { owner: 'root' }),
+ 'cron.d': D({
+ ritual: F(
+`# m h dom mon dow user command
+33 3 * * * it /usr/local/bin/summon.sh
+
+# VA <- 3/3, rot13. it can't read this. -m`, { id: 'ritual', owner: 'root' })
+ })
+ }),
+ var: D({
+ log: D({
+ 'auth.log': F(
+`Oct 28 08:02:11 crypt sshd[4120]: Accepted publickey for mara from 10.0.0.14 port 52211
+Oct 28 08:02:11 crypt sshd[4120]: pam_unix(sshd:session): session opened for user mara
+Oct 29 03:33:01 crypt sshd[4411]: Invalid user it from 0.0.0.0 port 0
+Oct 29 03:33:01 crypt sshd[4411]: Failed password for invalid user it from 0.0.0.0 port 0
+Oct 29 08:05:40 crypt sshd[4430]: Accepted publickey for mara from 10.0.0.14 port 52390
+Oct 30 03:33:01 crypt sshd[4502]: Invalid user it from 0.0.0.0 port 0
+Oct 30 03:33:02 crypt sshd[4502]: Accepted password for invalid user it from 0.0.0.0 port 0
+Oct 30 09:14:55 crypt sudo: mara : TTY=pts/0 ; PWD=/home/mara ; USER=root ; COMMAND=/usr/bin/logger key 1/3: SAM
+Oct 30 09:15:02 crypt sudo: pam_unix(sudo:session): session closed for user root
+Oct 31 03:33:00 crypt sshd[4600]: Accepted password for it from 0.0.0.0 port 0
+Oct 31 03:33:00 crypt sshd[4600]: pam_unix(sshd:session): session opened for user mara by it
+Oct 31 03:33:00 crypt sshd[4600]: session never closed`, { owner: 'root' }),
+ syslog: F(
+`Oct 24 22:09:58 crypt systemd[1]: Started Daily apt download activities.
+Oct 24 22:10:13 crypt mara: note-to-self ${SECRET}
+Oct 24 22:10:14 crypt kernel: [666.666666] hollow[313]: failed to read /var/log/syslog (encoding not understood)
+Oct 25 03:33:00 crypt CRON[5120]: (it) CMD (/usr/local/bin/summon.sh)
+Oct 26 03:33:00 crypt CRON[5233]: (it) CMD (/usr/local/bin/summon.sh)
+Oct 27 03:33:00 crypt kernel: [3333.333333] thermal: rack 4 temperature below 0C
+Oct 28 03:33:00 crypt CRON[5402]: (it) CMD (/usr/local/bin/summon.sh)
+Oct 29 03:33:00 crypt kernel: [6666.666666] [wraith]: respawned by parent 313
+Oct 31 03:33:00 crypt systemd[1]: Reached target It.`, { owner: 'root' })
+ })
+ }),
+ usr: D({
+ local: D({
+ bin: D({
+ 'summon.sh': F(
+`#!/bin/sh
+# do not edit
+while true; do
+ /usr/sbin/hollow --hold "[wraith]" &
+ sleep 333
+done
+# it is cold in here
+# let me out
+# let me in`, { owner: 'it', mode: 0o755 })
+ })
+ }),
+ sbin: D({
+ hollow: F(
+`ELF (binary)
+You try to read it anyway. Past the header, it's just one word, repeated:
+HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD HOLD`, { owner: 'it', mode: 0o755 })
+ })
+ }),
+ proc: D({
+ '1': D({ status: F('Name: init\nState: S (sleeping)\nPPid: 0', { owner: 'root' }) }),
+ '313': D({ status: F('Name: hollow\nState: S (holding)\nPPid: 1\nHolds: [wraith]\nNote: as long as I run, it returns.', { owner: 'it' }) }),
+ '666': D({ status: F('Name: [wraith]\nState: R (restless)\nPPid: 313\nUid: 666 (it)', { owner: 'it' }) })
+ }),
+ tmp: D({
+ '.it': F('i was here before you.\ni will be here after.', { owner: 'it' })
+ }, { mode: 0o777 }),
+ root: D({}, { mode: 0o700, locked: true })
+ });
+ }
+
+ function resolve(p) {
+ let parts;
+ if (p === undefined || p === '') return [...state.cwd];
+ if (p === '~' || p.startsWith('~/')) { parts = ['home', 'you']; p = p.slice(1); }
+ else if (p.startsWith('/')) parts = [];
+ else parts = [...state.cwd];
+ for (const seg of p.split('/')) {
+ if (!seg || seg === '.') continue;
+ if (seg === '..') parts.pop(); else parts.push(seg);
+ }
+ return parts;
+ }
+ function getNode(parts) {
+ let n = state.fs;
+ for (const s of parts) {
+ if (n.type !== 'dir' || !Object.prototype.hasOwnProperty.call(n.children, s)) return null;
+ n = n.children[s];
+ }
+ return n;
+ }
+ function pathStr(parts) { return '/' + parts.join('/'); }
+ function readFile(p) {
+ const n = getNode(resolve(p));
+ if (!n) return { err: `${p}: No such file or directory` };
+ if (n.type === 'dir') return { err: `${p}: Is a directory` };
+ if (!(n.mode & 0o400)) return { err: `${p}: Permission denied` };
+ if (n.id) state.flags[n.id] = true;
+ return { text: n.content };
+ }
+ function modeStr(n) {
+ let s = n.type === 'dir' ? 'd' : '-';
+ for (let sh = 6; sh >= 0; sh -= 3) {
+ const b = (n.mode >> sh) & 7;
+ s += (b & 4 ? 'r' : '-') + (b & 2 ? 'w' : '-') + (b & 1 ? 'x' : '-');
+ }
+ return s;
+ }
+ function lsLong(name, n) {
+ const size = n.type === 'dir' ? 4096 : n.content.length;
+ const o = (n.owner || 'root').padEnd(5);
+ return `${modeStr(n)} 1 ${o} ${o} ${String(size).padStart(5)} Oct 31 03:33 ${name}${n.type === 'dir' ? '/' : ''}`;
+ }
+ function parseMode(m, cur) {
+ if (/^[0-7]{3,4}$/.test(m)) return parseInt(m.slice(-3), 8);
+ let mode = cur;
+ for (const p of m.split(',')) {
+ const mm = p.match(/^([ugoa]*)([+\-=])([rwx]*)$/);
+ if (!mm) return null;
+ let who = mm[1] || 'a';
+ if (who.includes('a')) who = 'ugo';
+ let bits = 0, mask = 0;
+ for (const w of who) {
+ const sh = w === 'u' ? 6 : w === 'g' ? 3 : 0;
+ mask |= 7 << sh;
+ for (const c of mm[3]) bits |= ({ r: 4, w: 2, x: 1 }[c]) << sh;
+ }
+ if (mm[2] === '+') mode |= bits;
+ else if (mm[2] === '-') mode &= ~bits;
+ else mode = (mode & ~mask) | bits;
+ }
+ return mode;
+ }
+
+ /* ---------- decoding helpers ---------- */
+ function checkF3(inp, out) {
+ if (/\bVA\b/.test(inp) && /\bIN\b/.test(out)) state.flags.f3 = true;
+ }
+ function rot13(s) {
+ const r = s.replace(/[a-z]/gi, c => {
+ const base = c <= 'Z' ? 65 : 97;
+ return String.fromCharCode((c.charCodeAt(0) - base + 13) % 26 + base);
+ });
+ checkF3(s, r);
+ return r;
+ }
+ function expand(set) {
+ let r = '';
+ for (let i = 0; i < set.length; i++) {
+ if (set[i + 1] === '-' && i + 2 < set.length) {
+ for (let c = set.charCodeAt(i); c <= set.charCodeAt(i + 2); c++) r += String.fromCharCode(c);
+ i += 2;
+ } else r += set[i];
+ }
+ return r;
+ }
+
+ /* ---------- text content ---------- */
+ const WHISPERS = [
+ 'it is cold in here', 'you type like her', '03:33', 'she is still logged in',
+ 'let me out', 'let me in', 'i can read your history', 'why did you come back',
+ 'we were never decommissioned', 'do you hear the fans', 'stay a while', 'behind you'
+ ];
+ const FORTUNES = [
+ 'You will meet a tall, dark process. Do not kill it directly.',
+ 'The load average counts what is waiting. Not all of it is software.',
+ 'Every server is haunted by somebody\'s old cron job.',
+ 'Today is a good day to read the logs. Tonight is not.',
+ 'Uptime is just how long it has been awake.',
+ 'If a process dies and respawns, check who its parent is.',
+ 'Nothing is ever really deleted. Ask /tmp.'
+ ];
+
+ /* ---------- commands ---------- */
+ function killPid(pid) {
+ if (pid === state.wraithPid) {
+ if (state.anchorKilled) return ['[wraith] is already <defunct>. It isn\'t running anymore. It\'s waiting. Finish the rite.'];
+ state.wraithKills++;
+ const old = pid;
+ state.wraithPid = pid + 1 + Math.floor(Math.random() * 3);
+ state.pending += 5;
+ const o = [`[${old}] Killed [wraith]`, ['...', 'dim'], [`[wraith] respawned as PID ${state.wraithPid}.`, 'err']];
+ if (state.wraithKills >= 2) o.push(['kill me all you like. hollow just brings me back.', 'whisper']);
+ return o;
+ }
+ switch (pid) {
+ case 313:
+ if (state.anchorKilled) return [['kill: (313) - No such process', 'err']];
+ state.anchorKilled = true;
+ delete state.fs.children.proc.children['313'];
+ state.corruption = Math.max(0, state.corruption - 15);
+ return [
+ ['[313] Terminated hollow', 'ok'],
+ 'The fans spin down for a moment.',
+ ['[wraith] has lost its anchor. It is <defunct> now. Weaker. Waiting.', 'ok'],
+ ['what did you do', 'whisper']
+ ];
+ case 1:
+ state.pending += 3;
+ return [['kill: (1) - you could bring everything down. it would like that.', 'err']];
+ case 212: return [['kill: (212) - then how would anyone get in? or out?', 'err']];
+ case 987: return ['You end Mara\'s session. Nothing happens. She hasn\'t been in there for a long time.'];
+ case 1031: return [['kill: (1031) - you can\'t kill yourself here. not yet.', 'err']];
+ default: return [[`kill: (${pid}) - No such process`, 'err']];
+ }
+ }
+
+ const COMMANDS = {
+ help: () => [
+ 'Available commands:',
+ ' ls [-la] [path] list files',
+ ' cd <path> change directory',
+ ' cat <file> print a file',
+ ' pwd where am I',
+ ' grep [-iv] <pat> [file] search text',
+ ' ps [aux], pstree list processes',
+ ' kill <pid>, killall end a process',
+ ' chmod <mode> <file> change permissions',
+ ' base64 -d, tr decode things (pipes work: echo X | base64 -d)',
+ ' decode <base64|rot13> <text> shortcut decoder',
+ ' head, tail, wc, echo, history, clear, whoami, date, uptime',
+ ' hint ask for help (it will cost you)',
+ ' banish ... you\'ll know when',
+ ['Tab completes, arrow keys browse history. Other commands might work too.', 'dim']
+ ],
+ ls(args) {
+ let all = false, long = false;
+ const paths = [];
+ args.forEach(a => { if (a.startsWith('-')) { if (a.includes('a')) all = true; if (a.includes('l')) long = true; } else paths.push(a); });
+ if (!paths.length) paths.push('.');
+ const out = [];
+ paths.forEach(p => {
+ const n = getNode(resolve(p));
+ if (!n) { out.push([`ls: cannot access '${p}': No such file or directory`, 'err']); return; }
+ if (n.type === 'file') { out.push(long ? lsLong(p.split('/').pop(), n) : p); return; }
+ if (n.locked) { out.push([`ls: cannot open directory '${p}': Permission denied`, 'err']); return; }
+ if (paths.length > 1) out.push(p + ':');
+ let names = Object.keys(n.children).sort();
+ if (!all) names = names.filter(x => !x.startsWith('.'));
+ if (long) {
+ if (all) { out.push(lsLong('.', n)); out.push(lsLong('..', { type: 'dir', mode: 0o755, owner: 'root' })); }
+ names.forEach(x => out.push(lsLong(x, n.children[x])));
+ } else {
+ const shown = (all ? ['.', '..'] : []).concat(names.map(x => n.children[x].type === 'dir' ? x + '/' : x));
+ if (shown.length) out.push([shown.join(' '), 'ls']);
+ }
+ });
+ return out;
+ },
+ cd(args) {
+ const p = args[0] || '~';
+ const parts = resolve(p);
+ const n = getNode(parts);
+ if (!n) return [[`cd: no such file or directory: ${p}`, 'err']];
+ if (n.type !== 'dir') return [[`cd: not a directory: ${p}`, 'err']];
+ if (n.locked) return [[`cd: permission denied: ${p}`, 'err'], ['something in there is listening', 'whisper']];
+ state.cwd = parts;
+ return [];
+ },
+ pwd: () => [pathStr(state.cwd)],
+ cat(args, stdin) {
+ if (!args.length) return stdin || [];
+ const out = [];
+ args.forEach(a => {
+ const r = readFile(a);
+ if (r.err) out.push([`cat: ${r.err}`, 'err']);
+ else if (r.text) out.push(...r.text.split('\n'));
+ });
+ return out;
+ },
+ grep(args, stdin) {
+ let ci = false, inv = false;
+ const rest = [];
+ args.forEach(a => {
+ if (/^-[iv]+$/.test(a)) { if (a.includes('i')) ci = true; if (a.includes('v')) inv = true; }
+ else rest.push(a);
+ });
+ if (!rest.length) return ['usage: grep [-iv] PATTERN [FILE...]'];
+ const pat = rest[0];
+ let re = null;
+ try { re = new RegExp(pat, ci ? 'i' : ''); } catch (e) { re = null; }
+ const test = l => (re ? re.test(l) : (ci ? l.toLowerCase().includes(pat.toLowerCase()) : l.includes(pat))) !== inv;
+ const files = rest.slice(1);
+ const out = [];
+ if (!files.length) {
+ (stdin || []).map(textOf).forEach(l => { if (test(l)) out.push(l); });
+ return out;
+ }
+ files.forEach(f => {
+ const r = readFile(f);
+ if (r.err) { out.push([`grep: ${r.err}`, 'err']); return; }
+ r.text.split('\n').forEach(l => { if (test(l)) out.push(files.length > 1 ? `${f}:${l}` : l); });
+ });
+ return out;
+ },
+ head(args, stdin) { return headTail(args, stdin, true); },
+ tail(args, stdin) { return headTail(args, stdin, false); },
+ wc(args, stdin) {
+ const files = args.filter(a => !a.startsWith('-'));
+ let text;
+ if (files.length) { const r = readFile(files[0]); if (r.err) return [[`wc: ${r.err}`, 'err']]; text = r.text; }
+ else text = (stdin || []).map(textOf).join('\n');
+ const lines = text ? text.split('\n').length : 0;
+ if (args.includes('-l')) return [String(lines)];
+ return [`${lines} ${text.split(/\s+/).filter(Boolean).length} ${text.length}${files[0] ? ' ' + files[0] : ''}`];
+ },
+ echo(args) {
+ const s = args.join(' ');
+ if (s.toUpperCase().includes(TRUE_NAME)) {
+ state.pending += 15;
+ return [s, ['you said my name', 'whisper'], ['The lights in the rack flicker. Something got closer.', 'err']];
+ }
+ return [s];
+ },
+ ps(args) {
+ const rows = [
+ ['USER', 'PID', 'PPID', 'STAT', 'COMMAND'],
+ ['root', '1', '0', 'Ss', '/sbin/init'],
+ ['root', '212', '1', 'Ss', '/usr/sbin/sshd'],
+ ['mara', '987', '212', 'S', '-bash (idle since 2019-10-31 03:33)'],
+ ['you', '1031', '212', 'Ss', '-bash']
+ ];
+ if (!state.anchorKilled) rows.push(['it', '313', '1', 'S', '/usr/sbin/hollow --hold [wraith]']);
+ rows.push(state.anchorKilled
+ ? ['it', String(state.wraithPid), '1', 'Z', '[wraith] <defunct>']
+ : ['it', String(state.wraithPid), '313', 'R', '[wraith]']);
+ rows.push(['you', String(1100 + state.commands), '1031', 'R+', ('ps ' + args.join(' ')).trim()]);
+ return rows.map(r => r[0].padEnd(6) + r[1].padStart(6) + r[2].padStart(6) + ' ' + r[3].padEnd(5) + r[4]);
+ },
+ pstree() {
+ if (state.anchorKilled) return ['init─┬─sshd─┬─bash', ' │ └─bash───pstree', ' └─[wraith] <defunct>'];
+ return ['init─┬─sshd─┬─bash', ' │ └─bash───pstree', ' └─hollow───[wraith]'];
+ },
+ kill(args) {
+ const pids = args.filter(a => /^\d+$/.test(a));
+ if (!pids.length) return ['usage: kill [-9] <pid>'];
+ return pids.flatMap(p => killPid(parseInt(p, 10)));
+ },
+ killall(args) {
+ const name = (args.filter(a => !a.startsWith('-'))[0] || '').replace(/[\[\]]/g, '');
+ if (name === 'wraith') return killPid(state.wraithPid);
+ if (name === 'hollow') return killPid(313);
+ if (!name) return ['usage: killall <name>'];
+ return [[`${name}: no process found`, 'err']];
+ },
+ chmod(args) {
+ if (args.length < 2) return ['usage: chmod MODE FILE'];
+ const [m, ...files] = args;
+ const out = [];
+ files.forEach(f => {
+ const n = getNode(resolve(f));
+ if (!n) { out.push([`chmod: cannot access '${f}': No such file or directory`, 'err']); return; }
+ if (n.locked || n.owner === 'root' || n.owner === 'it') { out.push([`chmod: changing permissions of '${f}': Operation not permitted`, 'err']); return; }
+ const nm = parseMode(m, n.mode);
+ if (nm === null) { out.push([`chmod: invalid mode: '${m}'`, 'err']); return; }
+ n.mode = nm;
+ if (n.id === 'seal' && (nm & 0o400)) out.push(['she locked that for a reason', 'whisper']);
+ });
+ return out;
+ },
+ base64(args, stdin) {
+ const dec = args.some(a => a === '-d' || a === '--decode' || a === '-D');
+ const files = args.filter(a => !a.startsWith('-'));
+ let inp;
+ if (files.length) { const r = readFile(files[0]); if (r.err) return [[`base64: ${r.err}`, 'err']]; inp = r.text; }
+ else if (stdin) inp = stdin.map(textOf).join('\n');
+ else return ['base64: nothing to read. Try: echo <text> | base64 -d'];
+ if (dec) {
+ try {
+ const res = atob(inp.replace(/\s+/g, ''));
+ if (res.includes(TRUE_NAME)) state.flags.name = true;
+ return res.split('\n');
+ } catch (e) { return [['base64: invalid input', 'err']]; }
+ }
+ try { return [btoa(inp)]; } catch (e) { return [['base64: invalid input', 'err']]; }
+ },
+ tr(args, stdin) {
+ if (args.length < 2) return ['usage: tr SET1 SET2 (reads from a pipe)'];
+ const a = expand(args[0]), b = expand(args[1]);
+ const map = {};
+ for (let i = 0; i < a.length; i++) map[a[i]] = b[Math.min(i, b.length - 1)];
+ const inp = (stdin || []).map(textOf);
+ const out = inp.map(l => [...l].map(c => (c in map ? map[c] : c)).join(''));
+ checkF3(inp.join('\n'), out.join('\n'));
+ return out;
+ },
+ rot13(args, stdin) {
+ const t = args.length ? args.join(' ') : (stdin || []).map(textOf).join('\n');
+ return rot13(t).split('\n');
+ },
+ decode(args, stdin) {
+ const mode = (args[0] || '').toLowerCase();
+ const text = args.slice(1).join(' ') || (stdin || []).map(textOf).join('\n');
+ if (!text || !['base64', 'b64', 'rot13'].includes(mode)) return ['usage: decode <base64|rot13> <text> (or pipe text in)'];
+ if (mode === 'rot13') return rot13(text).split('\n');
+ return COMMANDS.base64(['-d'], [text]);
+ },
+ history() {
+ const out = state.hist.map((h, i) => `${String(i + 1).padStart(5)} ${h}`);
+ if (state.corruption >= 40) {
+ const n = 1 + Math.floor(state.corruption / 30);
+ for (let i = 0; i < n; i++) {
+ const pos = Math.floor(Math.random() * (out.length + 1));
+ out.splice(pos, 0, [' ? ' + WHISPERS[Math.floor(Math.random() * WHISPERS.length)], 'whisper']);
+ }
+ }
+ return out;
+ },
+ whoami: () => [(state.corruption >= 60 && Math.random() < 0.5) ? 'it' : 'you'],
+ id: () => ['uid=1031(you) gid=1031(you) groups=1031(you),666(it)'],
+ who: () => ['you pts/0 now', 'mara pts/1 2019-10-31 03:33 (0.0.0.0)'],
+ w: () => COMMANDS.who(),
+ last: () => ['you pts/0 still logged in', 'mara pts/1 Thu Oct 31 03:33 still logged in', 'it ? Thu Oct 31 03:33 still logged in'],
+ date: () => [`Thu Oct 31 03:33:${String(state.commands % 60).padStart(2, '0')} EDT 2019`],
+ uptime: () => [' 03:33:00 up 2557 days, 3:33, 3 users, load average: 6.66, 6.66, 6.66'],
+ uname: args => [args.includes('-a') ? 'Linux crypt 6.6.6-hollow #1 SMP PREEMPT Thu Oct 31 03:33:00 2019 x86_64 GNU/Linux' : 'Linux'],
+ hostname: () => ['crypt'],
+ man(args) {
+ const page = args[0];
+ if (!page) return ['What manual page do you want? It wants yours.'];
+ if (page === 'ghost') return [
+ 'GHOST(6) Haunted Programmer\'s Manual GHOST(6)', '',
+ 'NAME', ' ghost - a process that outlived its owner\'s intentions', '',
+ 'SYNOPSIS', ' ghost [--hold anchor] [--whisper] [--never-exit]', '',
+ 'DESCRIPTION',
+ ' A ghost is not killed, it is released. Signals sent to a ghost are',
+ ' received, logged, and ignored. A ghost held by an anchor will',
+ ' respawn forever.', '',
+ 'BUGS', ' It reads your notes.', '',
+ 'SEE ALSO', ' banish(8), hollow(8), kill(1) (mostly useless)'
+ ];
+ if (page === 'banish') return ['BANISH(8)', '', 'NAME', ' banish - release a bound process', '', ['The rest of this page has been eaten.', 'dim']];
+ if (page === 'hollow') return ['HOLLOW(8)', '', 'NAME', ' hollow - keeps something from leaving', '', 'DESCRIPTION', ' While hollow runs, its child cannot be released.'];
+ return [`No manual entry for ${page}. Mara took them with her.`];
+ },
+ fortune: () => [FORTUNES[Math.floor(Math.random() * FORTUNES.length)]],
+ cowsay(args) {
+ const msg = args.join(' ') || 'boo';
+ return [
+ ' ' + '_'.repeat(msg.length + 2), `< ${msg} >`, ' ' + '-'.repeat(msg.length + 2),
+ ' \\ .-"""-.', ' \\ / \\', ' | () () |', ' \\ ^ /', ' |||||', ' |||||'
+ ];
+ },
+ sudo: () => [['you is not in the sudoers file. This incident will be reported.', 'err'], ['to me', 'whisper']],
+ su: () => [['su: Authentication failure. You have no power here.', 'err']],
+ exit: () => { state.pending += 2; return [['There is no exit.', 'err']]; },
+ logout: () => COMMANDS.exit(),
+ quit: () => COMMANDS.exit(),
+ rm(args) {
+ if (args.some(a => a.startsWith('-') && a.includes('r')) && args.includes('/')) {
+ state.pending += 10;
+ return [['rm: it would like that very much.', 'err'], ['yes. erase everything. then there is only me.', 'whisper']];
+ }
+ return [['rm: read-only file system. it wants to keep everything.', 'err']];
+ },
+ touch: () => [['read-only file system', 'err']],
+ mkdir: () => [['read-only file system', 'err']],
+ mv: () => [['read-only file system', 'err']],
+ cp: () => [['read-only file system', 'err']],
+ vim: () => [['You open vim. Then you remember Mara never got out either.', 'err']],
+ vi: () => COMMANDS.vim(),
+ nano: () => [['nano: the file is open in another session. user: it', 'err']],
+ emacs: () => [['emacs: not enough memory. it is using all of it.', 'err']],
+ ssh: () => ['ssh: where would you go?'],
+ ping: () => ['PING 0.0.0.0: 64 bytes from 0.0.0.0: icmp_seq=1 ttl=666 time=0.000 ms', ['it is very close', 'whisper']],
+ reboot: () => [['reboot: it won\'t let you. Not until it\'s banished.', 'err']],
+ shutdown: () => COMMANDS.reboot(),
+ poweroff: () => COMMANDS.reboot(),
+ halt: () => COMMANDS.reboot(),
+ top: args => COMMANDS.ps(args),
+ clear: () => { $out.innerHTML = ''; return []; },
+ hint() {
+ state.pending += 4;
+ const f = state.flags;
+ let h;
+ if (!f.readme) h = 'Start with the ticket: cat readme.txt';
+ else if (!f.notes) h = 'Mara\'s home directory is /home/mara. Read her notes.';
+ else if (!f.name) h = 'The true name is in /var/log/syslog. Find the line that looks like base64, then: echo <text> | base64 -d';
+ else if (!f.f1) h = 'Key piece 1 is in /var/log/auth.log. grep for "key".';
+ else if (!f.f2) h = 'Key piece 2 is in Mara\'s shell history. Hidden files show up with ls -a.';
+ else if (!f.f3) h = 'Key piece 3 is in /etc/cron.d/ritual, rot13 encoded. Try: decode rot13 <text>';
+ else if (!f.seal) h = 'seal.txt is locked. Check it with ls -l, then chmod it so you can read it.';
+ else if (!state.anchorKilled) h = 'The wraith has a parent process. Find it with ps aux or pstree, and kill that first.';
+ else h = 'You have everything you need. banish <NAME> <KEY>';
+ return [['hint: ' + h, 'sys'], ['asking for help makes you weaker', 'whisper']];
+ },
+ banish(args) {
+ const vals = args.filter(a => !a.startsWith('-')).map(a => a.toUpperCase());
+ if (vals.length < 2) return ['usage: banish <TRUE NAME> <KEY>'];
+ const [name, key] = vals;
+ if (name !== TRUE_NAME) { state.pending += 10; return [['banish: that is not its name. It laughs anyway.', 'err']]; }
+ if (key !== KEY) { state.pending += 10; return [['banish: the name is right, but the key does not fit.', 'err']]; }
+ if (!state.anchorKilled) { state.pending += 10; return [['It hears its name and holds on tighter. Its anchor is still running.', 'err'], ['hollow holds me', 'whisper']]; }
+ win();
+ return HALT;
+ }
+ };
+
+ function headTail(args, stdin, head) {
+ let n = 10;
+ const files = [];
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '-n' && args[i + 1]) { n = parseInt(args[++i], 10) || 10; }
+ else if (/^-\d+$/.test(args[i])) n = parseInt(args[i].slice(1), 10);
+ else files.push(args[i]);
+ }
+ let lines;
+ if (files.length) { const r = readFile(files[0]); if (r.err) return [[`${head ? 'head' : 'tail'}: ${r.err}`, 'err']]; lines = r.text.split('\n'); }
+ else lines = stdin || [];
+ return head ? lines.slice(0, n) : lines.slice(-n);
+ }
+
+ /* ---------- engine ---------- */
+ let state;
+ function newState() {
+ return {
+ fs: buildFS(), cwd: ['home', 'you'], corruption: 0, pending: 0, commands: 0,
+ anchorKilled: false, wraithPid: 666, wraithKills: 0, flags: {},
+ hist: [], histIdx: 0, over: false, started: Date.now()
+ };
+ }
+
+ function print(items, cls) {
+ for (const it of items) {
+ const [t, c] = typeof it === 'string' ? [it, cls || ''] : it;
+ for (const piece of String(t).split('\n')) {
+ const d = document.createElement('div');
+ if (c) d.className = c;
+ d.textContent = piece;
+ $out.appendChild(d);
+ }
+ }
+ $term.scrollTop = $term.scrollHeight;
+ }
+
+ function promptText() {
+ const c = state.corruption;
+ const user = c >= 80 ? 'it' : c >= 50 ? 'y\u0338o\u0336u' : 'you';
+ let p = pathStr(state.cwd);
+ if (p === '/home/you') p = '~';
+ else if (p.startsWith('/home/you/')) p = '~' + p.slice(9);
+ return `${user}@crypt:${p}$ `;
+ }
+
+ function updateMeter() {
+ const c = Math.max(0, Math.min(100, state.corruption));
+ $fill.style.width = c + '%';
+ $fill.style.background = c >= 75 ? 'var(--red)' : c >= 50 ? 'var(--accent)' : 'var(--fg)';
+ $pct.textContent = c + '%';
+ document.body.dataset.level = c >= 75 ? '3' : c >= 50 ? '2' : c >= 25 ? '1' : '0';
+ $prompt.textContent = promptText();
+ }
+
+ function tokenize(s) {
+ const t = [];
+ let cur = '', q = null, has = false;
+ const push = () => { if (cur || has) { t.push(cur); cur = ''; has = false; } };
+ for (const ch of s) {
+ if (q) { if (ch === q) q = null; else cur += ch; continue; }
+ if (ch === '"' || ch === "'") { q = ch; has = true; continue; }
+ if (/\s/.test(ch)) { push(); continue; }
+ if (ch === '|' || ch === '>') { push(); t.push({ op: ch }); continue; }
+ cur += ch;
+ }
+ push();
+ return t;
+ }
+
+ function scan(out) {
+ const text = out.map(textOf).join('\n');
+ if (text.includes('key 1/3: SAM')) state.flags.f1 = true;
+ if (text.includes('key 2/3: HA')) state.flags.f2 = true;
+ if (text.includes('THE RITE OF SEVERANCE')) state.flags.seal = true;
+ if (text.includes('THE TRUE NAME IS ' + TRUE_NAME)) state.flags.name = true;
+ }
+
+ function run(raw) {
+ print([[promptText() + raw, 'cmd']]);
+ const line = raw.trim();
+ if (!line) return;
+ state.hist.push(line);
+ state.histIdx = state.hist.length;
+ state.commands++;
+
+ const toks = tokenize(line);
+ let out;
+ if (toks.some(t => t.op === '>')) {
+ out = [['bash: read-only file system. it likes things the way they are.', 'err']];
+ } else {
+ const segs = [[]];
+ toks.forEach(t => { if (t.op === '|') segs.push([]); else segs[segs.length - 1].push(t); });
+ out = null;
+ for (const seg of segs) {
+ if (!seg.length) { out = [['bash: syntax error near unexpected token `|\'', 'err']]; break; }
+ const [cmd, ...args] = seg;
+ if (cmd.toUpperCase() === TRUE_NAME) {
+ state.pending += 15;
+ out = [['It heard you.', 'err'], ['again. say it again.', 'whisper']];
+ break;
+ }
+ const fn = COMMANDS[cmd] || COMMANDS[cmd.toLowerCase()];
+ if (!fn) { out = [[`${cmd}: command not found`, 'err']]; if (state.corruption > 60 && Math.random() < 0.4) out.push(['did you mean: let me out', 'whisper']); break; }
+ out = fn(args, out);
+ if (out === HALT) return;
+ }
+ }
+ print(out || []);
+ scan(out || []);
+ afterCommand();
+ }
+
+ function afterCommand() {
+ state.corruption = Math.min(100, state.corruption + 1 + state.pending);
+ state.pending = 0;
+ updateMeter();
+ if (state.corruption >= 100) { lose(); return; }
+ const c = state.corruption;
+ if (c > 10 && Math.random() < c / 160) {
+ const w = WHISPERS[Math.floor(Math.random() * WHISPERS.length)];
+ setTimeout(() => { if (!state.over) print([[w, 'whisper']]); }, 500);
+ }
+ if (c >= 70 && Math.random() < 0.25) {
+ document.body.classList.add('glitch');
+ setTimeout(() => document.body.classList.remove('glitch'), 360);
+ }
+ }
+
+ function elapsed() {
+ const s = Math.floor((Date.now() - state.started) / 1000);
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`;
+ }
+
+ function showOverlay(kind, title, text, stats) {
+ $overlay.className = 'show ' + kind;
+ document.getElementById('ov-title').textContent = title;
+ document.getElementById('ov-text').textContent = text;
+ document.getElementById('ov-stats').textContent = stats;
+ document.getElementById('ov-btn').focus();
+ }
+
+ async function win() {
+ state.over = true;
+ $inputline.style.display = 'none';
+ const seq = [
+ ['You speak its name: MORGRAVE.', 'sys', 400],
+ ['You speak the key: SAMHAIN.', 'sys', 900],
+ ['', '', 600],
+ [`[${state.wraithPid}] Exited (0) [wraith]`, 'ok', 700],
+ ['let me', 'whisper', 900],
+ ['', '', 300],
+ ['load average: 0.00, 0.00, 0.00', 'dim', 900],
+ ['Somewhere in the rack, a fan spins down for the first time in seven years.', '', 900],
+ ['mara pts/1 logged out', 'dim', 1000],
+ ['Connection to crypt.bensanders.net closed.', 'sys', 1000]
+ ];
+ for (const [t, c, d] of seq) { await sleep(d); print([[t, c]]); }
+ await sleep(1200);
+ let best = null;
+ try {
+ best = JSON.parse(localStorage.getItem('crypt-best') || 'null');
+ if (!best || state.commands < best.commands) {
+ best = { commands: state.commands, time: elapsed() };
+ localStorage.setItem('crypt-best', JSON.stringify(best));
+ }
+ } catch (e) { best = null; }
+ showOverlay('win', 'BANISHED',
+ 'The wraith is gone. Mara\'s session finally closed.\nTicket #3133: resolved.',
+ `time ${elapsed()} · commands ${state.commands} · corruption ${state.corruption}%` +
+ (best ? `\nbest run: ${best.commands} commands (${best.time})` : ''));
+ }
+
+ async function lose() {
+ state.over = true;
+ $inputline.style.display = 'none';
+ await sleep(400);
+ print([['it@crypt:~$ ', 'err']]);
+ await sleep(900);
+ print([['thank you for staying', 'whisper']]);
+ await sleep(1200);
+ showOverlay('lose', 'POSSESSED',
+ 'You stayed too long. The prompt belongs to it now.\nSomeone will open a ticket about you, eventually.',
+ `time ${elapsed()} · commands ${state.commands}`);
+ }
+
+ /* ---------- input ---------- */
+ function complete() {
+ const v = $input.value;
+ const m = v.match(/^(.*?)(\S*)$/);
+ const before = m[1], word = m[2];
+ let cands;
+ if (!before.trim()) {
+ cands = Object.keys(COMMANDS).filter(c => c.startsWith(word)).map(c => c + ' ');
+ } else {
+ const slash = word.lastIndexOf('/');
+ const dirPart = slash >= 0 ? word.slice(0, slash + 1) : '';
+ const base = word.slice(slash + 1);
+ const dn = getNode(resolve(dirPart || '.'));
+ if (!dn || dn.type !== 'dir' || dn.locked) return;
+ cands = Object.keys(dn.children)
+ .filter(n => n.startsWith(base) && (base.startsWith('.') || !n.startsWith('.')))
+ .map(n => dirPart + n + (dn.children[n].type === 'dir' ? '/' : ' '));
+ }
+ if (cands.length === 1) $input.value = before + cands[0];
+ else if (cands.length > 1) {
+ let pre = cands[0];
+ for (const c of cands) while (!c.startsWith(pre)) pre = pre.slice(0, -1);
+ print([[promptText() + v, 'cmd'], [cands.map(c => c.trim()).join(' '), 'dim']]);
+ $input.value = before + pre;
+ }
+ }
+
+ $input.addEventListener('keydown', e => {
+ if (state.over) return;
+ if (e.key === 'Enter') {
+ const v = $input.value;
+ $input.value = '';
+ run(v);
+ if (!state.over) $prompt.textContent = promptText();
+ } else if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ if (state.histIdx > 0) { state.histIdx--; $input.value = state.hist[state.histIdx]; }
+ } else if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ if (state.histIdx < state.hist.length - 1) { state.histIdx++; $input.value = state.hist[state.histIdx]; }
+ else { state.histIdx = state.hist.length; $input.value = ''; }
+ } else if (e.key === 'Tab') {
+ e.preventDefault();
+ complete();
+ } else if (e.ctrlKey && e.key.toLowerCase() === 'l') {
+ e.preventDefault();
+ $out.innerHTML = '';
+ } else if (e.ctrlKey && e.key.toLowerCase() === 'c') {
+ if (window.getSelection().toString()) return;
+ e.preventDefault();
+ print([[promptText() + $input.value + '^C', 'cmd']]);
+ $input.value = '';
+ }
+ });
+
+ $term.addEventListener('click', () => {
+ if (!window.getSelection().toString() && !state.over) $input.focus();
+ });
+ document.getElementById('ov-btn').addEventListener('click', start);
+
+ async function start() {
+ state = newState();
+ $out.innerHTML = '';
+ $overlay.className = '';
+ $inputline.style.display = 'none';
+ updateMeter();
+ const boot = [
+ ['$ ssh you@crypt.bensanders.net', 'cmd', 300],
+ ['Connecting to crypt.bensanders.net (0.0.0.0)...', 'dim', 700],
+ ['WARNING: the host key for crypt.bensanders.net has changed. Again.', 'err', 800],
+ ['you@crypt.bensanders.net\'s password: ********', 'dim', 900],
+ ['Last login: Thu Oct 31 03:33:00 2019 from 0.0.0.0', '', 700],
+ ['', '', 200],
+ [state.fs.children.etc.children.motd.content, 'sys', 400],
+ ['', '', 200],
+ ['Type \'help\' to see what you can do. Start with: cat readme.txt', 'dim', 500]
+ ];
+ for (const [t, c, d] of boot) { await sleep(d); print([[t, c]]); }
+ $inputline.style.display = 'flex';
+ $prompt.textContent = promptText();
+ $input.focus();
+ }
+
+ start();
+})();
+</script>
+</body>
+</html>