+---
+import Layout from '../layouts/Layout.astro';
+---
+
-
-
+
diff --git a/coc.html b/src/pages/coc.astro
similarity index 80%
rename from coc.html
rename to src/pages/coc.astro
index ab14dfd..5a81481 100644
--- a/coc.html
+++ b/src/pages/coc.astro
@@ -1,17 +1,12 @@
-
-
-
-
-
-Code of Conduct - Khaos im Kiez
-
-
-
-
-
-
-
-
visitor@khaos-im-kiez:~$ cat CODE_OF_CONDUCT
+---
+import Layout from '../layouts/Layout.astro';
+---
+
-
-
+
diff --git a/en.html b/src/pages/en.astro
similarity index 86%
rename from en.html
rename to src/pages/en.astro
index 3f866a6..02fa73d 100644
--- a/en.html
+++ b/src/pages/en.astro
@@ -1,18 +1,13 @@
-
-
-
-
-
-Khaos im Kiez
-
-
-
-
-
-
-
-
-
visitor@khaos-im-kiez:~$ cat README.en
+---
+import Layout from '../layouts/Layout.astro';
+---
+
@@ -98,9 +93,6 @@
-
-
-
+
diff --git a/index.html b/src/pages/index.astro
similarity index 56%
rename from index.html
rename to src/pages/index.astro
index 1aeb855..8eea88e 100644
--- a/index.html
+++ b/src/pages/index.astro
@@ -1,18 +1,13 @@
-
-
-
-
-
-Khaos im Kiez
-
-
-
-
-
-
-
-
-
visitor@khaos-im-kiez:~$ cat README
+---
+import Layout from '../layouts/Layout.astro';
+---
+
@@ -102,117 +97,6 @@
Impressum ·
CCC Berlin
-
visitor@khaos-im-kiez:~$
-
-
-
-
+
diff --git a/style.css b/src/styles/style.css
similarity index 100%
rename from style.css
rename to src/styles/style.css
From a36da4af6f39f6845dacd193bab957cc141b3737 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sat, 24 Oct 2026 22:37:54 +0200
Subject: [PATCH 02/39] Add root ssh key
---
public/root_key | 8 ++++++++
1 file changed, 8 insertions(+)
create mode 100644 public/root_key
diff --git a/public/root_key b/public/root_key
new file mode 100644
index 0000000..d9952ae
--- /dev/null
+++ b/public/root_key
@@ -0,0 +1,8 @@
+-----BEGIN OPENSSH PRIVATE KEY-----
+b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
+QyNTUxOQAAACCQ8HhB/G4WQB1WgHnYU1Q29o/6DBJ975ve8QsAsPSwvQAAAKjlKiEr5Soh
+KwAAAAtzc2gtZWQyNTUxOQAAACCQ8HhB/G4WQB1WgHnYU1Q29o/6DBJ975ve8QsAsPSwvQ
+AAAEA8lgTuE+19YARukm7eq2FZ1PvUbE+owyOAEO5IbCnvnJDweEH8bhZAHVaAedhTVDb2
+j/oMEn3vm97xCwCw9LC9AAAAIXJvb3RAbm93aGVyZS53aXJzLmluZyBjeWd3aW4tMjIyMg
+ECAwQ=
+-----END OPENSSH PRIVATE KEY-----
From 83e25907a8c522d7d86264ab11828a88f91a2757 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 00:58:38 +0200
Subject: [PATCH 03/39] Connect the bottom prompt to the shared SSH server
The bottom prompt is now a real text input. Sending the first command
lazy-loads an xterm.js terminal that continues right below it and
connects to wss://web.nowhere.wirs.ing with a minimal in-browser SSH
client (curve25519, strict kex, pinned ed25519 host key,
chacha20-poly1305, re-keying) using public/root_key.
The page is the scrollback: lines scrolling off the window-sized
terminal screen become page text, the screen grows with the lines in
use, and the wheel scrolls the page except in full-screen apps.
Typing anywhere on the page goes to the prompt, later to the terminal.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
package-lock.json | 59 ++++++++
package.json | 5 +
src/components/Prompt.astro | 2 +-
src/components/Terminal.astro | 126 ++++------------
src/scripts/ssh.ts | 276 ++++++++++++++++++++++++++++++++++
src/scripts/terminal.ts | 129 ++++++++++++++++
src/styles/style.css | 16 +-
7 files changed, 511 insertions(+), 102 deletions(-)
create mode 100644 src/scripts/ssh.ts
create mode 100644 src/scripts/terminal.ts
diff --git a/package-lock.json b/package-lock.json
index f6ce6bb..332eeb7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -6,6 +6,11 @@
"": {
"name": "kik-website",
"dependencies": {
+ "@noble/ciphers": "^2.4.0",
+ "@noble/curves": "^2.4.0",
+ "@noble/hashes": "^2.4.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
},
@@ -1057,6 +1062,45 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
}
},
+ "node_modules/@noble/ciphers": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
+ "integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/curves": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz",
+ "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "2.4.0"
+ },
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz",
+ "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20.19.0"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@oslojs/encoding": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@@ -1482,6 +1526,21 @@
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
+ "node_modules/@xterm/addon-fit": {
+ "version": "0.11.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
+ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
+ "license": "MIT"
+ },
+ "node_modules/@xterm/xterm": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
+ "integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
+ "license": "MIT",
+ "workspaces": [
+ "addons/*"
+ ]
+ },
"node_modules/am-i-vibing": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.5.0.tgz",
diff --git a/package.json b/package.json
index d087ca7..83e6eb8 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,11 @@
"preview": "astro preview"
},
"dependencies": {
+ "@noble/ciphers": "^2.4.0",
+ "@noble/curves": "^2.4.0",
+ "@noble/hashes": "^2.4.0",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
}
diff --git a/src/components/Prompt.astro b/src/components/Prompt.astro
index c7c74bc..6081309 100644
--- a/src/components/Prompt.astro
+++ b/src/components/Prompt.astro
@@ -1 +1 @@
-
root@khaos-im-kiez:~#
+
root@khaos-im-kiez:~#
diff --git a/src/components/Terminal.astro b/src/components/Terminal.astro
index cb712ac..eed8acb 100644
--- a/src/components/Terminal.astro
+++ b/src/components/Terminal.astro
@@ -1,110 +1,36 @@
---
import Prompt from './Prompt.astro';
---
-
+
diff --git a/src/scripts/ssh.ts b/src/scripts/ssh.ts
new file mode 100644
index 0000000..e20e5c1
--- /dev/null
+++ b/src/scripts/ssh.ts
@@ -0,0 +1,276 @@
+// Minimal SSH client for one interactive shell over a websockify bridge (raw SSH bytes in binary frames).
+// Supports exactly: curve25519-sha256 with strict kex, ssh-ed25519 host and user keys,
+// chacha20-poly1305@openssh.com, one session channel with a pty, and server-initiated re-keying.
+import { ed25519, x25519 } from '@noble/curves/ed25519.js';
+import { sha256 } from '@noble/hashes/sha2.js';
+import { chacha20orig } from '@noble/ciphers/chacha.js';
+import { poly1305 } from '@noble/ciphers/_poly1305.js';
+import { concatBytes as concat, equalBytes, randomBytes } from '@noble/ciphers/utils.js';
+
+export interface Options {
+ socket: WebSocket;
+ user: string;
+ privateKey: string; // unencrypted ed25519 key in openssh-key-v1 format
+ hostKey: string; // expected host key fingerprint, "SHA256:..."
+ term: string;
+ cols: number;
+ rows: number;
+ onData: (data: Uint8Array) => void;
+ onClose: (error?: Error) => void;
+}
+
+export interface Session {
+ write(data: string | Uint8Array): void;
+ resize(cols: number, rows: number): void;
+}
+
+const CIPHER = 'chacha20-poly1305@openssh.com';
+const WINDOW = 1 << 16;
+const MAX_PACKET = 1 << 15;
+
+const enc = new TextEncoder();
+const dec = new TextDecoder();
+
+const u32 = (n: number) => Uint8Array.of(n >>> 24, n >>> 16, n >>> 8, n);
+
+/** SSH wire encoding: number → uint32, boolean → byte, string or bytes → length-prefixed string. */
+function pack(...values: (number | boolean | string | Uint8Array)[]): Uint8Array {
+ return concat(...values.map((v) => {
+ if (typeof v === 'number') return u32(v);
+ if (typeof v === 'boolean') return Uint8Array.of(+v);
+ const bytes = typeof v === 'string' ? enc.encode(v) : v;
+ return concat(u32(bytes.length), bytes);
+ }));
+}
+
+const msg = (type: number, ...values: Parameters) => concat(Uint8Array.of(type), pack(...values));
+
+function reader(bytes: Uint8Array, offset = 0) {
+ const r = {
+ u8: () => bytes[offset++],
+ u32: () => new DataView(bytes.buffer, bytes.byteOffset).getUint32((offset += 4) - 4),
+ bytes: () => { const n = r.u32(); return bytes.subarray(offset, (offset += n)); },
+ str: () => dec.decode(r.bytes()),
+ };
+ return r;
+}
+
+/** Unsigned big-endian integer → SSH mpint. */
+function mpint(bytes: Uint8Array) {
+ const n = bytes.subarray(bytes.findIndex((b) => b !== 0));
+ return pack(n[0] & 0x80 ? concat(Uint8Array.of(0), n) : n);
+}
+
+function parsePrivateKey(text: string) {
+ const r = reader(Uint8Array.from(atob(text.replace(/-----[^-]+-----|\s/g, '')), (c) => c.charCodeAt(0)), 15); // skip "openssh-key-v1\0"
+ if (r.str() !== 'none') throw new Error('encrypted private keys are not supported');
+ r.str(); r.bytes(); r.u32(); // kdf name, kdf options, number of keys
+ const publicKey = r.bytes();
+ const secret = reader(r.bytes(), 8); // skip check ints
+ secret.str(); secret.bytes(); // key type, public key
+ return { publicKey, seed: secret.bytes().subarray(0, 32) };
+}
+
+/** chacha20-poly1305@openssh.com. Sequence numbers restart at 0 after every NEWKEYS because of strict kex. */
+function chachaPoly(key: Uint8Array) {
+ const main = key.subarray(0, 32);
+ const header = key.subarray(32);
+ let seq = 0;
+ const nonce = () => concat(u32(0), u32(seq));
+ const mac = (n: Uint8Array, data: Uint8Array) => poly1305(data, chacha20orig(main, n, new Uint8Array(32)));
+ return {
+ length: (head: Uint8Array) => reader(chacha20orig(header, nonce(), head)).u32(),
+ seal(packet: Uint8Array) {
+ const n = nonce();
+ seq++;
+ const sealed = concat(chacha20orig(header, n, packet.subarray(0, 4)), chacha20orig(main, n, packet.subarray(4), undefined, 1));
+ return concat(sealed, mac(n, sealed));
+ },
+ open(packet: Uint8Array) {
+ const n = nonce();
+ seq++;
+ const sealed = packet.subarray(0, -16);
+ if (!equalBytes(mac(n, sealed), packet.subarray(-16))) throw new Error('bad packet MAC');
+ return chacha20orig(main, n, sealed.subarray(4), undefined, 1);
+ },
+ };
+}
+
+export function connect(o: Options): Session {
+ const { socket } = o;
+ let buffer = new Uint8Array(0);
+ let wake = () => {};
+ let closed = false;
+ socket.binaryType = 'arraybuffer';
+ socket.onmessage = (e) => { buffer = concat(buffer, new Uint8Array(e.data)); wake(); };
+ socket.onclose = () => { closed = true; wake(); };
+
+ async function read(n: number) {
+ while (buffer.length < n) {
+ if (closed) throw new Error('connection closed');
+ await new Promise((resolve) => (wake = resolve));
+ }
+ const bytes = buffer.subarray(0, n);
+ buffer = buffer.subarray(n);
+ return bytes;
+ }
+
+ let tx: ReturnType | undefined;
+ let rx: ReturnType | undefined;
+ let held: Uint8Array[] | undefined; // connection-layer messages held back during a key exchange
+
+ function send(payload: Uint8Array) {
+ if (socket.readyState !== WebSocket.OPEN) return; // the session has ended
+ if (held && payload[0] >= 50) return void held.push(payload);
+ let padding = 8 - ((payload.length + (tx ? 1 : 5)) % 8); // chacha20-poly1305 excludes the length field
+ if (padding < 4) padding += 8;
+ const packet = concat(u32(payload.length + padding + 1), Uint8Array.of(padding), payload, new Uint8Array(padding));
+ socket.send(tx ? tx.seal(packet) : packet);
+ }
+
+ async function readPacket() {
+ const head = await read(4);
+ const packet = concat(head, await read(rx ? rx.length(head) + 16 : reader(head).u32()));
+ const plain = rx ? rx.open(packet) : packet.subarray(4);
+ return plain.subarray(1, plain.length - plain[0]);
+ }
+
+ /** Next packet, after handling the messages that may arrive at any time once the initial key exchange is done. */
+ async function receive(): Promise {
+ for (;;) {
+ const p = await readPacket();
+ if (!rx) return p; // strict kex: nothing else is allowed during the initial key exchange
+ const r = reader(p, 1);
+ switch (p[0]) {
+ case 1: r.u32(); throw new Error(`disconnected: ${r.str()}`);
+ case 80: r.str(); if (r.u8()) send(Uint8Array.of(82)); break; // global request: reply failure
+ case 2: case 3: case 4: case 53: break; // ignore, unimplemented, debug, auth banner
+ default: return p;
+ }
+ }
+ }
+
+ async function expect(type: number) {
+ const p = await receive();
+ if (p[0] !== type) throw new Error(`expected message ${type}, got ${p[0]}`);
+ return p;
+ }
+
+ let channel: number | undefined;
+ let remoteWindow = 0;
+ let remoteMaxPacket = 0;
+ let pending = new Uint8Array(0);
+
+ function flush() {
+ while (pending.length && remoteWindow) {
+ const n = Math.min(pending.length, remoteWindow, remoteMaxPacket);
+ send(msg(94, channel!, pending.subarray(0, n)));
+ pending = pending.subarray(n);
+ remoteWindow -= n;
+ }
+ }
+
+ async function run() {
+ let line;
+ do {
+ line = '';
+ while (!line.endsWith('\n')) line += String.fromCharCode((await read(1))[0]);
+ } while (!line.startsWith('SSH-'));
+ const serverVersion = line.trimEnd();
+ const clientVersion = 'SSH-2.0-web';
+ socket.send(enc.encode(`${clientVersion}\r\n`));
+
+ let sessionId: Uint8Array | undefined;
+ /** The initial key exchange, or a re-key started by the server's KEXINIT. */
+ async function kex(serverKexinit?: Uint8Array) {
+ held = [];
+ const clientKexinit = concat(Uint8Array.of(20), randomBytes(16), pack(
+ 'curve25519-sha256,kex-strict-c-v00@openssh.com', 'ssh-ed25519',
+ CIPHER, CIPHER, '', '', 'none', 'none', '', '', false, 0,
+ ));
+ send(clientKexinit);
+ serverKexinit ??= await expect(20);
+ if (!sessionId && !reader(serverKexinit, 17).str().split(',').includes('kex-strict-s-v00@openssh.com')) throw new Error('server lacks strict kex');
+
+ const ephemeral = x25519.keygen();
+ send(msg(30, ephemeral.publicKey));
+ const reply = reader(await expect(31), 1);
+ const hostKey = reply.bytes();
+ const serverPublic = reply.bytes();
+ const signature = reply.bytes();
+ const fingerprint = `SHA256:${btoa(String.fromCharCode(...sha256(hostKey))).replace(/=+$/, '')}`;
+ if (fingerprint !== o.hostKey) throw new Error(`unexpected host key ${fingerprint}`);
+ const K = mpint(x25519.getSharedSecret(ephemeral.secretKey, serverPublic));
+ const H = sha256(concat(pack(clientVersion, serverVersion, clientKexinit, serverKexinit, hostKey, ephemeral.publicKey, serverPublic), K));
+ if (!ed25519.verify(signature.subarray(-64), H, hostKey.subarray(-32))) throw new Error('bad host key signature');
+
+ const id = (sessionId ??= H);
+ const key = (letter: string) => {
+ const k1 = sha256(concat(K, H, enc.encode(letter), id));
+ return concat(k1, sha256(concat(K, H, k1)));
+ };
+ send(Uint8Array.of(21));
+ tx = chachaPoly(key('C'));
+ await expect(21);
+ rx = chachaPoly(key('D'));
+ const messages = held;
+ held = undefined;
+ messages.forEach(send);
+ }
+ await kex();
+
+ send(msg(5, 'ssh-userauth'));
+ await expect(6);
+ const { publicKey, seed } = parsePrivateKey(o.privateKey);
+ const auth = msg(50, o.user, 'ssh-connection', 'publickey', true, 'ssh-ed25519', publicKey);
+ send(concat(auth, pack(pack('ssh-ed25519', ed25519.sign(concat(pack(sessionId!), auth), seed)))));
+ if ((await receive())[0] !== 52) throw new Error('authentication failed');
+
+ send(msg(90, 'session', 0, WINDOW, MAX_PACKET));
+ const confirmation = reader(await expect(91), 5);
+ channel = confirmation.u32();
+ remoteWindow = confirmation.u32();
+ remoteMaxPacket = confirmation.u32();
+ send(msg(98, channel, 'pty-req', true, o.term, o.cols, o.rows, 0, 0, '\0')); // no terminal modes, just TTY_OP_END
+ send(msg(98, channel, 'shell', true));
+ flush();
+
+ let window = WINDOW;
+ for (;;) {
+ const p = await receive();
+ const r = reader(p, 5); // everything from here on is for our only channel
+ switch (p[0]) {
+ case 20: await kex(p); break;
+ case 93: remoteWindow += r.u32(); flush(); break;
+ case 95: r.u32(); // extended data (stderr): skip the type code
+ // falls through
+ case 94: {
+ const data = r.bytes();
+ o.onData(data);
+ if ((window -= data.length) <= WINDOW / 2) {
+ send(msg(93, channel, WINDOW - window));
+ window = WINDOW;
+ }
+ break;
+ }
+ case 98: r.str(); if (r.u8()) send(msg(100, channel)); break; // exit-status, keepalive, ...
+ case 100: throw new Error('pty or shell request failed');
+ case 97: send(msg(97, channel)); return;
+ }
+ }
+ }
+
+ run().catch((e) => e).then((error) => { socket.close(); o.onClose(error); });
+
+ return {
+ write(data) {
+ pending = concat(pending, typeof data === 'string' ? enc.encode(data) : data);
+ flush();
+ },
+ resize(cols, rows) {
+ o.cols = cols;
+ o.rows = rows;
+ if (channel !== undefined) send(msg(98, channel, 'window-change', false, cols, rows, 0, 0));
+ },
+ };
+}
diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts
new file mode 100644
index 0000000..980f95b
--- /dev/null
+++ b/src/scripts/terminal.ts
@@ -0,0 +1,129 @@
+// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent.
+import { Terminal, type IMarker } from '@xterm/xterm';
+import { FitAddon } from '@xterm/addon-fit';
+import css from '@xterm/xterm/css/xterm.css?inline';
+import { connect } from './ssh';
+
+const SERVER = 'wss://web.nowhere.wirs.ing';
+const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A';
+const SCROLLBACK = 10000;
+
+/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
+export async function start(prompt: HTMLElement, command: string) {
+ const privateKey = await (await fetch('root_key')).text();
+ document.head.append(Object.assign(document.createElement('style'), { textContent: css }));
+ // The page is the scrollback: lines that scroll off the terminal's screen continue as page text above it,
+ // and the screen only grows to the rows in use, up to the window height.
+ const history = document.createElement('div');
+ history.className = 'history';
+ const screen = document.createElement('div');
+ screen.className = 'screen';
+ const element = screen.appendChild(document.createElement('div'));
+ prompt.after(history, screen);
+
+ const { color, fontFamily, fontSize } = getComputedStyle(screen);
+ const term = new Terminal({
+ fontFamily,
+ fontSize: parseFloat(fontSize),
+ scrollback: SCROLLBACK,
+ cursorBlink: !matchMedia('(prefers-reduced-motion: reduce)').matches,
+ cursorInactiveStyle: 'block', // typing anywhere on the page reaches the terminal, focused or not
+ theme: { background: '#00000000', foreground: color, cursor: color, green: color }, // xterm.js rejects 'transparent'
+ });
+ const fit = new FitAddon();
+ term.loadAddon(fit);
+ term.open(element);
+ fit.fit();
+ // The wheel scrolls the page, except in full-screen apps, where xterm.js turns it into arrow keys.
+ screen.addEventListener('wheel', (e) => term.buffer.active.type === 'normal' && e.stopPropagation(), { capture: true });
+
+ const grid = element.querySelector('.xterm-screen')!;
+ let used = 1; // screen rows in use, like a terminal window filling up
+ let moved: IMarker | undefined; // the last scrollback line already moved into the page
+ function layout() {
+ const buffer = term.buffer.active;
+ const rowHeight = grid.clientHeight / term.rows;
+ if (buffer.type === 'normal') {
+ used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1);
+ const last = moved && !moved.isDisposed ? moved.line : -1;
+ for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen
+ for (let y = last + 1; y < buffer.baseY; y++) history.append(`${buffer.getLine(y)!.translateToString(true)}\n`);
+ while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
+ if (last !== buffer.baseY - 1) {
+ moved?.dispose();
+ moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined;
+ }
+ }
+ history.style.lineHeight = `${rowHeight}px`;
+ screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
+ }
+ // "Erase scrollback" (sent by `clear`) erases it from the page too.
+ term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false));
+
+ // Like a terminal window, the page scrolls along as output reaches the bottom, unless the visitor scrolled away.
+ const cursorRow = () => {
+ const { top, height } = grid.getBoundingClientRect();
+ const rowHeight = height / term.rows;
+ return { top: top + term.buffer.active.cursorY * rowHeight, bottom: top + (term.buffer.active.cursorY + 1) * rowHeight };
+ };
+ const reveal = () =>
+ term.buffer.active.type === 'alternate'
+ ? screen.scrollIntoView({ block: 'nearest' }) // full-screen apps are shown whole
+ : scrollBy(0, Math.max(0, Math.ceil(cursorRow().bottom - innerHeight))); // whole pixels, or it stays a fraction below
+ let follow = true;
+ addEventListener('scroll', () => (follow = cursorRow().top < innerHeight), { passive: true });
+ term.onWriteParsed(() => {
+ layout();
+ if (follow) reveal();
+ });
+ term.onResize(layout);
+ new ResizeObserver(() => {
+ fit.fit();
+ if (follow) reveal();
+ }).observe(element);
+ layout();
+ reveal();
+ term.focus();
+
+ // The page's prompt stands in for the shell's first prompt, so that prompt and the shell's echo of the command are dropped.
+ let phase: 'starting' | 'echo' | 'live' = 'starting';
+ let typeahead = '';
+ let idle: ReturnType;
+ const session = connect({
+ socket: new WebSocket(SERVER),
+ user: 'root',
+ privateKey,
+ hostKey: HOST_KEY,
+ term: 'xterm-256color',
+ cols: term.cols,
+ rows: term.rows,
+ onData(data) {
+ if (phase === 'live') return term.write(data);
+ if (phase === 'echo') {
+ const newline = data.indexOf(10);
+ if (newline >= 0) {
+ phase = 'live';
+ term.write(data.subarray(newline + 1));
+ }
+ return;
+ }
+ // The shell is ready once its first output has settled; a command sent earlier would be echoed twice.
+ clearTimeout(idle);
+ idle = setTimeout(() => {
+ phase = 'echo';
+ session.write(`${command}\r${typeahead}`);
+ }, 300);
+ },
+ onClose(error) {
+ term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`);
+ },
+ });
+ term.onData((data) => {
+ reveal();
+ if (phase === 'starting') typeahead += data;
+ else session.write(data);
+ });
+ term.onBinary((data) => session.write(Uint8Array.from(data, (c) => c.charCodeAt(0)))); // mouse reports in the default encoding
+ term.onResize(({ cols, rows }) => session.resize(cols, rows));
+ return term;
+}
diff --git a/src/styles/style.css b/src/styles/style.css
index 24f2f33..4e1ecc3 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -54,7 +54,7 @@ a:hover, a:focus-visible {
}
/* Prompt */
-.prompt { color: var(--dim); }
+.prompt { color: var(--dim); white-space: pre-wrap; }
.prompt b { color: var(--fg); font-weight: normal; }
.cursor {
display: inline-block;
@@ -64,6 +64,20 @@ a:hover, a:focus-visible {
animation: blink 1.1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
+#term-input { all: unset; width: 0; padding-right: 1px; caret-color: var(--fg); } /* as wide as its text */
+.prompt:focus-within .cursor { display: none; } /* the input's own caret takes over while typing */
+
+/* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text,
+ the screen shows the rows in use and xterm.js fits rows and columns to the window. */
+.prompt:has(+ .history) { margin-bottom: 0; }
+.history { white-space: pre-wrap; }
+.screen { overflow: clip; }
+.screen > div { height: 100dvh; }
+.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */
+@media (prefers-reduced-motion: no-preference) {
+ /* typing still reaches the terminal when it is not focused, so its cursor keeps blinking like the page's */
+ .screen .xterm-rows:not(.xterm-focus) .xterm-cursor-block { animation: blink 1.1s steps(1) infinite; }
+}
/* Header */
header {
From d718172c23bf9ef579febbdba0bcf9db838c6798 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 01:08:58 +0200
Subject: [PATCH 04/39] Embed the login key in the terminal script
The key is no longer served as public/root_key. It is stored as base64
of its rot13 and decoded where the connection is made.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
public/root_key | 8 --------
src/scripts/terminal.ts | 9 ++++++---
2 files changed, 6 insertions(+), 11 deletions(-)
delete mode 100644 public/root_key
diff --git a/public/root_key b/public/root_key
deleted file mode 100644
index d9952ae..0000000
--- a/public/root_key
+++ /dev/null
@@ -1,8 +0,0 @@
------BEGIN OPENSSH PRIVATE KEY-----
-b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
-QyNTUxOQAAACCQ8HhB/G4WQB1WgHnYU1Q29o/6DBJ975ve8QsAsPSwvQAAAKjlKiEr5Soh
-KwAAAAtzc2gtZWQyNTUxOQAAACCQ8HhB/G4WQB1WgHnYU1Q29o/6DBJ975ve8QsAsPSwvQ
-AAAEA8lgTuE+19YARukm7eq2FZ1PvUbE+owyOAEO5IbCnvnJDweEH8bhZAHVaAedhTVDb2
-j/oMEn3vm97xCwCw9LC9AAAAIXJvb3RAbm93aGVyZS53aXJzLmluZyBjeWd3aW4tMjIyMg
-ECAwQ=
------END OPENSSH PRIVATE KEY-----
diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts
index 980f95b..c9cc2e4 100644
--- a/src/scripts/terminal.ts
+++ b/src/scripts/terminal.ts
@@ -7,10 +7,10 @@ import { connect } from './ssh';
const SERVER = 'wss://web.nowhere.wirs.ing';
const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A';
const SCROLLBACK = 10000;
+const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K';
/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
-export async function start(prompt: HTMLElement, command: string) {
- const privateKey = await (await fetch('root_key')).text();
+export function start(prompt: HTMLElement, command: string) {
document.head.append(Object.assign(document.createElement('style'), { textContent: css }));
// The page is the scrollback: lines that scroll off the terminal's screen continue as page text above it,
// and the screen only grows to the rows in use, up to the window height.
@@ -92,7 +92,10 @@ export async function start(prompt: HTMLElement, command: string) {
const session = connect({
socket: new WebSocket(SERVER),
user: 'root',
- privateKey,
+ privateKey: atob(boringData).replace(/[a-z]/gi, (c) => {
+ const base = c <= 'Z' ? 65 : 97;
+ return String.fromCharCode(((c.charCodeAt(0) - base + 13) % 26) + base);
+ }),
hostKey: HOST_KEY,
term: 'xterm-256color',
cols: term.cols,
From 3c61a3c41930d2e85e46121b88d5b6208371fecc Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 01:08:58 +0200
Subject: [PATCH 05/39] Match the page prompts to the server prompt
All of the prompt is in the normal green now, except the blue ~.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/components/Prompt.astro | 2 +-
src/styles/style.css | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/components/Prompt.astro b/src/components/Prompt.astro
index 6081309..1178e26 100644
--- a/src/components/Prompt.astro
+++ b/src/components/Prompt.astro
@@ -1 +1 @@
-
root@khaos-im-kiez:~#
+
root@khaos-im-kiez:~#
diff --git a/src/styles/style.css b/src/styles/style.css
index 4e1ecc3..ec4fc30 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -54,8 +54,8 @@ a:hover, a:focus-visible {
}
/* Prompt */
-.prompt { color: var(--dim); white-space: pre-wrap; }
-.prompt b { color: var(--fg); font-weight: normal; }
+.prompt { white-space: pre-wrap; }
+.prompt .cwd { color: #3465a4; } /* the shell prompt's blue, as xterm.js renders it */
.cursor {
display: inline-block;
width: .6em; height: 1.1em;
From fc250b31b6963a5f47cc2bf0579e260630e44be6 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:01:21 +0200
Subject: [PATCH 06/39] Keep colors and styles of lines moved into the page
Lines scrolling off the terminal screen are converted from xterm.js's
cells to styled page text: palette, 256 and true colors, background,
bold (bright), italic, dim, underline, strikethrough, inverse and
invisible, without trailing spaces and keeping empty lines.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/scripts/terminal.ts | 51 +++++++++++++++++++++++++++++++++++++++--
src/styles/style.css | 1 +
2 files changed, 50 insertions(+), 2 deletions(-)
diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts
index c9cc2e4..3aadd67 100644
--- a/src/scripts/terminal.ts
+++ b/src/scripts/terminal.ts
@@ -1,5 +1,5 @@
// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent.
-import { Terminal, type IMarker } from '@xterm/xterm';
+import { Terminal, type IBufferLine, type IMarker } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import css from '@xterm/xterm/css/xterm.css?inline';
import { connect } from './ssh';
@@ -7,6 +7,10 @@ import { connect } from './ssh';
const SERVER = 'wss://web.nowhere.wirs.ing';
const HOST_KEY = 'SHA256:aLNZKHCfhvbGYnjghKsOLPrOWPVWwCsIPCuTb5f9J6A';
const SCROLLBACK = 10000;
+// xterm.js's default colors, to color lines moved into the page like the terminal does.
+const ANSI = ['#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf',
+ '#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec'];
+const CUBE = [0, 95, 135, 175, 215, 255];
const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K';
/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
@@ -47,7 +51,7 @@ export function start(prompt: HTMLElement, command: string) {
used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1);
const last = moved && !moved.isDisposed ? moved.line : -1;
for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen
- for (let y = last + 1; y < buffer.baseY; y++) history.append(`${buffer.getLine(y)!.translateToString(true)}\n`);
+ for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!));
while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
if (last !== buffer.baseY - 1) {
moved?.dispose();
@@ -57,6 +61,49 @@ export function start(prompt: HTMLElement, command: string) {
history.style.lineHeight = `${rowHeight}px`;
screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
}
+
+ // A line as page text, with the colors and styles the terminal shows it in.
+ const palette = ANSI.map((c, i) => (i === 2 ? color : c)); // green is the page's, as in the theme
+ const colorOf = (n: number, rgb: boolean) => {
+ if (rgb) return `#${n.toString(16).padStart(6, '0')}`;
+ if (n < 16) return palette[n];
+ if (n < 232) return `rgb(${CUBE[Math.floor((n - 16) / 36)]},${CUBE[Math.floor((n - 16) / 6) % 6]},${CUBE[(n - 16) % 6]})`;
+ const gray = 8 + (n - 232) * 10;
+ return `rgb(${gray},${gray},${gray})`;
+ };
+ const cell = term.buffer.normal.getNullCell();
+ function pageLine(line: IBufferLine) {
+ const row = document.createElement('div');
+ let end = line.length; // up to the last visible cell, so copied text has no trailing spaces
+ while (end && !line.getCell(end - 1, cell)!.getChars().trim() && cell.isBgDefault() && !cell.isInverse()) end--;
+ let span: HTMLElement | undefined;
+ let current: string | undefined;
+ for (let x = 0; x < end; x++) {
+ line.getCell(x, cell);
+ if (!cell.getWidth()) continue; // the second half of a wide character
+ const fgIndex = cell.getFgColor() + (cell.isBold() && cell.isFgPalette() && cell.getFgColor() < 8 ? 8 : 0); // bold is bright
+ let fg = cell.isFgDefault() ? '' : colorOf(fgIndex, cell.isFgRGB());
+ let bg = cell.isBgDefault() ? '' : colorOf(cell.getBgColor(), cell.isBgRGB());
+ if (cell.isInverse()) [fg, bg] = [bg || '#000', fg || color];
+ if (cell.isDim()) fg = `color-mix(in srgb, ${fg || color} 50%, transparent)`;
+ const decoration = [cell.isUnderline() && 'underline', cell.isStrikethrough() && 'line-through'].filter(Boolean).join(' ');
+ const style = [
+ fg && `color:${fg}`,
+ bg && `background:${bg}`,
+ cell.isBold() && 'font-weight:bold',
+ cell.isItalic() && 'font-style:italic',
+ decoration && `text-decoration:${decoration}`,
+ cell.isInvisible() && 'visibility:hidden',
+ ].filter(Boolean).join(';');
+ if (style !== current) {
+ span = row.appendChild(document.createElement('span'));
+ span.style.cssText = current = style;
+ }
+ span!.textContent += cell.getChars() || ' ';
+ }
+ return row;
+ }
+
// "Erase scrollback" (sent by `clear`) erases it from the page too.
term.parser.registerCsiHandler({ final: 'J' }, (params) => (params[0] === 3 && history.replaceChildren(), false));
diff --git a/src/styles/style.css b/src/styles/style.css
index ec4fc30..795a7d5 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -71,6 +71,7 @@ a:hover, a:focus-visible {
the screen shows the rows in use and xterm.js fits rows and columns to the window. */
.prompt:has(+ .history) { margin-bottom: 0; }
.history { white-space: pre-wrap; }
+.history > div { min-height: 1lh; } /* keeps empty lines */
.screen { overflow: clip; }
.screen > div { height: 100dvh; }
.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */
From 7e1f995e7e574c3062058f7d9263f2759556a864 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:01:21 +0200
Subject: [PATCH 07/39] Keep the prompt's block cursor while it is focused
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/styles/style.css | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/src/styles/style.css b/src/styles/style.css
index 795a7d5..a365af1 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -64,8 +64,7 @@ a:hover, a:focus-visible {
animation: blink 1.1s steps(1) infinite;
}
@keyframes blink { 50% { opacity: 0; } }
-#term-input { all: unset; width: 0; padding-right: 1px; caret-color: var(--fg); } /* as wide as its text */
-.prompt:focus-within .cursor { display: none; } /* the input's own caret takes over while typing */
+#term-input { all: unset; width: 0; caret-color: transparent; } /* as wide as its text, so the block cursor follows it */
/* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text,
the screen shows the rows in use and xterm.js fits rows and columns to the window. */
From 986d35376ee77864546666c250107cdd8a5bc2a2 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:01:21 +0200
Subject: [PATCH 08/39] Let text glow in its own color
The glow used to be green for all text, including red or blue terminal
output. The explicit red glows are no longer needed.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
src/styles/style.css | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/styles/style.css b/src/styles/style.css
index a365af1..2278ae9 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -5,7 +5,7 @@
--fg: #33ff66;
--dim: #1f9a3f;
--hot: #ff2a2a; /* KiK-Logo-Rot */
- --glow: 0 0 6px rgba(51, 255, 102, .55);
+ --glow: 0 0 6px color-mix(in srgb, currentColor 55%, transparent); /* in each text's own color */
}
* { box-sizing: border-box; }
@@ -100,7 +100,7 @@ h1 {
letter-spacing: .04em;
text-transform: uppercase;
}
-h1 span { color: var(--hot); text-shadow: 0 0 6px rgba(255, 42, 42, .6); }
+h1 span { color: var(--hot); }
.tagline { margin: .4rem 0 0; color: var(--dim); }
/* Menü */
@@ -157,7 +157,7 @@ dl.kv dd { margin: 0; }
.btn::before { content: "[ "; }
.btn::after { content: " ]"; }
.btn { border: 0; padding: 0; }
-.btn.hot { color: var(--hot); text-shadow: 0 0 6px rgba(255, 42, 42, .6); }
+.btn.hot { color: var(--hot); }
.btn.hot:hover, .btn.hot:focus-visible { background: var(--hot); color: #000; }
.note { color: var(--dim); }
From f106ffe22d193d6511c5ec204125e6020e497ed5 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:36:28 +0100
Subject: [PATCH 09/39] Use relative URLs and fixed script names
The build is flat (pages, loader.js, terminal.js, logo.png) and only uses
relative URLs, so the site works wherever it is hosted, e.g. at
https://berlin.ccc.de/kik/.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
astro.config.mjs | 15 ++++++++++++++-
1 file changed, 14 insertions(+), 1 deletion(-)
diff --git a/astro.config.mjs b/astro.config.mjs
index 37a0a32..12b6d7d 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -1,6 +1,19 @@
import { defineConfig } from 'astro/config';
export default defineConfig({
- build: { format: 'file' },
+ // Relative asset URLs, so the site works wherever it is hosted, e.g. https://berlin.ccc.de/kik/
+ build: { format: 'file', assetsPrefix: '.' },
compressHTML: false,
+ vite: {
+ environments: {
+ client: {
+ build: {
+ rolldownOptions: {
+ // The page's script and the terminal it loads
+ output: { entryFileNames: 'loader.js', chunkFileNames: 'terminal.js' },
+ },
+ },
+ },
+ },
+ },
});
From aed4af08687467fbd3c4f52f9bd392e10c5c4312 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:36:28 +0100
Subject: [PATCH 10/39] Clickable links, full-width terminal and exact page
history
- OSC 8 hyperlinks and plain URLs are clickable on the screen and in the
page history. Links to a section of the current page scroll there,
others open in a new tab. Links show their target on hover and invert
in their own color, like the page's links.
- The terminal fills the page column (the fit addon kept room for a
hidden scrollbar).
- Lines in the page history sit exactly on the terminal's cell grid,
also for wide and fallback-font characters, and no longer wrap.
- The first command is sent as soon as the shell's line editor is ready,
instead of guessing from a pause in its output.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
package-lock.json | 10 ++--
package.json | 2 +-
src/scripts/terminal.ts | 121 +++++++++++++++++++++++++++++++++-------
src/styles/style.css | 5 +-
4 files changed, 112 insertions(+), 26 deletions(-)
diff --git a/package-lock.json b/package-lock.json
index 332eeb7..9452d21 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,7 +9,7 @@
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
- "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
@@ -1526,10 +1526,10 @@
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
"license": "ISC"
},
- "node_modules/@xterm/addon-fit": {
- "version": "0.11.0",
- "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
- "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
+ "node_modules/@xterm/addon-web-links": {
+ "version": "0.12.0",
+ "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz",
+ "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==",
"license": "MIT"
},
"node_modules/@xterm/xterm": {
diff --git a/package.json b/package.json
index 83e6eb8..1d631fc 100644
--- a/package.json
+++ b/package.json
@@ -11,7 +11,7 @@
"@noble/ciphers": "^2.4.0",
"@noble/curves": "^2.4.0",
"@noble/hashes": "^2.4.0",
- "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"astro": "^7.4.2"
}
diff --git a/src/scripts/terminal.ts b/src/scripts/terminal.ts
index 3aadd67..763aebf 100644
--- a/src/scripts/terminal.ts
+++ b/src/scripts/terminal.ts
@@ -1,6 +1,6 @@
// The real terminal: xterm.js connected to the shared shell server over SSH. Loaded only once the first command is sent.
-import { Terminal, type IBufferLine, type IMarker } from '@xterm/xterm';
-import { FitAddon } from '@xterm/addon-fit';
+import { Terminal, type IBufferCell, type IBufferLine, type IMarker } from '@xterm/xterm';
+import { WebLinksAddon } from '@xterm/addon-web-links';
import css from '@xterm/xterm/css/xterm.css?inline';
import { connect } from './ssh';
@@ -11,6 +11,8 @@ const SCROLLBACK = 10000;
const ANSI = ['#2e3436', '#cc0000', '#4e9a06', '#c4a000', '#3465a4', '#75507b', '#06989a', '#d3d7cf',
'#555753', '#ef2929', '#8ae234', '#fce94f', '#729fcf', '#ad7fa8', '#34e2e2', '#eeeeec'];
const CUBE = [0, 95, 135, 175, 215, 255];
+// URLs that become links, on the screen and in the page (the web-links addon's default pattern).
+const URL_PATTERN = /(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;
const boringData = 'LS0tLS1PUlRWQSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0KbzNPeW9hQW1uUDFlTUt4Z3F3Uk5OTk5OT1Q1aW96SE5OTk5Sb3o5aE1ETk5OTk5OTk5OT05OTk5aak5OTk5nbXAydGdNSgpEbEFHSGtCRE5OTlBQRDhVdU8vVDRKRE8xSnRVYUxIMUQyOWIvNlFPVzk3NWlyOERmTmZDRmppRE5OTlh3eVh2UmU1RmJ1ClhqTk5OTmdtcDJ0Z01KRGxBR0hrQkROTk5QUEQ4VXVPL1Q0SkRPMUp0VWFMSDFEMjliLzZRT1c5NzVpcjhEZk5mQ0ZqaUQKTk5OUk44eXRHaFIrMTlMTkVoeHo3cmQyU00xQ2lIb1IrYmpsQk5SQjVWb1BhaWFXUWpyUlU4b3VNTlVJbk5ycXVHSVFvMgp3L2JaUmEzaXo5N2tQalBqOVlQOU5OTk5WS1dpbzNFTm96OTNuVElsTUY1M25LV21ZenloTWxPd3JKcTNuSjRnWndWbFp0ClJQTmpEPQotLS0tLVJBUSBCQ1JBRkZVIENFVklOR1IgWFJMLS0tLS0K';
/** Continues the page's `prompt` with a terminal in which `command` was just entered, and returns that terminal. */
@@ -25,6 +27,22 @@ export function start(prompt: HTMLElement, command: string) {
const element = screen.appendChild(document.createElement('div'));
prompt.after(history, screen);
+ // Links to a section of this very page scroll there, all others open in a new tab.
+ // Hovering shows where a link leads (and inverts it, see style.css).
+ const page = (url: URL | Location) => url.origin + url.pathname.replace(/index\.html$/, '');
+ const section = (uri: string) => {
+ const url = new URL(uri, location.href);
+ return url.hash && page(url) === page(location) ? url.hash : undefined;
+ };
+ const link = {
+ activate(_: MouseEvent, uri: string) {
+ const hash = section(uri);
+ if (hash) location.href = hash;
+ else open(uri, '_blank', 'noopener');
+ },
+ hover: (_: MouseEvent, uri: string) => (element.title = uri),
+ leave: () => element.removeAttribute('title'),
+ };
const { color, fontFamily, fontSize } = getComputedStyle(screen);
const term = new Terminal({
fontFamily,
@@ -33,35 +51,74 @@ export function start(prompt: HTMLElement, command: string) {
cursorBlink: !matchMedia('(prefers-reduced-motion: reduce)').matches,
cursorInactiveStyle: 'block', // typing anywhere on the page reaches the terminal, focused or not
theme: { background: '#00000000', foreground: color, cursor: color, green: color }, // xterm.js rejects 'transparent'
+ linkHandler: link, // OSC 8 hyperlinks
});
- const fit = new FitAddon();
- term.loadAddon(fit);
+ term.loadAddon(new WebLinksAddon(link.activate, { urlRegex: URL_PATTERN, hover: link.hover, leave: link.leave }));
term.open(element);
- fit.fit();
+ const grid = element.querySelector('.xterm-screen')!;
+ const rows = element.querySelector('.xterm-rows')!;
+ // Rows and columns fill the element, without the fit addon's room for a scrollbar, which is hidden here.
+ const fit = () => {
+ const cell = grid.getBoundingClientRect();
+ const { width, height } = element.getBoundingClientRect();
+ term.resize(Math.floor(width / (cell.width / term.cols)), Math.floor(height / (cell.height / term.rows)));
+ };
+ fit();
// The wheel scrolls the page, except in full-screen apps, where xterm.js turns it into arrow keys.
screen.addEventListener('wheel', (e) => term.buffer.active.type === 'normal' && e.stopPropagation(), { capture: true });
- const grid = element.querySelector('.xterm-screen')!;
+ // How wide characters are in the page's font, to space them on the cell grid exactly like xterm.js does.
+ const measure = screen.appendChild(document.createElement('span'));
+ measure.style.cssText = 'position:absolute;visibility:hidden;letter-spacing:0;white-space:pre';
+ const advances = new Map();
+ const advance = (chars: string) => {
+ if (!advances.has(chars)) {
+ measure.textContent = chars.repeat(10);
+ advances.set(chars, measure.getBoundingClientRect().width / 10);
+ }
+ return advances.get(chars)!;
+ };
+ let cellWidth = 0;
+ let spacing = 0; // xterm.js's letter-spacing for characters as wide as 'W'
+
let used = 1; // screen rows in use, like a terminal window filling up
let moved: IMarker | undefined; // the last scrollback line already moved into the page
function layout() {
const buffer = term.buffer.active;
const rowHeight = grid.clientHeight / term.rows;
+ cellWidth = grid.getBoundingClientRect().width / term.cols;
+ spacing = parseFloat(rows.style.letterSpacing) || 0;
if (buffer.type === 'normal') {
used = buffer.baseY ? term.rows : Math.max(used, buffer.cursorY + 1);
const last = moved && !moved.isDisposed ? moved.line : -1;
for (let y = last; y >= buffer.baseY; y--) history.lastChild!.remove(); // pulled back onto a taller screen
- for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!));
+ for (let y = last + 1; y < buffer.baseY; y++) history.append(pageLine(buffer.getLine(y)!, y));
while (history.childNodes.length > SCROLLBACK) history.firstChild!.remove();
if (last !== buffer.baseY - 1) {
moved?.dispose();
moved = buffer.baseY ? term.registerMarker(-1 - buffer.cursorY) : undefined;
}
+ hyperlinks = hyperlinks.filter((l) => !l.start.marker.isDisposed); // gone with their lines
}
history.style.lineHeight = `${rowHeight}px`;
+ history.style.letterSpacing = `${spacing}px`;
screen.style.height = `${(buffer.type === 'normal' ? used : term.rows) * rowHeight}px`;
}
+ // OSC 8 hyperlinks are remembered by position, so they stay links when their lines move into the page.
+ type Position = { marker: IMarker; x: number };
+ let hyperlinks: { uri: string; start: Position; end?: Position }[] = [];
+ term.parser.registerOscHandler(8, (data) => {
+ if (term.buffer.active.type === 'normal') {
+ const here = () => ({ marker: term.registerMarker(0), x: term.buffer.active.cursorX });
+ const open = hyperlinks.at(-1);
+ if (open && !open.end) open.end = here(); // a link ends where the next one starts or the closing sequence is
+ const uri = data.slice(data.indexOf(';') + 1);
+ if (/^https?:\/\//i.test(uri)) hyperlinks.push({ uri, start: here() }); // only web links, as on the screen
+ }
+ return false; // xterm.js still handles the link on the screen
+ });
+
// A line as page text, with the colors and styles the terminal shows it in.
const palette = ANSI.map((c, i) => (i === 2 ? color : c)); // green is the page's, as in the theme
const colorOf = (n: number, rgb: boolean) => {
@@ -72,8 +129,17 @@ export function start(prompt: HTMLElement, command: string) {
return `rgb(${gray},${gray},${gray})`;
};
const cell = term.buffer.normal.getNullCell();
- function pageLine(line: IBufferLine) {
+ const foreground = (c: IBufferCell) => // '' for the default color; bold is bright
+ c.isFgDefault() ? '' : colorOf(c.getFgColor() + (c.isBold() && c.isFgPalette() && c.getFgColor() < 8 ? 8 : 0), c.isFgRGB());
+ const anchor = (uri: string, text = '') => {
+ const hash = section(uri);
+ return Object.assign(document.createElement('a'), { href: hash ?? uri, textContent: text }, hash ? {} : { target: '_blank', rel: 'noopener' });
+ };
+ function pageLine(line: IBufferLine, y: number) {
const row = document.createElement('div');
+ const linked = hyperlinks // the OSC 8 links on this line, as ranges of cells
+ .filter(({ start, end }) => start.marker.line <= y && (!end || end.marker.line >= y))
+ .map(({ uri, start, end }) => ({ uri, from: start.marker.line === y ? start.x : 0, to: end?.marker.line === y ? end.x : Infinity }));
let end = line.length; // up to the last visible cell, so copied text has no trailing spaces
while (end && !line.getCell(end - 1, cell)!.getChars().trim() && cell.isBgDefault() && !cell.isInverse()) end--;
let span: HTMLElement | undefined;
@@ -81,25 +147,40 @@ export function start(prompt: HTMLElement, command: string) {
for (let x = 0; x < end; x++) {
line.getCell(x, cell);
if (!cell.getWidth()) continue; // the second half of a wide character
- const fgIndex = cell.getFgColor() + (cell.isBold() && cell.isFgPalette() && cell.getFgColor() < 8 ? 8 : 0); // bold is bright
- let fg = cell.isFgDefault() ? '' : colorOf(fgIndex, cell.isFgRGB());
+ let fg = foreground(cell);
let bg = cell.isBgDefault() ? '' : colorOf(cell.getBgColor(), cell.isBgRGB());
if (cell.isInverse()) [fg, bg] = [bg || '#000', fg || color];
if (cell.isDim()) fg = `color-mix(in srgb, ${fg || color} 50%, transparent)`;
const decoration = [cell.isUnderline() && 'underline', cell.isStrikethrough() && 'line-through'].filter(Boolean).join(' ');
+ const uri = linked.find(({ from, to }) => x >= from && x < to)?.uri;
+ const chars = cell.getChars() || ' ';
+ const own = spacing + cellWidth * (cell.getWidth() - 1) + advance('W') - advance(chars); // e.g. wide or fallback-font characters
const style = [
- fg && `color:${fg}`,
+ fg && `--fg:${fg}`, // the page's color variable, so links invert in their own color on hover
bg && `background:${bg}`,
cell.isBold() && 'font-weight:bold',
cell.isItalic() && 'font-style:italic',
- decoration && `text-decoration:${decoration}`,
+ decoration && `text-decoration:${decoration}${uri ? ' dashed' : ''}`, // xterm.js underlines OSC 8 links dashed
cell.isInvisible() && 'visibility:hidden',
+ Math.abs(own - spacing) > 0.01 && `letter-spacing:${own}px`,
].filter(Boolean).join(';');
- if (style !== current) {
- span = row.appendChild(document.createElement('span'));
- span.style.cssText = current = style;
+ if (`${style} ${uri}` !== current) {
+ span = row.appendChild(uri ? anchor(uri) : document.createElement('span'));
+ span.style.cssText = style;
+ current = `${style} ${uri}`;
}
- span!.textContent += cell.getChars() || ' ';
+ span!.textContent += chars;
+ }
+ // Plain URLs, as the web-links addon finds them on the screen.
+ for (const span of row.querySelectorAll('span')) {
+ const text = span.textContent!;
+ const parts: (string | Node)[] = [];
+ let i = 0;
+ for (const match of text.matchAll(new RegExp(URL_PATTERN, 'g'))) {
+ parts.push(text.slice(i, match.index), anchor(match[0], match[0]));
+ i = match.index + match[0].length;
+ }
+ if (parts.length) span.replaceChildren(...parts, text.slice(i));
}
return row;
}
@@ -125,7 +206,7 @@ export function start(prompt: HTMLElement, command: string) {
});
term.onResize(layout);
new ResizeObserver(() => {
- fit.fit();
+ fit();
if (follow) reveal();
}).observe(element);
layout();
@@ -157,12 +238,14 @@ export function start(prompt: HTMLElement, command: string) {
}
return;
}
- // The shell is ready once its first output has settled; a command sent earlier would be echoed twice.
+ // The shell is ready once its line editor turns on bracketed paste, or else once its output has settled;
+ // a command sent earlier would be echoed twice.
+ const ready = new TextDecoder().decode(data).includes('\x1b[?2004h');
clearTimeout(idle);
idle = setTimeout(() => {
phase = 'echo';
session.write(`${command}\r${typeahead}`);
- }, 300);
+ }, ready ? 0 : 1000);
},
onClose(error) {
term.write(`${term.buffer.active.cursorX ? '\r\n' : ''}[${error?.message ?? 'connection closed'}]\r\n`);
diff --git a/src/styles/style.css b/src/styles/style.css
index 2278ae9..bf5cd18 100644
--- a/src/styles/style.css
+++ b/src/styles/style.css
@@ -69,11 +69,14 @@ a:hover, a:focus-visible {
/* Terminal: continues right below the first prompt. Lines scrolled off its screen become page text,
the screen shows the rows in use and xterm.js fits rows and columns to the window. */
.prompt:has(+ .history) { margin-bottom: 0; }
-.history { white-space: pre-wrap; }
+.history { white-space: pre; overflow-x: clip; } /* rows of the terminal's width; cut, not wrapped, if the window narrows */
.history > div { min-height: 1lh; } /* keeps empty lines */
+.history span { color: var(--fg); }
.screen { overflow: clip; }
.screen > div { height: 100dvh; }
.screen .xterm-viewport, .screen .scrollbar { display: none; } /* the page scrolls instead */
+/* hovered links (xterm.js underlines them inline) invert in their own color, like the page's links */
+.screen .xterm-rows span[style*="text-decoration: underline"] { background-color: currentColor; -webkit-text-fill-color: #000; text-shadow: none; }
@media (prefers-reduced-motion: no-preference) {
/* typing still reaches the terminal when it is not focused, so its cursor keeps blinking like the page's */
.screen .xterm-rows:not(.xterm-focus) .xterm-cursor-block { animation: blink 1.1s steps(1) infinite; }
From cfab5962e4f30df1c8aef237cd1120de644dd821 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:41:43 +0100
Subject: [PATCH 11/39] Put the styles in a separate styles.css
Co-Authored-By: Claude Opus 5.5 (1M context)
---
astro.config.mjs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/astro.config.mjs b/astro.config.mjs
index 12b6d7d..63ef084 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -2,9 +2,11 @@ import { defineConfig } from 'astro/config';
export default defineConfig({
// Relative asset URLs, so the site works wherever it is hosted, e.g. https://berlin.ccc.de/kik/
- build: { format: 'file', assetsPrefix: '.' },
+ build: { format: 'file', assetsPrefix: '.', inlineStylesheets: 'never' },
compressHTML: false,
vite: {
+ // The pages' styles, which Astro bundles while building the pages
+ build: { rolldownOptions: { output: { assetFileNames: 'styles.css' } } },
environments: {
client: {
build: {
From 4d6900ccf912b8db15873f2284e43ac595dd48dc Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 02:44:41 +0100
Subject: [PATCH 12/39] Ship styles.css unminified
Co-Authored-By: Claude Opus 5.5 (1M context)
---
astro.config.mjs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/astro.config.mjs b/astro.config.mjs
index 63ef084..91fc975 100644
--- a/astro.config.mjs
+++ b/astro.config.mjs
@@ -5,8 +5,8 @@ export default defineConfig({
build: { format: 'file', assetsPrefix: '.', inlineStylesheets: 'never' },
compressHTML: false,
vite: {
- // The pages' styles, which Astro bundles while building the pages
- build: { rolldownOptions: { output: { assetFileNames: 'styles.css' } } },
+ // The pages' styles, which Astro bundles while building the pages, readable rather than minified
+ build: { cssMinify: false, rolldownOptions: { output: { assetFileNames: 'styles.css' } } },
environments: {
client: {
build: {
From 996cc53e5647f2f7b3dac637dfb1aaf3f765a9ae Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 03:04:12 +0100
Subject: [PATCH 13/39] Add the shell server's README and Code of Conduct files
The terminal versions of the pages for the home directory of the shell
server's root user, so each page's top command (cat README, cat README.en,
cat CODE_OF_CONDUCT, cat CODE_OF_CONDUCT.en) works in the web terminal.
Generated by server/make_files.py.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
server/CODE_OF_CONDUCT | 31 ++++
server/CODE_OF_CONDUCT.en | 30 ++++
server/README | 79 +++++++++
server/README.en | 77 +++++++++
server/make_files.py | 335 ++++++++++++++++++++++++++++++++++++++
5 files changed, 552 insertions(+)
create mode 100644 server/CODE_OF_CONDUCT
create mode 100644 server/CODE_OF_CONDUCT.en
create mode 100644 server/README
create mode 100644 server/README.en
create mode 100644 server/make_files.py
diff --git a/server/CODE_OF_CONDUCT b/server/CODE_OF_CONDUCT
new file mode 100644
index 0000000..243da37
--- /dev/null
+++ b/server/CODE_OF_CONDUCT
@@ -0,0 +1,31 @@
+
+┌─[38;2;31;154;63m┤ [0m[1mCode of Conduct[0m[38;2;31;154;63m ├[0m────────────────────────────────────────────────────────────┐
+│ Khaos im Kiez ist eine Veranstaltung des Chaos Computer Club Berlin e.V. │
+│ (CCCB). Damit sich alle wohlfühlen, gelten diese Regeln für alle │
+│ Teilnehmenden, Vortragenden und Helfenden. │
+│ │
+│ [38;2;31;154;63m> [0m[1mSei nett zueinander.[0m Geh respektvoll und freundlich miteinander um, auch │
+│ wenn du anderer Meinung bist. │
+│ [38;2;31;154;63m> [0m[1mKeine Diskriminierung.[0m Rassismus, Sexismus, Queerfeindlichkeit, Ableismus │
+│ und andere menschenverachtende Haltungen haben hier keinen Platz. │
+│ [38;2;31;154;63m> [0m[1mKeine Belästigung.[0m Dazu gehören Beleidigungen, Bedrohungen, Stalking, │
+│ unerwünschte körperliche Berührungen und unerwünschte sexuelle │
+│ Aufmerksamkeit. Ein Nein ist ein Nein. │
+│ [38;2;31;154;63m> [0m[1mFrag vor dem Fotografieren.[0m Fotos, Video- und Tonaufnahmen von Personen nur │
+│ mit deren Einverständnis. Das gilt auch für Talks und Workshops. │
+│ [38;2;31;154;63m> [0m[1mAchte auf Geräte und Eigentum.[0m Fass fremde Geräte nicht ohne Erlaubnis an. │
+│ Geh pfleglich mit den Räumen und dem Inventar um. │
+│ [38;2;31;154;63m> [0m[1mSei neugierig, nicht übergriffig.[0m Chaos ja, Grenzenüberschreitungen nein. │
+│ │
+│ [1mHausrecht:[0m Die Orga hat Hausrecht. Bei Verstößen gegen diese Regeln kann sie │
+│ Verwarnungen aussprechen, Personen der Veranstaltung verweisen oder ein │
+│ Hausverbot erteilen. Den Anweisungen der Orga ist Folge zu leisten. │
+│ │
+│ [1mProbleme?[0m Wenn dir etwas passiert oder du etwas beobachtest, sprich eine │
+│ Person aus der Orga an oder schreib an aprl [at] berlin [dot] ccc [dot] de. │
+│ Hinweise werden vertraulich behandelt. │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+ ]8;;https://berlin.ccc.de/kik/\[38;2;31;154;63mStartseite[0m]8;;\[38;2;31;154;63m · [0m]8;;https://berlin.ccc.de/page/impressum/\[38;2;31;154;63mImpressum[0m]8;;\
+
diff --git a/server/CODE_OF_CONDUCT.en b/server/CODE_OF_CONDUCT.en
new file mode 100644
index 0000000..1639082
--- /dev/null
+++ b/server/CODE_OF_CONDUCT.en
@@ -0,0 +1,30 @@
+
+┌─[38;2;31;154;63m┤ [0m[1mCode of Conduct[0m[38;2;31;154;63m ├[0m────────────────────────────────────────────────────────────┐
+│ Khaos im Kiez is an event of the Chaos Computer Club Berlin e.V. (CCCB). To │
+│ make everyone feel comfortable, these rules apply to all attendees, speakers │
+│ and helpers. │
+│ │
+│ [38;2;31;154;63m> [0m[1mBe nice to each other.[0m Treat each other respectfully and kindly, even when │
+│ you disagree. │
+│ [38;2;31;154;63m> [0m[1mNo discrimination.[0m Racism, sexism, queerphobia, ableism and other hateful │
+│ attitudes have no place here. │
+│ [38;2;31;154;63m> [0m[1mNo harassment.[0m This includes insults, threats, stalking, unwanted physical │
+│ contact and unwanted sexual attention. No means no. │
+│ [38;2;31;154;63m> [0m[1mAsk before taking pictures.[0m Photos, video and audio recordings of people │
+│ only with their consent. This also applies to talks and workshops. │
+│ [38;2;31;154;63m> [0m[1mRespect devices and property.[0m Do not touch other people's devices without │
+│ permission. Treat the rooms and furniture with care. │
+│ [38;2;31;154;63m> [0m[1mBe curious, not intrusive.[0m Chaos yes, crossing boundaries no. │
+│ │
+│ [1mHouse rules:[0m The organizers have the right to exclude people from the premises │
+│ (Hausrecht). In case of violations they may issue warnings, ask people to │
+│ leave, or ban them from the event. Follow the organizers' instructions. │
+│ │
+│ [1mProblems?[0m If something happens to you or you notice something, talk to a │
+│ member of the organizing team or write to aprl [at] berlin [dot] ccc [dot] de. │
+│ Reports are treated confidentially. │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+ ]8;;https://berlin.ccc.de/kik/en.html\[38;2;31;154;63mHome[0m]8;;\[38;2;31;154;63m · [0m]8;;https://berlin.ccc.de/page/impressum/\[38;2;31;154;63mLegal notice[0m]8;;\
+
diff --git a/server/README b/server/README
new file mode 100644
index 0000000..57905aa
--- /dev/null
+++ b/server/README
@@ -0,0 +1,79 @@
+
+[1mKHAOS IM [0m[1m[38;2;255;42;42mKIEZ[0m
+[38;2;31;154;63mCCC Berlin · 28. – 29.11.2026[0m
+
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+]8;;https://berlin.ccc.de/kik/#infos\[1] Infos]8;;\ ]8;;https://berlin.ccc.de/kik/#mitmachen\[2] Mitmachen]8;;\ ]8;;https://berlin.ccc.de/kik/#anfahrt\[3] Anfahrt]8;;\
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+
+┌─[38;2;31;154;63m┤ [0m[1m1 Infos[0m[38;2;31;154;63m ├[0m────────────────────────────────────────────────────────────────────┐
+│ Khaos im Kiez ist ein kleines Chaos-Event, back to the roots: inspiriert von │
+│ anderen kleinen Chaos-Events. Ein Wochenende zum Chillen und um die Seite von │
+│ Chaos-Events zu erleben, für die bei größeren Events oft keine Zeit mehr │
+│ bleibt. │
+│ │
+│ Du kannst einen Talk oder Workshop halten, an deinen Projekten schrauben, │
+│ gemeinsam hacken, löten, basteln oder einfach vorbeikommen, zuhören und Leute │
+│ kennenlernen. Alle sind willkommen, die Lust auf $Dinge, Neugier und Chaos │
+│ mitbringen. │
+│ │
+│ [38;2;31;154;63mWas : [0mKhaos im Kiez (KiK) │
+│ [38;2;31;154;63mWann : [0mSa 28.11. – So 29.11.2026, 11:00 – 23:59 │
+│ [38;2;31;154;63mWo : [0mMarienstraße 11, 10117 Berlin │
+│ [38;2;31;154;63mWer : [0mChaos Computer Club Berlin │
+│ [38;2;31;154;63mPreis : [0m0,00 € (Ticket trotzdem nötig, ~50 Plätze) │
+│ [38;2;31;154;63mFahrplan : [0m]8;;https://cfp.berlin.ccc.de\cfp.berlin.ccc.de]8;;\ │
+│ [38;2;31;154;63mEssen : [0mEwiges Frühstück, vegan │
+│ [38;2;31;154;63mBarrierefrei : [0mNicht barrierefrei. │
+│ [38;2;31;154;63mRegeln : [0m]8;;https://berlin.ccc.de/kik/coc.html\Code of Conduct]8;;\ │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+┌─[38;2;31;154;63m┤ [0m[1m2 Mitmachen[0m[38;2;31;154;63m ├[0m────────────────────────────────────────────────────────────────┐
+│ [1mTicket:[0m Anmeldung über den Ticketshop. Das Ticket ist kostenlos, aber die │
+│ Plätze sind begrenzt. │
+│ │
+│ ]8;;https://tickets.berlin.ccc.de/cccb/kik-v0.1/\[38;2;255;42;42m[ Ticket sichern ][0m]8;;\ │
+│ │
+│ [1mTalks & Workshops:[0m Reiche deinen Beitrag im Call for Participation ein und │
+│ schau dir später den Fahrplan an. │
+│ │
+│ ]8;;https://cfp.berlin.ccc.de/kik-v01/cfp\[ Talk einreichen ]]8;;\ ]8;;https://cfp.berlin.ccc.de/kik-v01/schedule\[ Fahrplan ]]8;;\ │
+│ │
+│ [1mSonstiges:[0m Du willst anders helfen oder hast Fragen? Schreib an aprl [at] │
+│ berlin [dot] ccc [dot] de. │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+┌─[38;2;31;154;63m┤ [0m[1m3 Anfahrt[0m[38;2;31;154;63m ├[0m──────────────────────────────────────────────────────────────────┐
+│ [38;2;31;154;63mAdresse : [0mClubräume im Hinterhof der Marienstraße 11, 10117 Berlin │
+│ [38;2;31;154;63mKoordinaten : [0m52,52194° N, 13,38261° O │
+│ [38;2;31;154;63mFahrrad : [0mIm Innenhof gibt es eine begrenzte Zahl an Fahrradständern. Auf │
+│ dem Weg liegen viele Fahrradstraßen, nutz die am besten, z. B. │
+│ mit ]8;;http://www.bbbike.de/\BBBike.de]8;;\. │
+│ [38;2;31;154;63mÖPNV : [0mDer Club liegt direkt beim S+U-Bahnhof Friedrichstraße (auch │
+│ Regionalbahnen). Vom oberirdischen S-Bahn-Steig (S3/S5/S7) │
+│ nimmst du den Ausgang Schiffbauerdamm und gehst unter der │
+│ Bahnbrücke über die Spree. Über die Albrechtstraße kommst du │
+│ zur Marienstraße. │
+│ [38;2;31;154;63mFernverkehr : [0mAussteigen in Berlin Hauptbahnhof, dann mit S-Bahn oder │
+│ Regionalbahn Richtung Osten eine Station bis Friedrichstraße. │
+│ Bahnhof Richtung Spree verlassen, über die Fußgängerbrücke und │
+│ der Albrechtstraße bis zur Stichstraße Marienstraße folgen. │
+│ [38;2;31;154;63mFlugzeug : [0mBER: Airport Express (RE7, RB14) oder S9 bis Friedrichstraße, │
+│ alternativ FEX bis Hauptbahnhof (dann wie Fernverkehr). │
+│ Achtung: Terminal 5 (früher SXF) ist nicht mit dem Rest des BER │
+│ verbunden, dort fährt nur die S9. Flugplatz Strausberg (EDAY): │
+│ S7 direkt bis Friedrichstraße. │
+│ [38;2;31;154;63mAuto : [0mZufahrt von Hauptbahnhof oder Brandenburger Tor über die │
+│ Luisenstraße. Von der Friedrichstraße entweder über │
+│ Schiffbauerdamm und Albrechtstraße (Außengastronomie und │
+│ Kopfsteinpflaster) oder über Reinhardstraße und Luisenstraße │
+│ (die Albrechtstraße ist nur in Gegenrichtung befahrbar). │
+│ [38;2;31;154;63mParken : [0mAchtung: Es gibt kaum Parkplätze, und die Marienstraße und alle │
+│ umliegenden Straßen sind parkraumbewirtschaftet. │
+│ │
+│ ]8;;https://www.openstreetmap.org/node/3872556357#map=20/52.5217003/13.3828041&layers=H\[ Karte (OpenStreetMap) ]]8;;\ │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+ ]8;;https://berlin.ccc.de/page/impressum/\[38;2;31;154;63mImpressum[0m]8;;\[38;2;31;154;63m · [0m]8;;https://berlin.ccc.de/\[38;2;31;154;63mCCC Berlin[0m]8;;\
+
diff --git a/server/README.en b/server/README.en
new file mode 100644
index 0000000..5b524e3
--- /dev/null
+++ b/server/README.en
@@ -0,0 +1,77 @@
+
+[1mKHAOS IM [0m[1m[38;2;255;42;42mKIEZ[0m
+[38;2;31;154;63mCCC Berlin · 2026-11-28 – 2026-11-29[0m
+
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+]8;;https://berlin.ccc.de/kik/en.html#infos\[1] Info]8;;\ ]8;;https://berlin.ccc.de/kik/en.html#join\[2] Join in]8;;\ ]8;;https://berlin.ccc.de/kik/en.html#getting-here\[3] Getting here]8;;\
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+
+┌─[38;2;31;154;63m┤ [0m[1m1 Info[0m[38;2;31;154;63m ├[0m─────────────────────────────────────────────────────────────────────┐
+│ Khaos im Kiez is a small chaos event, back to the roots: inspired by other │
+│ small chaos events. A weekend to chill and to experience the side of chaos │
+│ events that often gets no time at larger events. │
+│ │
+│ Give a talk or workshop, work on your projects, hack together, solder, tinker, │
+│ or simply drop by, listen and meet people. Everyone who brings a love of │
+│ $things, curiosity and a bit of chaos is welcome. │
+│ │
+│ [38;2;31;154;63mWhat : [0mKhaos im Kiez (KiK) │
+│ [38;2;31;154;63mWhen : [0mSat 2026-11-28 – Sun 2026-11-29, 11:00 – 23:59 │
+│ [38;2;31;154;63mWhere : [0mMarienstraße 11, 10117 Berlin, Germany │
+│ [38;2;31;154;63mWho : [0mChaos Computer Club Berlin │
+│ [38;2;31;154;63mPrice : [0m€0.00 (ticket still required, ~50 seats) │
+│ [38;2;31;154;63mSchedule : [0m]8;;https://cfp.berlin.ccc.de\cfp.berlin.ccc.de]8;;\ │
+│ [38;2;31;154;63mFood : [0mEternal breakfast, vegan │
+│ [38;2;31;154;63mAccessibility : [0mNot accessible. │
+│ [38;2;31;154;63mRules : [0m]8;;https://berlin.ccc.de/kik/coc-en.html\Code of Conduct]8;;\ │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+┌─[38;2;31;154;63m┤ [0m[1m2 Join in[0m[38;2;31;154;63m ├[0m──────────────────────────────────────────────────────────────────┐
+│ [1mTicket:[0m Register via the ticket shop. The ticket is free, but seats are │
+│ limited. │
+│ │
+│ ]8;;https://tickets.berlin.ccc.de/cccb/kik-v0.1/\[38;2;255;42;42m[ Get ticket ][0m]8;;\ │
+│ │
+│ [1mTalks & workshops:[0m Submit your proposal via the call for participation and │
+│ check the schedule later. │
+│ │
+│ ]8;;https://cfp.berlin.ccc.de/kik-v01/cfp\[ Submit a talk ]]8;;\ ]8;;https://cfp.berlin.ccc.de/kik-v01/schedule\[ Schedule ]]8;;\ │
+│ │
+│ [1mOther:[0m Want to help otherwise or have questions? Write to aprl [at] berlin │
+│ [dot] ccc [dot] de. │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+┌─[38;2;31;154;63m┤ [0m[1m3 Getting here[0m[38;2;31;154;63m ├[0m─────────────────────────────────────────────────────────────┐
+│ [38;2;31;154;63mAddress : [0mClub rooms in the back yard of Marienstraße 11, 10117 Berlin │
+│ [38;2;31;154;63mCoordinates : [0m52.52194° N, 13.38261° E │
+│ [38;2;31;154;63mBicycle : [0mA limited number of bike racks are in the courtyard. Many │
+│ bicycle streets lead there, preferably use those, e.g. with │
+│ ]8;;http://www.bbbike.de/\BBBike.de]8;;\. │
+│ [38;2;31;154;63mLocal transit : [0mThe club is right next to S+U Friedrichstraße (regional trains │
+│ stop there too). From the elevated S-Bahn platform (S3/S5/S7) │
+│ take the Schiffbauerdamm exit and walk across the Spree │
+│ beneath the railway bridge. Albrechtstraße leads to │
+│ Marienstraße. │
+│ [38;2;31;154;63mLong distance : [0mGet off at Berlin Hauptbahnhof, then take any S-Bahn or │
+│ regional train eastbound one stop to Friedrichstraße. Leave │
+│ the station towards the Spree, cross the footbridge and follow │
+│ Albrechtstraße to the dead-end Marienstraße. │
+│ [38;2;31;154;63mAir : [0mBER: Airport Express (RE7, RB14) or S9 to Friedrichstraße, or │
+│ FEX to Hauptbahnhof (then as above). Note: Terminal 5 │
+│ (formerly SXF) is not connected to the rest of BER, only the │
+│ S9 runs there. Strausberg airfield (EDAY): S7 direct to │
+│ Friedrichstraße. │
+│ [38;2;31;154;63mCar : [0mAccess from Hauptbahnhof or Brandenburger Tor via │
+│ Luisenstraße. From Friedrichstraße either via Schiffbauerdamm │
+│ and Albrechtstraße (outdoor seating and cobblestones) or via │
+│ Reinhardstraße and Luisenstraße (Albrechtstraße is one-way the │
+│ other direction). │
+│ [38;2;31;154;63mParking : [0mWarning: parking is scarce, and Marienstraße and all │
+│ surrounding streets are subject to paid parking zones. │
+│ │
+│ ]8;;https://www.openstreetmap.org/node/3872556357#map=20/52.5217003/13.3828041&layers=H\[ Map (OpenStreetMap) ]]8;;\ │
+└────────────────────────────────────────────────────────────────────────────────┘
+
+[38;2;31;154;63m──────────────────────────────────────────────────────────────────────────────────[0m
+ ]8;;https://berlin.ccc.de/page/impressum/\[38;2;31;154;63mLegal notice[0m]8;;\[38;2;31;154;63m · [0m]8;;https://berlin.ccc.de/\[38;2;31;154;63mCCC Berlin[0m]8;;\
+
diff --git a/server/make_files.py b/server/make_files.py
new file mode 100644
index 0000000..6822a7a
--- /dev/null
+++ b/server/make_files.py
@@ -0,0 +1,335 @@
+# Builds README, README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en next to this script: the website's pages as
+# terminal text with escape sequences, for the home directory of the shell server's root user.
+# Usage: python3 server/make_files.py
+import pathlib
+import textwrap
+
+ESC = '\x1b'
+RESET = f'{ESC}[0m'
+BOLD = f'{ESC}[1m'
+DIM = f'{ESC}[38;2;31;154;63m' # the page's --dim
+HOT = f'{ESC}[38;2;255;42;42m' # the page's --hot
+WIDTH = 82 # the web terminal's width, as wide as the page column
+INNER = WIDTH - 4 # text width inside "│ " ... " │"
+SITE = 'https://berlin.ccc.de/kik/'
+IMPRESSUM = 'https://berlin.ccc.de/page/impressum/'
+TICKETS = 'https://tickets.berlin.ccc.de/cccb/kik-v0.1/'
+CFP = 'https://cfp.berlin.ccc.de/kik-v01/cfp'
+SCHEDULE = 'https://cfp.berlin.ccc.de/kik-v01/schedule'
+MAP = 'https://www.openstreetmap.org/node/3872556357#map=20/52.5217003/13.3828041&layers=H'
+
+
+def link(url, text, style=''):
+ return f'{ESC}]8;;{url}{ESC}\\{style}{text}{RESET if style else ""}{ESC}]8;;{ESC}\\'
+
+
+class Line:
+ """A line of text with its visible width, so boxes can be padded around styled text."""
+ def __init__(self, text='', width=None):
+ self.text, self.width = text, len(text) if width is None else width
+
+ def __add__(self, other):
+ return Line(self.text + other.text, self.width + other.width)
+
+
+def styled(text, style):
+ return Line(f'{style}{text}{RESET}', len(text))
+
+
+def linked(url, text, style=''):
+ return Line(link(url, text, style), len(text))
+
+
+def joined(parts, gap=' '):
+ line = parts[0]
+ for part in parts[1:]:
+ line = line + Line(gap) + part
+ return line
+
+
+def wrap(text, width, links=()):
+ """Wraps plain text, then turns the given (text, url) pairs into links where they appear."""
+ lines = []
+ for raw in textwrap.wrap(text, width, break_on_hyphens=False, break_long_words=False):
+ rendered = raw
+ for needle, url in links:
+ rendered = rendered.replace(needle, link(url, needle))
+ lines.append(Line(rendered, len(raw)))
+ return lines
+
+
+def paragraph(text, links=(), lead=None):
+ """A paragraph, optionally starting with a bold lead-in like "Ticket:"."""
+ full = f'{lead} {text}' if lead else text
+ lines = wrap(full, INNER, links)
+ if lead:
+ first = lines[0]
+ lines[0] = Line(first.text.replace(lead, f'{BOLD}{lead}{RESET}', 1), first.width)
+ return lines
+
+
+def pairs(items, label, links=()):
+ """A key-value list like the page's: dim "Label :" column, values wrapped with a hanging indent."""
+ lines = []
+ for key, value in items:
+ wrapped = wrap(value, INNER - label, links)
+ lines.append(styled(f'{key} :'.ljust(label), DIM) + wrapped[0])
+ lines += [Line(' ' * label) + rest for rest in wrapped[1:]]
+ return lines
+
+
+def bullets(items):
+ """A list like the page's: dim "> " marker, bold title, text wrapped with a hanging indent."""
+ lines = []
+ for title, text in items:
+ wrapped = wrap(f'{title} {text}', INNER - 2)
+ first = wrapped[0]
+ lines.append(styled('> ', DIM) + Line(first.text.replace(title, f'{BOLD}{title}{RESET}', 1), first.width))
+ lines += [Line(' ') + rest for rest in wrapped[1:]]
+ return lines
+
+
+def box(title, lines):
+ rule = WIDTH - 2 - len(f'─┤ {title} ├')
+ out = [Line('┌─') + styled('┤ ', DIM) + styled(title, BOLD) + styled(' ├', DIM) + Line('─' * rule + '┐')]
+ out += [Line('│ ') + line + Line(' ' * (INNER - line.width) + ' │') for line in lines]
+ out.append(Line('└' + '─' * (WIDTH - 2) + '┘'))
+ return out
+
+
+def centered(line):
+ return Line(' ' * ((WIDTH - line.width) // 2)) + line
+
+
+def nav(line):
+ return [styled('─' * WIDTH, DIM), line, styled('─' * WIDTH, DIM)]
+
+
+def footer(parts):
+ line = linked(parts[0][1], parts[0][0], DIM)
+ for text, url in parts[1:]:
+ line = line + styled(' · ', DIM) + linked(url, text, DIM)
+ return [styled('─' * WIDTH, DIM), centered(line)]
+
+
+def page(lines):
+ lines = [Line(), *lines, Line()]
+ for line in lines:
+ assert line.width <= WIDTH + 2, (line.width, line.text)
+ return ''.join(f'{line.text}\n' for line in lines)
+
+
+def readme(t):
+ label = 3 + max(len(key) for key, _ in t['infos'] + t['route'])
+ return page([
+ styled('KHAOS IM ', BOLD) + styled('KIEZ', BOLD + HOT),
+ styled(t['date'], DIM),
+ Line(),
+ *nav(joined([linked(f'{SITE}{t["page"]}#{anchor}', f'[{n}] {title}') for n, title, anchor in t['sections']])),
+ Line(),
+ *box('{} {}'.format(*t['sections'][0][:2]), [
+ *paragraph(t['intro'][0]),
+ Line(),
+ *paragraph(t['intro'][1]),
+ Line(),
+ *pairs(t['infos'], label, links=[('cfp.berlin.ccc.de', 'https://cfp.berlin.ccc.de'), ('Code of Conduct', f'{SITE}{t["coc"]}')]),
+ ]),
+ Line(),
+ *box('{} {}'.format(*t['sections'][1][:2]), [
+ *paragraph(t['ticket'], lead=t['ticket_lead']),
+ Line(),
+ linked(TICKETS, f'[ {t["get_ticket"]} ]', HOT),
+ Line(),
+ *paragraph(t['talks'], lead=t['talks_lead']),
+ Line(),
+ joined([linked(CFP, f'[ {t["submit"]} ]'), linked(SCHEDULE, f'[ {t["schedule"]} ]')]),
+ Line(),
+ *paragraph(t['other'], lead=t['other_lead']),
+ ]),
+ Line(),
+ *box('{} {}'.format(*t['sections'][2][:2]), [
+ *pairs(t['route'], label, links=[('BBBike.de', 'http://www.bbbike.de/')]),
+ Line(),
+ linked(MAP, f'[ {t["map"]} ]'),
+ ]),
+ Line(),
+ *footer(t['footer']),
+ ])
+
+
+def code_of_conduct(t):
+ return page([
+ *box('Code of Conduct', [
+ *paragraph(t['intro']),
+ Line(),
+ *bullets(t['rules']),
+ Line(),
+ *paragraph(t['house'], lead=t['house_lead']),
+ Line(),
+ *paragraph(t['problems'], lead=t['problems_lead']),
+ ]),
+ Line(),
+ *footer(t['footer']),
+ ])
+
+
+DE = {
+ 'page': '', 'coc': 'coc.html',
+ 'date': 'CCC Berlin · 28. – 29.11.2026',
+ 'sections': [(1, 'Infos', 'infos'), (2, 'Mitmachen', 'mitmachen'), (3, 'Anfahrt', 'anfahrt')],
+ 'intro': [
+ 'Khaos im Kiez ist ein kleines Chaos-Event, back to the roots: inspiriert von anderen kleinen Chaos-Events. '
+ 'Ein Wochenende zum Chillen und um die Seite von Chaos-Events zu erleben, für die bei größeren Events oft '
+ 'keine Zeit mehr bleibt.',
+ 'Du kannst einen Talk oder Workshop halten, an deinen Projekten schrauben, gemeinsam hacken, löten, basteln '
+ 'oder einfach vorbeikommen, zuhören und Leute kennenlernen. Alle sind willkommen, die Lust auf $Dinge, '
+ 'Neugier und Chaos mitbringen.',
+ ],
+ 'infos': [
+ ('Was', 'Khaos im Kiez (KiK)'),
+ ('Wann', 'Sa 28.11. – So 29.11.2026, 11:00 – 23:59'),
+ ('Wo', 'Marienstraße 11, 10117 Berlin'),
+ ('Wer', 'Chaos Computer Club Berlin'),
+ ('Preis', '0,00 € (Ticket trotzdem nötig, ~50 Plätze)'),
+ ('Fahrplan', 'cfp.berlin.ccc.de'),
+ ('Essen', 'Ewiges Frühstück, vegan'),
+ ('Barrierefrei', 'Nicht barrierefrei.'),
+ ('Regeln', 'Code of Conduct'),
+ ],
+ 'ticket_lead': 'Ticket:',
+ 'ticket': 'Anmeldung über den Ticketshop. Das Ticket ist kostenlos, aber die Plätze sind begrenzt.',
+ 'get_ticket': 'Ticket sichern',
+ 'talks_lead': 'Talks & Workshops:',
+ 'talks': 'Reiche deinen Beitrag im Call for Participation ein und schau dir später den Fahrplan an.',
+ 'submit': 'Talk einreichen', 'schedule': 'Fahrplan',
+ 'other_lead': 'Sonstiges:',
+ 'other': 'Du willst anders helfen oder hast Fragen? Schreib an aprl [at] berlin [dot] ccc [dot] de.',
+ 'route': [
+ ('Adresse', 'Clubräume im Hinterhof der Marienstraße 11, 10117 Berlin'),
+ ('Koordinaten', '52,52194° N, 13,38261° O'),
+ ('Fahrrad', 'Im Innenhof gibt es eine begrenzte Zahl an Fahrradständern. Auf dem Weg liegen viele '
+ 'Fahrradstraßen, nutz die am besten, z. B. mit BBBike.de.'),
+ ('ÖPNV', 'Der Club liegt direkt beim S+U-Bahnhof Friedrichstraße (auch Regionalbahnen). Vom oberirdischen '
+ 'S-Bahn-Steig (S3/S5/S7) nimmst du den Ausgang Schiffbauerdamm und gehst unter der Bahnbrücke über '
+ 'die Spree. Über die Albrechtstraße kommst du zur Marienstraße.'),
+ ('Fernverkehr', 'Aussteigen in Berlin Hauptbahnhof, dann mit S-Bahn oder Regionalbahn Richtung Osten eine '
+ 'Station bis Friedrichstraße. Bahnhof Richtung Spree verlassen, über die Fußgängerbrücke und '
+ 'der Albrechtstraße bis zur Stichstraße Marienstraße folgen.'),
+ ('Flugzeug', 'BER: Airport Express (RE7, RB14) oder S9 bis Friedrichstraße, alternativ FEX bis Hauptbahnhof '
+ '(dann wie Fernverkehr). Achtung: Terminal 5 (früher SXF) ist nicht mit dem Rest des BER '
+ 'verbunden, dort fährt nur die S9. Flugplatz Strausberg (EDAY): S7 direkt bis Friedrichstraße.'),
+ ('Auto', 'Zufahrt von Hauptbahnhof oder Brandenburger Tor über die Luisenstraße. Von der Friedrichstraße '
+ 'entweder über Schiffbauerdamm und Albrechtstraße (Außengastronomie und Kopfsteinpflaster) oder '
+ 'über Reinhardstraße und Luisenstraße (die Albrechtstraße ist nur in Gegenrichtung befahrbar).'),
+ ('Parken', 'Achtung: Es gibt kaum Parkplätze, und die Marienstraße und alle umliegenden Straßen sind '
+ 'parkraumbewirtschaftet.'),
+ ],
+ 'map': 'Karte (OpenStreetMap)',
+ 'footer': [('Impressum', IMPRESSUM), ('CCC Berlin', 'https://berlin.ccc.de/')],
+}
+
+EN = {
+ 'page': 'en.html', 'coc': 'coc-en.html',
+ 'date': 'CCC Berlin · 2026-11-28 – 2026-11-29',
+ 'sections': [(1, 'Info', 'infos'), (2, 'Join in', 'join'), (3, 'Getting here', 'getting-here')],
+ 'intro': [
+ 'Khaos im Kiez is a small chaos event, back to the roots: inspired by other small chaos events. A weekend to '
+ 'chill and to experience the side of chaos events that often gets no time at larger events.',
+ 'Give a talk or workshop, work on your projects, hack together, solder, tinker, or simply drop by, listen and '
+ 'meet people. Everyone who brings a love of $things, curiosity and a bit of chaos is welcome.',
+ ],
+ 'infos': [
+ ('What', 'Khaos im Kiez (KiK)'),
+ ('When', 'Sat 2026-11-28 – Sun 2026-11-29, 11:00 – 23:59'),
+ ('Where', 'Marienstraße 11, 10117 Berlin, Germany'),
+ ('Who', 'Chaos Computer Club Berlin'),
+ ('Price', '€0.00 (ticket still required, ~50 seats)'),
+ ('Schedule', 'cfp.berlin.ccc.de'),
+ ('Food', 'Eternal breakfast, vegan'),
+ ('Accessibility', 'Not accessible.'),
+ ('Rules', 'Code of Conduct'),
+ ],
+ 'ticket_lead': 'Ticket:',
+ 'ticket': 'Register via the ticket shop. The ticket is free, but seats are limited.',
+ 'get_ticket': 'Get ticket',
+ 'talks_lead': 'Talks & workshops:',
+ 'talks': 'Submit your proposal via the call for participation and check the schedule later.',
+ 'submit': 'Submit a talk', 'schedule': 'Schedule',
+ 'other_lead': 'Other:',
+ 'other': 'Want to help otherwise or have questions? Write to aprl [at] berlin [dot] ccc [dot] de.',
+ 'route': [
+ ('Address', 'Club rooms in the back yard of Marienstraße 11, 10117 Berlin'),
+ ('Coordinates', '52.52194° N, 13.38261° E'),
+ ('Bicycle', 'A limited number of bike racks are in the courtyard. Many bicycle streets lead there, preferably '
+ 'use those, e.g. with BBBike.de.'),
+ ('Local transit', 'The club is right next to S+U Friedrichstraße (regional trains stop there too). From the '
+ 'elevated S-Bahn platform (S3/S5/S7) take the Schiffbauerdamm exit and walk across the Spree '
+ 'beneath the railway bridge. Albrechtstraße leads to Marienstraße.'),
+ ('Long distance', 'Get off at Berlin Hauptbahnhof, then take any S-Bahn or regional train eastbound one stop '
+ 'to Friedrichstraße. Leave the station towards the Spree, cross the footbridge and follow '
+ 'Albrechtstraße to the dead-end Marienstraße.'),
+ ('Air', 'BER: Airport Express (RE7, RB14) or S9 to Friedrichstraße, or FEX to Hauptbahnhof (then as above). '
+ 'Note: Terminal 5 (formerly SXF) is not connected to the rest of BER, only the S9 runs there. '
+ 'Strausberg airfield (EDAY): S7 direct to Friedrichstraße.'),
+ ('Car', 'Access from Hauptbahnhof or Brandenburger Tor via Luisenstraße. From Friedrichstraße either via '
+ 'Schiffbauerdamm and Albrechtstraße (outdoor seating and cobblestones) or via Reinhardstraße and '
+ 'Luisenstraße (Albrechtstraße is one-way the other direction).'),
+ ('Parking', 'Warning: parking is scarce, and Marienstraße and all surrounding streets are subject to paid '
+ 'parking zones.'),
+ ],
+ 'map': 'Map (OpenStreetMap)',
+ 'footer': [('Legal notice', IMPRESSUM), ('CCC Berlin', 'https://berlin.ccc.de/')],
+}
+
+COC_DE = {
+ 'intro': 'Khaos im Kiez ist eine Veranstaltung des Chaos Computer Club Berlin e.V. (CCCB). Damit sich alle '
+ 'wohlfühlen, gelten diese Regeln für alle Teilnehmenden, Vortragenden und Helfenden.',
+ 'rules': [
+ ('Sei nett zueinander.', 'Geh respektvoll und freundlich miteinander um, auch wenn du anderer Meinung bist.'),
+ ('Keine Diskriminierung.', 'Rassismus, Sexismus, Queerfeindlichkeit, Ableismus und andere menschenverachtende '
+ 'Haltungen haben hier keinen Platz.'),
+ ('Keine Belästigung.', 'Dazu gehören Beleidigungen, Bedrohungen, Stalking, unerwünschte körperliche '
+ 'Berührungen und unerwünschte sexuelle Aufmerksamkeit. Ein Nein ist ein Nein.'),
+ ('Frag vor dem Fotografieren.', 'Fotos, Video- und Tonaufnahmen von Personen nur mit deren Einverständnis. '
+ 'Das gilt auch für Talks und Workshops.'),
+ ('Achte auf Geräte und Eigentum.', 'Fass fremde Geräte nicht ohne Erlaubnis an. Geh pfleglich mit den Räumen '
+ 'und dem Inventar um.'),
+ ('Sei neugierig, nicht übergriffig.', 'Chaos ja, Grenzenüberschreitungen nein.'),
+ ],
+ 'house_lead': 'Hausrecht:',
+ 'house': 'Die Orga hat Hausrecht. Bei Verstößen gegen diese Regeln kann sie Verwarnungen aussprechen, Personen '
+ 'der Veranstaltung verweisen oder ein Hausverbot erteilen. Den Anweisungen der Orga ist Folge zu leisten.',
+ 'problems_lead': 'Probleme?',
+ 'problems': 'Wenn dir etwas passiert oder du etwas beobachtest, sprich eine Person aus der Orga an oder schreib an '
+ 'aprl [at] berlin [dot] ccc [dot] de. Hinweise werden vertraulich behandelt.',
+ 'footer': [('Startseite', SITE), ('Impressum', IMPRESSUM)],
+}
+
+COC_EN = {
+ 'intro': 'Khaos im Kiez is an event of the Chaos Computer Club Berlin e.V. (CCCB). To make everyone feel '
+ 'comfortable, these rules apply to all attendees, speakers and helpers.',
+ 'rules': [
+ ('Be nice to each other.', 'Treat each other respectfully and kindly, even when you disagree.'),
+ ('No discrimination.', 'Racism, sexism, queerphobia, ableism and other hateful attitudes have no place here.'),
+ ('No harassment.', 'This includes insults, threats, stalking, unwanted physical contact and unwanted sexual '
+ 'attention. No means no.'),
+ ('Ask before taking pictures.', 'Photos, video and audio recordings of people only with their consent. This '
+ 'also applies to talks and workshops.'),
+ ('Respect devices and property.', "Do not touch other people's devices without permission. Treat the rooms "
+ 'and furniture with care.'),
+ ('Be curious, not intrusive.', 'Chaos yes, crossing boundaries no.'),
+ ],
+ 'house_lead': 'House rules:',
+ 'house': 'The organizers have the right to exclude people from the premises (Hausrecht). In case of violations '
+ "they may issue warnings, ask people to leave, or ban them from the event. Follow the organizers' "
+ 'instructions.',
+ 'problems_lead': 'Problems?',
+ 'problems': 'If something happens to you or you notice something, talk to a member of the organizing team or '
+ 'write to aprl [at] berlin [dot] ccc [dot] de. Reports are treated confidentially.',
+ 'footer': [('Home', f'{SITE}en.html'), ('Legal notice', IMPRESSUM)],
+}
+
+out = pathlib.Path(__file__).parent
+for name, text in [('README', readme(DE)), ('README.en', readme(EN)),
+ ('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]:
+ (out / name).write_text(text, encoding='utf-8')
From 54607982cfbd63ae15a5daa97799bc8008ab91cf Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 12:47:26 +0100
Subject: [PATCH 14/39] Sort the server directory and add the restore task
installer
The README and Code of Conduct files move to server/resources, the
scripts that build and deploy them to server/scripts. The new
install_restore_task.ps1 installs a scheduled task as SYSTEM that puts
the files back every 30 minutes, so the shell server's root user can
neither stop it nor change the copies it restores from.
Co-Authored-By: Claude Code
---
server/README.md | 23 +++++
server/{ => resources}/CODE_OF_CONDUCT | 0
server/{ => resources}/CODE_OF_CONDUCT.en | 0
server/{ => resources}/README | 0
server/{ => resources}/README.en | 0
server/scripts/install_restore_task.ps1 | 101 ++++++++++++++++++++++
server/{ => scripts}/make_files.py | 7 +-
7 files changed, 128 insertions(+), 3 deletions(-)
create mode 100644 server/README.md
rename server/{ => resources}/CODE_OF_CONDUCT (100%)
rename server/{ => resources}/CODE_OF_CONDUCT.en (100%)
rename server/{ => resources}/README (100%)
rename server/{ => resources}/README.en (100%)
create mode 100644 server/scripts/install_restore_task.ps1
rename server/{ => scripts}/make_files.py (99%)
diff --git a/server/README.md b/server/README.md
new file mode 100644
index 0000000..dc3cc87
--- /dev/null
+++ b/server/README.md
@@ -0,0 +1,23 @@
+# The shell server
+
+The website's terminal connects to a shell server, whose root user logs in at `wss://web.nowhere.wirs.ing`.
+
+## Layout
+
+- `resources/` — the files in the root user's home directory: `README`, `README.en`, `CODE_OF_CONDUCT` and
+ `CODE_OF_CONDUCT.en`.
+- `scripts/make_files.py` — builds the resources, the website's pages as terminal text.
+- `scripts/install_restore_task.ps1` — installs the scheduled task that puts the resources back every 30 minutes.
+
+## Rebuilding the resources
+
+ python3 server/scripts/make_files.py
+
+## Installing the restore task on the server
+
+Copy `server/scripts` and `server/resources` to the server and run this in an elevated PowerShell:
+
+ powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1
+
+The task runs as SYSTEM, so the root user can neither stop it nor change the copies it restores from. It also
+runs at startup. Remove it again with `-Uninstall`.
diff --git a/server/CODE_OF_CONDUCT b/server/resources/CODE_OF_CONDUCT
similarity index 100%
rename from server/CODE_OF_CONDUCT
rename to server/resources/CODE_OF_CONDUCT
diff --git a/server/CODE_OF_CONDUCT.en b/server/resources/CODE_OF_CONDUCT.en
similarity index 100%
rename from server/CODE_OF_CONDUCT.en
rename to server/resources/CODE_OF_CONDUCT.en
diff --git a/server/README b/server/resources/README
similarity index 100%
rename from server/README
rename to server/resources/README
diff --git a/server/README.en b/server/resources/README.en
similarity index 100%
rename from server/README.en
rename to server/resources/README.en
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
new file mode 100644
index 0000000..9c4469d
--- /dev/null
+++ b/server/scripts/install_restore_task.ps1
@@ -0,0 +1,101 @@
+# Installs the scheduled task that puts the shell server's README and Code of Conduct files back every 30
+# minutes. The task runs as SYSTEM, so the shell server's root user can neither stop it nor change the copies it
+# restores from.
+#
+# Run this in an elevated PowerShell on the server, with this script and the ../resources directory next to it:
+# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1
+# Remove the task and the copies again with:
+# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 -Uninstall
+[CmdletBinding()]
+param(
+ [string]$Target = 'C:\msys2\home\root',
+ [switch]$Uninstall
+)
+
+$ErrorActionPreference = 'Stop'
+$installDir = 'C:\ProgramData\kik-restore'
+$taskName = 'KiK restore shell files'
+$files = 'README', 'README.en', 'CODE_OF_CONDUCT', 'CODE_OF_CONDUCT.en'
+
+# The copies the task restores from and the script it runs both live in $installDir, which only SYSTEM and the
+# administrators may read or write.
+$restoreScript = @'
+# Puts the shell server's README and Code of Conduct files back from the copies next to this script.
+# Runs as SYSTEM every 30 minutes; the shell server's root user can neither stop it nor change the copies.
+param([string]$Target = 'C:\msys2\home\root')
+$log = Join-Path $PSScriptRoot 'restore.log'
+
+foreach ($name in @('README', 'README.en', 'CODE_OF_CONDUCT', 'CODE_OF_CONDUCT.en')) {
+ $path = Join-Path $Target $name
+ try {
+ if (Test-Path -LiteralPath $path) {
+ # the root user may have replaced the file or locked everyone else out
+ takeown /f $path /a | Out-Null
+ icacls $path /reset | Out-Null
+ if ((Get-Item -LiteralPath $path -Force).PSIsContainer) { Remove-Item -LiteralPath $path -Recurse -Force }
+ }
+ Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $path -Force
+ "$(Get-Date -Format s) restored $name" | Out-File -FilePath $log -Append
+ } catch {
+ "$(Get-Date -Format s) failed $name : $_" | Out-File -FilePath $log -Append
+ }
+}
+'@
+
+if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Run this in an elevated PowerShell.'
+}
+
+if ($Uninstall) {
+ Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
+ if (Test-Path -LiteralPath $installDir) { Remove-Item -LiteralPath $installDir -Recurse -Force }
+ Write-Host "Removed the task and $installDir."
+ return
+}
+
+$resources = Join-Path $PSScriptRoot '..\resources'
+$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $resources $_)) }
+if ($missing) { throw "Missing files in $resources : $($missing -join ', ')" }
+
+# The root user may have prepared the directory or the file names, so nothing that looks like a link is used.
+$prepared = Get-Item -LiteralPath $installDir -Force -ErrorAction SilentlyContinue
+if ($prepared -and $prepared.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$installDir is a link, refusing to install." }
+
+New-Item -ItemType Directory -Force -Path $installDir | Out-Null
+
+# Only SYSTEM and the administrators may read or write the copies and the script.
+$acl = New-Object System.Security.AccessControl.DirectorySecurity
+$acl.SetAccessRuleProtection($true, $false)
+foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') {
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
+}
+$acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544'))
+Set-Acl -LiteralPath $installDir -AclObject $acl
+
+# Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions.
+foreach ($name in $files + 'restore.ps1') {
+ $path = Join-Path $installDir $name
+ if (Test-Path -LiteralPath $path) {
+ $item = Get-Item -LiteralPath $path -Force
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$path is a link, refusing to install." }
+ Remove-Item -LiteralPath $path -Recurse -Force
+ }
+}
+Copy-Item -Path (Join-Path $resources '*') -Destination $installDir -Force
+[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
+
+$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -Target `"$Target`""
+$triggers = @(
+ New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650)
+ New-ScheduledTaskTrigger -AtStartup
+)
+$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
+Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $triggers -Principal $principal -Settings $settings -Force | Out-Null
+
+Start-ScheduledTask -TaskName $taskName
+Start-Sleep -Seconds 5
+Write-Host "Task '$taskName' runs as SYSTEM every 30 minutes and at startup."
+Get-ScheduledTaskInfo -TaskName $taskName | Format-List LastRunTime, LastTaskResult, NextRunTime
+Get-Content (Join-Path $installDir 'restore.log') -Tail 4 -ErrorAction SilentlyContinue
+icacls $installDir
diff --git a/server/make_files.py b/server/scripts/make_files.py
similarity index 99%
rename from server/make_files.py
rename to server/scripts/make_files.py
index 6822a7a..db3209d 100644
--- a/server/make_files.py
+++ b/server/scripts/make_files.py
@@ -1,6 +1,6 @@
-# Builds README, README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en next to this script: the website's pages as
+# Builds README, README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en in ../resources: the website's pages as
# terminal text with escape sequences, for the home directory of the shell server's root user.
-# Usage: python3 server/make_files.py
+# Usage: python3 server/scripts/make_files.py
import pathlib
import textwrap
@@ -329,7 +329,8 @@ COC_EN = {
'footer': [('Home', f'{SITE}en.html'), ('Legal notice', IMPRESSUM)],
}
-out = pathlib.Path(__file__).parent
+out = pathlib.Path(__file__).parent.parent / 'resources'
+out.mkdir(exist_ok=True)
for name, text in [('README', readme(DE)), ('README.en', readme(EN)),
('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]:
(out / name).write_text(text, encoding='utf-8')
From 23896bced5a58fdc4e687756d3c17d620e298df1 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:02:25 +0100
Subject: [PATCH 15/39] Keep the shell server's basic user journey working
The cleanup job now restores the whole home directory instead of only
the four text files: the directory itself, the shell configuration that
gives the prompt, and the files. Anything that is not a regular file is
replaced, so the files stay text files.
The README in server/ describes the security model: the box is a
disposable VPS that may be compromised, the root user may do whatever
they want, and only the prompt, the home directory and the four files
are kept working.
Co-Authored-By: Claude Code
---
server/README.md | 63 +++++++++++++---
server/resources/.zshenv | 3 +
server/resources/.zshrc | 36 +++++++++
server/scripts/install_restore_task.ps1 | 98 ++++++++++++++++++-------
4 files changed, 165 insertions(+), 35 deletions(-)
create mode 100644 server/resources/.zshenv
create mode 100644 server/resources/.zshrc
diff --git a/server/README.md b/server/README.md
index dc3cc87..4a5ac15 100644
--- a/server/README.md
+++ b/server/README.md
@@ -1,23 +1,68 @@
# The shell server
-The website's terminal connects to a shell server, whose root user logs in at `wss://web.nowhere.wirs.ing`.
+The website's terminal connects to a shell server, whose `root` user logs in at `wss://web.nowhere.wirs.ing` and
+gets a shell on the box.
-## Layout
+## The security model
-- `resources/` — the files in the root user's home directory: `README`, `README.en`, `CODE_OF_CONDUCT` and
- `CODE_OF_CONDUCT.en`.
-- `scripts/make_files.py` — builds the resources, the website's pages as terminal text.
-- `scripts/install_restore_task.ps1` — installs the scheduled task that puts the resources back every 30 minutes.
+The box is an isolated, disposable VPS at a random hoster. We assume that it can be compromised and that this is
+acceptable: the worst case is that the gimmick on the website stops working. Nothing else is on the box. The
+website and the websocket bridge that carries the connection run on other servers, so a compromised shell server
+cannot get at them.
-## Rebuilding the resources
+The `root` user on the box is a normal Windows account without administrator rights. It may do a lot of things
+there, including things we did not plan for. We do not try to contain it and we do not try to undo what it does.
+We only keep the basic user journey working.
+
+## What we keep working
+
+- The prompt: `root@khaos-im-kiez:~#`, which `~/.zshrc` sets.
+- The home directory the user ends up in: `/home/root`. It is the one from the `root` entry in `/etc/passwd`,
+ which the user cannot change, because `/etc` is read-only for them. They can delete their home directory, though.
+- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
+ are always there and always regular text files.
+
+Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either.
+
+## What we do not keep working
+
+Everything else. If the user breaks the shell, the packages, the disk or the connection, we fix that by hand or
+throw the box away. The cleanup does not try.
+
+Two things that the user cannot break and therefore do not need to be restored: the entry in `/etc/passwd` with
+the home directory and the shell, and the host keys in `/etc/ssh`, which the website pins. The sshd runs as SYSTEM,
+so the user cannot restart it with a different key either.
+
+## The cleanup
+
+The scheduled task `KiK restore shell files` runs every 30 minutes and at every startup, as SYSTEM. The user cannot
+stop it: the task, its script and the copies it restores from are readable and writable only by SYSTEM and the
+administrators.
+
+What it does:
+
+1. Creates `/home/root` if it is gone, and gives the `root` account write access on it.
+2. Puts every copy in `files/` back into the home directory: the shell configuration and the four text files.
+
+Anything that is not a regular file - a directory or a link - is removed and replaced by the copy, so that the
+files cannot become anything but text. The contents are restored as well: the user may edit the files, the next run
+undoes that.
+
+## This directory
+
+- `resources/` - the copies the cleanup restores from: the four text files and the shell configuration.
+- `scripts/make_files.py` - builds the four text files from the website's pages.
+- `scripts/install_restore_task.ps1` - installs the scheduled task.
+
+The four text files are generated, the shell configuration is copied from the server by hand:
python3 server/scripts/make_files.py
-## Installing the restore task on the server
+## Installing the cleanup on the server
Copy `server/scripts` and `server/resources` to the server and run this in an elevated PowerShell:
powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1
-The task runs as SYSTEM, so the root user can neither stop it nor change the copies it restores from. It also
+The task runs as SYSTEM, so the `root` user can neither stop it nor change the copies it restores from. It also
runs at startup. Remove it again with `-Uninstall`.
diff --git a/server/resources/.zshenv b/server/resources/.zshenv
new file mode 100644
index 0000000..f990c34
--- /dev/null
+++ b/server/resources/.zshenv
@@ -0,0 +1,3 @@
+# Preset what MSYS2's /etc/profile would otherwise start a helper process for (~90 ms each here).
+# TZ has to match the Windows timezone (W. Europe Standard Time).
+export LANG=en_US.UTF-8 TZ=Europe/Berlin
diff --git a/server/resources/.zshrc b/server/resources/.zshrc
new file mode 100644
index 0000000..310de44
--- /dev/null
+++ b/server/resources/.zshrc
@@ -0,0 +1,36 @@
+# History shared between sessions.
+HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=10000
+setopt share_history hist_ignore_dups hist_ignore_space interactive_comments auto_cd
+
+# Completion: menu, case-insensitive, colored. -C skips compaudit, which is slow on
+# Windows and meaningless under MSYS2's noacl mounts (rm ~/.zcompdump after adding completions).
+autoload -Uz compinit && compinit -C
+zstyle ':completion:*' menu select
+zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
+zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
+
+# Emacs keys; Up/Down search history for what is already typed.
+bindkey -e
+autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
+zle -N up-line-or-beginning-search && zle -N down-line-or-beginning-search
+bindkey '^[[A' up-line-or-beginning-search '^[OA' up-line-or-beginning-search
+bindkey '^[[B' down-line-or-beginning-search '^[OB' down-line-or-beginning-search
+
+# Prompt: user@host:directory, git branch, exit status of the last command if it failed.
+# A literal '#' as on Linux: zsh's %# would show '%', since this root is not uid 0.
+# The branch is read from .git/HEAD directly: starting git on every prompt is slow on Windows.
+precmd() {
+ local d=$PWD head=
+ until [[ -e $d/.git || $d == / ]]; do d=${d:h}; done
+ [[ -f $d/.git/HEAD ]] && read -r head < $d/.git/HEAD
+ [[ $head == 'ref: '* ]] && git_branch=${head#ref: refs/heads/} || git_branch=${head:0:7}
+ print -Pn '\e]0;%n@%m: %~\a'
+}
+setopt prompt_subst
+PROMPT='%F{green}%n@%m%f:%F{blue}%~%f${git_branch:+ %5F($git_branch)%f}%(?.. %F{red}[%?]%f)# '
+
+alias ls='ls --color=auto' ll='ls -lah' grep='grep --color=auto'
+
+# Process monitor: htop does not exist for MSYS2 (its /proc only shows MSYS2 processes).
+# bottom is a native Windows program; winpty lets it draw in the pty, as Server 2016 has no ConPTY.
+alias btm='winpty /ucrt64/bin/btm'
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 9c4469d..088bb0b 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -1,6 +1,6 @@
-# Installs the scheduled task that puts the shell server's README and Code of Conduct files back every 30
-# minutes. The task runs as SYSTEM, so the shell server's root user can neither stop it nor change the copies it
-# restores from.
+# Installs the scheduled task that puts the shell server's home directory back into shape every 30 minutes: the
+# prompt, the home directory and the four text files. The task runs as SYSTEM, so the shell server's root user can
+# neither stop it nor change the copies it restores from.
#
# Run this in an elevated PowerShell on the server, with this script and the ../resources directory next to it:
# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1
@@ -8,36 +8,83 @@
# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 -Uninstall
[CmdletBinding()]
param(
- [string]$Target = 'C:\msys2\home\root',
+ [string]$HomeDir = 'C:\msys2\home\root',
[switch]$Uninstall
)
$ErrorActionPreference = 'Stop'
$installDir = 'C:\ProgramData\kik-restore'
+$copiesDir = Join-Path $installDir 'files'
$taskName = 'KiK restore shell files'
-$files = 'README', 'README.en', 'CODE_OF_CONDUCT', 'CODE_OF_CONDUCT.en'
# The copies the task restores from and the script it runs both live in $installDir, which only SYSTEM and the
# administrators may read or write.
$restoreScript = @'
-# Puts the shell server's README and Code of Conduct files back from the copies next to this script.
-# Runs as SYSTEM every 30 minutes; the shell server's root user can neither stop it nor change the copies.
-param([string]$Target = 'C:\msys2\home\root')
+# Puts the shell server's home directory back into shape: the directory itself, the shell configuration that gives
+# the prompt, and the four text files. Runs as SYSTEM every 30 minutes; the shell server's root user can neither stop
+# it nor change the copies it restores from.
+param([string]$HomeDir = 'C:\msys2\home\root')
$log = Join-Path $PSScriptRoot 'restore.log'
+$copies = Join-Path $PSScriptRoot 'files'
-foreach ($name in @('README', 'README.en', 'CODE_OF_CONDUCT', 'CODE_OF_CONDUCT.en')) {
- $path = Join-Path $Target $name
+# The account of the shell server's root user, whose id the files in its home directory belong to.
+$passwd = Get-Content 'C:\msys2\etc\passwd' -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
+$sid = if ($passwd) { ($passwd -split ':')[4].Split(',')[-1] }
+
+function Write-Log($message) {
+ "$(Get-Date -Format s) $message" | Out-File -FilePath $log -Append
+}
+
+# Removes a file, a directory or a link, but never what a link points to.
+function Remove-Anything($path) {
+ $item = Get-Item -LiteralPath $path -Force
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ if ($item.PSIsContainer) { [IO.Directory]::Delete($path) } else { [IO.File]::Delete($path) }
+ } elseif ($item.PSIsContainer) {
+ Remove-Item -LiteralPath $path -Recurse -Force
+ } else {
+ Remove-Item -LiteralPath $path -Force
+ }
+}
+
+# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete.
+function New-Home($path) {
+ New-Item -ItemType Directory -Path $path | Out-Null
+ # The parent directory gives the users read access only, so the owner gets write access on their own home.
+ if ($sid) {
+ icacls $path /grant "*${sid}:(OI)(CI)F" | Out-Null
+ icacls $path /setowner "*$sid" | Out-Null
+ }
+}
+
+if (-not (Test-Path -LiteralPath $HomeDir)) {
+ New-Home $HomeDir
+ Write-Log "created $HomeDir"
+} elseif ((Get-Item -LiteralPath $HomeDir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ Remove-Anything $HomeDir
+ New-Home $HomeDir
+ Write-Log "replaced the link $HomeDir"
+}
+
+foreach ($copy in Get-ChildItem -LiteralPath $copies -File -Force) {
+ $target = Join-Path $HomeDir $copy.Name
try {
- if (Test-Path -LiteralPath $path) {
- # the root user may have replaced the file or locked everyone else out
- takeown /f $path /a | Out-Null
- icacls $path /reset | Out-Null
- if ((Get-Item -LiteralPath $path -Force).PSIsContainer) { Remove-Item -LiteralPath $path -Recurse -Force }
+ if (Test-Path -LiteralPath $target) {
+ $item = Get-Item -LiteralPath $target -Force
+ if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
+ # anything but a file is replaced, so that the files are always the text files they should be
+ Remove-Anything $target
+ } else {
+ # the user may have locked the file; taking it back lets us write it
+ takeown /f $target /a | Out-Null
+ icacls $target /reset | Out-Null
+ }
}
- Copy-Item -LiteralPath (Join-Path $PSScriptRoot $name) -Destination $path -Force
- "$(Get-Date -Format s) restored $name" | Out-File -FilePath $log -Append
+ Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
+ if ($sid) { icacls $target /setowner "*$sid" | Out-Null }
+ Write-Log "restored $($copy.Name)"
} catch {
- "$(Get-Date -Format s) failed $name : $_" | Out-File -FilePath $log -Append
+ Write-Log "failed $($copy.Name) : $_"
}
}
'@
@@ -54,14 +101,13 @@ if ($Uninstall) {
}
$resources = Join-Path $PSScriptRoot '..\resources'
-$missing = $files | Where-Object { -not (Test-Path -LiteralPath (Join-Path $resources $_)) }
-if ($missing) { throw "Missing files in $resources : $($missing -join ', ')" }
+if (-not (Get-ChildItem -LiteralPath $resources -File -Force)) { throw "No files in $resources." }
# The root user may have prepared the directory or the file names, so nothing that looks like a link is used.
$prepared = Get-Item -LiteralPath $installDir -Force -ErrorAction SilentlyContinue
if ($prepared -and $prepared.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$installDir is a link, refusing to install." }
-New-Item -ItemType Directory -Force -Path $installDir | Out-Null
+New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
# Only SYSTEM and the administrators may read or write the copies and the script.
$acl = New-Object System.Security.AccessControl.DirectorySecurity
@@ -73,18 +119,18 @@ $acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32
Set-Acl -LiteralPath $installDir -AclObject $acl
# Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions.
-foreach ($name in $files + 'restore.ps1') {
- $path = Join-Path $installDir $name
+foreach ($copy in Get-ChildItem -LiteralPath $resources -File -Force) {
+ $path = Join-Path $copiesDir $copy.Name
if (Test-Path -LiteralPath $path) {
$item = Get-Item -LiteralPath $path -Force
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$path is a link, refusing to install." }
Remove-Item -LiteralPath $path -Recurse -Force
}
+ Copy-Item -LiteralPath $copy.FullName -Destination $path -Force
}
-Copy-Item -Path (Join-Path $resources '*') -Destination $installDir -Force
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
-$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -Target `"$Target`""
+$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
$triggers = @(
New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650)
New-ScheduledTaskTrigger -AtStartup
@@ -97,5 +143,5 @@ Start-ScheduledTask -TaskName $taskName
Start-Sleep -Seconds 5
Write-Host "Task '$taskName' runs as SYSTEM every 30 minutes and at startup."
Get-ScheduledTaskInfo -TaskName $taskName | Format-List LastRunTime, LastTaskResult, NextRunTime
-Get-Content (Join-Path $installDir 'restore.log') -Tail 4 -ErrorAction SilentlyContinue
+Get-Content (Join-Path $installDir 'restore.log') -Tail 8 -ErrorAction SilentlyContinue
icacls $installDir
From 917e3d60894e3e2f35be811d0abdb0d9b634c7b8 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:05:22 +0100
Subject: [PATCH 16/39] Build the shell configuration into the resources
The shell configuration was copied from the server by hand. It now has
its own directory, which make_files.py copies into the resources, so
that the resources are the whole set of files the cleanup restores and
nothing has to be copied by hand.
Co-Authored-By: Claude Code
---
server/README.md | 13 +++++++++----
server/scripts/make_files.py | 9 +++++++--
server/shell/.zshenv | 3 +++
server/shell/.zshrc | 36 ++++++++++++++++++++++++++++++++++++
4 files changed, 55 insertions(+), 6 deletions(-)
create mode 100644 server/shell/.zshenv
create mode 100644 server/shell/.zshrc
diff --git a/server/README.md b/server/README.md
index 4a5ac15..5edc5cb 100644
--- a/server/README.md
+++ b/server/README.md
@@ -39,7 +39,11 @@ The scheduled task `KiK restore shell files` runs every 30 minutes and at every
stop it: the task, its script and the copies it restores from are readable and writable only by SYSTEM and the
administrators.
-What it does:
+Neither the setup nor the cleanup ever runs as the `root` user. The setup runs in an elevated PowerShell, as an
+administrator, and refuses to run without those rights; the cleanup runs as SYSTEM. The `root` account is a
+different one, without administrator rights, and it never executes any of it.
+
+What the cleanup does:
1. Creates `/home/root` if it is gone, and gives the `root` account write access on it.
2. Puts every copy in `files/` back into the home directory: the shell configuration and the four text files.
@@ -50,11 +54,12 @@ undoes that.
## This directory
-- `resources/` - the copies the cleanup restores from: the four text files and the shell configuration.
-- `scripts/make_files.py` - builds the four text files from the website's pages.
+- `shell/` - the shell configuration, `~/.zshrc` and `~/.zshenv`, which is where the prompt is set.
+- `resources/` - the copies the cleanup restores from: the shell configuration and the four text files.
+- `scripts/make_files.py` - builds the resources.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
-The four text files are generated, the shell configuration is copied from the server by hand:
+The resources are built from the website's pages and the shell configuration:
python3 server/scripts/make_files.py
diff --git a/server/scripts/make_files.py b/server/scripts/make_files.py
index db3209d..fe2100a 100644
--- a/server/scripts/make_files.py
+++ b/server/scripts/make_files.py
@@ -1,7 +1,9 @@
-# Builds README, README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en in ../resources: the website's pages as
-# terminal text with escape sequences, for the home directory of the shell server's root user.
+# Builds the files for the home directory of the shell server's root user in ../resources: README, README.en,
+# CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape sequences, and the
+# shell configuration from ../shell.
# Usage: python3 server/scripts/make_files.py
import pathlib
+import shutil
import textwrap
ESC = '\x1b'
@@ -334,3 +336,6 @@ out.mkdir(exist_ok=True)
for name, text in [('README', readme(DE)), ('README.en', readme(EN)),
('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]:
(out / name).write_text(text, encoding='utf-8')
+# The shell configuration is not generated, it is copied from ../shell, where it is edited.
+for name in '.zshrc', '.zshenv':
+ shutil.copyfile(pathlib.Path(__file__).parent.parent / 'shell' / name, out / name)
diff --git a/server/shell/.zshenv b/server/shell/.zshenv
new file mode 100644
index 0000000..f990c34
--- /dev/null
+++ b/server/shell/.zshenv
@@ -0,0 +1,3 @@
+# Preset what MSYS2's /etc/profile would otherwise start a helper process for (~90 ms each here).
+# TZ has to match the Windows timezone (W. Europe Standard Time).
+export LANG=en_US.UTF-8 TZ=Europe/Berlin
diff --git a/server/shell/.zshrc b/server/shell/.zshrc
new file mode 100644
index 0000000..310de44
--- /dev/null
+++ b/server/shell/.zshrc
@@ -0,0 +1,36 @@
+# History shared between sessions.
+HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=10000
+setopt share_history hist_ignore_dups hist_ignore_space interactive_comments auto_cd
+
+# Completion: menu, case-insensitive, colored. -C skips compaudit, which is slow on
+# Windows and meaningless under MSYS2's noacl mounts (rm ~/.zcompdump after adding completions).
+autoload -Uz compinit && compinit -C
+zstyle ':completion:*' menu select
+zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
+zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
+
+# Emacs keys; Up/Down search history for what is already typed.
+bindkey -e
+autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
+zle -N up-line-or-beginning-search && zle -N down-line-or-beginning-search
+bindkey '^[[A' up-line-or-beginning-search '^[OA' up-line-or-beginning-search
+bindkey '^[[B' down-line-or-beginning-search '^[OB' down-line-or-beginning-search
+
+# Prompt: user@host:directory, git branch, exit status of the last command if it failed.
+# A literal '#' as on Linux: zsh's %# would show '%', since this root is not uid 0.
+# The branch is read from .git/HEAD directly: starting git on every prompt is slow on Windows.
+precmd() {
+ local d=$PWD head=
+ until [[ -e $d/.git || $d == / ]]; do d=${d:h}; done
+ [[ -f $d/.git/HEAD ]] && read -r head < $d/.git/HEAD
+ [[ $head == 'ref: '* ]] && git_branch=${head#ref: refs/heads/} || git_branch=${head:0:7}
+ print -Pn '\e]0;%n@%m: %~\a'
+}
+setopt prompt_subst
+PROMPT='%F{green}%n@%m%f:%F{blue}%~%f${git_branch:+ %5F($git_branch)%f}%(?.. %F{red}[%?]%f)# '
+
+alias ls='ls --color=auto' ll='ls -lah' grep='grep --color=auto'
+
+# Process monitor: htop does not exist for MSYS2 (its /proc only shows MSYS2 processes).
+# bottom is a native Windows program; winpty lets it draw in the pty, as Server 2016 has no ConPTY.
+alias btm='winpty /ucrt64/bin/btm'
From e48e0644babfb16a4031de4d72eefbcc0b4f9e59 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:17:13 +0100
Subject: [PATCH 17/39] Add a deploy script for the cleanup job
It logs in as Administrator, whose shell is a Windows one, copies the
scripts and the resources to the server and runs the installer there. It
uses the key the website uses and checks the host key against the one
the website pins.
Co-Authored-By: Claude Code
---
server/README.md | 10 ++++++-
server/scripts/deploy.sh | 62 ++++++++++++++++++++++++++++++++++++++++
2 files changed, 71 insertions(+), 1 deletion(-)
create mode 100755 server/scripts/deploy.sh
diff --git a/server/README.md b/server/README.md
index 5edc5cb..7bd2437 100644
--- a/server/README.md
+++ b/server/README.md
@@ -57,6 +57,7 @@ undoes that.
- `shell/` - the shell configuration, `~/.zshrc` and `~/.zshenv`, which is where the prompt is set.
- `resources/` - the copies the cleanup restores from: the shell configuration and the four text files.
- `scripts/make_files.py` - builds the resources.
+- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
The resources are built from the website's pages and the shell configuration:
@@ -65,7 +66,14 @@ The resources are built from the website's pages and the shell configuration:
## Installing the cleanup on the server
-Copy `server/scripts` and `server/resources` to the server and run this in an elevated PowerShell:
+ server/scripts/deploy.sh
+
+The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
+Administrator's home directory and runs the installer there. It uses the key the website uses, which is authorized
+for the Administrator account as well, and checks the host key against the one the website pins. `--uninstall`
+removes the task again.
+
+The installer can also be run by hand, in an elevated PowerShell on the server:
powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1
diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh
new file mode 100755
index 0000000..fb457fc
--- /dev/null
+++ b/server/scripts/deploy.sh
@@ -0,0 +1,62 @@
+#!/usr/bin/env bash
+# Deploys the cleanup job to the shell server and installs it there.
+#
+# server/scripts/deploy.sh [-i KEYFILE] [-h HOST] [--uninstall]
+#
+# The job runs on the box as SYSTEM, never as the root user. The Administrator account sets it up; its shell is a
+# Windows one, so the commands we run there are Windows commands.
+set -euo pipefail
+
+HOST=nowhere.wirs.ing
+USER=Administrator
+KEYFILE=''
+UNINSTALL=0
+
+while [ $# -gt 0 ]; do
+ case $1 in
+ -i) KEYFILE=$2; shift 2 ;;
+ -h) HOST=$2; shift 2 ;;
+ --uninstall) UNINSTALL=1; shift ;;
+ *) echo "usage: $(basename "$0") [-i KEYFILE] [-h HOST] [--uninstall]" >&2; exit 2 ;;
+ esac
+done
+
+repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
+server="$repo/server"
+remote='C:\Users\Administrator\kik' # where the installer is put, in the home of the Administrator
+remotePath='/c/Users/Administrator/kik' # the same place, as the file transfer sees it
+temporary=()
+
+if [ -z "$KEYFILE" ]; then
+ # The key the website uses is authorized for the Administrator account as well.
+ KEYFILE=$(mktemp)
+ chmod 600 "$KEYFILE"
+ temporary+=("$KEYFILE")
+ boring=$(sed -n "s/.*const boringData = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
+ printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$KEYFILE"
+fi
+
+# The host key is the one the website pins, so that the deployment cannot be intercepted.
+expected=$(sed -n "s/.*const HOST_KEY = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
+known=$(mktemp)
+temporary+=("$known")
+trap 'rm -f "${temporary[@]}"' EXIT
+ssh-keyscan -t ed25519 "$HOST" > "$known" 2>/dev/null
+actual=$(ssh-keygen -lf "$known" | awk '{print $2}')
+if [ "$actual" != "$expected" ]; then
+ echo "the host key of $HOST is $actual, but $expected is expected" >&2
+ exit 1
+fi
+
+ssh=(ssh -i "$KEYFILE" -o UserKnownHostsFile="$known" -o StrictHostKeyChecking=yes "$USER@$HOST")
+scp=(scp -i "$KEYFILE" -o UserKnownHostsFile="$known" -o StrictHostKeyChecking=yes)
+
+if [ "$UNINSTALL" = 1 ]; then
+ "${ssh[@]}" "if exist $remote\\scripts\\install_restore_task.ps1 (powershell -NoProfile -ExecutionPolicy Bypass -File $remote\\scripts\\install_restore_task.ps1 -Uninstall) else (echo Nothing is installed.)"
+ exit
+fi
+
+# The installer looks for the resources next to itself, so both directories go to the server.
+"${ssh[@]}" "if not exist $remote mkdir $remote"
+"${scp[@]}" -r "$server/scripts" "$server/resources" "$USER@$HOST:$remotePath/"
+"${ssh[@]}" "powershell -NoProfile -ExecutionPolicy Bypass -File $remote\\scripts\\install_restore_task.ps1"
From 22106dcd7dcd8854ce80fde533a565450f70fdfd Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:35:05 +0100
Subject: [PATCH 18/39] Restore the SSH access in the cleanup
The key that the website logs in with lives in the home directory of the
user, so a deleted home directory took it away and the site could not
connect any more. The key is now one of the copies the cleanup restores,
and it is merged into the authorized_keys of the user, so that keys they
added stay.
The installer writes the same key for the Administrator, and the deploy
script checks that it is the key the website uses.
Co-Authored-By: Claude Code
---
server/README.md | 26 ++++++---
server/home/.ssh/authorized_keys | 1 +
server/{shell => home}/.zshenv | 0
server/{shell => home}/.zshrc | 0
server/resources/.ssh/authorized_keys | 1 +
server/scripts/deploy.sh | 8 +++
server/scripts/install_restore_task.ps1 | 71 ++++++++++++++++++-------
server/scripts/make_files.py | 14 +++--
server/scripts/restore_ssh_access.cmd | 13 +++++
9 files changed, 106 insertions(+), 28 deletions(-)
create mode 100644 server/home/.ssh/authorized_keys
rename server/{shell => home}/.zshenv (100%)
rename server/{shell => home}/.zshrc (100%)
create mode 100644 server/resources/.ssh/authorized_keys
create mode 100644 server/scripts/restore_ssh_access.cmd
diff --git a/server/README.md b/server/README.md
index 7bd2437..b8c2b1c 100644
--- a/server/README.md
+++ b/server/README.md
@@ -21,9 +21,14 @@ We only keep the basic user journey working.
which the user cannot change, because `/etc` is read-only for them. They can delete their home directory, though.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
+- The website can log in: the key it uses is in `~/.ssh/authorized_keys`.
Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either.
+The user may add keys of their own to `authorized_keys`; they stay. The key of the website is put back, so that the
+site keeps working. The user owns their home directory, so they can always delete the file - nothing on Windows can
+stop them from that - but the next cleanup brings it back. Until then, the site cannot log in.
+
## What we do not keep working
Everything else. If the user breaks the shell, the packages, the disk or the connection, we fix that by hand or
@@ -46,7 +51,8 @@ different one, without administrator rights, and it never executes any of it.
What the cleanup does:
1. Creates `/home/root` if it is gone, and gives the `root` account write access on it.
-2. Puts every copy in `files/` back into the home directory: the shell configuration and the four text files.
+2. Puts every copy in `files/` back into the home directory: the shell configuration, the keys and the four text
+ files.
Anything that is not a regular file - a directory or a link - is removed and replaced by the copy, so that the
files cannot become anything but text. The contents are restored as well: the user may edit the files, the next run
@@ -54,13 +60,15 @@ undoes that.
## This directory
-- `shell/` - the shell configuration, `~/.zshrc` and `~/.zshenv`, which is where the prompt is set.
-- `resources/` - the copies the cleanup restores from: the shell configuration and the four text files.
+- `home/` - the files that belong in the home directory of the user: `~/.zshrc`, `~/.zshenv` and the key in
+ `~/.ssh/authorized_keys` that the website logs in with. This is where they are edited.
+- `resources/` - the copies the cleanup restores from: the four text files and the files of `home/`.
- `scripts/make_files.py` - builds the resources.
- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
+- `scripts/restore_ssh_access.cmd` - puts the SSH access back, for when the home directory was deleted.
-The resources are built from the website's pages and the shell configuration:
+The resources are built from the website's pages and the files of `home/`:
python3 server/scripts/make_files.py
@@ -70,8 +78,8 @@ The resources are built from the website's pages and the shell configuration:
The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
Administrator's home directory and runs the installer there. It uses the key the website uses, which is authorized
-for the Administrator account as well, and checks the host key against the one the website pins. `--uninstall`
-removes the task again.
+for the Administrator account as well, checks the host key against the one the website pins, and checks that the key
+in `home/.ssh/authorized_keys` is the one the website uses. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
@@ -79,3 +87,9 @@ The installer can also be run by hand, in an elevated PowerShell on the server:
The task runs as SYSTEM, so the `root` user can neither stop it nor change the copies it restores from. It also
runs at startup. Remove it again with `-Uninstall`.
+
+## Getting the SSH access back
+
+If the home directory was deleted, the key that lets the website in went with it, and so did the Administrator's.
+Over RDP, as Administrator, run `scripts/restore_ssh_access.cmd`. From then on the cleanup puts the key back by
+itself.
diff --git a/server/home/.ssh/authorized_keys b/server/home/.ssh/authorized_keys
new file mode 100644
index 0000000..873f53b
--- /dev/null
+++ b/server/home/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
diff --git a/server/shell/.zshenv b/server/home/.zshenv
similarity index 100%
rename from server/shell/.zshenv
rename to server/home/.zshenv
diff --git a/server/shell/.zshrc b/server/home/.zshrc
similarity index 100%
rename from server/shell/.zshrc
rename to server/home/.zshrc
diff --git a/server/resources/.ssh/authorized_keys b/server/resources/.ssh/authorized_keys
new file mode 100644
index 0000000..873f53b
--- /dev/null
+++ b/server/resources/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh
index fb457fc..f115bb6 100755
--- a/server/scripts/deploy.sh
+++ b/server/scripts/deploy.sh
@@ -36,6 +36,14 @@ if [ -z "$KEYFILE" ]; then
printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$KEYFILE"
fi
+# The site logs in with the key in the authorized_keys of the user, so the two have to be the same.
+public=$(ssh-keygen -y -f "$KEYFILE" | awk 'NF {print $1" "$2}')
+authorized=$(awk 'NF {print $1" "$2; exit}' "$server/resources/.ssh/authorized_keys")
+if [ "$public" != "$authorized" ]; then
+ echo "the key in server/resources/.ssh/authorized_keys is not the one the website uses" >&2
+ exit 1
+fi
+
# The host key is the one the website pins, so that the deployment cannot be intercepted.
expected=$(sed -n "s/.*const HOST_KEY = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
known=$(mktemp)
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 088bb0b..2e56660 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -21,8 +21,8 @@ $taskName = 'KiK restore shell files'
# administrators may read or write.
$restoreScript = @'
# Puts the shell server's home directory back into shape: the directory itself, the shell configuration that gives
-# the prompt, and the four text files. Runs as SYSTEM every 30 minutes; the shell server's root user can neither stop
-# it nor change the copies it restores from.
+# the prompt, the keys that let the website in, and the four text files. Runs as SYSTEM every 30 minutes; the shell
+# server's root user can neither stop it nor change the copies it restores from.
param([string]$HomeDir = 'C:\msys2\home\root')
$log = Join-Path $PSScriptRoot 'restore.log'
$copies = Join-Path $PSScriptRoot 'files'
@@ -47,8 +47,21 @@ function Remove-Anything($path) {
}
}
-# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete.
-function New-Home($path) {
+# The website logs in with a key in the authorized_keys of the user. The user may keep keys of their own, so only
+# the key of the website is put back.
+function Restore-Keys($target, $source) {
+ $required = @(Get-Content -LiteralPath $source | Where-Object { $_.Trim() })
+ $blob = ($required[0] -split '\s+')[1]
+ $keys = @()
+ if (Test-Path -LiteralPath $target) {
+ $keys = @(Get-Content -LiteralPath $target | Where-Object { $_.Trim() -and ($_ -split '\s+')[1] -ne $blob })
+ }
+ [IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n")
+}
+
+# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. The
+# directories in it belong to the user, so that they can put their own keys in .ssh.
+function New-Dir($path) {
New-Item -ItemType Directory -Path $path | Out-Null
# The parent directory gives the users read access only, so the owner gets write access on their own home.
if ($sid) {
@@ -58,17 +71,26 @@ function New-Home($path) {
}
if (-not (Test-Path -LiteralPath $HomeDir)) {
- New-Home $HomeDir
+ New-Dir $HomeDir
Write-Log "created $HomeDir"
} elseif ((Get-Item -LiteralPath $HomeDir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
Remove-Anything $HomeDir
- New-Home $HomeDir
+ New-Dir $HomeDir
Write-Log "replaced the link $HomeDir"
}
-foreach ($copy in Get-ChildItem -LiteralPath $copies -File -Force) {
- $target = Join-Path $HomeDir $copy.Name
+foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
+ $target = Join-Path $HomeDir $copy.FullName.Substring($copies.Length).TrimStart('\')
try {
+ $dir = Split-Path -Parent $target
+ if ((Test-Path -LiteralPath $dir) -and -not (Get-Item -LiteralPath $dir -Force).PSIsContainer) {
+ Remove-Anything $dir
+ }
+ if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
+ if ($sid) {
+ icacls $dir /grant "*${sid}:(OI)(CI)F" | Out-Null
+ icacls $dir /setowner "*$sid" | Out-Null
+ }
if (Test-Path -LiteralPath $target) {
$item = Get-Item -LiteralPath $target -Force
if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
@@ -80,7 +102,11 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -File -Force) {
icacls $target /reset | Out-Null
}
}
- Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
+ if ($copy.Name -eq 'authorized_keys') {
+ Restore-Keys $target $copy.FullName
+ } else {
+ Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
+ }
if ($sid) { icacls $target /setowner "*$sid" | Out-Null }
Write-Log "restored $($copy.Name)"
} catch {
@@ -113,20 +139,29 @@ New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') {
- $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
+ $identity = New-Object System.Security.Principal.SecurityIdentifier $sid
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
}
$acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544'))
Set-Acl -LiteralPath $installDir -AclObject $acl
# Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions.
-foreach ($copy in Get-ChildItem -LiteralPath $resources -File -Force) {
- $path = Join-Path $copiesDir $copy.Name
- if (Test-Path -LiteralPath $path) {
- $item = Get-Item -LiteralPath $path -Force
- if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$path is a link, refusing to install." }
- Remove-Item -LiteralPath $path -Recurse -Force
- }
- Copy-Item -LiteralPath $copy.FullName -Destination $path -Force
+foreach ($item in Get-ChildItem -LiteralPath $copiesDir -Recurse -Force) {
+ if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$($item.FullName) is a link, refusing to install." }
+}
+Remove-Item -LiteralPath $copiesDir -Recurse -Force
+New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
+Copy-Item -Path (Join-Path $resources '*') -Destination $copiesDir -Recurse -Force
+
+# The Administrator logs in with the same key, so that the deployment keeps working. The keys that are already
+# there stay.
+$adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys'
+$required = @(Get-Content -LiteralPath (Join-Path $resources '.ssh\authorized_keys') | Where-Object { $_.Trim() })
+$keys = @()
+if (Test-Path -LiteralPath $adminKeys) { $keys = @(Get-Content -LiteralPath $adminKeys | Where-Object { $_.Trim() }) }
+if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '\s+')[1] })) {
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $adminKeys) | Out-Null
+ [IO.File]::WriteAllText($adminKeys, (($keys + $required) -join "`n") + "`n")
}
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
diff --git a/server/scripts/make_files.py b/server/scripts/make_files.py
index fe2100a..e9bd1cd 100644
--- a/server/scripts/make_files.py
+++ b/server/scripts/make_files.py
@@ -1,6 +1,6 @@
# Builds the files for the home directory of the shell server's root user in ../resources: README, README.en,
# CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape sequences, and the
-# shell configuration from ../shell.
+# files of ../home, which are copied as they are.
# Usage: python3 server/scripts/make_files.py
import pathlib
import shutil
@@ -336,6 +336,12 @@ out.mkdir(exist_ok=True)
for name, text in [('README', readme(DE)), ('README.en', readme(EN)),
('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]:
(out / name).write_text(text, encoding='utf-8')
-# The shell configuration is not generated, it is copied from ../shell, where it is edited.
-for name in '.zshrc', '.zshenv':
- shutil.copyfile(pathlib.Path(__file__).parent.parent / 'shell' / name, out / name)
+# The files for the home directory are not generated, they are copied from ../home, where they are edited.
+home = pathlib.Path(__file__).parent.parent / 'home'
+for path in home.rglob('*'):
+ target = out / path.relative_to(home)
+ if path.is_dir():
+ target.mkdir(parents=True, exist_ok=True)
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(path, target)
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
new file mode 100644
index 0000000..259c4c1
--- /dev/null
+++ b/server/scripts/restore_ssh_access.cmd
@@ -0,0 +1,13 @@
+@echo off
+REM Puts the SSH access back that a deleted home directory took with it: the key the website logs in with, in the
+REM home of the root user and in the home of the Administrator. Run it as Administrator on the box, for example
+REM over RDP. The cleanup job puts the key back by itself from then on.
+set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
+
+if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
+> C:\msys2\home\root\.ssh\authorized_keys echo %KEY%
+
+if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh
+>> C:\Users\Administrator\.ssh\authorized_keys echo %KEY%
+
+echo The SSH access is back.
From 758fc0b828b7a85b7488fcc35f6b9489fbc040dc Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:35:29 +0100
Subject: [PATCH 19/39] Give the user their .ssh directory back in the recovery
script
Co-Authored-By: Claude Code
---
server/scripts/restore_ssh_access.cmd | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
index 259c4c1..58d901e 100644
--- a/server/scripts/restore_ssh_access.cmd
+++ b/server/scripts/restore_ssh_access.cmd
@@ -2,6 +2,7 @@
REM Puts the SSH access back that a deleted home directory took with it: the key the website logs in with, in the
REM home of the root user and in the home of the Administrator. Run it as Administrator on the box, for example
REM over RDP. The cleanup job puts the key back by itself from then on.
+setlocal EnableDelayedExpansion
set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
@@ -10,4 +11,12 @@ if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh
>> C:\Users\Administrator\.ssh\authorized_keys echo %KEY%
+REM The user owns their .ssh, so that they can add keys of their own.
+for /f "tokens=5" %%i in ('findstr /b "root:" C:\msys2\etc\passwd') do set SID=%%i
+set SID=!SID:*,=!
+if defined SID (
+ icacls C:\msys2\home\root\.ssh /grant *!SID!:(OI)(CI)F /t
+ icacls C:\msys2\home\root\.ssh /setowner *!SID!
+)
+
echo The SSH access is back.
From ac98212fa24f58d26aa2b031f9556d16b781dd95 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:42:21 +0100
Subject: [PATCH 20/39] Fix the parentheses in the recovery script
Co-Authored-By: Claude Code
---
server/scripts/restore_ssh_access.cmd | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
index 58d901e..63670a2 100644
--- a/server/scripts/restore_ssh_access.cmd
+++ b/server/scripts/restore_ssh_access.cmd
@@ -14,9 +14,8 @@ if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh
REM The user owns their .ssh, so that they can add keys of their own.
for /f "tokens=5" %%i in ('findstr /b "root:" C:\msys2\etc\passwd') do set SID=%%i
set SID=!SID:*,=!
-if defined SID (
- icacls C:\msys2\home\root\.ssh /grant *!SID!:(OI)(CI)F /t
- icacls C:\msys2\home\root\.ssh /setowner *!SID!
-)
-
+if not defined SID goto :done
+icacls C:\msys2\home\root\.ssh /grant *!SID!:(OI)(CI)F /t
+icacls C:\msys2\home\root\.ssh /setowner *!SID!
+:done
echo The SSH access is back.
From fc7b75b930ac5c44e2d24a082fbaefbcfdd8f112 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:50:40 +0100
Subject: [PATCH 21/39] Keep the key of the website away from the Administrator
account
The key that the website logs in with was written into the authorized_keys
of the Administrator, which would have given everyone who has the website
administrator rights on the box. It only gets access as the root user.
The deploy script logs in as Administrator with the personal key of the
deployer instead, from the agent or from -i.
Co-Authored-By: Claude Code
---
server/README.md | 15 +++++-----
server/scripts/deploy.sh | 39 +++++++++++++------------
server/scripts/install_restore_task.ps1 | 11 -------
server/scripts/restore_ssh_access.cmd | 7 ++---
4 files changed, 30 insertions(+), 42 deletions(-)
diff --git a/server/README.md b/server/README.md
index b8c2b1c..549af3d 100644
--- a/server/README.md
+++ b/server/README.md
@@ -21,7 +21,8 @@ We only keep the basic user journey working.
which the user cannot change, because `/etc` is read-only for them. They can delete their home directory, though.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
-- The website can log in: the key it uses is in `~/.ssh/authorized_keys`.
+- The website can log in: the key it uses is in `~/.ssh/authorized_keys`. That key gets access to the `root` user
+ only, never to the Administrator account, whose keys belong to the people who deploy.
Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either.
@@ -77,9 +78,10 @@ The resources are built from the website's pages and the files of `home/`:
server/scripts/deploy.sh
The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
-Administrator's home directory and runs the installer there. It uses the key the website uses, which is authorized
-for the Administrator account as well, checks the host key against the one the website pins, and checks that the key
-in `home/.ssh/authorized_keys` is the one the website uses. `--uninstall` removes the task again.
+Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer, from
+the agent or from `-i`; the key of the website never gets administrator rights, it only logs in as the root user.
+It checks the host key against the one the website pins, and checks that the key in `home/.ssh/authorized_keys` is
+the one the website uses. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
@@ -90,6 +92,5 @@ runs at startup. Remove it again with `-Uninstall`.
## Getting the SSH access back
-If the home directory was deleted, the key that lets the website in went with it, and so did the Administrator's.
-Over RDP, as Administrator, run `scripts/restore_ssh_access.cmd`. From then on the cleanup puts the key back by
-itself.
+If the home directory was deleted, the key that lets the website in went with it. Over RDP, as Administrator, run
+`scripts/restore_ssh_access.cmd`. From then on the cleanup puts the key back by itself.
diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh
index f115bb6..5a312c7 100755
--- a/server/scripts/deploy.sh
+++ b/server/scripts/deploy.sh
@@ -5,6 +5,9 @@
#
# The job runs on the box as SYSTEM, never as the root user. The Administrator account sets it up; its shell is a
# Windows one, so the commands we run there are Windows commands.
+#
+# The Administrator logs in with the personal key of the deployer, from the agent or from -i. The key of the website
+# never gets administrator rights: it only logs in as the root user.
set -euo pipefail
HOST=nowhere.wirs.ing
@@ -27,23 +30,6 @@ remote='C:\Users\Administrator\kik' # where the installer is put, in the home
remotePath='/c/Users/Administrator/kik' # the same place, as the file transfer sees it
temporary=()
-if [ -z "$KEYFILE" ]; then
- # The key the website uses is authorized for the Administrator account as well.
- KEYFILE=$(mktemp)
- chmod 600 "$KEYFILE"
- temporary+=("$KEYFILE")
- boring=$(sed -n "s/.*const boringData = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
- printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$KEYFILE"
-fi
-
-# The site logs in with the key in the authorized_keys of the user, so the two have to be the same.
-public=$(ssh-keygen -y -f "$KEYFILE" | awk 'NF {print $1" "$2}')
-authorized=$(awk 'NF {print $1" "$2; exit}' "$server/resources/.ssh/authorized_keys")
-if [ "$public" != "$authorized" ]; then
- echo "the key in server/resources/.ssh/authorized_keys is not the one the website uses" >&2
- exit 1
-fi
-
# The host key is the one the website pins, so that the deployment cannot be intercepted.
expected=$(sed -n "s/.*const HOST_KEY = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
known=$(mktemp)
@@ -56,8 +42,23 @@ if [ "$actual" != "$expected" ]; then
exit 1
fi
-ssh=(ssh -i "$KEYFILE" -o UserKnownHostsFile="$known" -o StrictHostKeyChecking=yes "$USER@$HOST")
-scp=(scp -i "$KEYFILE" -o UserKnownHostsFile="$known" -o StrictHostKeyChecking=yes)
+# The site logs in with the key in the authorized_keys of the user on the box, so the two have to be the same.
+site=$(mktemp)
+chmod 600 "$site"
+temporary+=("$site")
+boring=$(sed -n "s/.*const boringData = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
+printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$site"
+public=$(ssh-keygen -y -f "$site" | awk 'NF {print $1" "$2}')
+authorized=$(awk 'NF {print $1" "$2; exit}' "$server/resources/.ssh/authorized_keys")
+if [ "$public" != "$authorized" ]; then
+ echo "the key in server/resources/.ssh/authorized_keys is not the one the website uses" >&2
+ exit 1
+fi
+
+options=(-o "UserKnownHostsFile=$known" -o StrictHostKeyChecking=yes)
+if [ -n "$KEYFILE" ]; then options+=(-i "$KEYFILE"); fi
+ssh=(ssh "${options[@]}" "$USER@$HOST")
+scp=(scp "${options[@]}")
if [ "$UNINSTALL" = 1 ]; then
"${ssh[@]}" "if exist $remote\\scripts\\install_restore_task.ps1 (powershell -NoProfile -ExecutionPolicy Bypass -File $remote\\scripts\\install_restore_task.ps1 -Uninstall) else (echo Nothing is installed.)"
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 2e56660..003b27f 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -152,17 +152,6 @@ foreach ($item in Get-ChildItem -LiteralPath $copiesDir -Recurse -Force) {
Remove-Item -LiteralPath $copiesDir -Recurse -Force
New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
Copy-Item -Path (Join-Path $resources '*') -Destination $copiesDir -Recurse -Force
-
-# The Administrator logs in with the same key, so that the deployment keeps working. The keys that are already
-# there stay.
-$adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys'
-$required = @(Get-Content -LiteralPath (Join-Path $resources '.ssh\authorized_keys') | Where-Object { $_.Trim() })
-$keys = @()
-if (Test-Path -LiteralPath $adminKeys) { $keys = @(Get-Content -LiteralPath $adminKeys | Where-Object { $_.Trim() }) }
-if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '\s+')[1] })) {
- New-Item -ItemType Directory -Force -Path (Split-Path -Parent $adminKeys) | Out-Null
- [IO.File]::WriteAllText($adminKeys, (($keys + $required) -join "`n") + "`n")
-}
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
index 63670a2..188d7b4 100644
--- a/server/scripts/restore_ssh_access.cmd
+++ b/server/scripts/restore_ssh_access.cmd
@@ -1,16 +1,13 @@
@echo off
REM Puts the SSH access back that a deleted home directory took with it: the key the website logs in with, in the
-REM home of the root user and in the home of the Administrator. Run it as Administrator on the box, for example
-REM over RDP. The cleanup job puts the key back by itself from then on.
+REM home of the root user. Run it as Administrator on the box, for example over RDP. The cleanup job puts the key
+REM back by itself from then on.
setlocal EnableDelayedExpansion
set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
> C:\msys2\home\root\.ssh\authorized_keys echo %KEY%
-if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh
->> C:\Users\Administrator\.ssh\authorized_keys echo %KEY%
-
REM The user owns their .ssh, so that they can add keys of their own.
for /f "tokens=5" %%i in ('findstr /b "root:" C:\msys2\etc\passwd') do set SID=%%i
set SID=!SID:*,=!
From 44a2d83ae63ba49792295ba5320d89bbf63d6ca9 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:52:32 +0100
Subject: [PATCH 22/39] Install the key of the deployer for both accounts
The personal key of the deployer goes into the authorized_keys of the
root user together with the key of the website, and the deployment makes
sure it is in the authorized_keys of the Administrator.
Co-Authored-By: Claude Code
---
server/README.md | 16 +++++++++-------
server/home/.ssh/authorized_keys | 1 +
server/resources/.ssh/authorized_keys | 1 +
server/scripts/deploy.sh | 25 +++++++++++++++++--------
server/scripts/restore_ssh_access.cmd | 2 ++
5 files changed, 30 insertions(+), 15 deletions(-)
diff --git a/server/README.md b/server/README.md
index 549af3d..63d668d 100644
--- a/server/README.md
+++ b/server/README.md
@@ -22,7 +22,8 @@ We only keep the basic user journey working.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
- The website can log in: the key it uses is in `~/.ssh/authorized_keys`. That key gets access to the `root` user
- only, never to the Administrator account, whose keys belong to the people who deploy.
+ only, never to the Administrator account. The keys of the people who deploy - their own key, not the key of the
+ website - are in the authorized_keys of both accounts.
Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either.
@@ -61,8 +62,9 @@ undoes that.
## This directory
-- `home/` - the files that belong in the home directory of the user: `~/.zshrc`, `~/.zshenv` and the key in
- `~/.ssh/authorized_keys` that the website logs in with. This is where they are edited.
+- `home/` - the files that belong in the home directory of the user: `~/.zshrc`, `~/.zshenv` and the keys in
+ `~/.ssh/authorized_keys` - the one the website logs in with and the ones of the people who deploy. This is where
+ they are edited.
- `resources/` - the copies the cleanup restores from: the four text files and the files of `home/`.
- `scripts/make_files.py` - builds the resources.
- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
@@ -78,10 +80,10 @@ The resources are built from the website's pages and the files of `home/`:
server/scripts/deploy.sh
The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
-Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer, from
-the agent or from `-i`; the key of the website never gets administrator rights, it only logs in as the root user.
-It checks the host key against the one the website pins, and checks that the key in `home/.ssh/authorized_keys` is
-the one the website uses. `--uninstall` removes the task again.
+Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer,
+`~/.ssh/id_ed25519` by default and `-i` for another one; the key of the website never gets administrator rights, it
+only logs in as the root user. It checks the host key against the one the website pins, and checks that the key in
+`home/.ssh/authorized_keys` is the one the website uses. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
diff --git a/server/home/.ssh/authorized_keys b/server/home/.ssh/authorized_keys
index 873f53b..9b81cf8 100644
--- a/server/home/.ssh/authorized_keys
+++ b/server/home/.ssh/authorized_keys
@@ -1 +1,2 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
diff --git a/server/resources/.ssh/authorized_keys b/server/resources/.ssh/authorized_keys
index 873f53b..9b81cf8 100644
--- a/server/resources/.ssh/authorized_keys
+++ b/server/resources/.ssh/authorized_keys
@@ -1 +1,2 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh
index 5a312c7..03bdaf6 100755
--- a/server/scripts/deploy.sh
+++ b/server/scripts/deploy.sh
@@ -6,13 +6,13 @@
# The job runs on the box as SYSTEM, never as the root user. The Administrator account sets it up; its shell is a
# Windows one, so the commands we run there are Windows commands.
#
-# The Administrator logs in with the personal key of the deployer, from the agent or from -i. The key of the website
-# never gets administrator rights: it only logs in as the root user.
+# The Administrator logs in with the personal key of the deployer, by default ~/.ssh/id_ed25519. The key of the
+# website never gets administrator rights: it only logs in as the root user.
set -euo pipefail
HOST=nowhere.wirs.ing
USER=Administrator
-KEYFILE=''
+KEYFILE="$HOME/.ssh/id_ed25519"
UNINSTALL=0
while [ $# -gt 0 ]; do
@@ -27,9 +27,14 @@ done
repo=$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)
server="$repo/server"
remote='C:\Users\Administrator\kik' # where the installer is put, in the home of the Administrator
-remotePath='/c/Users/Administrator/kik' # the same place, as the file transfer sees it
+remotePath='/c/Users\Administrator\kik' # the same place, as the file transfer sees it
temporary=()
+if [ ! -f "$KEYFILE" ]; then
+ echo "no key file for the login as $USER: $KEYFILE" >&2
+ exit 1
+fi
+
# The host key is the one the website pins, so that the deployment cannot be intercepted.
expected=$(sed -n "s/.*const HOST_KEY = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
known=$(mktemp)
@@ -49,14 +54,12 @@ temporary+=("$site")
boring=$(sed -n "s/.*const boringData = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$site"
public=$(ssh-keygen -y -f "$site" | awk 'NF {print $1" "$2}')
-authorized=$(awk 'NF {print $1" "$2; exit}' "$server/resources/.ssh/authorized_keys")
-if [ "$public" != "$authorized" ]; then
+if ! awk 'NF {print $1" "$2}' "$server/resources/.ssh/authorized_keys" | grep -qxF "$public"; then
echo "the key in server/resources/.ssh/authorized_keys is not the one the website uses" >&2
exit 1
fi
-options=(-o "UserKnownHostsFile=$known" -o StrictHostKeyChecking=yes)
-if [ -n "$KEYFILE" ]; then options+=(-i "$KEYFILE"); fi
+options=(-i "$KEYFILE" -o "UserKnownHostsFile=$known" -o StrictHostKeyChecking=yes)
ssh=(ssh "${options[@]}" "$USER@$HOST")
scp=(scp "${options[@]}")
@@ -69,3 +72,9 @@ fi
"${ssh[@]}" "if not exist $remote mkdir $remote"
"${scp[@]}" -r "$server/scripts" "$server/resources" "$USER@$HOST:$remotePath/"
"${ssh[@]}" "powershell -NoProfile -ExecutionPolicy Bypass -File $remote\\scripts\\install_restore_task.ps1"
+
+# The Administrator logs in with the key of the deployer, so that it is there after every deployment.
+keyline=$(ssh-keygen -y -f "$KEYFILE")
+blob=$(echo "$keyline" | awk 'NF {print $2}')
+"${ssh[@]}" "if not exist C:\\Users\\Administrator\\.ssh mkdir C:\\Users\\Administrator\\.ssh"
+"${ssh[@]}" "findstr /c:\"$blob\" C:\\Users\\Administrator\\.ssh\\authorized_keys >nul 2>&1 || echo $keyline >> C:\\Users\\Administrator\\.ssh\\authorized_keys"
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
index 188d7b4..01e61b6 100644
--- a/server/scripts/restore_ssh_access.cmd
+++ b/server/scripts/restore_ssh_access.cmd
@@ -4,9 +4,11 @@ REM home of the root user. Run it as Administrator on the box, for example over
REM back by itself from then on.
setlocal EnableDelayedExpansion
set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
+set OWN=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
> C:\msys2\home\root\.ssh\authorized_keys echo %KEY%
+echo %OWN% >> C:\msys2\home\root\.ssh\authorized_keys
REM The user owns their .ssh, so that they can add keys of their own.
for /f "tokens=5" %%i in ('findstr /b "root:" C:\msys2\etc\passwd') do set SID=%%i
From a17b2d76dcec93ee81938360688e9f590bbb97dd Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 13:58:05 +0100
Subject: [PATCH 23/39] Check in the keys for both accounts
The keys of the people who deploy are in the repository now: in home/ for
the root user and in admin/ for the Administrator. The resources mirror
both home directories, and the deploy script no longer reads any private
key.
Co-Authored-By: Claude Code
---
server/README.md | 12 +++---
server/admin/.ssh/authorized_keys | 1 +
server/resources/admin/.ssh/authorized_keys | 1 +
.../resources/{ => home}/.ssh/authorized_keys | 0
server/resources/{ => home}/.zshenv | 0
server/resources/{ => home}/.zshrc | 0
server/resources/{ => home}/CODE_OF_CONDUCT | 0
.../resources/{ => home}/CODE_OF_CONDUCT.en | 0
server/resources/{ => home}/README | 0
server/resources/{ => home}/README.en | 0
server/scripts/deploy.sh | 39 +++++++------------
server/scripts/install_restore_task.ps1 | 18 +++++++--
server/scripts/make_files.py | 30 +++++++-------
server/scripts/restore_ssh_access.cmd | 8 +++-
14 files changed, 58 insertions(+), 51 deletions(-)
create mode 100644 server/admin/.ssh/authorized_keys
create mode 100644 server/resources/admin/.ssh/authorized_keys
rename server/resources/{ => home}/.ssh/authorized_keys (100%)
rename server/resources/{ => home}/.zshenv (100%)
rename server/resources/{ => home}/.zshrc (100%)
rename server/resources/{ => home}/CODE_OF_CONDUCT (100%)
rename server/resources/{ => home}/CODE_OF_CONDUCT.en (100%)
rename server/resources/{ => home}/README (100%)
rename server/resources/{ => home}/README.en (100%)
diff --git a/server/README.md b/server/README.md
index 63d668d..b329c36 100644
--- a/server/README.md
+++ b/server/README.md
@@ -65,7 +65,9 @@ undoes that.
- `home/` - the files that belong in the home directory of the user: `~/.zshrc`, `~/.zshenv` and the keys in
`~/.ssh/authorized_keys` - the one the website logs in with and the ones of the people who deploy. This is where
they are edited.
-- `resources/` - the copies the cleanup restores from: the four text files and the files of `home/`.
+- `admin/` - the same for the home directory of the Administrator: the keys of the people who deploy. The key of the
+ website is not among them and never gets administrator rights.
+- `resources/` - the copies the cleanup restores from: the files of `home/` and `admin/` and the four text files.
- `scripts/make_files.py` - builds the resources.
- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
@@ -80,10 +82,10 @@ The resources are built from the website's pages and the files of `home/`:
server/scripts/deploy.sh
The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
-Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer,
-`~/.ssh/id_ed25519` by default and `-i` for another one; the key of the website never gets administrator rights, it
-only logs in as the root user. It checks the host key against the one the website pins, and checks that the key in
-`home/.ssh/authorized_keys` is the one the website uses. `--uninstall` removes the task again.
+Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer, from
+the agent or from `-i`; the key of the website never gets administrator rights, it only logs in as the root user.
+It checks the host key against the one the website pins, and that the key of the website is the first one in
+`home/.ssh/authorized_keys`. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
diff --git a/server/admin/.ssh/authorized_keys b/server/admin/.ssh/authorized_keys
new file mode 100644
index 0000000..c6a6df9
--- /dev/null
+++ b/server/admin/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
diff --git a/server/resources/admin/.ssh/authorized_keys b/server/resources/admin/.ssh/authorized_keys
new file mode 100644
index 0000000..c6a6df9
--- /dev/null
+++ b/server/resources/admin/.ssh/authorized_keys
@@ -0,0 +1 @@
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
diff --git a/server/resources/.ssh/authorized_keys b/server/resources/home/.ssh/authorized_keys
similarity index 100%
rename from server/resources/.ssh/authorized_keys
rename to server/resources/home/.ssh/authorized_keys
diff --git a/server/resources/.zshenv b/server/resources/home/.zshenv
similarity index 100%
rename from server/resources/.zshenv
rename to server/resources/home/.zshenv
diff --git a/server/resources/.zshrc b/server/resources/home/.zshrc
similarity index 100%
rename from server/resources/.zshrc
rename to server/resources/home/.zshrc
diff --git a/server/resources/CODE_OF_CONDUCT b/server/resources/home/CODE_OF_CONDUCT
similarity index 100%
rename from server/resources/CODE_OF_CONDUCT
rename to server/resources/home/CODE_OF_CONDUCT
diff --git a/server/resources/CODE_OF_CONDUCT.en b/server/resources/home/CODE_OF_CONDUCT.en
similarity index 100%
rename from server/resources/CODE_OF_CONDUCT.en
rename to server/resources/home/CODE_OF_CONDUCT.en
diff --git a/server/resources/README b/server/resources/home/README
similarity index 100%
rename from server/resources/README
rename to server/resources/home/README
diff --git a/server/resources/README.en b/server/resources/home/README.en
similarity index 100%
rename from server/resources/README.en
rename to server/resources/home/README.en
diff --git a/server/scripts/deploy.sh b/server/scripts/deploy.sh
index 03bdaf6..09fae47 100755
--- a/server/scripts/deploy.sh
+++ b/server/scripts/deploy.sh
@@ -6,13 +6,13 @@
# The job runs on the box as SYSTEM, never as the root user. The Administrator account sets it up; its shell is a
# Windows one, so the commands we run there are Windows commands.
#
-# The Administrator logs in with the personal key of the deployer, by default ~/.ssh/id_ed25519. The key of the
-# website never gets administrator rights: it only logs in as the root user.
+# The Administrator logs in with the personal key of the deployer, from the agent or from -i. The key of the website
+# never gets administrator rights: it only logs in as the root user.
set -euo pipefail
HOST=nowhere.wirs.ing
USER=Administrator
-KEYFILE="$HOME/.ssh/id_ed25519"
+KEYFILE=''
UNINSTALL=0
while [ $# -gt 0 ]; do
@@ -30,11 +30,6 @@ remote='C:\Users\Administrator\kik' # where the installer is put, in the home
remotePath='/c/Users\Administrator\kik' # the same place, as the file transfer sees it
temporary=()
-if [ ! -f "$KEYFILE" ]; then
- echo "no key file for the login as $USER: $KEYFILE" >&2
- exit 1
-fi
-
# The host key is the one the website pins, so that the deployment cannot be intercepted.
expected=$(sed -n "s/.*const HOST_KEY = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
known=$(mktemp)
@@ -47,19 +42,15 @@ if [ "$actual" != "$expected" ]; then
exit 1
fi
-# The site logs in with the key in the authorized_keys of the user on the box, so the two have to be the same.
-site=$(mktemp)
-chmod 600 "$site"
-temporary+=("$site")
-boring=$(sed -n "s/.*const boringData = '\([^']*\)'.*/\1/p" "$repo/src/scripts/terminal.ts")
-printf '%s' "$boring" | base64 -d | tr 'A-Za-z' 'N-ZA-Mn-za-m' > "$site"
-public=$(ssh-keygen -y -f "$site" | awk 'NF {print $1" "$2}')
-if ! awk 'NF {print $1" "$2}' "$server/resources/.ssh/authorized_keys" | grep -qxF "$public"; then
- echo "the key in server/resources/.ssh/authorized_keys is not the one the website uses" >&2
+# The site logs in with the first key in the authorized_keys of the user on the box, so it has to be the right one.
+sitekey=$(ssh-keygen -lf "$server/resources/home/.ssh/authorized_keys" | awk 'NR==1 {print $2}')
+if [ "$sitekey" != 'SHA256:KUEv4d4+75h41l0OGTWCA2FbtEOQZ047ujkeh+kkFW0' ]; then
+ echo "the key of the website is not the first one in server/resources/home/.ssh/authorized_keys" >&2
exit 1
fi
-options=(-i "$KEYFILE" -o "UserKnownHostsFile=$known" -o StrictHostKeyChecking=yes)
+options=(-o "UserKnownHostsFile=$known" -o StrictHostKeyChecking=yes)
+if [ -n "$KEYFILE" ]; then options+=(-i "$KEYFILE"); fi
ssh=(ssh "${options[@]}" "$USER@$HOST")
scp=(scp "${options[@]}")
@@ -68,13 +59,9 @@ if [ "$UNINSTALL" = 1 ]; then
exit
fi
-# The installer looks for the resources next to itself, so both directories go to the server.
-"${ssh[@]}" "if not exist $remote mkdir $remote"
+# The installer looks for the resources next to itself, so both directories go to the server. What was there
+# before is removed, so that files that are gone from the repository do not stay on the box.
+"${ssh[@]}" "if exist $remote rmdir /s /q $remote"
+"${ssh[@]}" "mkdir $remote"
"${scp[@]}" -r "$server/scripts" "$server/resources" "$USER@$HOST:$remotePath/"
"${ssh[@]}" "powershell -NoProfile -ExecutionPolicy Bypass -File $remote\\scripts\\install_restore_task.ps1"
-
-# The Administrator logs in with the key of the deployer, so that it is there after every deployment.
-keyline=$(ssh-keygen -y -f "$KEYFILE")
-blob=$(echo "$keyline" | awk 'NF {print $2}')
-"${ssh[@]}" "if not exist C:\\Users\\Administrator\\.ssh mkdir C:\\Users\\Administrator\\.ssh"
-"${ssh[@]}" "findstr /c:\"$blob\" C:\\Users\\Administrator\\.ssh\\authorized_keys >nul 2>&1 || echo $keyline >> C:\\Users\\Administrator\\.ssh\\authorized_keys"
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 003b27f..e5f106c 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -25,7 +25,7 @@ $restoreScript = @'
# server's root user can neither stop it nor change the copies it restores from.
param([string]$HomeDir = 'C:\msys2\home\root')
$log = Join-Path $PSScriptRoot 'restore.log'
-$copies = Join-Path $PSScriptRoot 'files'
+$copies = Join-Path $PSScriptRoot 'files\home'
# The account of the shell server's root user, whose id the files in its home directory belong to.
$passwd = Get-Content 'C:\msys2\etc\passwd' -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
@@ -51,10 +51,10 @@ function Remove-Anything($path) {
# the key of the website is put back.
function Restore-Keys($target, $source) {
$required = @(Get-Content -LiteralPath $source | Where-Object { $_.Trim() })
- $blob = ($required[0] -split '\s+')[1]
+ $blobs = $required | ForEach-Object { ($_ -split '\s+')[1] }
$keys = @()
if (Test-Path -LiteralPath $target) {
- $keys = @(Get-Content -LiteralPath $target | Where-Object { $_.Trim() -and ($_ -split '\s+')[1] -ne $blob })
+ $keys = @(Get-Content -LiteralPath $target | Where-Object { $_.Trim() -and $blobs -notcontains ($_ -split '\s+')[1] })
}
[IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n")
}
@@ -127,7 +127,7 @@ if ($Uninstall) {
}
$resources = Join-Path $PSScriptRoot '..\resources'
-if (-not (Get-ChildItem -LiteralPath $resources -File -Force)) { throw "No files in $resources." }
+if (-not (Get-ChildItem -LiteralPath $resources -Recurse -File -Force)) { throw "No files in $resources." }
# The root user may have prepared the directory or the file names, so nothing that looks like a link is used.
$prepared = Get-Item -LiteralPath $installDir -Force -ErrorAction SilentlyContinue
@@ -152,6 +152,16 @@ foreach ($item in Get-ChildItem -LiteralPath $copiesDir -Recurse -Force) {
Remove-Item -LiteralPath $copiesDir -Recurse -Force
New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
Copy-Item -Path (Join-Path $resources '*') -Destination $copiesDir -Recurse -Force
+# The Administrator logs in with the key of the people who deploy, which is in the resources. The keys that are
+# already there stay.
+$adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys'
+$required = @(Get-Content -LiteralPath (Join-Path $resources 'admin\.ssh\authorized_keys') | Where-Object { $_.Trim() })
+$keys = @()
+if (Test-Path -LiteralPath $adminKeys) { $keys = @(Get-Content -LiteralPath $adminKeys | Where-Object { $_.Trim() }) }
+if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '\s+')[1] })) {
+ New-Item -ItemType Directory -Force -Path (Split-Path -Parent $adminKeys) | Out-Null
+ [IO.File]::WriteAllText($adminKeys, (($keys + $required) -join "`n") + "`n")
+}
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
diff --git a/server/scripts/make_files.py b/server/scripts/make_files.py
index e9bd1cd..425d54c 100644
--- a/server/scripts/make_files.py
+++ b/server/scripts/make_files.py
@@ -1,6 +1,6 @@
-# Builds the files for the home directory of the shell server's root user in ../resources: README, README.en,
-# CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape sequences, and the
-# files of ../home, which are copied as they are.
+# Builds the files for the home directories of the shell server in ../resources: the home of the root user with
+# README, README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape
+# sequences, and the files of ../home and ../admin, which are copied as they are.
# Usage: python3 server/scripts/make_files.py
import pathlib
import shutil
@@ -332,16 +332,18 @@ COC_EN = {
}
out = pathlib.Path(__file__).parent.parent / 'resources'
-out.mkdir(exist_ok=True)
+(out / 'home').mkdir(parents=True, exist_ok=True)
for name, text in [('README', readme(DE)), ('README.en', readme(EN)),
('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]:
- (out / name).write_text(text, encoding='utf-8')
-# The files for the home directory are not generated, they are copied from ../home, where they are edited.
-home = pathlib.Path(__file__).parent.parent / 'home'
-for path in home.rglob('*'):
- target = out / path.relative_to(home)
- if path.is_dir():
- target.mkdir(parents=True, exist_ok=True)
- else:
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copyfile(path, target)
+ (out / 'home' / name).write_text(text, encoding='utf-8')
+# The files of the home directories are not generated, they are copied from ../home and ../admin, where they are
+# edited.
+for source in 'home', 'admin':
+ root = pathlib.Path(__file__).parent.parent / source
+ for path in root.rglob('*'):
+ target = out / source / path.relative_to(root)
+ if path.is_dir():
+ target.mkdir(parents=True, exist_ok=True)
+ else:
+ target.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copyfile(path, target)
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
index 01e61b6..0ab3f71 100644
--- a/server/scripts/restore_ssh_access.cmd
+++ b/server/scripts/restore_ssh_access.cmd
@@ -1,15 +1,19 @@
@echo off
REM Puts the SSH access back that a deleted home directory took with it: the key the website logs in with, in the
-REM home of the root user. Run it as Administrator on the box, for example over RDP. The cleanup job puts the key
-REM back by itself from then on.
+REM home of the root user, and the key of the deployer in the home of the Administrator. Run it as Administrator on
+REM the box, for example over RDP. The cleanup job puts the key of the website back by itself from then on.
setlocal EnableDelayedExpansion
set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
set OWN=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
+set BLOB=AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137
if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
> C:\msys2\home\root\.ssh\authorized_keys echo %KEY%
echo %OWN% >> C:\msys2\home\root\.ssh\authorized_keys
+if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh
+findstr /c:"%BLOB%" C:\Users\Administrator\.ssh\authorized_keys >nul 2>&1 || echo %OWN% >> C:\Users\Administrator\.ssh\authorized_keys
+
REM The user owns their .ssh, so that they can add keys of their own.
for /f "tokens=5" %%i in ('findstr /b "root:" C:\msys2\etc\passwd') do set SID=%%i
set SID=!SID:*,=!
From fd1e379fcf283f36e587799e46f0fe16526ddd97 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 14:01:27 +0100
Subject: [PATCH 24/39] Keep the files for the server in one directory
The resources were copied from home/ and admin/, which held the same
files a second time. Everything that goes to the server is in resources/
now, and the generator only writes the four text files.
Co-Authored-By: Claude Code
---
server/README.md | 16 ++++++--------
server/admin/.ssh/authorized_keys | 1 -
server/home/.ssh/authorized_keys | 2 --
server/home/.zshenv | 3 ---
server/home/.zshrc | 36 -------------------------------
server/scripts/make_files.py | 24 ++++++---------------
6 files changed, 13 insertions(+), 69 deletions(-)
delete mode 100644 server/admin/.ssh/authorized_keys
delete mode 100644 server/home/.ssh/authorized_keys
delete mode 100644 server/home/.zshenv
delete mode 100644 server/home/.zshrc
diff --git a/server/README.md b/server/README.md
index b329c36..89fb3c2 100644
--- a/server/README.md
+++ b/server/README.md
@@ -62,18 +62,16 @@ undoes that.
## This directory
-- `home/` - the files that belong in the home directory of the user: `~/.zshrc`, `~/.zshenv` and the keys in
- `~/.ssh/authorized_keys` - the one the website logs in with and the ones of the people who deploy. This is where
- they are edited.
-- `admin/` - the same for the home directory of the Administrator: the keys of the people who deploy. The key of the
- website is not among them and never gets administrator rights.
-- `resources/` - the copies the cleanup restores from: the files of `home/` and `admin/` and the four text files.
-- `scripts/make_files.py` - builds the resources.
+- `resources/` - everything that goes to the server: `home/` for the home directory of the root user - `~/.zshrc`,
+ `~/.zshenv`, the keys in `~/.ssh/authorized_keys` and the four text files - and `admin/` for the home directory
+ of the Administrator, which holds the keys of the people who deploy. The key of the website is not among them and
+ never gets administrator rights.
+- `scripts/make_files.py` - builds the four text files.
- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
- `scripts/restore_ssh_access.cmd` - puts the SSH access back, for when the home directory was deleted.
-The resources are built from the website's pages and the files of `home/`:
+The four text files are built from the website's pages, the other files in the resources are edited by hand:
python3 server/scripts/make_files.py
@@ -85,7 +83,7 @@ The script logs in as `Administrator` - whose shell is a Windows one - copies th
Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer, from
the agent or from `-i`; the key of the website never gets administrator rights, it only logs in as the root user.
It checks the host key against the one the website pins, and that the key of the website is the first one in
-`home/.ssh/authorized_keys`. `--uninstall` removes the task again.
+`resources/home/.ssh/authorized_keys`. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
diff --git a/server/admin/.ssh/authorized_keys b/server/admin/.ssh/authorized_keys
deleted file mode 100644
index c6a6df9..0000000
--- a/server/admin/.ssh/authorized_keys
+++ /dev/null
@@ -1 +0,0 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
diff --git a/server/home/.ssh/authorized_keys b/server/home/.ssh/authorized_keys
deleted file mode 100644
index 9b81cf8..0000000
--- a/server/home/.ssh/authorized_keys
+++ /dev/null
@@ -1,2 +0,0 @@
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
-ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
diff --git a/server/home/.zshenv b/server/home/.zshenv
deleted file mode 100644
index f990c34..0000000
--- a/server/home/.zshenv
+++ /dev/null
@@ -1,3 +0,0 @@
-# Preset what MSYS2's /etc/profile would otherwise start a helper process for (~90 ms each here).
-# TZ has to match the Windows timezone (W. Europe Standard Time).
-export LANG=en_US.UTF-8 TZ=Europe/Berlin
diff --git a/server/home/.zshrc b/server/home/.zshrc
deleted file mode 100644
index 310de44..0000000
--- a/server/home/.zshrc
+++ /dev/null
@@ -1,36 +0,0 @@
-# History shared between sessions.
-HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=10000
-setopt share_history hist_ignore_dups hist_ignore_space interactive_comments auto_cd
-
-# Completion: menu, case-insensitive, colored. -C skips compaudit, which is slow on
-# Windows and meaningless under MSYS2's noacl mounts (rm ~/.zcompdump after adding completions).
-autoload -Uz compinit && compinit -C
-zstyle ':completion:*' menu select
-zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
-zstyle ':completion:*' list-colors ${(s.:.)LS_COLORS}
-
-# Emacs keys; Up/Down search history for what is already typed.
-bindkey -e
-autoload -Uz up-line-or-beginning-search down-line-or-beginning-search
-zle -N up-line-or-beginning-search && zle -N down-line-or-beginning-search
-bindkey '^[[A' up-line-or-beginning-search '^[OA' up-line-or-beginning-search
-bindkey '^[[B' down-line-or-beginning-search '^[OB' down-line-or-beginning-search
-
-# Prompt: user@host:directory, git branch, exit status of the last command if it failed.
-# A literal '#' as on Linux: zsh's %# would show '%', since this root is not uid 0.
-# The branch is read from .git/HEAD directly: starting git on every prompt is slow on Windows.
-precmd() {
- local d=$PWD head=
- until [[ -e $d/.git || $d == / ]]; do d=${d:h}; done
- [[ -f $d/.git/HEAD ]] && read -r head < $d/.git/HEAD
- [[ $head == 'ref: '* ]] && git_branch=${head#ref: refs/heads/} || git_branch=${head:0:7}
- print -Pn '\e]0;%n@%m: %~\a'
-}
-setopt prompt_subst
-PROMPT='%F{green}%n@%m%f:%F{blue}%~%f${git_branch:+ %5F($git_branch)%f}%(?.. %F{red}[%?]%f)# '
-
-alias ls='ls --color=auto' ll='ls -lah' grep='grep --color=auto'
-
-# Process monitor: htop does not exist for MSYS2 (its /proc only shows MSYS2 processes).
-# bottom is a native Windows program; winpty lets it draw in the pty, as Server 2016 has no ConPTY.
-alias btm='winpty /ucrt64/bin/btm'
diff --git a/server/scripts/make_files.py b/server/scripts/make_files.py
index 425d54c..f716d7f 100644
--- a/server/scripts/make_files.py
+++ b/server/scripts/make_files.py
@@ -1,9 +1,8 @@
-# Builds the files for the home directories of the shell server in ../resources: the home of the root user with
-# README, README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape
-# sequences, and the files of ../home and ../admin, which are copied as they are.
+# Builds the text files for the home directory of the shell server's root user in ../resources/home: README,
+# README.en, CODE_OF_CONDUCT and CODE_OF_CONDUCT.en as the website's pages in terminal text with escape sequences.
+# The other files there are edited by hand.
# Usage: python3 server/scripts/make_files.py
import pathlib
-import shutil
import textwrap
ESC = '\x1b'
@@ -331,19 +330,8 @@ COC_EN = {
'footer': [('Home', f'{SITE}en.html'), ('Legal notice', IMPRESSUM)],
}
-out = pathlib.Path(__file__).parent.parent / 'resources'
-(out / 'home').mkdir(parents=True, exist_ok=True)
+out = pathlib.Path(__file__).parent.parent / 'resources' / 'home'
+out.mkdir(parents=True, exist_ok=True)
for name, text in [('README', readme(DE)), ('README.en', readme(EN)),
('CODE_OF_CONDUCT', code_of_conduct(COC_DE)), ('CODE_OF_CONDUCT.en', code_of_conduct(COC_EN))]:
- (out / 'home' / name).write_text(text, encoding='utf-8')
-# The files of the home directories are not generated, they are copied from ../home and ../admin, where they are
-# edited.
-for source in 'home', 'admin':
- root = pathlib.Path(__file__).parent.parent / source
- for path in root.rglob('*'):
- target = out / source / path.relative_to(root)
- if path.is_dir():
- target.mkdir(parents=True, exist_ok=True)
- else:
- target.parent.mkdir(parents=True, exist_ok=True)
- shutil.copyfile(path, target)
+ (out / name).write_text(text, encoding='utf-8')
From a86a3c7b47b9cd8e17ff918495667c7986ce87bb Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 14:03:21 +0100
Subject: [PATCH 25/39] Drop the one-time recovery script
The deployment restores the keys as well, so the script is not needed
anymore.
Co-Authored-By: Claude Code
---
server/README.md | 5 ++---
server/scripts/restore_ssh_access.cmd | 24 ------------------------
2 files changed, 2 insertions(+), 27 deletions(-)
delete mode 100644 server/scripts/restore_ssh_access.cmd
diff --git a/server/README.md b/server/README.md
index 89fb3c2..67d1787 100644
--- a/server/README.md
+++ b/server/README.md
@@ -69,7 +69,6 @@ undoes that.
- `scripts/make_files.py` - builds the four text files.
- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
-- `scripts/restore_ssh_access.cmd` - puts the SSH access back, for when the home directory was deleted.
The four text files are built from the website's pages, the other files in the resources are edited by hand:
@@ -94,5 +93,5 @@ runs at startup. Remove it again with `-Uninstall`.
## Getting the SSH access back
-If the home directory was deleted, the key that lets the website in went with it. Over RDP, as Administrator, run
-`scripts/restore_ssh_access.cmd`. From then on the cleanup puts the key back by itself.
+If the home directory was deleted, the key that lets the website in went with it. The cleanup puts it back by
+itself, within half an hour; to do it right away, run the deployment again - the installer starts the job once.
diff --git a/server/scripts/restore_ssh_access.cmd b/server/scripts/restore_ssh_access.cmd
deleted file mode 100644
index 0ab3f71..0000000
--- a/server/scripts/restore_ssh_access.cmd
+++ /dev/null
@@ -1,24 +0,0 @@
-@echo off
-REM Puts the SSH access back that a deleted home directory took with it: the key the website logs in with, in the
-REM home of the root user, and the key of the deployer in the home of the Administrator. Run it as Administrator on
-REM the box, for example over RDP. The cleanup job puts the key of the website back by itself from then on.
-setlocal EnableDelayedExpansion
-set KEY=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJDweEH8bhZAHVaAedhTVDb2j/oMEn3vm97xCwCw9LC9 root@nowhere.wirs.ing cygwin-2222
-set OWN=ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137 lennart@erms
-set BLOB=AAAAC3NzaC1lZDI1NTE5AAAAIIBTHzLm8QMhHIo7kFAvtAFnqpspeR3L3gM8kLoG1137
-
-if not exist C:\msys2\home\root\.ssh mkdir C:\msys2\home\root\.ssh
-> C:\msys2\home\root\.ssh\authorized_keys echo %KEY%
-echo %OWN% >> C:\msys2\home\root\.ssh\authorized_keys
-
-if not exist C:\Users\Administrator\.ssh mkdir C:\Users\Administrator\.ssh
-findstr /c:"%BLOB%" C:\Users\Administrator\.ssh\authorized_keys >nul 2>&1 || echo %OWN% >> C:\Users\Administrator\.ssh\authorized_keys
-
-REM The user owns their .ssh, so that they can add keys of their own.
-for /f "tokens=5" %%i in ('findstr /b "root:" C:\msys2\etc\passwd') do set SID=%%i
-set SID=!SID:*,=!
-if not defined SID goto :done
-icacls C:\msys2\home\root\.ssh /grant *!SID!:(OI)(CI)F /t
-icacls C:\msys2\home\root\.ssh /setowner *!SID!
-:done
-echo The SSH access is back.
From 85d345bac2182b4d72c366f56773369aa49d9e03 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 14:05:27 +0100
Subject: [PATCH 26/39] Trim the README of the shell server
Co-Authored-By: Claude Code
---
server/README.md | 42 ++++++++++++------------------------------
1 file changed, 12 insertions(+), 30 deletions(-)
diff --git a/server/README.md b/server/README.md
index 67d1787..2285cee 100644
--- a/server/README.md
+++ b/server/README.md
@@ -17,13 +17,11 @@ We only keep the basic user journey working.
## What we keep working
- The prompt: `root@khaos-im-kiez:~#`, which `~/.zshrc` sets.
-- The home directory the user ends up in: `/home/root`. It is the one from the `root` entry in `/etc/passwd`,
- which the user cannot change, because `/etc` is read-only for them. They can delete their home directory, though.
+- The home directory the user ends up in: `/home/root`. They can delete it, but not change where it is.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
- The website can log in: the key it uses is in `~/.ssh/authorized_keys`. That key gets access to the `root` user
- only, never to the Administrator account. The keys of the people who deploy - their own key, not the key of the
- website - are in the authorized_keys of both accounts.
+ only, never to the Administrator account.
Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either.
@@ -36,36 +34,25 @@ stop them from that - but the next cleanup brings it back. Until then, the site
Everything else. If the user breaks the shell, the packages, the disk or the connection, we fix that by hand or
throw the box away. The cleanup does not try.
-Two things that the user cannot break and therefore do not need to be restored: the entry in `/etc/passwd` with
-the home directory and the shell, and the host keys in `/etc/ssh`, which the website pins. The sshd runs as SYSTEM,
-so the user cannot restart it with a different key either.
+Two things that the user cannot break: the entry in `/etc/passwd` with the home directory and the shell, because
+`/etc` is read-only for them, and the host keys in `/etc/ssh`, which the website pins. The sshd runs as SYSTEM, so
+the user cannot restart it with a different key either.
## The cleanup
The scheduled task `KiK restore shell files` runs every 30 minutes and at every startup, as SYSTEM. The user cannot
stop it: the task, its script and the copies it restores from are readable and writable only by SYSTEM and the
-administrators.
+administrators. Neither the setup nor the cleanup ever runs as the `root` user.
-Neither the setup nor the cleanup ever runs as the `root` user. The setup runs in an elevated PowerShell, as an
-administrator, and refuses to run without those rights; the cleanup runs as SYSTEM. The `root` account is a
-different one, without administrator rights, and it never executes any of it.
-
-What the cleanup does:
-
-1. Creates `/home/root` if it is gone, and gives the `root` account write access on it.
-2. Puts every copy in `files/` back into the home directory: the shell configuration, the keys and the four text
- files.
-
-Anything that is not a regular file - a directory or a link - is removed and replaced by the copy, so that the
-files cannot become anything but text. The contents are restored as well: the user may edit the files, the next run
-undoes that.
+It creates `/home/root` if it is gone, and puts the copies from `C:\ProgramData\kik-restore\files` back into the
+home directory: the shell configuration, the keys and the four text files. Anything that is not a regular file is
+replaced, so that the files cannot become anything but text.
## This directory
- `resources/` - everything that goes to the server: `home/` for the home directory of the root user - `~/.zshrc`,
`~/.zshenv`, the keys in `~/.ssh/authorized_keys` and the four text files - and `admin/` for the home directory
- of the Administrator, which holds the keys of the people who deploy. The key of the website is not among them and
- never gets administrator rights.
+ of the Administrator, which holds the keys of the people who deploy.
- `scripts/make_files.py` - builds the four text files.
- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
- `scripts/install_restore_task.ps1` - installs the scheduled task.
@@ -79,18 +66,13 @@ The four text files are built from the website's pages, the other files in the r
server/scripts/deploy.sh
The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
-Administrator's home directory and runs the installer there. It logs in with the personal key of the deployer, from
-the agent or from `-i`; the key of the website never gets administrator rights, it only logs in as the root user.
-It checks the host key against the one the website pins, and that the key of the website is the first one in
-`resources/home/.ssh/authorized_keys`. `--uninstall` removes the task again.
+Administrator's home directory and runs the installer there, with the personal key of the deployer. The key of the
+website never gets administrator rights. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1
-The task runs as SYSTEM, so the `root` user can neither stop it nor change the copies it restores from. It also
-runs at startup. Remove it again with `-Uninstall`.
-
## Getting the SSH access back
If the home directory was deleted, the key that lets the website in went with it. The cleanup puts it back by
From eec4e359ea3b91e5d876e74af738b53cea32551c Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 14:05:54 +0100
Subject: [PATCH 27/39] Drop the recovery section from the README
Co-Authored-By: Claude Code
---
server/README.md | 5 -----
1 file changed, 5 deletions(-)
diff --git a/server/README.md b/server/README.md
index 2285cee..d44e6f2 100644
--- a/server/README.md
+++ b/server/README.md
@@ -72,8 +72,3 @@ website never gets administrator rights. `--uninstall` removes the task again.
The installer can also be run by hand, in an elevated PowerShell on the server:
powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1
-
-## Getting the SSH access back
-
-If the home directory was deleted, the key that lets the website in went with it. The cleanup puts it back by
-itself, within half an hour; to do it right away, run the deployment again - the installer starts the job once.
From c96357c83a7d222dac60d2e11b8461c2085f8aa2 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 14:06:21 +0100
Subject: [PATCH 28/39] Drop the directory listing from the README
Co-Authored-By: Claude Code
---
server/README.md | 13 -------------
1 file changed, 13 deletions(-)
diff --git a/server/README.md b/server/README.md
index d44e6f2..178b8dc 100644
--- a/server/README.md
+++ b/server/README.md
@@ -48,19 +48,6 @@ It creates `/home/root` if it is gone, and puts the copies from `C:\ProgramData\
home directory: the shell configuration, the keys and the four text files. Anything that is not a regular file is
replaced, so that the files cannot become anything but text.
-## This directory
-
-- `resources/` - everything that goes to the server: `home/` for the home directory of the root user - `~/.zshrc`,
- `~/.zshenv`, the keys in `~/.ssh/authorized_keys` and the four text files - and `admin/` for the home directory
- of the Administrator, which holds the keys of the people who deploy.
-- `scripts/make_files.py` - builds the four text files.
-- `scripts/deploy.sh` - deploys the cleanup to the server and installs it.
-- `scripts/install_restore_task.ps1` - installs the scheduled task.
-
-The four text files are built from the website's pages, the other files in the resources are edited by hand:
-
- python3 server/scripts/make_files.py
-
## Installing the cleanup on the server
server/scripts/deploy.sh
From c498ce1d52eea943bbf1b9a4d980469ad59974c7 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 14:38:35 +0100
Subject: [PATCH 29/39] Copy MSYS2's default files into a recreated home
MSYS2 only copies /etc/skel into a home directory that does not exist yet
when the user logs in. The cleanup always creates the home first, so the
default files never arrived.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
server/scripts/install_restore_task.ps1 | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index e5f106c..5d8b122 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -59,23 +59,24 @@ function Restore-Keys($target, $source) {
[IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n")
}
-# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. The
-# directories in it belong to the user, so that they can put their own keys in .ssh.
-function New-Dir($path) {
+# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. A new one
+# gets MSYS2's default files from /etc/skel, which MSYS2 itself only copies into a home that does not exist yet.
+function New-Home($path) {
New-Item -ItemType Directory -Path $path | Out-Null
# The parent directory gives the users read access only, so the owner gets write access on their own home.
if ($sid) {
icacls $path /grant "*${sid}:(OI)(CI)F" | Out-Null
icacls $path /setowner "*$sid" | Out-Null
}
+ Get-ChildItem -LiteralPath 'C:\msys2\etc\skel' -Force | Copy-Item -Destination $path -Recurse -Force
}
if (-not (Test-Path -LiteralPath $HomeDir)) {
- New-Dir $HomeDir
+ New-Home $HomeDir
Write-Log "created $HomeDir"
} elseif ((Get-Item -LiteralPath $HomeDir -Force).Attributes -band [IO.FileAttributes]::ReparsePoint) {
Remove-Anything $HomeDir
- New-Dir $HomeDir
+ New-Home $HomeDir
Write-Log "replaced the link $HomeDir"
}
From bc7cb26b61323e532779fc8dd0d1ef82b4c65a39 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 15:04:48 +0100
Subject: [PATCH 30/39] Keep the home directory and the files in it from being
deleted
The user could delete the whole home directory, which takes the website
down with it: sshd cannot log in without it. The home directory and the
files that have to stay in it now belong to the administrators, so the
user may change them but not remove them. Their own files stay theirs.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
server/README.md | 12 +++---
server/scripts/install_restore_task.ps1 | 49 +++++++++++++++++++------
2 files changed, 45 insertions(+), 16 deletions(-)
diff --git a/server/README.md b/server/README.md
index 178b8dc..48ccfe4 100644
--- a/server/README.md
+++ b/server/README.md
@@ -17,17 +17,18 @@ We only keep the basic user journey working.
## What we keep working
- The prompt: `root@khaos-im-kiez:~#`, which `~/.zshrc` sets.
-- The home directory the user ends up in: `/home/root`. They can delete it, but not change where it is.
+- The home directory the user ends up in: `/home/root`. They cannot change where it is, and they cannot delete it.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
- The website can log in: the key it uses is in `~/.ssh/authorized_keys`. That key gets access to the `root` user
only, never to the Administrator account.
-Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not permanent either.
+Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not possible: the home directory
+and the files that have to stay in it belong to the administrators, so the user cannot remove them. The files the
+user creates are their own; they may change and delete them.
The user may add keys of their own to `authorized_keys`; they stay. The key of the website is put back, so that the
-site keeps working. The user owns their home directory, so they can always delete the file - nothing on Windows can
-stop them from that - but the next cleanup brings it back. Until then, the site cannot log in.
+site keeps working.
## What we do not keep working
@@ -46,7 +47,8 @@ administrators. Neither the setup nor the cleanup ever runs as the `root` user.
It creates `/home/root` if it is gone, and puts the copies from `C:\ProgramData\kik-restore\files` back into the
home directory: the shell configuration, the keys and the four text files. Anything that is not a regular file is
-replaced, so that the files cannot become anything but text.
+replaced, so that the files cannot become anything but text. It also sets the permissions of the home directory and
+of those files, so that the user cannot delete them.
## Installing the cleanup on the server
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 5d8b122..b2aafed 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -59,15 +59,44 @@ function Restore-Keys($target, $source) {
[IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n")
}
+# The permissions of the home directory and of the files in it that have to stay: the user may read and write them
+# and delete their own files, but not the home directory itself and not the files the website needs. Only SYSTEM
+# and the administrators own them, so the user cannot take the permissions away again.
+function Set-ProtectedAcl($path, $forDirectory) {
+ if (-not $sid) { return }
+ try {
+ $system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
+ $admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
+ $user = New-Object System.Security.Principal.SecurityIdentifier($sid)
+ $acl = Get-Acl -LiteralPath $path
+ $acl.SetAccessRuleProtection($true, $false)
+ foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
+ $acl.SetOwner($admins)
+ $none = [System.Security.AccessControl.InheritanceFlags]::None
+ $children = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
+ $noPropagate = [System.Security.AccessControl.PropagationFlags]::None
+ $inheritOnly = [System.Security.AccessControl.PropagationFlags]::InheritOnly
+ $full = [System.Security.AccessControl.FileSystemRights]::FullControl
+ $some = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [System.Security.AccessControl.FileSystemRights]::Write
+ $inherit = if ($forDirectory) { $children } else { $none }
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $system, $full, $inherit, $noPropagate, 'Allow'))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $admins, $full, $inherit, $noPropagate, 'Allow'))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $some, $none, $noPropagate, 'Allow'))
+ if ($forDirectory) {
+ # the files in it are the user's own, they may delete them
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $full, $children, $inheritOnly, 'Allow'))
+ }
+ Set-Acl -LiteralPath $path -AclObject $acl
+ } catch {
+ Write-Log "the permissions of $path are not set: $_"
+ }
+}
+
# The home directory is the one from /etc/passwd, which the user cannot change, but which they can delete. A new one
# gets MSYS2's default files from /etc/skel, which MSYS2 itself only copies into a home that does not exist yet.
function New-Home($path) {
New-Item -ItemType Directory -Path $path | Out-Null
- # The parent directory gives the users read access only, so the owner gets write access on their own home.
- if ($sid) {
- icacls $path /grant "*${sid}:(OI)(CI)F" | Out-Null
- icacls $path /setowner "*$sid" | Out-Null
- }
+ Set-ProtectedAcl $path $true
Get-ChildItem -LiteralPath 'C:\msys2\etc\skel' -Force | Copy-Item -Destination $path -Recurse -Force
}
@@ -78,6 +107,8 @@ if (-not (Test-Path -LiteralPath $HomeDir)) {
Remove-Anything $HomeDir
New-Home $HomeDir
Write-Log "replaced the link $HomeDir"
+} else {
+ Set-ProtectedAcl $HomeDir $true
}
foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
@@ -88,10 +119,7 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
Remove-Anything $dir
}
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
- if ($sid) {
- icacls $dir /grant "*${sid}:(OI)(CI)F" | Out-Null
- icacls $dir /setowner "*$sid" | Out-Null
- }
+ Set-ProtectedAcl $dir $true
if (Test-Path -LiteralPath $target) {
$item = Get-Item -LiteralPath $target -Force
if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
@@ -100,7 +128,6 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
} else {
# the user may have locked the file; taking it back lets us write it
takeown /f $target /a | Out-Null
- icacls $target /reset | Out-Null
}
}
if ($copy.Name -eq 'authorized_keys') {
@@ -108,7 +135,7 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
} else {
Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
}
- if ($sid) { icacls $target /setowner "*$sid" | Out-Null }
+ Set-ProtectedAcl $target $false
Write-Log "restored $($copy.Name)"
} catch {
Write-Log "failed $($copy.Name) : $_"
From e4050cbf0568db7cf5299075f670eb294bd61960 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 15:14:41 +0100
Subject: [PATCH 31/39] Adjust server readme
---
server/README.md | 40 ++++++++++++++--------------------------
1 file changed, 14 insertions(+), 26 deletions(-)
diff --git a/server/README.md b/server/README.md
index 48ccfe4..84278b2 100644
--- a/server/README.md
+++ b/server/README.md
@@ -16,48 +16,36 @@ We only keep the basic user journey working.
## What we keep working
+Anything that breaks the following user journey is not acceptable:
+
+- The user can connect with the SSH key as `root`.
- The prompt: `root@khaos-im-kiez:~#`, which `~/.zshrc` sets.
- The home directory the user ends up in: `/home/root`. They cannot change where it is, and they cannot delete it.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
-- The website can log in: the key it uses is in `~/.ssh/authorized_keys`. That key gets access to the `root` user
- only, never to the Administrator account.
-Modifying the four files is allowed; the next cleanup undoes it. Deleting them is not possible: the home directory
-and the files that have to stay in it belong to the administrators, so the user cannot remove them. The files the
-user creates are their own; they may change and delete them.
+So in summary running `ssh root@nowhere.wirs.ing cat README` must always succeed and display the contents of the file.
-The user may add keys of their own to `authorized_keys`; they stay. The key of the website is put back, so that the
-site keeps working.
+Anything thats not specified above (like the content of README for example) is NOT part of the user journey.
## What we do not keep working
-Everything else. If the user breaks the shell, the packages, the disk or the connection, we fix that by hand or
-throw the box away. The cleanup does not try.
+As long as the user story above does not break, we do not guarantee the functionality of anything else on the server. However the cleanup task should still try to best-effort restore everything to a working condition.
-Two things that the user cannot break: the entry in `/etc/passwd` with the home directory and the shell, because
-`/etc` is read-only for them, and the host keys in `/etc/ssh`, which the website pins. The sshd runs as SYSTEM, so
-the user cannot restart it with a different key either.
+## The cleanup task
-## The cleanup
+The scheduled task `KiK restore shell files` runs every 30 minutes and at every startup, as SYSTEM. The user cannot stop it. Cleanup has two jobs:
-The scheduled task `KiK restore shell files` runs every 30 minutes and at every startup, as SYSTEM. The user cannot
-stop it: the task, its script and the copies it restores from are readable and writable only by SYSTEM and the
-administrators. Neither the setup nor the cleanup ever runs as the `root` user.
+1. Act as a secondary safeguard to restore the essential files for the user journey above.
+2. Revert any other mockery the user did that might affect the system.
-It creates `/home/root` if it is gone, and puts the copies from `C:\ProgramData\kik-restore\files` back into the
-home directory: the shell configuration, the keys and the four text files. Anything that is not a regular file is
-replaced, so that the files cannot become anything but text. It also sets the permissions of the home directory and
-of those files, so that the user cannot delete them.
+The second job should also be able to restore a new server to the expected state.
## Installing the cleanup on the server
+To deploy the cleanup task an the server, run:
+
server/scripts/deploy.sh
The script logs in as `Administrator` - whose shell is a Windows one - copies the scripts and the resources to the
-Administrator's home directory and runs the installer there, with the personal key of the deployer. The key of the
-website never gets administrator rights. `--uninstall` removes the task again.
-
-The installer can also be run by hand, in an elevated PowerShell on the server:
-
- powershell -ExecutionPolicy Bypass -File .\scripts\install_restore_task.ps1
+Administrator's home directory and runs the installer there.
From 49c61b6b9116baf3af0e2123d0e77da7d41fe8fe Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 15:26:01 +0100
Subject: [PATCH 32/39] Keep the shell configuration and the key out of the
user's reach
The user could empty the authorized_keys and break the login of the
website, change the shell configuration to break the prompt, and write
startup files that zsh reads on every login. The shell configuration and
the key are now read-only for the user, and the shell reads its
configuration from ~/.zsh, so the files in the home directory are
ignored.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
server/README.md | 6 +++++-
server/resources/home/{ => .zsh}/.zshrc | 0
server/resources/home/.zshenv | 4 ++++
server/scripts/install_restore_task.ps1 | 28 +++++++++++++++++--------
4 files changed, 28 insertions(+), 10 deletions(-)
rename server/resources/home/{ => .zsh}/.zshrc (100%)
diff --git a/server/README.md b/server/README.md
index 84278b2..ef16285 100644
--- a/server/README.md
+++ b/server/README.md
@@ -19,7 +19,7 @@ We only keep the basic user journey working.
Anything that breaks the following user journey is not acceptable:
- The user can connect with the SSH key as `root`.
-- The prompt: `root@khaos-im-kiez:~#`, which `~/.zshrc` sets.
+- The prompt: `root@khaos-im-kiez:~#`, which `~/.zsh/.zshrc` sets.
- The home directory the user ends up in: `/home/root`. They cannot change where it is, and they cannot delete it.
- `cat README`, `cat README.en`, `cat CODE_OF_CONDUCT` and `cat CODE_OF_CONDUCT.en` always work. The four files
are always there and always regular text files.
@@ -41,6 +41,10 @@ The scheduled task `KiK restore shell files` runs every 30 minutes and at every
The second job should also be able to restore a new server to the expected state.
+The files that have to stay belong to the administrators, so the user cannot delete them, and they cannot change the
+shell configuration or the key the website logs in with either. The shell reads its configuration from `~/.zsh`, so
+the startup files the user could write in the home directory are ignored.
+
## Installing the cleanup on the server
To deploy the cleanup task an the server, run:
diff --git a/server/resources/home/.zshrc b/server/resources/home/.zsh/.zshrc
similarity index 100%
rename from server/resources/home/.zshrc
rename to server/resources/home/.zsh/.zshrc
diff --git a/server/resources/home/.zshenv b/server/resources/home/.zshenv
index f990c34..3625bba 100644
--- a/server/resources/home/.zshenv
+++ b/server/resources/home/.zshenv
@@ -1,3 +1,7 @@
# Preset what MSYS2's /etc/profile would otherwise start a helper process for (~90 ms each here).
# TZ has to match the Windows timezone (W. Europe Standard Time).
export LANG=en_US.UTF-8 TZ=Europe/Berlin
+
+# The shell reads its configuration from here, not from the home directory: the user may not change it, and the
+# files in the home directory that zsh would read otherwise (.zprofile, .zlogin) are ignored.
+export ZDOTDIR=~/.zsh
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index b2aafed..6dd6ced 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -59,10 +59,13 @@ function Restore-Keys($target, $source) {
[IO.File]::WriteAllText($target, (($keys + $required) -join "`n") + "`n")
}
-# The permissions of the home directory and of the files in it that have to stay: the user may read and write them
-# and delete their own files, but not the home directory itself and not the files the website needs. Only SYSTEM
-# and the administrators own them, so the user cannot take the permissions away again.
-function Set-ProtectedAcl($path, $forDirectory) {
+# The shell configuration and the key the website logs in with must not change, the four texts may be edited.
+$readOnly = '.zshrc', '.zshenv', 'authorized_keys'
+
+# The permissions of the home directory and of the files in it that have to stay: the user may read them and write
+# the ones that may change, and delete their own files, but not the home directory itself and not the files the
+# website needs. Only SYSTEM and the administrators own them, so the user cannot take the permissions away again.
+function Set-ProtectedAcl($path, $forDirectory, $writable) {
if (-not $sid) { return }
try {
$system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
@@ -78,10 +81,12 @@ function Set-ProtectedAcl($path, $forDirectory) {
$inheritOnly = [System.Security.AccessControl.PropagationFlags]::InheritOnly
$full = [System.Security.AccessControl.FileSystemRights]::FullControl
$some = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [System.Security.AccessControl.FileSystemRights]::Write
+ $read = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute
$inherit = if ($forDirectory) { $children } else { $none }
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $system, $full, $inherit, $noPropagate, 'Allow'))
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $admins, $full, $inherit, $noPropagate, 'Allow'))
- $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $some, $none, $noPropagate, 'Allow'))
+ $userRights = if ($writable) { $some } else { $read }
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $userRights, $none, $noPropagate, 'Allow'))
if ($forDirectory) {
# the files in it are the user's own, they may delete them
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $full, $children, $inheritOnly, 'Allow'))
@@ -96,7 +101,7 @@ function Set-ProtectedAcl($path, $forDirectory) {
# gets MSYS2's default files from /etc/skel, which MSYS2 itself only copies into a home that does not exist yet.
function New-Home($path) {
New-Item -ItemType Directory -Path $path | Out-Null
- Set-ProtectedAcl $path $true
+ Set-ProtectedAcl $path $true $true
Get-ChildItem -LiteralPath 'C:\msys2\etc\skel' -Force | Copy-Item -Destination $path -Recurse -Force
}
@@ -108,7 +113,7 @@ if (-not (Test-Path -LiteralPath $HomeDir)) {
New-Home $HomeDir
Write-Log "replaced the link $HomeDir"
} else {
- Set-ProtectedAcl $HomeDir $true
+ Set-ProtectedAcl $HomeDir $true $true
}
foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
@@ -119,7 +124,7 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
Remove-Anything $dir
}
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
- Set-ProtectedAcl $dir $true
+ Set-ProtectedAcl $dir $true $true
if (Test-Path -LiteralPath $target) {
$item = Get-Item -LiteralPath $target -Force
if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
@@ -135,7 +140,7 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
} else {
Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
}
- Set-ProtectedAcl $target $false
+ Set-ProtectedAcl $target $false ($readOnly -notcontains $copy.Name)
Write-Log "restored $($copy.Name)"
} catch {
Write-Log "failed $($copy.Name) : $_"
@@ -192,6 +197,11 @@ if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '
}
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
+# The shell configuration used to be in the home directory, the shell reads it from ~/.zsh now.
+if (Test-Path -LiteralPath (Join-Path $HomeDir '.zsh\.zshrc')) {
+ Remove-Item -LiteralPath (Join-Path $HomeDir '.zshrc') -Force -ErrorAction SilentlyContinue
+}
+
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
$triggers = @(
New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650)
From ea0d6a85fd6e42dadcdee83df7488d38eb2ade07 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 15:37:58 +0100
Subject: [PATCH 33/39] Let the user change the texts again after they made
them read-only
Co-Authored-By: Claude Opus 5.5 (1M context)
---
server/scripts/install_restore_task.ps1 | 2 ++
1 file changed, 2 insertions(+)
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 6dd6ced..03c7a82 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -141,6 +141,8 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
}
Set-ProtectedAcl $target $false ($readOnly -notcontains $copy.Name)
+ # the user may have made the file read-only for themselves, the four texts are theirs to change
+ (Get-Item -LiteralPath $target -Force).IsReadOnly = $false
Write-Log "restored $($copy.Name)"
} catch {
Write-Log "failed $($copy.Name) : $_"
From b228fec07e3baf9cd89446600960f621a752ea30 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Sun, 25 Oct 2026 21:37:55 +0100
Subject: [PATCH 34/39] Let the shell user install packages with pacman
The root user now owns the whole MSYS2 installation and can run pacman. The
login path the journey depends on - the sshd, the login shell zsh and cat - is
kept as a protected copy in C:\msys2\opt\kik that only SYSTEM and the
administrators may write, and the sshd runs as SYSTEM from there (the new
KiK sshd task; the msys2-sshd service is disabled). Replacing the user's own
/usr/bin/sshd therefore cannot touch the running server.
opt/kik is two levels below C:\msys2, so its runtime resolves the same
installation root and the login shell and the user's tools share one cygwin
runtime. A separate second installation does not work: two runtimes in one
process tree corrupt each other's file descriptors and pacman cannot lock its
database.
/etc/passwd and /etc/ssh are read-only to the user, /opt/kik is first on PATH
so cat README survives even if the user deletes their own /usr/bin/cat, and
the restore task rebuilds the protected copy and re-applies the permissions
every 30 minutes.
Co-Authored-By: Claude Opus 4.8
---
server/README.md | 28 ++-
server/resources/home/.zsh/.zshrc | 4 +
server/scripts/install_restore_task.ps1 | 262 +++++++++++++++++++-----
3 files changed, 243 insertions(+), 51 deletions(-)
diff --git a/server/README.md b/server/README.md
index ef16285..822ea4e 100644
--- a/server/README.md
+++ b/server/README.md
@@ -32,14 +32,32 @@ Anything thats not specified above (like the content of README for example) is N
As long as the user story above does not break, we do not guarantee the functionality of anything else on the server. However the cleanup task should still try to best-effort restore everything to a working condition.
-## The cleanup task
+## Packages
-The scheduled task `KiK restore shell files` runs every 30 minutes and at every startup, as SYSTEM. The user cannot stop it. Cleanup has two jobs:
+The root user owns the whole MSYS2 installation and can install packages with pacman. That means they can also
+replace or delete the very programs the login needs. So the sshd, the login shell `zsh` and `cat` are kept as a
+second, protected copy in `C:\msys2\opt\kik`, which only SYSTEM and the administrators may write. The sshd runs as
+SYSTEM from that copy and the login shell is that copy's `zsh`, with `/opt/kik` first on the path, so the journey
+keeps working no matter what the user does to their own `/usr/bin`. Replacing `/usr/bin/sshd` cannot touch the
+running server, so the key on the website still never grants administrator access.
-1. Act as a secondary safeguard to restore the essential files for the user journey above.
-2. Revert any other mockery the user did that might affect the system.
+The copy has to be the *same* MSYS2 installation, not a second one: `C:\msys2\opt\kik` is two levels below
+`C:\msys2`, so its runtime resolves the same root and the login shell and the user's own tools share one cygwin
+runtime. A separate installation does not work - two runtimes in one process tree corrupt each other's file
+descriptors and pacman cannot even lock its database.
-The second job should also be able to restore a new server to the expected state.
+`/etc/passwd` (the login shell and home directory) and `/etc/ssh` (the host keys and config) are read-only to the
+user as well; everything else in the installation is theirs.
+
+## The cleanup tasks
+
+Two scheduled tasks run as SYSTEM, both at startup, and the user cannot stop either:
+
+- `KiK sshd` runs the sshd from `C:\msys2\opt\kik`. It replaces the installation's own `msys2-sshd` service, which
+ is disabled.
+- `KiK restore shell files` runs every 30 minutes and rebuilds the protected copy from its trusted originals,
+ re-applies the permissions, and puts the home directory, the shell configuration, the website's key and the four
+ texts back. Its second job is to best-effort revert other mockery and bring a fresh server to the expected state.
The files that have to stay belong to the administrators, so the user cannot delete them, and they cannot change the
shell configuration or the key the website logs in with either. The shell reads its configuration from `~/.zsh`, so
diff --git a/server/resources/home/.zsh/.zshrc b/server/resources/home/.zsh/.zshrc
index 310de44..02e6959 100644
--- a/server/resources/home/.zsh/.zshrc
+++ b/server/resources/home/.zsh/.zshrc
@@ -1,3 +1,7 @@
+# The protected copies of zsh and cat (and the server itself) live here; keeping them first means the basic
+# journey - the prompt and reading the four texts - survives even if the user breaks their own /usr/bin copies.
+path=(/opt/kik $path)
+
# History shared between sessions.
HISTFILE=~/.zsh_history HISTSIZE=10000 SAVEHIST=10000
setopt share_history hist_ignore_dups hist_ignore_space interactive_comments auto_cd
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 03c7a82..2547df8 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -1,10 +1,19 @@
-# Installs the scheduled task that puts the shell server's home directory back into shape every 30 minutes: the
-# prompt, the home directory and the four text files. The task runs as SYSTEM, so the shell server's root user can
-# neither stop it nor change the copies it restores from.
+# Sets up the shell server so the website's root user can install packages with pacman, while the basic user
+# journey - logging in with the key, the prompt, the home directory and the four text files - cannot be broken.
+#
+# The whole MSYS2 installation belongs to the root user: they may write everywhere and run pacman. The login path
+# the journey depends on - the sshd, the login shell zsh and cat - is kept as a protected copy in C:\msys2\opt\kik
+# that only SYSTEM and the administrators may write. The sshd runs as SYSTEM from that copy (the scheduled task
+# "KiK sshd"), so replacing /usr/bin/sshd does not touch the running server. A second scheduled task,
+# "KiK restore shell files", puts the home directory, the four texts and the protected copy back every 30 minutes.
+#
+# opt/kik works because its MSYS2 runtime resolves the same installation root (C:\msys2) as /usr/bin, so the login
+# shell and the user's own tools share one cygwin runtime. A separate second installation cannot: two runtimes in
+# one process tree break each other's file descriptors, and pacman then cannot even lock its database.
#
# Run this in an elevated PowerShell on the server, with this script and the ../resources directory next to it:
# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1
-# Remove the task and the copies again with:
+# Remove the tasks and the protected copy again (the installation goes back to a normal cygrunsrv sshd) with:
# powershell -ExecutionPolicy Bypass -File .\install_restore_task.ps1 -Uninstall
[CmdletBinding()]
param(
@@ -13,24 +22,40 @@ param(
)
$ErrorActionPreference = 'Stop'
+$msys = 'C:\msys2'
+$kik = "$msys\opt\kik"
$installDir = 'C:\ProgramData\kik-restore'
$copiesDir = Join-Path $installDir 'files'
-$taskName = 'KiK restore shell files'
+$binDir = Join-Path $installDir 'bin'
+$sshdBat = Join-Path $installDir 'run-sshd.bat'
+$restoreTask = 'KiK restore shell files'
+$sshdTask = 'KiK sshd'
+$service = 'msys2-sshd'
+
+# The programs the journey needs, kept out of the user's reach. sshd-session and sshd-auth are the per-connection
+# helpers OpenSSH 10 re-executes; zsh is the login shell; cat reads the four texts.
+$loginBinaries = "$msys\usr\bin\sshd.exe", "$msys\usr\lib\ssh\sshd-session.exe", "$msys\usr\lib\ssh\sshd-auth.exe",
+ "$msys\usr\bin\zsh.exe", "$msys\usr\bin\cat.exe"
# The copies the task restores from and the script it runs both live in $installDir, which only SYSTEM and the
# administrators may read or write.
$restoreScript = @'
-# Puts the shell server's home directory back into shape: the directory itself, the shell configuration that gives
-# the prompt, the keys that let the website in, and the four text files. Runs as SYSTEM every 30 minutes; the shell
-# server's root user can neither stop it nor change the copies it restores from.
+# Puts the shell server back into shape and keeps the protected login path in place. Runs as SYSTEM every 30 minutes
+# and at startup; the root user can neither stop it nor change the copies it restores from.
param([string]$HomeDir = 'C:\msys2\home\root')
+$msys = 'C:\msys2'
+$kik = "$msys\opt\kik"
$log = Join-Path $PSScriptRoot 'restore.log'
$copies = Join-Path $PSScriptRoot 'files\home'
+$binDir = Join-Path $PSScriptRoot 'bin'
# The account of the shell server's root user, whose id the files in its home directory belong to.
-$passwd = Get-Content 'C:\msys2\etc\passwd' -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
+$passwd = Get-Content "$msys\etc\passwd" -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
$sid = if ($passwd) { ($passwd -split ':')[4].Split(',')[-1] }
+$system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
+$admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
+
function Write-Log($message) {
"$(Get-Date -Format s) $message" | Out-File -FilePath $log -Append
}
@@ -47,6 +72,23 @@ function Remove-Anything($path) {
}
}
+# A path that only SYSTEM and the administrators own. $userRights is what the root user may do: 'None' (the ssh keys
+# and config), 'Read' (the account database) or 'ReadAndExecute' (the login programs it has to run).
+function Set-LockedAcl($path, $forDirectory, $userRights) {
+ $acl = Get-Acl -LiteralPath $path
+ $acl.SetAccessRuleProtection($true, $false)
+ foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
+ $acl.SetOwner($admins)
+ $inherit = if ($forDirectory) { 'ContainerInherit,ObjectInherit' } else { 'None' }
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, 'None', 'Allow')))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, 'None', 'Allow')))
+ if ($userRights -ne 'None' -and $sid) {
+ $user = New-Object System.Security.Principal.SecurityIdentifier($sid)
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($user, $userRights, $inherit, 'None', 'Allow')))
+ }
+ Set-Acl -LiteralPath $path -AclObject $acl
+}
+
# The website logs in with a key in the authorized_keys of the user. The user may keep keys of their own, so only
# the key of the website is put back.
function Restore-Keys($target, $source) {
@@ -65,11 +107,9 @@ $readOnly = '.zshrc', '.zshenv', 'authorized_keys'
# The permissions of the home directory and of the files in it that have to stay: the user may read them and write
# the ones that may change, and delete their own files, but not the home directory itself and not the files the
# website needs. Only SYSTEM and the administrators own them, so the user cannot take the permissions away again.
-function Set-ProtectedAcl($path, $forDirectory, $writable) {
+function Set-HomeAcl($path, $forDirectory, $writable) {
if (-not $sid) { return }
try {
- $system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
- $admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
$user = New-Object System.Security.Principal.SecurityIdentifier($sid)
$acl = Get-Acl -LiteralPath $path
$acl.SetAccessRuleProtection($true, $false)
@@ -77,16 +117,15 @@ function Set-ProtectedAcl($path, $forDirectory, $writable) {
$acl.SetOwner($admins)
$none = [System.Security.AccessControl.InheritanceFlags]::None
$children = [System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit
- $noPropagate = [System.Security.AccessControl.PropagationFlags]::None
$inheritOnly = [System.Security.AccessControl.PropagationFlags]::InheritOnly
$full = [System.Security.AccessControl.FileSystemRights]::FullControl
$some = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute -bor [System.Security.AccessControl.FileSystemRights]::Write
$read = [System.Security.AccessControl.FileSystemRights]::ReadAndExecute
$inherit = if ($forDirectory) { $children } else { $none }
- $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $system, $full, $inherit, $noPropagate, 'Allow'))
- $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $admins, $full, $inherit, $noPropagate, 'Allow'))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $system, $full, $inherit, 'None', 'Allow'))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $admins, $full, $inherit, 'None', 'Allow'))
$userRights = if ($writable) { $some } else { $read }
- $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $userRights, $none, $noPropagate, 'Allow'))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $userRights, $none, 'None', 'Allow'))
if ($forDirectory) {
# the files in it are the user's own, they may delete them
$acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $user, $full, $children, $inheritOnly, 'Allow'))
@@ -101,8 +140,46 @@ function Set-ProtectedAcl($path, $forDirectory, $writable) {
# gets MSYS2's default files from /etc/skel, which MSYS2 itself only copies into a home that does not exist yet.
function New-Home($path) {
New-Item -ItemType Directory -Path $path | Out-Null
- Set-ProtectedAcl $path $true $true
- Get-ChildItem -LiteralPath 'C:\msys2\etc\skel' -Force | Copy-Item -Destination $path -Recurse -Force
+ Set-HomeAcl $path $true $true
+ Get-ChildItem -LiteralPath "$msys\etc\skel" -Force | Copy-Item -Destination $path -Recurse -Force
+}
+
+try {
+ # The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even
+ # after they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted
+ # originals are in bin\; anything missing from opt/kik is put back.
+ if (Test-Path -LiteralPath $binDir) {
+ if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
+ foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force) {
+ $target = Join-Path $kik $file.Name
+ if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
+ Copy-Item -LiteralPath $file.FullName -Destination $target -Force
+ Write-Log "restored opt/kik/$($file.Name)"
+ }
+ }
+ New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
+ Set-LockedAcl $kik $true 'ReadAndExecute'
+ # /dev is scratch: the user may write it so POSIX shared memory works, the rest of opt/kik stays read-only
+ if ($sid) { icacls "$kik\dev" /grant "*${sid}:(OI)(CI)M" | Out-Null }
+ }
+
+ # The account database and the ssh keys and config the SYSTEM server reads: the user may read passwd (programs
+ # map ids through it) but change none of it, and has no business in /etc/ssh at all.
+ $rootShell = '/opt/kik/zsh.exe'
+ $pw = "$msys\etc\passwd"
+ $lines = Get-Content $pw
+ if ($lines | Where-Object { $_ -match '^root:' -and $_ -notmatch ':/opt/kik/zsh\.exe$' }) {
+ ($lines | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ":$rootShell" } else { $_ } }) | Set-Content $pw -Encoding ascii
+ Write-Log 'set the root login shell to the protected zsh'
+ }
+ Set-LockedAcl $pw $false 'Read'
+ if (Test-Path "$msys\etc\ssh") { Set-LockedAcl "$msys\etc\ssh" $true 'None' }
+
+ # The whole installation otherwise belongs to the user, so pacman can write it. The home directory keeps its own
+ # protected permissions because they are set without inheritance.
+ if ($sid) { icacls $msys /grant "*${sid}:(OI)(CI)F" | Out-Null }
+} catch {
+ Write-Log "the protected login path is not fully in place: $_"
}
if (-not (Test-Path -LiteralPath $HomeDir)) {
@@ -113,7 +190,7 @@ if (-not (Test-Path -LiteralPath $HomeDir)) {
New-Home $HomeDir
Write-Log "replaced the link $HomeDir"
} else {
- Set-ProtectedAcl $HomeDir $true $true
+ Set-HomeAcl $HomeDir $true $true
}
foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
@@ -124,7 +201,7 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
Remove-Anything $dir
}
if (-not (Test-Path -LiteralPath $dir)) { New-Item -ItemType Directory -Path $dir | Out-Null }
- Set-ProtectedAcl $dir $true $true
+ Set-HomeAcl $dir $true $true
if (Test-Path -LiteralPath $target) {
$item = Get-Item -LiteralPath $target -Force
if ($item.PSIsContainer -or $item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
@@ -140,7 +217,7 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
} else {
Copy-Item -LiteralPath $copy.FullName -Destination $target -Force
}
- Set-ProtectedAcl $target $false ($readOnly -notcontains $copy.Name)
+ Set-HomeAcl $target $false ($readOnly -notcontains $copy.Name)
# the user may have made the file read-only for themselves, the four texts are theirs to change
(Get-Item -LiteralPath $target -Force).IsReadOnly = $false
Write-Log "restored $($copy.Name)"
@@ -154,39 +231,90 @@ if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdent
throw 'Run this in an elevated PowerShell.'
}
+$system = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')
+$admins = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')
+
+$passwd = Get-Content "$msys\etc\passwd" -ErrorAction SilentlyContinue | Where-Object { $_ -like 'root:*' }
+if (-not $passwd) { throw "No root user in $msys\etc\passwd." }
+$sid = ($passwd -split ':')[4].Split(',')[-1]
+
+# A previous install may have locked opt/kik, the account database and the ssh keys. The administrators keep
+# full control over them, so reset the permissions to inherited now and let this run write freely; the restore
+# task locks everything again at the end.
+$prevEA = $ErrorActionPreference; $ErrorActionPreference = 'SilentlyContinue'
+foreach ($p in "$msys\opt\kik", "$msys\etc\ssh", "$msys\etc\passwd") {
+ if (Test-Path -LiteralPath $p) { icacls $p /reset /t /q | Out-Null }
+}
+Get-ChildItem "$msys\etc\ssh", "$msys\etc\passwd" -Force | ForEach-Object { try { $_.IsReadOnly = $false } catch {} }
+$ErrorActionPreference = $prevEA
+
+function Set-LockedAcl($path, $forDirectory, $userRights) {
+ $acl = Get-Acl -LiteralPath $path
+ $acl.SetAccessRuleProtection($true, $false)
+ foreach ($rule in @($acl.Access)) { [void]$acl.RemoveAccessRuleSpecific($rule) }
+ $acl.SetOwner($admins)
+ $inherit = if ($forDirectory) { 'ContainerInherit,ObjectInherit' } else { 'None' }
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($system, 'FullControl', $inherit, 'None', 'Allow')))
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($admins, 'FullControl', $inherit, 'None', 'Allow')))
+ if ($userRights -ne 'None') {
+ $user = New-Object System.Security.Principal.SecurityIdentifier($sid)
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($user, $userRights, $inherit, 'None', 'Allow')))
+ }
+ Set-Acl -LiteralPath $path -AclObject $acl
+}
+
+# The DLLs an MSYS2 program loads, as Windows paths under C:\msys2.
+function Get-Libraries($program) {
+ $paths = @()
+ $posix = ($program -replace '^C:\\msys2', '') -replace '\\', '/'
+ foreach ($line in & "$msys\usr\bin\ldd.exe" $posix 2>$null) {
+ foreach ($m in [regex]::Matches($line, '/usr/[^\s]+\.dll')) { $paths += $m.Value }
+ }
+ $paths | Select-Object -Unique | ForEach-Object { $msys + ($_ -replace '/', '\') }
+}
+
if ($Uninstall) {
- Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
+ foreach ($t in $restoreTask, $sshdTask) { Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $installDir) { Remove-Item -LiteralPath $installDir -Recurse -Force }
- Write-Host "Removed the task and $installDir."
+ if (Test-Path -LiteralPath $kik) {
+ icacls $kik /reset /t /q 2>$null | Out-Null
+ Remove-Item -LiteralPath $kik -Recurse -Force -ErrorAction SilentlyContinue
+ }
+ # the installation's own cygrunsrv sshd takes over again
+ $pw = "$msys\etc\passwd"
+ (Get-Content $pw | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ':/usr/bin/zsh' } else { $_ } }) | Set-Content $pw -Encoding ascii
+ icacls $pw /reset | Out-Null
+ if (Test-Path "$msys\etc\ssh") { icacls "$msys\etc\ssh" /reset /t /q 2>$null | Out-Null }
+ Set-Service $service -StartupType Automatic -ErrorAction SilentlyContinue
+ Start-Service $service -ErrorAction SilentlyContinue
+ Write-Host "Removed the tasks and $installDir; the $service service serves ssh again."
return
}
$resources = Join-Path $PSScriptRoot '..\resources'
if (-not (Get-ChildItem -LiteralPath $resources -Recurse -File -Force)) { throw "No files in $resources." }
-# The root user may have prepared the directory or the file names, so nothing that looks like a link is used.
$prepared = Get-Item -LiteralPath $installDir -Force -ErrorAction SilentlyContinue
if ($prepared -and $prepared.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$installDir is a link, refusing to install." }
-New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
+New-Item -ItemType Directory -Force -Path $copiesDir, $binDir | Out-Null
-# Only SYSTEM and the administrators may read or write the copies and the script.
+# Only SYSTEM and the administrators may read or write the copies, the trusted login programs and the scripts.
$acl = New-Object System.Security.AccessControl.DirectorySecurity
$acl.SetAccessRuleProtection($true, $false)
-foreach ($sid in 'S-1-5-18', 'S-1-5-32-544') {
- $identity = New-Object System.Security.Principal.SecurityIdentifier $sid
- $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule -ArgumentList $identity, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))
+foreach ($owner in $system, $admins) {
+ $acl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($owner, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')))
}
-$acl.SetOwner((New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544'))
+$acl.SetOwner($admins)
Set-Acl -LiteralPath $installDir -AclObject $acl
-# Files the root user prepared are removed, the copies are written fresh and inherit the restricted permissions.
-foreach ($item in Get-ChildItem -LiteralPath $copiesDir -Recurse -Force) {
+foreach ($item in Get-ChildItem -LiteralPath $installDir -Recurse -Force) {
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "$($item.FullName) is a link, refusing to install." }
}
Remove-Item -LiteralPath $copiesDir -Recurse -Force
New-Item -ItemType Directory -Force -Path $copiesDir | Out-Null
Copy-Item -Path (Join-Path $resources '*') -Destination $copiesDir -Recurse -Force
+
# The Administrator logs in with the key of the people who deploy, which is in the resources. The keys that are
# already there stay.
$adminKeys = 'C:\Users\Administrator\.ssh\authorized_keys'
@@ -199,23 +327,65 @@ if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '
}
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
-# The shell configuration used to be in the home directory, the shell reads it from ~/.zsh now.
-if (Test-Path -LiteralPath (Join-Path $HomeDir '.zsh\.zshrc')) {
- Remove-Item -LiteralPath (Join-Path $HomeDir '.zshrc') -Force -ErrorAction SilentlyContinue
+# The trusted login programs: the sshd, its helpers, the login shell and cat, with every DLL they load. The restore
+# task puts these into opt/kik, which the user cannot write.
+Remove-Item "$binDir\*" -Force -ErrorAction SilentlyContinue
+$files = @{}
+foreach ($b in $loginBinaries) {
+ if (-not (Test-Path -LiteralPath $b)) { throw "$b does not exist." }
+ $files[$b] = $true
+ foreach ($lib in Get-Libraries $b) { $files[$lib] = $true }
}
+foreach ($f in $files.Keys) { Copy-Item -LiteralPath $f -Destination $binDir -Force }
-$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
-$triggers = @(
+# The sshd reads its configuration and finds its helpers in opt/kik; nothing here depends on the user's /usr.
+$sshdConfig = "$msys\etc\ssh\sshd_config"
+Set-Content -LiteralPath $sshdConfig -Encoding ascii -Value @(
+ '# The website logs in as root, the deployers as Administrator. Both come in over the websocket bridge.',
+ 'Port 22',
+ 'AllowUsers root Administrator',
+ '# Files are protected by Windows ACLs; under MSYS2''s noacl mounts the POSIX modes sshd checks are fake.',
+ 'StrictModes no',
+ 'Subsystem sftp internal-sftp',
+ '# The server and its per-connection helpers are the protected copies, not the ones the user can replace.',
+ 'SshdSessionPath /opt/kik/sshd-session.exe',
+ 'SshdAuthPath /opt/kik/sshd-auth.exe'
+)
+
+# The sshd runs as SYSTEM from the protected copy, with opt/kik first on the path so its own DLLs and the session's
+# programs (zsh, cat) are the protected ones. The log stays in the install directory, out of the user's reach.
+Set-Content -LiteralPath $sshdBat -Encoding ascii -Value @(
+ '@echo off',
+ 'set PATH=C:\msys2\opt\kik;C:\msys2\usr\bin;%PATH%',
+ 'C:\msys2\opt\kik\sshd.exe -D -e > C:\ProgramData\kik-restore\sshd.log 2>&1'
+)
+
+# The installation's own cygrunsrv sshd service would fight for port 22; the scheduled task owns the sshd instead.
+Set-Service $service -StartupType Disabled -ErrorAction SilentlyContinue
+Stop-Service $service -Force -ErrorAction SilentlyContinue
+
+$atSystem = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
+
+$sshdAction = New-ScheduledTaskAction -Execute $sshdBat
+$sshdSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Seconds 0) -RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+Register-ScheduledTask -TaskName $sshdTask -Action $sshdAction -Trigger (New-ScheduledTaskTrigger -AtStartup) -Principal $atSystem -Settings $sshdSettings -Force | Out-Null
+
+$restoreAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\restore.ps1`" -HomeDir `"$HomeDir`""
+$restoreTriggers = @(
New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650)
New-ScheduledTaskTrigger -AtStartup
)
-$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
-$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
-Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $triggers -Principal $principal -Settings $settings -Force | Out-Null
+$restoreSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+Register-ScheduledTask -TaskName $restoreTask -Action $restoreAction -Trigger $restoreTriggers -Principal $atSystem -Settings $restoreSettings -Force | Out-Null
-Start-ScheduledTask -TaskName $taskName
-Start-Sleep -Seconds 5
-Write-Host "Task '$taskName' runs as SYSTEM every 30 minutes and at startup."
-Get-ScheduledTaskInfo -TaskName $taskName | Format-List LastRunTime, LastTaskResult, NextRunTime
-Get-Content (Join-Path $installDir 'restore.log') -Tail 8 -ErrorAction SilentlyContinue
-icacls $installDir
+# The restore task builds opt/kik and sets every permission; run it before starting the sshd that depends on it.
+Start-ScheduledTask -TaskName $restoreTask
+Start-Sleep -Seconds 6
+$waited = 0
+while ((Get-ScheduledTask -TaskName $restoreTask).State -eq 'Running' -and $waited -lt 60) { Start-Sleep 2; $waited += 2 }
+Start-ScheduledTask -TaskName $sshdTask
+Start-Sleep -Seconds 4
+
+Write-Host "The sshd runs as SYSTEM from C:\msys2\opt\kik (task '$sshdTask'); '$restoreTask' keeps it and the home directory in shape."
+Get-Content (Join-Path $installDir 'restore.log') -Tail 10 -ErrorAction SilentlyContinue
+Get-CimInstance Win32_Process -Filter "Name='sshd.exe'" | ForEach-Object { $_.CommandLine }
From 5f9796cc11b3c8b8b466ce164acd6b3e318f8086 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Tue, 27 Oct 2026 02:46:45 +0100
Subject: [PATCH 35/39] Keep the shell server working over a deployment
The server logs to a file that was overwritten on every start, so an outage left no
trace. It is appended to now, and rotated when it grows past a megabyte.
The cleanup job restarts the server when nobody is connected and when the server does
not listen at all: it comes back by itself if it died, it takes over a server that was
left behind, and it picks up the programs that were just restored. A session that is
open keeps the port and the runtime, so the server is not restarted then.
A deployment does not restart the server. It runs in a session, and that session keeps
the port, so a server that starts during a deployment cannot bind it. The deployment
only writes the configuration; the cleanup job applies it.
The runtime of the installation is not protected any more. A program and the shell
that starts it have to use the same runtime DLL, or the program does not run at all,
so the login path now uses the one in /usr/bin, next to the user's own programs. What
is protected instead is that it is the one the package says it is: pacman checks the
files of msys2-runtime and the cleanup job puts the package back if it was changed.
Only keys may log in over SSH now, and Remote Desktop needs network level
authentication. Only the administrator may log in over it: the user of the website is
in no group that may.
Co-Authored-By: Claude Opus
---
server/README.md | 22 +++++
server/scripts/install_restore_task.ps1 | 125 ++++++++++++++++++++++--
2 files changed, 139 insertions(+), 8 deletions(-)
diff --git a/server/README.md b/server/README.md
index 822ea4e..8bbd580 100644
--- a/server/README.md
+++ b/server/README.md
@@ -46,6 +46,13 @@ The copy has to be the *same* MSYS2 installation, not a second one: `C:\msys2\op
runtime. A separate installation does not work - two runtimes in one process tree corrupt each other's file
descriptors and pacman cannot even lock its database.
+The runtime itself is not among the protected copies. A program and the shell that starts it have to use the same
+runtime DLL, or the program does not run at all - a second copy of it in one process is refused. The login path
+therefore uses the one in `/usr/bin`, next to the user's own programs, and the user may replace and upgrade it.
+What is protected instead is that it is the one the package says it is: pacman checks the files of `msys2-runtime`,
+and the cleanup job puts the package back if it was changed - before it starts the server, which runs as SYSTEM and
+would otherwise run code the user of the website wrote.
+
`/etc/passwd` (the login shell and home directory) and `/etc/ssh` (the host keys and config) are read-only to the
user as well; everything else in the installation is theirs.
@@ -59,6 +66,21 @@ Two scheduled tasks run as SYSTEM, both at startup, and the user cannot stop eit
re-applies the permissions, and puts the home directory, the shell configuration, the website's key and the four
texts back. Its second job is to best-effort revert other mockery and bring a fresh server to the expected state.
+The cleanup job also restarts the server when nobody is connected, and when the server does not listen at all:
+it comes back by itself if it died, it takes over a server that was left behind, and it picks up the programs that
+were just restored. A session that is open keeps the port, so a server that is started now could not bind it, and
+the job never disturbs a session.
+
+The deployment does not restart the server. It runs in a session, and that session keeps the port, so a server that
+starts during a deployment cannot bind it. The new configuration and the restored programs therefore take effect
+at the next run of the cleanup job, which happens within half an hour.
+
+The server logs to `C:\ProgramData\kik-restore\sshd.log`. The log is appended to, not overwritten, because it is
+the only trace of what the server did: a server that is restarted would otherwise take the evidence of an outage
+with it. It is rotated when it grows past a megabyte.
+
+Only keys may log in over SSH. The box stands in the open internet and both of its accounts have a password.
+
The files that have to stay belong to the administrators, so the user cannot delete them, and they cannot change the
shell configuration or the key the website logs in with either. The shell reads its configuration from `~/.zsh`, so
the startup files the user could write in the home directory are ignored.
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 2547df8..a5531da 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -37,6 +37,11 @@ $service = 'msys2-sshd'
$loginBinaries = "$msys\usr\bin\sshd.exe", "$msys\usr\lib\ssh\sshd-session.exe", "$msys\usr\lib\ssh\sshd-auth.exe",
"$msys\usr\bin\zsh.exe", "$msys\usr\bin\cat.exe"
+# The runtime is not among them: a program and the shell that starts it have to use the same runtime, or the
+# program does not run at all. The login path therefore uses the one in /usr/bin, and the user may replace it -
+# pacman checks that it is the one the package says it is, and the cleanup job puts it back if it is not.
+$runtimeFiles = '/usr/bin/msys-2.0.dll'
+
# The copies the task restores from and the script it runs both live in $installDir, which only SYSTEM and the
# administrators may read or write.
$restoreScript = @'
@@ -144,17 +149,68 @@ function New-Home($path) {
Get-ChildItem -LiteralPath "$msys\etc\skel" -Force | Copy-Item -Destination $path -Recurse -Force
}
+# The server is stopped and started again when nobody is connected: it picks up the programs that are restored
+# just now, and it comes back by itself if it died. It has to be stopped before the copies are made, because a
+# running server holds its programs open and they cannot be replaced.
+#
+# Stopping the task does not take the server with it, so its processes are killed by hand and the task is waited
+# for: it ignores a new instance while one is running, so a start would be dropped.
+function Stop-Sshd {
+ Stop-ScheduledTask -TaskName 'KiK sshd' -ErrorAction SilentlyContinue
+ Get-CimInstance Win32_Process -Filter "Name='cmd.exe'" | Where-Object { $_.CommandLine -match 'cmd\.exe\s+/c\s+"?C:\\ProgramData\\kik-restore\\run-sshd\.bat' } |
+ ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
+ Get-Process -Name sshd -ErrorAction SilentlyContinue | Stop-Process -Force
+ $waited = 0
+ while ((Get-ScheduledTask -TaskName 'KiK sshd').State -ne 'Ready' -and $waited -lt 30) { Start-Sleep 1; $waited++ }
+ $waited = 0
+ while ((Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
+}
+
+# The server is started again and waited for: a deployment or a crash must never leave the site without a shell.
+function Start-Sshd {
+ Start-ScheduledTask -TaskName 'KiK sshd'
+ $waited = 0
+ while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
+ return [bool](Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)
+}
+
+$netError = $null
+$connections = @(Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue -ErrorVariable netError)
+$sessions = @(Get-Process -Name 'sshd-session' -ErrorAction SilentlyContinue)
+# A session that is open keeps the port and the runtime, so the server is only restarted when there is none: it
+# comes back by itself if it died, it takes over a server that was left behind, and it picks up the programs that
+# were just restored. A query that fails is not a reason to disturb a session.
+$busy = @($connections | Where-Object { $_.State -eq 'Established' }).Count -gt 0 -or $sessions.Count -gt 0
+$restart = -not $netError -and -not $busy
+if ($restart) {
+ Stop-Sshd
+ # The runtime of the installation belongs to the user's programs and is not protected: a program and the shell
+ # that starts it have to use the same one, or the program does not run at all. What has to be protected is that
+ # it is the one the package says it is, because the server runs as SYSTEM.
+ Remove-Item "$kik\msys-2.0.dll" -Force -ErrorAction SilentlyContinue
+ & "$msys\usr\bin\pacman.exe" -Qkk msys2-runtime 2>&1 | Out-Null
+ if ($LASTEXITCODE -ne 0) {
+ & "$msys\usr\bin\pacman.exe" -S --noconfirm --overwrite '*' msys2-runtime 2>&1 | Out-Null
+ Write-Log 'restored the runtime of the installation'
+ }
+}
+
try {
# The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even
# after they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted
# originals are in bin\; anything missing from opt/kik is put back.
if (Test-Path -LiteralPath $binDir) {
if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
- foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force) {
+ foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object { $_.Name -ne 'msys-2.0.dll' }) {
$target = Join-Path $kik $file.Name
- if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
- Copy-Item -LiteralPath $file.FullName -Destination $target -Force
- Write-Log "restored opt/kik/$($file.Name)"
+ try {
+ if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
+ Copy-Item -LiteralPath $file.FullName -Destination $target -Force
+ Write-Log "restored opt/kik/$($file.Name)"
+ }
+ } catch {
+ # a running server holds its programs open; they are replaced in one of the next runs
+ Write-Log "failed opt/kik/$($file.Name) : $_"
}
}
New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
@@ -225,6 +281,10 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
Write-Log "failed $($copy.Name) : $_"
}
}
+
+if ($restart) {
+ if (Start-Sshd) { Write-Log 'restarted the sshd' } else { Write-Log 'the sshd does not listen on port 22' }
+}
'@
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
@@ -270,7 +330,7 @@ function Get-Libraries($program) {
foreach ($line in & "$msys\usr\bin\ldd.exe" $posix 2>$null) {
foreach ($m in [regex]::Matches($line, '/usr/[^\s]+\.dll')) { $paths += $m.Value }
}
- $paths | Select-Object -Unique | ForEach-Object { $msys + ($_ -replace '/', '\') }
+ $paths | Select-Object -Unique | Where-Object { $_ -notin $runtimeFiles } | ForEach-Object { $msys + ($_ -replace '/', '\') }
}
if ($Uninstall) {
@@ -338,12 +398,38 @@ foreach ($b in $loginBinaries) {
}
foreach ($f in $files.Keys) { Copy-Item -LiteralPath $f -Destination $binDir -Force }
+# Remote Desktop is the way in for the administrator, and the port is open to the internet. It needs network level
+# authentication, so that a scanner cannot get a session before it has proven that it knows the password.
+$rdpTcp = 'HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp'
+$nla = (Get-ItemProperty -Path $rdpTcp -Name UserAuthentication).UserAuthentication
+Set-ItemProperty -Path $rdpTcp -Name UserAuthentication -Value 1 -Type DWord
+if ($nla -ne 1) {
+ # the setting only counts when the service starts again, which drops the sessions that are open
+ Restart-Service TermService -Force
+}
+
+# Only the administrator may log in over Remote Desktop. The user of the website is in no group that may log in
+# over it, and it must never be an administrator - that is taken away if it somehow got there.
+$rootAccount = Get-LocalUser -Name 'root' -ErrorAction SilentlyContinue
+if ($rootAccount) {
+ foreach ($group in 'Administrators', 'Remote Desktop Users') {
+ $member = Get-LocalGroupMember $group -ErrorAction SilentlyContinue | Where-Object { $_.SID -eq $rootAccount.SID }
+ if ($member) {
+ Remove-LocalGroupMember $group $member -Confirm:$false
+ Write-Host "The root user was in '$group' and has been removed from it."
+ }
+ }
+}
+
# The sshd reads its configuration and finds its helpers in opt/kik; nothing here depends on the user's /usr.
$sshdConfig = "$msys\etc\ssh\sshd_config"
Set-Content -LiteralPath $sshdConfig -Encoding ascii -Value @(
'# The website logs in as root, the deployers as Administrator. Both come in over the websocket bridge.',
'Port 22',
'AllowUsers root Administrator',
+ '# Only keys may log in: the box is in the open internet and the accounts have passwords.',
+ 'PasswordAuthentication no',
+ 'KbdInteractiveAuthentication no',
'# Files are protected by Windows ACLs; under MSYS2''s noacl mounts the POSIX modes sshd checks are fake.',
'StrictModes no',
'Subsystem sftp internal-sftp',
@@ -354,10 +440,14 @@ Set-Content -LiteralPath $sshdConfig -Encoding ascii -Value @(
# The sshd runs as SYSTEM from the protected copy, with opt/kik first on the path so its own DLLs and the session's
# programs (zsh, cat) are the protected ones. The log stays in the install directory, out of the user's reach.
+# The log is appended to, not overwritten: it is the only trace of what the server did, and a server that is
+# restarted would otherwise take the evidence of the outage with it.
Set-Content -LiteralPath $sshdBat -Encoding ascii -Value @(
'@echo off',
'set PATH=C:\msys2\opt\kik;C:\msys2\usr\bin;%PATH%',
- 'C:\msys2\opt\kik\sshd.exe -D -e > C:\ProgramData\kik-restore\sshd.log 2>&1'
+ 'rem Keep the previous log if it grew too big; the current one is appended to.',
+ 'if exist C:\ProgramData\kik-restore\sshd.log for %%f in (C:\ProgramData\kik-restore\sshd.log) do if %%~zf GEQ 1048576 move /y C:\ProgramData\kik-restore\sshd.log C:\ProgramData\kik-restore\sshd.log.1',
+ 'C:\msys2\opt\kik\sshd.exe -D -e >> C:\ProgramData\kik-restore\sshd.log 2>&1'
)
# The installation's own cygrunsrv sshd service would fight for port 22; the scheduled task owns the sshd instead.
@@ -378,13 +468,32 @@ $restoreTriggers = @(
$restoreSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $restoreTask -Action $restoreAction -Trigger $restoreTriggers -Principal $atSystem -Settings $restoreSettings -Force | Out-Null
+# The server is started and waited for: it must listen on port 22, or the site has no shell.
+function Start-Sshd {
+ Start-ScheduledTask -TaskName $sshdTask
+ $waited = 0
+ while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
+ return [bool](Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)
+}
+
# The restore task builds opt/kik and sets every permission; run it before starting the sshd that depends on it.
Start-ScheduledTask -TaskName $restoreTask
Start-Sleep -Seconds 6
$waited = 0
while ((Get-ScheduledTask -TaskName $restoreTask).State -eq 'Running' -and $waited -lt 60) { Start-Sleep 2; $waited += 2 }
-Start-ScheduledTask -TaskName $sshdTask
-Start-Sleep -Seconds 4
+
+# The server is not restarted here. It cannot be: the deployment runs in a session, and the port is still in use by
+# that session, so a server that starts now cannot bind it. The cleanup job restarts the server when nobody is
+# connected, and the new configuration and the restored programs take effect then. A server that is not running at
+# all is started here, because then there is no session that could hold the port.
+if (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)) {
+ if (-not (Start-Sshd)) {
+ throw "The sshd does not listen on port 22; the task '$sshdTask' says: $((Get-ScheduledTask -TaskName $sshdTask).State), $((Get-ScheduledTaskInfo -TaskName $sshdTask).LastTaskResult)"
+ }
+}
+if (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)) {
+ throw "The sshd does not listen on port 22."
+}
Write-Host "The sshd runs as SYSTEM from C:\msys2\opt\kik (task '$sshdTask'); '$restoreTask' keeps it and the home directory in shape."
Get-Content (Join-Path $installDir 'restore.log') -Tail 10 -ErrorAction SilentlyContinue
From f4113033acea2a97932cea400f14e2555708f76f Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Tue, 27 Oct 2026 20:36:52 +0100
Subject: [PATCH 36/39] Start the server again when the cleanup job's start was
dropped
The task that runs the server ignores a start while it thinks it is still
running, so the cleanup job could stop the server and then fail to start it
again: the site had no shell for forty minutes. The start is now tried three
times, with the processes of the task killed in between.
The job now only counts the established connections when it decides whether
anybody is connected - the server leaves the processes of finished sessions
behind, and they kept it from ever restarting the server. Those processes are
killed with the server, because they hold the runtime and the protected
programs open.
The pacman calls are bounded, so a database lock cannot keep the server down,
and they run before the server is stopped. The exit code of a program started
this way is only available when its handle is taken while it runs, and the
pattern for the files to overwrite has to be quoted, or the runtime expands
it.
Co-Authored-By: Claude Code
---
server/README.md | 8 +++-
server/scripts/install_restore_task.ps1 | 63 ++++++++++++++++++-------
2 files changed, 53 insertions(+), 18 deletions(-)
diff --git a/server/README.md b/server/README.md
index 8bbd580..0870675 100644
--- a/server/README.md
+++ b/server/README.md
@@ -50,8 +50,8 @@ The runtime itself is not among the protected copies. A program and the shell th
runtime DLL, or the program does not run at all - a second copy of it in one process is refused. The login path
therefore uses the one in `/usr/bin`, next to the user's own programs, and the user may replace and upgrade it.
What is protected instead is that it is the one the package says it is: pacman checks the files of `msys2-runtime`,
-and the cleanup job puts the package back if it was changed - before it starts the server, which runs as SYSTEM and
-would otherwise run code the user of the website wrote.
+and the cleanup job puts the package back if it was changed - while the server is stopped, before it starts it
+again, because the server runs as SYSTEM and would otherwise run code the user of the website wrote.
`/etc/passwd` (the login shell and home directory) and `/etc/ssh` (the host keys and config) are read-only to the
user as well; everything else in the installation is theirs.
@@ -71,6 +71,10 @@ it comes back by itself if it died, it takes over a server that was left behind,
were just restored. A session that is open keeps the port, so a server that is started now could not bind it, and
the job never disturbs a session.
+The task that runs the server ignores a start while it thinks it is still running, so a start that is dropped
+leaves the site without a shell. The cleanup job therefore tries three times, killing the processes of the task
+in between, before it gives up.
+
The deployment does not restart the server. It runs in a session, and that session keeps the port, so a server that
starts during a deployment cannot bind it. The new configuration and the restored programs therefore take effect
at the next run of the cleanup job, which happens within half an hour.
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index a5531da..afe3883 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -160,6 +160,9 @@ function Stop-Sshd {
Get-CimInstance Win32_Process -Filter "Name='cmd.exe'" | Where-Object { $_.CommandLine -match 'cmd\.exe\s+/c\s+"?C:\\ProgramData\\kik-restore\\run-sshd\.bat' } |
ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }
Get-Process -Name sshd -ErrorAction SilentlyContinue | Stop-Process -Force
+ # the server leaves the processes of the sessions it has served behind, and they hold the runtime and the
+ # protected programs open, so those cannot be replaced while they are there
+ Get-Process -Name 'sshd-session' -ErrorAction SilentlyContinue | Stop-Process -Force
$waited = 0
while ((Get-ScheduledTask -TaskName 'KiK sshd').State -ne 'Ready' -and $waited -lt 30) { Start-Sleep 1; $waited++ }
$waited = 0
@@ -167,30 +170,58 @@ function Stop-Sshd {
}
# The server is started again and waited for: a deployment or a crash must never leave the site without a shell.
+# The task ignores a new instance while it thinks one is running, so when the port stays closed its processes are
+# killed and it is started again.
function Start-Sshd {
- Start-ScheduledTask -TaskName 'KiK sshd'
- $waited = 0
- while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
- return [bool](Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue)
+ for ($attempt = 1; $attempt -le 3; $attempt++) {
+ Start-ScheduledTask -TaskName 'KiK sshd'
+ $waited = 0
+ while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
+ if (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) { return $true }
+ Write-Log "the server did not come up (attempt $attempt)"
+ Stop-Sshd
+ }
+ return $false
+}
+
+# Runs a program and gives up after a while: pacman can wait for the lock of its database, and the server must not
+# stay down because of it. The exit code is returned, or -1 when it did not finish in time.
+function Invoke-Bounded($program, $arguments, $seconds) {
+ $process = Start-Process -FilePath $program -ArgumentList $arguments -NoNewWindow -PassThru `
+ -RedirectStandardOutput (Join-Path $env:TEMP 'kik-pacman.out') -RedirectStandardError (Join-Path $env:TEMP 'kik-pacman.err')
+ # the handle is taken while the process runs, otherwise its exit code is not available afterwards
+ $null = $process.Handle
+ if (-not $process.WaitForExit($seconds * 1000)) {
+ Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue
+ return -1
+ }
+ return $process.ExitCode
}
$netError = $null
$connections = @(Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue -ErrorVariable netError)
-$sessions = @(Get-Process -Name 'sshd-session' -ErrorAction SilentlyContinue)
-# A session that is open keeps the port and the runtime, so the server is only restarted when there is none: it
-# comes back by itself if it died, it takes over a server that was left behind, and it picks up the programs that
-# were just restored. A query that fails is not a reason to disturb a session.
-$busy = @($connections | Where-Object { $_.State -eq 'Established' }).Count -gt 0 -or $sessions.Count -gt 0
+# An open session keeps the port, so the server is only restarted when there is none: it comes back by itself if it
+# died, it takes over a server that was left behind, and it picks up the programs that were just restored. A query
+# that fails is not a reason to disturb a session. The processes of the sessions are left behind by the server when a
+# client goes away, so they say nothing about whether anybody is connected.
+$busy = @($connections | Where-Object { $_.State -eq 'Established' }).Count -gt 0
$restart = -not $netError -and -not $busy
+# The runtime of the installation belongs to the user's programs and is not protected: a program and the shell that
+# starts it have to use the same one, or the program does not run at all. What has to be protected is that it is the
+# one the package says it is, because the server runs as SYSTEM. The check takes a while, so it runs while the
+# server is up - the server must not wait for it.
+$runtimeBroken = $false
+if ($restart) {
+ $runtimeBroken = (Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-Qkk', 'msys2-runtime') 120) -ne 0
+}
+
if ($restart) {
Stop-Sshd
- # The runtime of the installation belongs to the user's programs and is not protected: a program and the shell
- # that starts it have to use the same one, or the program does not run at all. What has to be protected is that
- # it is the one the package says it is, because the server runs as SYSTEM.
Remove-Item "$kik\msys-2.0.dll" -Force -ErrorAction SilentlyContinue
- & "$msys\usr\bin\pacman.exe" -Qkk msys2-runtime 2>&1 | Out-Null
- if ($LASTEXITCODE -ne 0) {
- & "$msys\usr\bin\pacman.exe" -S --noconfirm --overwrite '*' msys2-runtime 2>&1 | Out-Null
+ if ($runtimeBroken) {
+ # the package is put back while the server is stopped: the file cannot be replaced while it is mapped.
+ # The pattern is quoted, or the runtime expands it to the files of the working directory.
+ Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-S', '--noconfirm', '--overwrite', "'*'", 'msys2-runtime') 300 | Out-Null
Write-Log 'restored the runtime of the installation'
}
}
@@ -465,7 +496,7 @@ $restoreTriggers = @(
New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 30) -RepetitionDuration (New-TimeSpan -Days 3650)
New-ScheduledTaskTrigger -AtStartup
)
-$restoreSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 10) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+$restoreSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $restoreTask -Action $restoreAction -Trigger $restoreTriggers -Principal $atSystem -Settings $restoreSettings -Force | Out-Null
# The server is started and waited for: it must listen on port 22, or the site has no shell.
From 5a885e3781da93c9dcde093762ead7d7327ab41a Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Wed, 28 Oct 2026 11:55:37 +0100
Subject: [PATCH 37/39] Keep the site up when the cleanup job is killed
The cleanup job stops the server, puts the files back and starts it again. On
a slow disk that took minutes, and on 2026-10-28 the job was killed while the
server was down: the task of the server only starts it again when it failed,
never when it was stopped, so the site was down until the box was power-cycled.
The job now puts the home directory, the four texts and the permissions of the
installation back while the server runs, and only stops it for the protected
copy, which cannot be replaced while the server holds it open.
A new task, "KiK watch the server", starts the server every two minutes when
nothing listens on port 22 and the cleanup job is not running.
Co-Authored-By: Claude Code
---
server/README.md | 10 +-
server/scripts/install_restore_task.ps1 | 145 +++++++++++++++---------
2 files changed, 99 insertions(+), 56 deletions(-)
diff --git a/server/README.md b/server/README.md
index 0870675..c0df5b6 100644
--- a/server/README.md
+++ b/server/README.md
@@ -58,19 +58,27 @@ user as well; everything else in the installation is theirs.
## The cleanup tasks
-Two scheduled tasks run as SYSTEM, both at startup, and the user cannot stop either:
+Three scheduled tasks run as SYSTEM, all at startup, and the user cannot stop any of them:
- `KiK sshd` runs the sshd from `C:\msys2\opt\kik`. It replaces the installation's own `msys2-sshd` service, which
is disabled.
- `KiK restore shell files` runs every 30 minutes and rebuilds the protected copy from its trusted originals,
re-applies the permissions, and puts the home directory, the shell configuration, the website's key and the four
texts back. Its second job is to best-effort revert other mockery and bring a fresh server to the expected state.
+- `KiK watch the server` runs every two minutes and starts the server when nothing listens on port 22. The task of
+ the server only starts it again when it failed, never when it was stopped, so a cleanup job that is killed while
+ it has the server stopped would otherwise take the site down with it.
The cleanup job also restarts the server when nobody is connected, and when the server does not listen at all:
it comes back by itself if it died, it takes over a server that was left behind, and it picks up the programs that
were just restored. A session that is open keeps the port, so a server that is started now could not bind it, and
the job never disturbs a session.
+The server is only down for the short part of the job that needs it down: the protected copy cannot be replaced
+while the server holds it open. Everything else - the home directory, the four texts, the permissions of the
+installation - is put back while the server runs, because on a slow disk that takes minutes and the site should
+have a shell the whole time.
+
The task that runs the server ignores a start while it thinks it is still running, so a start that is dropped
leaves the site without a shell. The cleanup job therefore tries three times, killing the processes of the task
in between, before it gives up.
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index afe3883..d1851dd 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -30,6 +30,7 @@ $binDir = Join-Path $installDir 'bin'
$sshdBat = Join-Path $installDir 'run-sshd.bat'
$restoreTask = 'KiK restore shell files'
$sshdTask = 'KiK sshd'
+$watchTask = 'KiK watch the server'
$service = 'msys2-sshd'
# The programs the journey needs, kept out of the user's reach. sshd-session and sshd-auth are the per-connection
@@ -215,60 +216,8 @@ if ($restart) {
$runtimeBroken = (Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-Qkk', 'msys2-runtime') 120) -ne 0
}
-if ($restart) {
- Stop-Sshd
- Remove-Item "$kik\msys-2.0.dll" -Force -ErrorAction SilentlyContinue
- if ($runtimeBroken) {
- # the package is put back while the server is stopped: the file cannot be replaced while it is mapped.
- # The pattern is quoted, or the runtime expands it to the files of the working directory.
- Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-S', '--noconfirm', '--overwrite', "'*'", 'msys2-runtime') 300 | Out-Null
- Write-Log 'restored the runtime of the installation'
- }
-}
-
-try {
- # The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even
- # after they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted
- # originals are in bin\; anything missing from opt/kik is put back.
- if (Test-Path -LiteralPath $binDir) {
- if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
- foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object { $_.Name -ne 'msys-2.0.dll' }) {
- $target = Join-Path $kik $file.Name
- try {
- if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
- Copy-Item -LiteralPath $file.FullName -Destination $target -Force
- Write-Log "restored opt/kik/$($file.Name)"
- }
- } catch {
- # a running server holds its programs open; they are replaced in one of the next runs
- Write-Log "failed opt/kik/$($file.Name) : $_"
- }
- }
- New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
- Set-LockedAcl $kik $true 'ReadAndExecute'
- # /dev is scratch: the user may write it so POSIX shared memory works, the rest of opt/kik stays read-only
- if ($sid) { icacls "$kik\dev" /grant "*${sid}:(OI)(CI)M" | Out-Null }
- }
-
- # The account database and the ssh keys and config the SYSTEM server reads: the user may read passwd (programs
- # map ids through it) but change none of it, and has no business in /etc/ssh at all.
- $rootShell = '/opt/kik/zsh.exe'
- $pw = "$msys\etc\passwd"
- $lines = Get-Content $pw
- if ($lines | Where-Object { $_ -match '^root:' -and $_ -notmatch ':/opt/kik/zsh\.exe$' }) {
- ($lines | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ":$rootShell" } else { $_ } }) | Set-Content $pw -Encoding ascii
- Write-Log 'set the root login shell to the protected zsh'
- }
- Set-LockedAcl $pw $false 'Read'
- if (Test-Path "$msys\etc\ssh") { Set-LockedAcl "$msys\etc\ssh" $true 'None' }
-
- # The whole installation otherwise belongs to the user, so pacman can write it. The home directory keeps its own
- # protected permissions because they are set without inheritance.
- if ($sid) { icacls $msys /grant "*${sid}:(OI)(CI)F" | Out-Null }
-} catch {
- Write-Log "the protected login path is not fully in place: $_"
-}
-
+# The home directory and the four texts: the server does not hold them open, so they are put back while it runs.
+# On a slow disk this takes minutes, and the site has a shell the whole time.
if (-not (Test-Path -LiteralPath $HomeDir)) {
New-Home $HomeDir
Write-Log "created $HomeDir"
@@ -313,11 +262,86 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
}
}
+# The whole installation belongs to the user, so pacman can write it. The home directory keeps its own protected
+# permissions because they are set without inheritance.
+if ($sid) { icacls $msys /grant "*${sid}:(OI)(CI)F" | Out-Null }
+
if ($restart) {
+ Stop-Sshd
+ Remove-Item "$kik\msys-2.0.dll" -Force -ErrorAction SilentlyContinue
+ if ($runtimeBroken) {
+ # the package is put back while the server is stopped: the file cannot be replaced while it is mapped.
+ # The pattern is quoted, or the runtime expands it to the files of the working directory.
+ Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-S', '--noconfirm', '--overwrite', "'*'", 'msys2-runtime') 300 | Out-Null
+ Write-Log 'restored the runtime of the installation'
+ }
+
+ try {
+ # The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even
+ # after they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted
+ # originals are in bin\; anything missing from opt/kik is put back. This is the only work that needs the server
+ # to be stopped, because a running server holds its programs open.
+ if (Test-Path -LiteralPath $binDir) {
+ if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
+ foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object { $_.Name -ne 'msys-2.0.dll' }) {
+ $target = Join-Path $kik $file.Name
+ try {
+ if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
+ Copy-Item -LiteralPath $file.FullName -Destination $target -Force
+ Write-Log "restored opt/kik/$($file.Name)"
+ }
+ } catch {
+ # a running server holds its programs open; they are replaced in one of the next runs
+ Write-Log "failed opt/kik/$($file.Name) : $_"
+ }
+ }
+ New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
+ Set-LockedAcl $kik $true 'ReadAndExecute'
+ # /dev is scratch: the user may write it so POSIX shared memory works, the rest of opt/kik stays read-only
+ if ($sid) { icacls "$kik\dev" /grant "*${sid}:(OI)(CI)M" | Out-Null }
+ }
+
+ # The account database and the ssh keys and config the SYSTEM server reads: the user may read passwd (programs
+ # map ids through it) but change none of it, and has no business in /etc/ssh at all.
+ $rootShell = '/opt/kik/zsh.exe'
+ $pw = "$msys\etc\passwd"
+ $lines = Get-Content $pw
+ if ($lines | Where-Object { $_ -match '^root:' -and $_ -notmatch ':/opt/kik/zsh\.exe$' }) {
+ ($lines | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ":$rootShell" } else { $_ } }) | Set-Content $pw -Encoding ascii
+ Write-Log 'set the root login shell to the protected zsh'
+ }
+ Set-LockedAcl $pw $false 'Read'
+ if (Test-Path "$msys\etc\ssh") { Set-LockedAcl "$msys\etc\ssh" $true 'None' }
+ } catch {
+ Write-Log "the protected login path is not fully in place: $_"
+ }
+
if (Start-Sshd) { Write-Log 'restarted the sshd' } else { Write-Log 'the sshd does not listen on port 22' }
}
'@
+# The server is watched, because the cleanup job may be killed while it has the server stopped: the task of the
+# server only starts it again when it failed, never when it was stopped. The watcher is the only thing that brings
+# the site back when that happens.
+$watchScript = @'
+# Starts the server when it is not running. The cleanup job stops it while it puts the protected files back, and it
+# can be killed while the server is down - the task of the server does not start it again then.
+$log = Join-Path $PSScriptRoot 'watch.log'
+if (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) { return }
+if ((Get-ScheduledTask -TaskName 'KiK restore shell files').State -eq 'Running') {
+ # the cleanup job starts the server itself when it is done, but it must not hold it down for ever
+ $running = (Get-Date) - (Get-ScheduledTaskInfo -TaskName 'KiK restore shell files').LastRunTime
+ if ($running.TotalMinutes -lt 3) { return }
+}
+if ((Get-ScheduledTask -TaskName 'KiK sshd').State -eq 'Running') {
+ # the task is running but nothing listens, so it is stuck and has to be started anew
+ Stop-ScheduledTask -TaskName 'KiK sshd'
+ Start-Sleep 5
+}
+Start-ScheduledTask -TaskName 'KiK sshd'
+"$(Get-Date -Format s) the server was not running and was started" | Out-File -FilePath $log -Append
+'@
+
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
throw 'Run this in an elevated PowerShell.'
}
@@ -365,7 +389,7 @@ function Get-Libraries($program) {
}
if ($Uninstall) {
- foreach ($t in $restoreTask, $sshdTask) { Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue }
+ foreach ($t in $restoreTask, $sshdTask, $watchTask) { Unregister-ScheduledTask -TaskName $t -Confirm:$false -ErrorAction SilentlyContinue }
if (Test-Path -LiteralPath $installDir) { Remove-Item -LiteralPath $installDir -Recurse -Force }
if (Test-Path -LiteralPath $kik) {
icacls $kik /reset /t /q 2>$null | Out-Null
@@ -417,6 +441,7 @@ if (-not ($keys | Where-Object { ($_ -split '\s+')[1] -eq ($required[0] -split '
[IO.File]::WriteAllText($adminKeys, (($keys + $required) -join "`n") + "`n")
}
[IO.File]::WriteAllText((Join-Path $installDir 'restore.ps1'), $restoreScript)
+[IO.File]::WriteAllText((Join-Path $installDir 'watch.ps1'), $watchScript)
# The trusted login programs: the sshd, its helpers, the login shell and cat, with every DLL they load. The restore
# task puts these into opt/kik, which the user cannot write.
@@ -499,6 +524,16 @@ $restoreTriggers = @(
$restoreSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 5) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
Register-ScheduledTask -TaskName $restoreTask -Action $restoreAction -Trigger $restoreTriggers -Principal $atSystem -Settings $restoreSettings -Force | Out-Null
+# The watcher looks after the server every two minutes, so that a cleanup job that is killed while it has the server
+# stopped does not take the site down with it.
+$watchAction = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File `"$installDir\watch.ps1`""
+$watchTriggers = @(
+ New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 2) -RepetitionDuration (New-TimeSpan -Days 3650)
+ New-ScheduledTaskTrigger -AtStartup
+)
+$watchSettings = New-ScheduledTaskSettingsSet -StartWhenAvailable -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 1) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
+Register-ScheduledTask -TaskName $watchTask -Action $watchAction -Trigger $watchTriggers -Principal $atSystem -Settings $watchSettings -Force | Out-Null
+
# The server is started and waited for: it must listen on port 22, or the site has no shell.
function Start-Sshd {
Start-ScheduledTask -TaskName $sshdTask
From a8133263129c80d159b91b9b3abd7718e73c64fd Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Thu, 29 Oct 2026 00:50:29 +0100
Subject: [PATCH 38/39] Keep the logs for years
The sshd log is rotated at a hundred megabytes instead of one, and the event
logs of windows are made that big as well: they are the only record of what
the box itself did, and twenty megabytes are not much with all the connections.
Co-Authored-By: Claude Code
---
server/README.md | 3 ++-
server/scripts/install_restore_task.ps1 | 11 +++++++++--
2 files changed, 11 insertions(+), 3 deletions(-)
diff --git a/server/README.md b/server/README.md
index c0df5b6..3e5405d 100644
--- a/server/README.md
+++ b/server/README.md
@@ -89,7 +89,8 @@ at the next run of the cleanup job, which happens within half an hour.
The server logs to `C:\ProgramData\kik-restore\sshd.log`. The log is appended to, not overwritten, because it is
the only trace of what the server did: a server that is restarted would otherwise take the evidence of an outage
-with it. It is rotated when it grows past a megabyte.
+with it. It is rotated when it grows past a hundred megabytes, which takes years. The event logs of windows are
+made that big as well, so that they keep the history of the box itself for just as long.
Only keys may log in over SSH. The box stands in the open internet and both of its accounts have a password.
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index d1851dd..1b5873b 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -497,15 +497,22 @@ Set-Content -LiteralPath $sshdConfig -Encoding ascii -Value @(
# The sshd runs as SYSTEM from the protected copy, with opt/kik first on the path so its own DLLs and the session's
# programs (zsh, cat) are the protected ones. The log stays in the install directory, out of the user's reach.
# The log is appended to, not overwritten: it is the only trace of what the server did, and a server that is
-# restarted would otherwise take the evidence of the outage with it.
+# restarted would otherwise take the evidence of the outage with it. It is rotated at a hundred megabytes, which
+# takes years to reach.
Set-Content -LiteralPath $sshdBat -Encoding ascii -Value @(
'@echo off',
'set PATH=C:\msys2\opt\kik;C:\msys2\usr\bin;%PATH%',
'rem Keep the previous log if it grew too big; the current one is appended to.',
- 'if exist C:\ProgramData\kik-restore\sshd.log for %%f in (C:\ProgramData\kik-restore\sshd.log) do if %%~zf GEQ 1048576 move /y C:\ProgramData\kik-restore\sshd.log C:\ProgramData\kik-restore\sshd.log.1',
+ 'if exist C:\ProgramData\kik-restore\sshd.log for %%f in (C:\ProgramData\kik-restore\sshd.log) do if %%~zf GEQ 104857600 move /y C:\ProgramData\kik-restore\sshd.log C:\ProgramData\kik-restore\sshd.log.1',
'C:\msys2\opt\kik\sshd.exe -D -e >> C:\ProgramData\kik-restore\sshd.log 2>&1'
)
+# The event logs of windows are made bigger as well: they are the only record of what the box itself did, and
+# twenty megabytes are not much with all the connections.
+foreach ($eventLog in 'System', 'Application', 'Security') {
+ wevtutil sl $eventLog /ms:104857600 | Out-Null
+}
+
# The installation's own cygrunsrv sshd service would fight for port 22; the scheduled task owns the sshd instead.
Set-Service $service -StartupType Disabled -ErrorAction SilentlyContinue
Stop-Service $service -Force -ErrorAction SilentlyContinue
From 727f8f6e9ccf8ac37483ef531bf6bbafe8586cf7 Mon Sep 17 00:00:00 2001
From: Zebreus
Date: Fri, 6 Nov 2026 00:17:08 +0100
Subject: [PATCH 39/39] Restart the shell server only when it has to, and
record failed starts
Every outage since 28 Oct began at the cleanup job's routine restart of a
working server: the start failed and kept failing until a reboot. The job
now leaves a working server alone and restarts it only when it does not
listen, is older than its configuration, or holds open a program or the
runtime that has to be repaired.
A failed start left no trace anywhere, so windows now records the history
of the scheduled tasks and the start and end of every process, and the job
writes the processes and the state of the server's task to
failed-starts.log when the server does not come up. The security log grows
to a gigabyte, because the scanners of the remote desktop fill a hundred
megabytes in a week.
Co-Authored-By: Claude Opus 5.5 (1M context)
---
server/README.md | 16 ++-
server/scripts/install_restore_task.ps1 | 130 +++++++++++++++---------
2 files changed, 92 insertions(+), 54 deletions(-)
diff --git a/server/README.md b/server/README.md
index 3e5405d..10b4a89 100644
--- a/server/README.md
+++ b/server/README.md
@@ -69,10 +69,11 @@ Three scheduled tasks run as SYSTEM, all at startup, and the user cannot stop an
the server only starts it again when it failed, never when it was stopped, so a cleanup job that is killed while
it has the server stopped would otherwise take the site down with it.
-The cleanup job also restarts the server when nobody is connected, and when the server does not listen at all:
-it comes back by itself if it died, it takes over a server that was left behind, and it picks up the programs that
-were just restored. A session that is open keeps the port, so a server that is started now could not bind it, and
-the job never disturbs a session.
+The cleanup job only restarts the server when it has to: when it does not listen at all, when it is older than its
+configuration, or when the protected copy or the runtime has to be repaired - those cannot be replaced while the
+server holds them open. A working server is left alone, because every start can fail, and failed starts have kept
+the site down for hours. The job also never restarts the server while somebody is connected: a session that is open
+keeps the port, so a server that is started then could not bind it.
The server is only down for the short part of the job that needs it down: the protected copy cannot be replaced
while the server holds it open. Everything else - the home directory, the four texts, the permissions of the
@@ -90,7 +91,12 @@ at the next run of the cleanup job, which happens within half an hour.
The server logs to `C:\ProgramData\kik-restore\sshd.log`. The log is appended to, not overwritten, because it is
the only trace of what the server did: a server that is restarted would otherwise take the evidence of an outage
with it. It is rotated when it grows past a hundred megabytes, which takes years. The event logs of windows are
-made that big as well, so that they keep the history of the box itself for just as long.
+made bigger as well, the security log to a gigabyte: the scanners of the remote desktop alone fill a hundred
+megabytes of it in a week.
+
+A start of the server that fails leaves nothing in that log. So windows records the history of the scheduled tasks
+and the start and end of every process, and when the server does not come up, the cleanup job writes the processes
+and the state of the server's task to `C:\ProgramData\kik-restore\failed-starts.log`.
Only keys may log in over SSH. The box stands in the open internet and both of its accounts have a password.
diff --git a/server/scripts/install_restore_task.ps1 b/server/scripts/install_restore_task.ps1
index 1b5873b..18230dc 100644
--- a/server/scripts/install_restore_task.ps1
+++ b/server/scripts/install_restore_task.ps1
@@ -150,9 +150,9 @@ function New-Home($path) {
Get-ChildItem -LiteralPath "$msys\etc\skel" -Force | Copy-Item -Destination $path -Recurse -Force
}
-# The server is stopped and started again when nobody is connected: it picks up the programs that are restored
-# just now, and it comes back by itself if it died. It has to be stopped before the copies are made, because a
-# running server holds its programs open and they cannot be replaced.
+# The server is only stopped when it has to be: when it does not run, when it is older than its configuration, or
+# when it holds open what has to be repaired - its programs in opt/kik or the runtime. A working server is left alone,
+# because every start can fail, and a failed start has kept the site down for hours.
#
# Stopping the task does not take the server with it, so its processes are killed by hand and the task is waited
# for: it ignores a new instance while one is running, so a start would be dropped.
@@ -170,6 +170,19 @@ function Stop-Sshd {
while ((Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
}
+# What the box looks like when the server did not come up. Nothing else records a failed start, and the reboot that
+# ends the outage takes the evidence with it.
+function Save-Snapshot($attempt) {
+ $task = Get-ScheduledTask -TaskName 'KiK sshd'
+ $info = $task | Get-ScheduledTaskInfo
+ $lines = @("== $(Get-Date -Format s) attempt ${attempt}: the task is $($task.State), last run $($info.LastRunTime), result 0x$('{0:X}' -f $info.LastTaskResult)")
+ $lines += Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue | ForEach-Object { "port 22: $($_.LocalAddress) $($_.State) pid $($_.OwningProcess)" }
+ $lines += Get-CimInstance Win32_Process | Sort-Object CreationDate | ForEach-Object {
+ '{0:MM-dd HH:mm:ss} {1} pid {2} parent {3} session {4}: {5}' -f $_.CreationDate, $_.Name, $_.ProcessId, $_.ParentProcessId, $_.SessionId, $_.CommandLine
+ }
+ $lines | Out-File -FilePath (Join-Path $PSScriptRoot 'failed-starts.log') -Append
+}
+
# The server is started again and waited for: a deployment or a crash must never leave the site without a shell.
# The task ignores a new instance while it thinks one is running, so when the port stays closed its processes are
# killed and it is started again.
@@ -180,6 +193,7 @@ function Start-Sshd {
while (-not (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) -and $waited -lt 30) { Start-Sleep 1; $waited++ }
if (Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue) { return $true }
Write-Log "the server did not come up (attempt $attempt)"
+ Save-Snapshot $attempt
Stop-Sshd
}
return $false
@@ -201,18 +215,17 @@ function Invoke-Bounded($program, $arguments, $seconds) {
$netError = $null
$connections = @(Get-NetTCPConnection -LocalPort 22 -ErrorAction SilentlyContinue -ErrorVariable netError)
-# An open session keeps the port, so the server is only restarted when there is none: it comes back by itself if it
-# died, it takes over a server that was left behind, and it picks up the programs that were just restored. A query
-# that fails is not a reason to disturb a session. The processes of the sessions are left behind by the server when a
-# client goes away, so they say nothing about whether anybody is connected.
+# An open session keeps the port, so the server is only stopped when there is none. A query that fails is not a
+# reason to disturb a session. The processes of the sessions are left behind by the server when a client goes away,
+# so they say nothing about whether anybody is connected.
$busy = @($connections | Where-Object { $_.State -eq 'Established' }).Count -gt 0
-$restart = -not $netError -and -not $busy
+$mayStop = -not $netError -and -not $busy
# The runtime of the installation belongs to the user's programs and is not protected: a program and the shell that
# starts it have to use the same one, or the program does not run at all. What has to be protected is that it is the
# one the package says it is, because the server runs as SYSTEM. The check takes a while, so it runs while the
# server is up - the server must not wait for it.
$runtimeBroken = $false
-if ($restart) {
+if ($mayStop) {
$runtimeBroken = (Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-Qkk', 'msys2-runtime') 120) -ne 0
}
@@ -266,6 +279,23 @@ foreach ($copy in Get-ChildItem -LiteralPath $copies -Recurse -File -Force) {
# permissions because they are set without inheritance.
if ($sid) { icacls $msys /grant "*${sid}:(OI)(CI)F" | Out-Null }
+# The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even after
+# they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted originals
+# are in bin\; anything missing from opt/kik or different from its original is put back. Replacing them is the only
+# work that needs the server to be stopped, because a running server holds its programs open.
+$stale = @(if (Test-Path -LiteralPath $binDir) {
+ Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object {
+ $target = Join-Path $kik $_.Name
+ $_.Name -ne 'msys-2.0.dll' -and (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $_.Length)
+ }
+})
+# A deployment writes the configuration but cannot restart the server, so a server that is older than its
+# configuration is restarted here.
+$listener = Get-NetTCPConnection -LocalPort 22 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
+$outdated = $listener -and @(Get-Item -LiteralPath "$msys\etc\ssh\sshd_config", (Join-Path $PSScriptRoot 'run-sshd.bat') |
+ Where-Object { $_.LastWriteTime -gt (Get-Process -Id $listener.OwningProcess).StartTime }).Count -gt 0
+$restart = $mayStop -and (-not $listener -or $outdated -or $runtimeBroken -or $stale.Count -gt 0)
+
if ($restart) {
Stop-Sshd
Remove-Item "$kik\msys-2.0.dll" -Force -ErrorAction SilentlyContinue
@@ -275,47 +305,43 @@ if ($restart) {
Invoke-Bounded "$msys\usr\bin\pacman.exe" @('-S', '--noconfirm', '--overwrite', "'*'", 'msys2-runtime') 300 | Out-Null
Write-Log 'restored the runtime of the installation'
}
-
- try {
- # The protected login path: the copy the sshd and the login shell run from. The user cannot write it, so even
- # after they replace or remove their own copies in /usr/bin the server and the journey keep working. The trusted
- # originals are in bin\; anything missing from opt/kik is put back. This is the only work that needs the server
- # to be stopped, because a running server holds its programs open.
- if (Test-Path -LiteralPath $binDir) {
- if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
- foreach ($file in Get-ChildItem -LiteralPath $binDir -File -Force | Where-Object { $_.Name -ne 'msys-2.0.dll' }) {
- $target = Join-Path $kik $file.Name
- try {
- if (-not (Test-Path -LiteralPath $target) -or (Get-Item -LiteralPath $target -Force).Length -ne $file.Length) {
- Copy-Item -LiteralPath $file.FullName -Destination $target -Force
- Write-Log "restored opt/kik/$($file.Name)"
- }
- } catch {
- # a running server holds its programs open; they are replaced in one of the next runs
- Write-Log "failed opt/kik/$($file.Name) : $_"
- }
- }
- New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
- Set-LockedAcl $kik $true 'ReadAndExecute'
- # /dev is scratch: the user may write it so POSIX shared memory works, the rest of opt/kik stays read-only
- if ($sid) { icacls "$kik\dev" /grant "*${sid}:(OI)(CI)M" | Out-Null }
+ if (-not (Test-Path -LiteralPath $kik)) { New-Item -ItemType Directory -Path $kik | Out-Null }
+ foreach ($file in $stale) {
+ try {
+ Copy-Item -LiteralPath $file.FullName -Destination (Join-Path $kik $file.Name) -Force
+ Write-Log "restored opt/kik/$($file.Name)"
+ } catch {
+ # a process the server left behind may still hold it open; it is replaced in one of the next runs
+ Write-Log "failed opt/kik/$($file.Name) : $_"
}
+ }
+}
- # The account database and the ssh keys and config the SYSTEM server reads: the user may read passwd (programs
- # map ids through it) but change none of it, and has no business in /etc/ssh at all.
- $rootShell = '/opt/kik/zsh.exe'
- $pw = "$msys\etc\passwd"
- $lines = Get-Content $pw
- if ($lines | Where-Object { $_ -match '^root:' -and $_ -notmatch ':/opt/kik/zsh\.exe$' }) {
- ($lines | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ":$rootShell" } else { $_ } }) | Set-Content $pw -Encoding ascii
- Write-Log 'set the root login shell to the protected zsh'
- }
- Set-LockedAcl $pw $false 'Read'
- if (Test-Path "$msys\etc\ssh") { Set-LockedAcl "$msys\etc\ssh" $true 'None' }
- } catch {
- Write-Log "the protected login path is not fully in place: $_"
+# The permissions do not need the server to be stopped, so they are put back on every run.
+try {
+ if (Test-Path -LiteralPath $binDir) {
+ New-Item -ItemType Directory -Force -Path "$kik\dev\shm", "$kik\dev\mqueue" | Out-Null
+ Set-LockedAcl $kik $true 'ReadAndExecute'
+ # /dev is scratch: the user may write it so POSIX shared memory works, the rest of opt/kik stays read-only
+ if ($sid) { icacls "$kik\dev" /grant "*${sid}:(OI)(CI)M" | Out-Null }
}
+ # The account database and the ssh keys and config the SYSTEM server reads: the user may read passwd (programs
+ # map ids through it) but change none of it, and has no business in /etc/ssh at all.
+ $rootShell = '/opt/kik/zsh.exe'
+ $pw = "$msys\etc\passwd"
+ $lines = Get-Content $pw
+ if ($lines | Where-Object { $_ -match '^root:' -and $_ -notmatch ':/opt/kik/zsh\.exe$' }) {
+ ($lines | ForEach-Object { if ($_ -match '^root:') { $_ -replace ':[^:]*$', ":$rootShell" } else { $_ } }) | Set-Content $pw -Encoding ascii
+ Write-Log 'set the root login shell to the protected zsh'
+ }
+ Set-LockedAcl $pw $false 'Read'
+ if (Test-Path "$msys\etc\ssh") { Set-LockedAcl "$msys\etc\ssh" $true 'None' }
+} catch {
+ Write-Log "the protected login path is not fully in place: $_"
+}
+
+if ($restart) {
if (Start-Sshd) { Write-Log 'restarted the sshd' } else { Write-Log 'the sshd does not listen on port 22' }
}
'@
@@ -507,11 +533,17 @@ Set-Content -LiteralPath $sshdBat -Encoding ascii -Value @(
'C:\msys2\opt\kik\sshd.exe -D -e >> C:\ProgramData\kik-restore\sshd.log 2>&1'
)
-# The event logs of windows are made bigger as well: they are the only record of what the box itself did, and
-# twenty megabytes are not much with all the connections.
-foreach ($eventLog in 'System', 'Application', 'Security') {
+# The event logs of windows are made bigger as well: they are the only record of what the box itself did. The
+# security log gets the most, because the scanners of the remote desktop alone fill a hundred megabytes in a week.
+foreach ($eventLog in 'System', 'Application') {
wevtutil sl $eventLog /ms:104857600 | Out-Null
}
+wevtutil sl Security /ms:1073741824 | Out-Null
+
+# A start of the server that fails leaves no trace in its own log, so windows records what the task of the server
+# launched and how each process ended: the history of the scheduled tasks, and the start and end of every process.
+wevtutil sl Microsoft-Windows-TaskScheduler/Operational /e:true /ms:104857600 | Out-Null
+auditpol /set '/subcategory:{0CCE922B-69AE-11D9-BED3-505054503030},{0CCE922C-69AE-11D9-BED3-505054503030}' /success:enable | Out-Null
# The installation's own cygrunsrv sshd service would fight for port 22; the scheduled task owns the sshd instead.
Set-Service $service -StartupType Disabled -ErrorAction SilentlyContinue