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) <noreply@anthropic.com>
This commit is contained in:
parent
a36da4af6f
commit
83e25907a8
7 changed files with 511 additions and 102 deletions
59
package-lock.json
generated
59
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
<p class="prompt"><b>root@khaos-im-kiez</b>:~# <slot /><span class="cursor"></span></p>
|
||||
<p class="prompt"><b>root@khaos-im-kiez</b>:~# <slot /></p>
|
||||
|
|
|
|||
|
|
@ -1,110 +1,36 @@
|
|||
---
|
||||
import Prompt from './Prompt.astro';
|
||||
---
|
||||
<Prompt><span id="term-input"></span></Prompt>
|
||||
<Prompt><input id="term-input" aria-label="Terminal" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="send"><span class="cursor"></span></Prompt>
|
||||
|
||||
<script>
|
||||
const term = document.getElementById('term-input');
|
||||
let buf = '';
|
||||
let ws = null;
|
||||
let connected = false;
|
||||
let firstEnter = true;
|
||||
// A simple text input for the first command. The real terminal is only loaded once that command is sent.
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
|
||||
function appendOutput(text){
|
||||
if(!text) return;
|
||||
// append to term display (simple)
|
||||
term.textContent += text;
|
||||
}
|
||||
const input = document.getElementById('term-input') as HTMLInputElement;
|
||||
const prompt = input.parentElement!;
|
||||
let terminal: Terminal | undefined;
|
||||
|
||||
document.addEventListener('keydown', function(e){
|
||||
const t = e.target;
|
||||
if(t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable)) return;
|
||||
if(e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
// Typing anywhere on the page goes to the prompt, later to the terminal. Shortcuts, Space (scrolling) and focused links are left alone.
|
||||
addEventListener('keydown', (e) => {
|
||||
if (e.target !== document.body || e.key.length !== 1 || e.key === ' ' || e.metaKey || e.ctrlKey !== e.altKey) return;
|
||||
if (!terminal) return input.focus(); // the key itself then lands in the input
|
||||
e.preventDefault();
|
||||
terminal.focus();
|
||||
terminal.input(e.key);
|
||||
});
|
||||
prompt.addEventListener('click', () => input.focus());
|
||||
input.addEventListener('input', () => (input.style.width = `${input.value.length}ch`));
|
||||
|
||||
if(e.key === 'Backspace'){
|
||||
e.preventDefault();
|
||||
if(connected && ws && ws.readyState === WebSocket.OPEN){
|
||||
ws.send('\b');
|
||||
return;
|
||||
}
|
||||
buf = buf.slice(0, -1);
|
||||
term.textContent = buf;
|
||||
return;
|
||||
}
|
||||
if(e.key === 'Enter'){
|
||||
e.preventDefault();
|
||||
if(firstEnter && !connected){
|
||||
// open ssh over websocket connection with username/password auth
|
||||
// keep connection open; run command after connection
|
||||
const line = buf;
|
||||
buf = '';
|
||||
firstEnter = false;
|
||||
term.textContent = term.textContent + '\n';
|
||||
// try to connect - endpoint can be configured
|
||||
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + '/ssh';
|
||||
try {
|
||||
ws = new WebSocket(wsUrl);
|
||||
ws.onopen = function(){
|
||||
connected = true;
|
||||
// send auth info and/or initial command
|
||||
try {
|
||||
ws.send(JSON.stringify({type:'auth', line: line}));
|
||||
} catch(err) {
|
||||
ws.send(line + '\n');
|
||||
}
|
||||
ws.send('\n'); // run command/start shell
|
||||
};
|
||||
ws.onmessage = function(ev){
|
||||
appendOutput(ev.data);
|
||||
};
|
||||
ws.onclose = function(){
|
||||
connected = false;
|
||||
};
|
||||
ws.onerror = function(){
|
||||
connected = false;
|
||||
};
|
||||
} catch(err) {
|
||||
// fallback
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(connected && ws && ws.readyState === WebSocket.OPEN){
|
||||
ws.send('\r');
|
||||
term.textContent = term.textContent + '\n';
|
||||
return;
|
||||
}
|
||||
buf += '\n';
|
||||
term.textContent = buf;
|
||||
return;
|
||||
}
|
||||
if(e.key === 'Escape'){
|
||||
e.preventDefault();
|
||||
if(connected && ws && ws.readyState === WebSocket.OPEN){
|
||||
ws.send('\x1b');
|
||||
return;
|
||||
}
|
||||
buf = '';
|
||||
term.textContent = buf;
|
||||
return;
|
||||
}
|
||||
if(e.key === 'Tab'){
|
||||
e.preventDefault();
|
||||
if(connected && ws && ws.readyState === WebSocket.OPEN){
|
||||
ws.send('\t');
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(e.key.length === 1){
|
||||
e.preventDefault();
|
||||
if(connected && ws && ws.readyState === WebSocket.OPEN){
|
||||
ws.send(e.key);
|
||||
// don't echo locally if server echoes; but keep simple
|
||||
return;
|
||||
}
|
||||
buf += e.key;
|
||||
term.textContent = buf;
|
||||
return;
|
||||
}
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' || e.isComposing) return;
|
||||
e.preventDefault();
|
||||
const command = input.value;
|
||||
input.replaceWith(command);
|
||||
prompt.querySelector('.cursor')!.remove(); // the terminal's cursor continues on the next line
|
||||
import('../scripts/terminal')
|
||||
.then(({ start }) => start(prompt, command))
|
||||
.then((started) => (terminal = started))
|
||||
.catch((error) => prompt.append(` [${error.message}]`));
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
276
src/scripts/ssh.ts
Normal file
276
src/scripts/ssh.ts
Normal file
|
|
@ -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<typeof pack>) => 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<void>((resolve) => (wake = resolve));
|
||||
}
|
||||
const bytes = buffer.subarray(0, n);
|
||||
buffer = buffer.subarray(n);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
let tx: ReturnType<typeof chachaPoly> | undefined;
|
||||
let rx: ReturnType<typeof chachaPoly> | 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<Uint8Array> {
|
||||
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));
|
||||
},
|
||||
};
|
||||
}
|
||||
129
src/scripts/terminal.ts
Normal file
129
src/scripts/terminal.ts
Normal file
|
|
@ -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<typeof setTimeout>;
|
||||
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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue