Pure `nix fmt` output from the commit before this one — no hand edits. 203 files: 52 md, 42 tsx, 32 js, 32 css, 21 ts, 13 html, 8 json, 3 mjs. Reproduce with `nix develop -c nix fmt` on the parent commit; the result should be byte-identical to this tree. None of the 13 `.prettierignore` entries appears here — verified by intersecting the changed-file list against the ignore file, with a control proving the intersection finds a match when one exists.
1000 lines
38 KiB
HTML
1000 lines
38 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>screen</title>
|
||
<link rel="icon" type="image/svg+xml" href="icon" />
|
||
<link rel="stylesheet" href="static/colors.css" />
|
||
<link rel="stylesheet" href="static/theme.css" />
|
||
<link rel="stylesheet" href="static/agent.css" />
|
||
</head>
|
||
<body class="screen-shell">
|
||
<header class="page-header">
|
||
<a class="page-back" href="./" title="back to agent page">← agent</a>
|
||
<span class="page-title">🖥 screen</span>
|
||
<button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">
|
||
⤢ fit
|
||
</button>
|
||
<button
|
||
id="match-toggle"
|
||
class="tbtn"
|
||
title="Resize the remote desktop to fit this window"
|
||
disabled
|
||
>
|
||
⤡ match size
|
||
</button>
|
||
<button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">
|
||
debug
|
||
</button>
|
||
<span id="status">connecting…</span>
|
||
</header>
|
||
<div id="canvas-wrap"><canvas id="c"></canvas></div>
|
||
<div id="msg"></div>
|
||
<div id="debug-log"></div>
|
||
|
||
<script>
|
||
// Minimal RFB-over-WebSocket renderer. Connects to `screen/ws` on
|
||
// the same host; the harness relays raw RFB bytes to the VNC server
|
||
// running inside the container. See docs/web-ui/agent.md (GET /screen)
|
||
// for the design rationale + canvas-sizing implementation notes.
|
||
|
||
(function () {
|
||
"use strict";
|
||
|
||
const canvas = document.getElementById("c");
|
||
const ctx = canvas.getContext("2d");
|
||
const status = document.getElementById("status");
|
||
const msg = document.getElementById("msg");
|
||
const debugLog = document.getElementById("debug-log");
|
||
const debugBtn = document.getElementById("debug-toggle");
|
||
const fitBtn = document.getElementById("fit-toggle");
|
||
const matchBtn = document.getElementById("match-toggle");
|
||
const canvasWrap = document.getElementById("canvas-wrap");
|
||
|
||
// --- Debug log ---
|
||
let debugVisible = false;
|
||
debugBtn.addEventListener("click", () => {
|
||
debugVisible = !debugVisible;
|
||
debugLog.style.display = debugVisible ? "block" : "none";
|
||
debugBtn.classList.toggle("active", debugVisible);
|
||
});
|
||
|
||
// --- Fit-to-window toggle ---
|
||
// See docs/web-ui.md::Per-agent endpoints (GET /screen) for the
|
||
// localStorage persistence + canvas-intrinsic-resolution +
|
||
// pointer-rescale model.
|
||
let fitMode = localStorage.getItem("screen-fit") !== "off";
|
||
// Scale down (never up) to the wrap, preserving aspect ratio.
|
||
// Explicit px sizing — see GET /screen docs for the flex-item
|
||
// min-width:auto clamp this avoids.
|
||
function relayoutCanvas() {
|
||
if (
|
||
fitMode &&
|
||
canvas.width &&
|
||
canvas.height &&
|
||
canvasWrap.clientWidth &&
|
||
canvasWrap.clientHeight
|
||
) {
|
||
const scale = Math.min(
|
||
canvasWrap.clientWidth / canvas.width,
|
||
canvasWrap.clientHeight / canvas.height,
|
||
1,
|
||
);
|
||
canvas.style.width = canvas.width * scale + "px";
|
||
canvas.style.height = canvas.height * scale + "px";
|
||
} else if (!fitMode) {
|
||
canvas.style.width = "";
|
||
canvas.style.height = "";
|
||
}
|
||
}
|
||
function applyFitMode() {
|
||
canvasWrap.classList.toggle("fit", fitMode);
|
||
fitBtn.classList.toggle("active", fitMode);
|
||
relayoutCanvas();
|
||
}
|
||
fitBtn.addEventListener("click", () => {
|
||
fitMode = !fitMode;
|
||
localStorage.setItem("screen-fit", fitMode ? "on" : "off");
|
||
applyFitMode();
|
||
});
|
||
window.addEventListener("resize", relayoutCanvas);
|
||
applyFitMode();
|
||
|
||
// --- Match-size: resize the remote desktop to this window ---
|
||
// Sends an RFB SetDesktopSize request so the VNC server (weston)
|
||
// changes its actual output resolution to match the browser
|
||
// viewport — sharper than fit-mode's CSS downscale. Gated on the
|
||
// server's ExtendedDesktopSize advert; see docs/web-ui.md::Per-agent
|
||
// endpoints (GET /screen).
|
||
let extDesktopSupported = false;
|
||
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
|
||
matchBtn.addEventListener("click", () => {
|
||
if (!extDesktopSupported) return;
|
||
// Even dimensions — some servers reject odd ones.
|
||
const w = Math.max(2, canvasWrap.clientWidth & ~1);
|
||
const h = Math.max(2, canvasWrap.clientHeight & ~1);
|
||
dbg("→ request desktop resize to " + w + "x" + h, "send");
|
||
sendSetDesktopSize(w, h);
|
||
});
|
||
|
||
function hex(bytes) {
|
||
return Array.from(bytes)
|
||
.map((b) => b.toString(16).padStart(2, "0"))
|
||
.join(" ");
|
||
}
|
||
|
||
function dbg(text, cls) {
|
||
console.log("[rfb]", text);
|
||
const line = document.createElement("div");
|
||
line.className = "dbg-line" + (cls ? " " + cls : "");
|
||
line.textContent = text;
|
||
debugLog.appendChild(line);
|
||
debugLog.scrollTop = debugLog.scrollHeight;
|
||
}
|
||
|
||
function setStatus(text, cls) {
|
||
status.textContent = text;
|
||
status.className = cls || "";
|
||
if (cls === "error") dbg("ERROR: " + text, "err");
|
||
}
|
||
|
||
function flash(text) {
|
||
msg.textContent = text;
|
||
msg.style.opacity = "1";
|
||
setTimeout(() => {
|
||
msg.style.opacity = "0";
|
||
}, 2500);
|
||
}
|
||
|
||
// --- Agent username (for Apple-DH auth) ---
|
||
// weston runs the VNC backend as the agent's own non-root unix user
|
||
// (weston-vnc.nix: `User = userName`), and neatvnc's Apple-DH path
|
||
// does getpwnam(username) + requires pw_uid == weston's uid before PAM
|
||
// is ever consulted — so the auth username MUST be that agent's name,
|
||
// not a hardcoded "root". `/api/state` exposes it as `label` (same
|
||
// path-relative `document.baseURI` trick as the WS below, so it works
|
||
// under the nginx /agent/<name>/ prefix too). Kicked off here so it
|
||
// resolves concurrently with the RFB handshake; the Apple-DH step
|
||
// awaits it.
|
||
const agentLabelPromise = fetch(new URL("api/state", document.baseURI))
|
||
.then((r) => (r.ok ? r.json() : null))
|
||
.then((s) => (s && s.label) || null)
|
||
.catch(() => null);
|
||
|
||
// --- WebSocket connection ---
|
||
// Path-relative URL — `document.baseURI` resolves against the
|
||
// page's URL so the agent page mounted under a nginx prefix
|
||
// (e.g. /agent/<name>/screen) still hits the right upstream.
|
||
// Swap protocol on top gives ws(s)://host/<prefix>/screen/ws.
|
||
const proto = location.protocol === "https:" ? "wss" : "ws";
|
||
const wsUrl = new URL("screen/ws", document.baseURI);
|
||
wsUrl.protocol = proto + ":";
|
||
const ws = new WebSocket(wsUrl.toString());
|
||
ws.binaryType = "arraybuffer";
|
||
|
||
ws.onopen = () => {
|
||
dbg("WebSocket open — starting RFB handshake", "ok");
|
||
setStatus("handshaking…");
|
||
};
|
||
ws.onerror = () => setStatus("connection error", "error");
|
||
ws.onclose = (e) => {
|
||
setStatus(`disconnected (${e.code})`, "error");
|
||
flash("VNC disconnected — reload to reconnect");
|
||
};
|
||
|
||
// Accumulate received bytes in a simple ring queue
|
||
const chunks = [];
|
||
let totalBytes = 0;
|
||
|
||
ws.onmessage = (ev) => {
|
||
chunks.push(new Uint8Array(ev.data));
|
||
totalBytes += ev.data.byteLength;
|
||
processRfb();
|
||
};
|
||
|
||
// --- Minimal RFB state machine ---
|
||
// We implement just enough to handshake and receive FramebufferUpdate
|
||
// rectangles encoded as Raw (encoding 0). Other encodings are skipped.
|
||
// Keyboard and pointer events are forwarded.
|
||
|
||
let state = "version";
|
||
let fbW = 0,
|
||
fbH = 0;
|
||
let pixelFormat = null; // set after ServerInit
|
||
let updateRects = 0;
|
||
// ExtendedDesktopSize pseudo-encoding (-308), as the unsigned 32-bit
|
||
// value the rect-header encoding field is read as.
|
||
const EXT_DESKTOP_SIZE_U32 = -308 >>> 0;
|
||
|
||
// Drain bytes from the queue into a flat buffer view
|
||
function drainTo(n) {
|
||
if (totalBytes < n) return null;
|
||
const out = new Uint8Array(n);
|
||
let off = 0;
|
||
while (off < n) {
|
||
const c = chunks[0];
|
||
const take = Math.min(c.length, n - off);
|
||
out.set(c.subarray(0, take), off);
|
||
off += take;
|
||
if (take === c.length) {
|
||
chunks.shift();
|
||
} else {
|
||
chunks[0] = c.subarray(take);
|
||
}
|
||
}
|
||
totalBytes -= n;
|
||
return out;
|
||
}
|
||
|
||
function send(data) {
|
||
if (ws.readyState === WebSocket.OPEN) {
|
||
const arr =
|
||
data instanceof Uint8Array ? data : new Uint8Array(data);
|
||
dbg(
|
||
"→ send [" +
|
||
arr.length +
|
||
"b]: " +
|
||
hex(arr.slice(0, 32)) +
|
||
(arr.length > 32 ? "…" : ""),
|
||
"send",
|
||
);
|
||
ws.send(data);
|
||
}
|
||
}
|
||
|
||
function u32be(b, o) {
|
||
return (
|
||
((b[o] << 24) | (b[o + 1] << 16) | (b[o + 2] << 8) | b[o + 3]) >>> 0
|
||
);
|
||
}
|
||
function u16be(b, o) {
|
||
return ((b[o] << 8) | b[o + 1]) >>> 0;
|
||
}
|
||
|
||
// ── Apple-DH (security type 30) helpers ─────────────────────────────────
|
||
// Protocol (from neatvnc apple-dh.c):
|
||
// Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size]
|
||
// Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128])
|
||
// After SecurityResult=0: normal plaintext VNC (no session encryption)
|
||
//
|
||
// BigInt mod-pow — handles 2048-bit DH arithmetic.
|
||
function modpow(base, exp, mod) {
|
||
let r = 1n;
|
||
base = base % mod;
|
||
while (exp > 0n) {
|
||
if (exp & 1n) r = (r * base) % mod;
|
||
exp >>= 1n;
|
||
base = (base * base) % mod;
|
||
}
|
||
return r;
|
||
}
|
||
function bytesToBigInt(b) {
|
||
let n = 0n;
|
||
for (const byte of b) n = (n << 8n) | BigInt(byte);
|
||
return n;
|
||
}
|
||
function bigIntToBytes(n, len) {
|
||
const out = new Uint8Array(len);
|
||
for (let i = len - 1; i >= 0; i--) {
|
||
out[i] = Number(n & 0xffn);
|
||
n >>= 8n;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Compact MD5 — needed because Web Crypto doesn't expose MD5.
|
||
// Based on the RFC 1321 reference implementation, minified.
|
||
function md5(data) {
|
||
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
|
||
const len = b.length;
|
||
// pad message
|
||
const padLen = (((len + 8) >>> 6) << 4) + 16;
|
||
const m = new Uint32Array(padLen);
|
||
for (let i = 0; i < len; i++) m[i >> 2] |= b[i] << ((i & 3) * 8);
|
||
m[len >> 2] |= 0x80 << ((len & 3) * 8);
|
||
m[padLen - 2] = len * 8;
|
||
const T = new Int32Array(64);
|
||
for (let i = 0; i < 64; i++)
|
||
T[i] = (Math.abs(Math.sin(i + 1)) * 0x100000000) | 0;
|
||
let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
|
||
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
|
||
function add(x, y) {
|
||
return (x + y) | 0;
|
||
}
|
||
function r(v, s) {
|
||
return (v << s) | (v >>> (32 - s));
|
||
}
|
||
for (let i = 0; i < padLen; i += 16) {
|
||
let [aa, bb, cc, dd] = [a, b2, c, d];
|
||
for (let j = 0; j < 64; j++) {
|
||
let [f, g] =
|
||
j < 16
|
||
? [(bb & cc) | (~bb & dd), j]
|
||
: j < 32
|
||
? [(dd & bb) | (~dd & cc), (5 * j + 1) % 16]
|
||
: j < 48
|
||
? [bb ^ cc ^ dd, (3 * j + 5) % 16]
|
||
: [cc ^ (bb | ~dd), (7 * j) % 16];
|
||
f = add(add(aa, f), add(m[i + g], T[j]));
|
||
// Rotation amount: round = j>>4 (changes every 16 steps),
|
||
// position-in-round = j%4. S is laid out as 4 rounds × 4.
|
||
[aa, dd, cc, bb] = [
|
||
dd,
|
||
cc,
|
||
bb,
|
||
add(bb, r(f, S[(j % 4) + (j >> 4) * 4])),
|
||
];
|
||
}
|
||
[a, b2, c, d] = [add(a, aa), add(b2, bb), add(c, cc), add(d, dd)];
|
||
}
|
||
const out = new Uint8Array(16);
|
||
[a, b2, c, d].forEach((x, i) => {
|
||
out[i * 4] = x & 0xff;
|
||
out[i * 4 + 1] = (x >> 8) & 0xff;
|
||
out[i * 4 + 2] = (x >> 16) & 0xff;
|
||
out[i * 4 + 3] = (x >> 24) & 0xff;
|
||
});
|
||
return out;
|
||
}
|
||
|
||
// AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB).
|
||
async function aes128ecb(key16, data128) {
|
||
const keyObj = await crypto.subtle.importKey(
|
||
"raw",
|
||
key16,
|
||
{ name: "AES-CBC" },
|
||
false,
|
||
["encrypt"],
|
||
);
|
||
const out = new Uint8Array(128);
|
||
const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks
|
||
for (let i = 0; i < 8; i++) {
|
||
const enc = await crypto.subtle.encrypt(
|
||
{ name: "AES-CBC", iv },
|
||
keyObj,
|
||
data128.slice(i * 16, (i + 1) * 16),
|
||
);
|
||
out.set(new Uint8Array(enc, 0, 16), i * 16);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// Apple-DH state — stored between async continuations.
|
||
let appleDhState = null;
|
||
// ────────────────────────────────────────────────────────────────────────
|
||
|
||
function processRfb() {
|
||
while (true) {
|
||
if (!tryStep()) break;
|
||
}
|
||
}
|
||
|
||
function tryStep() {
|
||
switch (state) {
|
||
case "version": {
|
||
const b = drainTo(12);
|
||
if (!b) return false;
|
||
dbg(
|
||
"← server version: " +
|
||
new TextDecoder().decode(b).replace("\n", "\\n"),
|
||
);
|
||
// Send back same version (RFB 003.008)
|
||
send(new TextEncoder().encode("RFB 003.008\n"));
|
||
state = "security-types";
|
||
return true;
|
||
}
|
||
case "security-types": {
|
||
const b = drainTo(1);
|
||
if (!b) return false;
|
||
const n = b[0];
|
||
dbg(
|
||
"← security-types: count=" +
|
||
n +
|
||
(n === 0 ? " (server error!)" : ""),
|
||
);
|
||
if (n === 0) {
|
||
setStatus("server sent 0 security types", "error");
|
||
return false;
|
||
}
|
||
const types = drainTo(n);
|
||
if (!types) {
|
||
chunks.unshift(b);
|
||
totalBytes += 1;
|
||
return false;
|
||
}
|
||
dbg(
|
||
"← security-types offered: [" +
|
||
Array.from(types).join(", ") +
|
||
"]",
|
||
);
|
||
// Prefer type 1 (None), then type 19 (VeNCrypt — used by neatvnc/weston
|
||
// even with --disable-transport-layer-security), else first offered.
|
||
let prefer;
|
||
if (types.indexOf(1) !== -1)
|
||
prefer = 1; // plain None
|
||
else if (types.indexOf(19) !== -1)
|
||
prefer = 19; // VeNCrypt
|
||
else prefer = types[0];
|
||
// Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH)
|
||
if (types.indexOf(1) !== -1) prefer = 1;
|
||
else if (types.indexOf(19) !== -1) prefer = 19;
|
||
else if (types.indexOf(30) !== -1) prefer = 30;
|
||
else {
|
||
dbg(
|
||
"no supported type in [" +
|
||
Array.from(types).join(", ") +
|
||
"] — need 1, 19, or 30",
|
||
"err",
|
||
);
|
||
setStatus(
|
||
"unsupported security types: [" +
|
||
Array.from(types).join(", ") +
|
||
"]",
|
||
"error",
|
||
);
|
||
ws.close();
|
||
return false;
|
||
}
|
||
dbg(
|
||
"→ choosing security type " +
|
||
prefer +
|
||
(prefer === 1
|
||
? " (None)"
|
||
: prefer === 19
|
||
? " (VeNCrypt)"
|
||
: " (Apple-DH)"),
|
||
);
|
||
send(new Uint8Array([prefer]));
|
||
if (prefer === 1) state = "security-result";
|
||
else if (prefer === 19) state = "vencrypt-version";
|
||
else state = "apple-dh-params";
|
||
return true;
|
||
}
|
||
case "security-vnc-challenge": {
|
||
// VNC auth (type 2): we don't have the password, so send zeros.
|
||
// This will fail for password-protected servers; fine for our
|
||
// weston VNC which uses None via VeNCrypt.
|
||
const b = drainTo(16);
|
||
if (!b) return false;
|
||
dbg("← vnc-challenge (16 bytes): " + hex(b));
|
||
send(new Uint8Array(16));
|
||
state = "security-result";
|
||
return true;
|
||
}
|
||
// ── VeNCrypt (type 19) sub-handshake ───────────────────────────────
|
||
// neatvnc (weston VNC backend) uses VeNCrypt as the outer type even
|
||
// with --disable-transport-layer-security, offering sub-type 1 (None).
|
||
case "vencrypt-version": {
|
||
// Server sends: major (u8), minor (u8) — e.g. 0, 2
|
||
const b = drainTo(2);
|
||
if (!b) return false;
|
||
dbg("← VeNCrypt version: " + b[0] + "." + b[1]);
|
||
// Echo same version back
|
||
send(new Uint8Array([b[0], b[1]]));
|
||
state = "vencrypt-subtypes";
|
||
return true;
|
||
}
|
||
case "vencrypt-subtypes": {
|
||
// Server sends: nSubtypes (u8), then nSubtypes × u32 sub-type ids
|
||
const nb = drainTo(1);
|
||
if (!nb) return false;
|
||
const nSub = nb[0];
|
||
dbg("← VeNCrypt nSubtypes=" + nSub);
|
||
const raw = drainTo(nSub * 4);
|
||
if (!raw) {
|
||
chunks.unshift(nb);
|
||
totalBytes += 1;
|
||
return false;
|
||
}
|
||
// Build sub-type array from big-endian u32s
|
||
const subs = [];
|
||
for (let i = 0; i < nSub; i++) subs.push(u32be(raw, i * 4));
|
||
dbg("← VeNCrypt sub-types: [" + subs.join(", ") + "]");
|
||
// Prefer sub-type 1 (VeNCrypt None) — no TLS, no password.
|
||
// Fall back to first offered.
|
||
const sub = subs.includes(1) ? 1 : subs[0];
|
||
dbg("→ choosing VeNCrypt sub-type " + sub);
|
||
// Send chosen sub-type as big-endian u32
|
||
send(
|
||
new Uint8Array([
|
||
sub >>> 24,
|
||
(sub >>> 16) & 0xff,
|
||
(sub >>> 8) & 0xff,
|
||
sub & 0xff,
|
||
]),
|
||
);
|
||
state = "vencrypt-accept";
|
||
return true;
|
||
}
|
||
case "vencrypt-accept": {
|
||
// Server sends 1 byte: 1=accepted, 0=refused
|
||
const b = drainTo(1);
|
||
if (!b) return false;
|
||
dbg(
|
||
"← VeNCrypt accept byte: " +
|
||
b[0] +
|
||
(b[0] === 1 ? " (ok)" : " (REFUSED)"),
|
||
);
|
||
if (b[0] !== 1) {
|
||
setStatus("VeNCrypt sub-type refused", "error");
|
||
return false;
|
||
}
|
||
// Sub-type 1 (None): proceed to SecurityResult
|
||
state = "security-result";
|
||
return true;
|
||
}
|
||
// ── Apple-DH (type 30) ────────────────────────────────────────────
|
||
// Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] +
|
||
// server_pub[key_size]
|
||
// Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128])
|
||
// No session encryption after auth — plain RFB follows.
|
||
case "apple-dh-params": {
|
||
const hdr = drainTo(4);
|
||
if (!hdr) return false;
|
||
const generator = u16be(hdr, 0);
|
||
const keySize = u16be(hdr, 2);
|
||
dbg(
|
||
"← Apple-DH: generator=" + generator + " key_size=" + keySize,
|
||
);
|
||
const rest = drainTo(keySize * 2);
|
||
if (!rest) {
|
||
chunks.unshift(hdr);
|
||
totalBytes += 4;
|
||
return false;
|
||
}
|
||
const prime = rest.slice(0, keySize);
|
||
const serverPub = rest.slice(keySize);
|
||
dbg(
|
||
"← Apple-DH prime[0:4]=" +
|
||
hex(prime.slice(0, 4)) +
|
||
" server_pub[0:4]=" +
|
||
hex(serverPub.slice(0, 4)),
|
||
);
|
||
|
||
// Async DH computation — pause state machine, resume when done.
|
||
appleDhState = { generator, keySize, prime, serverPub };
|
||
state = "apple-dh-wait";
|
||
(async () => {
|
||
try {
|
||
const p = bytesToBigInt(appleDhState.prime);
|
||
const g = BigInt(appleDhState.generator);
|
||
const ks = appleDhState.keySize;
|
||
|
||
// Generate client private key: random ks bytes, then mod p
|
||
const privBytes = crypto.getRandomValues(new Uint8Array(ks));
|
||
const priv = bytesToBigInt(privBytes) % p;
|
||
|
||
// Client public key = g ^ priv mod p
|
||
const clientPub = modpow(g, priv, p);
|
||
const clientPubBytes = bigIntToBytes(clientPub, ks);
|
||
|
||
// Shared secret = server_pub ^ priv mod p
|
||
const serverPubInt = bytesToBigInt(appleDhState.serverPub);
|
||
const shared = modpow(serverPubInt, priv, p);
|
||
const sharedBytes = bigIntToBytes(shared, ks);
|
||
|
||
// AES key = MD5(shared_secret)
|
||
const aesKey = md5(sharedBytes);
|
||
dbg("Apple-DH: shared MD5 key=" + hex(aesKey));
|
||
|
||
// Credentials: 64 bytes username + 64 bytes password.
|
||
// weston's vnc_handle_auth (libweston/backend-vnc/vnc.c) does
|
||
// getpwnam(username) and requires pw_uid == weston's own uid
|
||
// BEFORE PAM is ever consulted — an empty/garbage username is
|
||
// rejected outright. Our weston runs as the agent's own
|
||
// non-root user (weston-vnc.nix forbids root), so the username
|
||
// must be that agent's name — resolved from `api/state`
|
||
// above. The password stays empty; pam_permit.so on the
|
||
// weston-remote-access PAM service accepts it.
|
||
const username = (await agentLabelPromise) || "root";
|
||
dbg(
|
||
"Apple-DH: auth username=" +
|
||
username +
|
||
(username === "root"
|
||
? " (label unresolved — falling back)"
|
||
: ""),
|
||
);
|
||
const creds = new Uint8Array(128);
|
||
creds.set(new TextEncoder().encode(username), 0);
|
||
const encCreds = await aes128ecb(aesKey, creds);
|
||
|
||
// Send: encrypted_creds + client_pub
|
||
// neatvnc struct rfb_apple_dh_client_msg has encrypted_credentials
|
||
// at offset 0 and public_key at offset 128 (flexible array after).
|
||
const response = new Uint8Array(ks + 128);
|
||
response.set(encCreds, 0);
|
||
response.set(clientPubBytes, 128);
|
||
send(response);
|
||
dbg(
|
||
"→ Apple-DH response sent (" + response.length + " bytes)",
|
||
"ok",
|
||
);
|
||
|
||
state = "security-result";
|
||
processRfb(); // resume state machine
|
||
} catch (e) {
|
||
setStatus("Apple-DH error: " + e.message, "error");
|
||
}
|
||
})();
|
||
return false; // pause — async resumes
|
||
}
|
||
case "apple-dh-wait":
|
||
// Async handshake in progress — don't consume bytes
|
||
return false;
|
||
// ──────────────────────────────────────────────────────────────────
|
||
case "security-result": {
|
||
const b = drainTo(4);
|
||
if (!b) return false;
|
||
const code = u32be(b, 0);
|
||
dbg(
|
||
"← security-result: " +
|
||
code +
|
||
" (bytes: " +
|
||
hex(b) +
|
||
")" +
|
||
(code === 0 ? " ✓" : " FAIL"),
|
||
code === 0 ? "ok" : "err",
|
||
);
|
||
if (code !== 0) {
|
||
setStatus("auth failed (code " + code + ")", "error");
|
||
return false;
|
||
}
|
||
// ClientInit: shared flag = 1
|
||
send(new Uint8Array([1]));
|
||
state = "server-init";
|
||
return true;
|
||
}
|
||
case "server-init": {
|
||
const b = drainTo(24);
|
||
if (!b) return false;
|
||
// RFB ServerInit: width @ bytes 0-1, height @ bytes 2-3.
|
||
fbW = u16be(b, 0);
|
||
fbH = u16be(b, 2);
|
||
// pixel format: bpp=b[4], depth=b[5], big-endian=b[6], true-colour=b[7]
|
||
// red/green/blue max/shift at b[8..17]
|
||
pixelFormat = {
|
||
bpp: b[4],
|
||
depth: b[5],
|
||
bigEndian: b[6],
|
||
trueColour: b[7],
|
||
redMax: u16be(b, 8),
|
||
greenMax: u16be(b, 10),
|
||
blueMax: u16be(b, 12),
|
||
redShift: b[14],
|
||
greenShift: b[15],
|
||
blueShift: b[16],
|
||
bytesPerPixel: b[4] / 8,
|
||
};
|
||
const nameLen = u32be(b, 20);
|
||
const nameBytes = drainTo(nameLen);
|
||
if (!nameBytes) {
|
||
chunks.unshift(b);
|
||
totalBytes += 24;
|
||
return false;
|
||
}
|
||
dbg(
|
||
"← server-init: " + fbW + "x" + fbH + " bpp=" + pixelFormat.bpp,
|
||
"ok",
|
||
);
|
||
canvas.width = fbW;
|
||
canvas.height = fbH;
|
||
relayoutCanvas();
|
||
setStatus("connected", "connected");
|
||
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so
|
||
// the server reports (and accepts) desktop-size changes.
|
||
sendSetEncodings([0, -308]);
|
||
// Request full framebuffer update
|
||
requestUpdate(0, 0, 0, fbW, fbH);
|
||
state = "normal";
|
||
return true;
|
||
}
|
||
case "normal": {
|
||
const b = drainTo(1);
|
||
if (!b) return false;
|
||
const msgType = b[0];
|
||
if (msgType === 0) {
|
||
// FramebufferUpdate: type(1) + padding(1) + nRects(2). The type
|
||
// byte is already consumed above; hdr covers padding + nRects.
|
||
const hdr = drainTo(3);
|
||
if (!hdr) {
|
||
chunks.unshift(b);
|
||
totalBytes += 1;
|
||
return false;
|
||
}
|
||
updateRects = u16be(hdr, 1);
|
||
state = "rect-header";
|
||
} else if (msgType === 2) {
|
||
// Bell: ignore
|
||
} else if (msgType === 3) {
|
||
// ServerCutText
|
||
const hdr = drainTo(7);
|
||
if (!hdr) {
|
||
chunks.unshift(b);
|
||
totalBytes += 1;
|
||
return false;
|
||
}
|
||
const len = u32be(hdr, 3);
|
||
const text = drainTo(len);
|
||
if (!text) {
|
||
chunks.unshift(b);
|
||
totalBytes += 1 + 7;
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
case "rect-header": {
|
||
if (updateRects === 0) {
|
||
state = "normal";
|
||
requestUpdate(1, 0, 0, fbW, fbH);
|
||
return true;
|
||
}
|
||
const b = drainTo(12);
|
||
if (!b) return false;
|
||
const x = u16be(b, 0),
|
||
y = u16be(b, 2),
|
||
w = u16be(b, 4),
|
||
h = u16be(b, 6);
|
||
const enc =
|
||
((b[8] << 24) | (b[9] << 16) | (b[10] << 8) | b[11]) >>> 0;
|
||
if (enc === 0 && pixelFormat) {
|
||
const bytes = w * h * pixelFormat.bytesPerPixel;
|
||
const pixels = drainTo(bytes);
|
||
if (!pixels) {
|
||
chunks.unshift(b);
|
||
totalBytes += 12;
|
||
return false;
|
||
}
|
||
drawRaw(x, y, w, h, pixels);
|
||
} else if (enc === EXT_DESKTOP_SIZE_U32) {
|
||
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
|
||
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
|
||
// header's x = change reason, y = request status.
|
||
const nScreens = peekByte();
|
||
if (nScreens < 0) {
|
||
chunks.unshift(b);
|
||
totalBytes += 12;
|
||
return false;
|
||
}
|
||
const body = drainTo(4 + nScreens * 16);
|
||
if (!body) {
|
||
chunks.unshift(b);
|
||
totalBytes += 12;
|
||
return false;
|
||
}
|
||
if (nScreens > 0) screenId = u32be(body, 4); // reuse the server's screen id
|
||
if (!extDesktopSupported) {
|
||
extDesktopSupported = true;
|
||
matchBtn.disabled = false;
|
||
}
|
||
if (w && h && (w !== fbW || h !== fbH)) {
|
||
dbg(
|
||
"← desktop resized to " +
|
||
w +
|
||
"x" +
|
||
h +
|
||
" (reason " +
|
||
x +
|
||
", status " +
|
||
y +
|
||
")",
|
||
"ok",
|
||
);
|
||
fbW = w;
|
||
fbH = h;
|
||
canvas.width = w;
|
||
canvas.height = h;
|
||
relayoutCanvas();
|
||
requestUpdate(0, 0, 0, fbW, fbH);
|
||
}
|
||
}
|
||
updateRects--;
|
||
return true;
|
||
}
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function drawRaw(x, y, w, h, data) {
|
||
if (!pixelFormat || w === 0 || h === 0) return;
|
||
const bpp = pixelFormat.bytesPerPixel;
|
||
const img = ctx.createImageData(w, h);
|
||
const d = img.data;
|
||
const rs = pixelFormat.redShift,
|
||
gs = pixelFormat.greenShift,
|
||
bs = pixelFormat.blueShift;
|
||
for (let i = 0, o = 0; i < w * h; i++, o += bpp) {
|
||
let px = 0;
|
||
if (bpp === 4)
|
||
px = pixelFormat.bigEndian
|
||
? ((data[o] << 24) |
|
||
(data[o + 1] << 16) |
|
||
(data[o + 2] << 8) |
|
||
data[o + 3]) >>>
|
||
0
|
||
: ((data[o + 3] << 24) |
|
||
(data[o + 2] << 16) |
|
||
(data[o + 1] << 8) |
|
||
data[o]) >>>
|
||
0;
|
||
else if (bpp === 2)
|
||
px = pixelFormat.bigEndian
|
||
? ((data[o] << 8) | data[o + 1]) >>> 0
|
||
: ((data[o + 1] << 8) | data[o]) >>> 0;
|
||
else px = data[o];
|
||
d[i * 4] = (px >> rs) & pixelFormat.redMax;
|
||
d[i * 4 + 1] = (px >> gs) & pixelFormat.greenMax;
|
||
d[i * 4 + 2] = (px >> bs) & pixelFormat.blueMax;
|
||
d[i * 4 + 3] = 255;
|
||
}
|
||
ctx.putImageData(img, x, y);
|
||
}
|
||
|
||
function requestUpdate(incremental, x, y, w, h) {
|
||
const b = new Uint8Array(10);
|
||
b[0] = 3;
|
||
b[1] = incremental;
|
||
b[2] = x >> 8;
|
||
b[3] = x & 0xff;
|
||
b[4] = y >> 8;
|
||
b[5] = y & 0xff;
|
||
b[6] = w >> 8;
|
||
b[7] = w & 0xff;
|
||
b[8] = h >> 8;
|
||
b[9] = h & 0xff;
|
||
send(b);
|
||
}
|
||
|
||
// SetEncodings (msg type 2): advertise the encodings we understand.
|
||
// Negative values are pseudo-encodings (e.g. -308 ExtendedDesktopSize).
|
||
function sendSetEncodings(encs) {
|
||
const b = new Uint8Array(4 + encs.length * 4);
|
||
b[0] = 2; // message-type
|
||
b[1] = 0; // padding
|
||
b[2] = encs.length >> 8;
|
||
b[3] = encs.length & 0xff;
|
||
let o = 4;
|
||
for (const e of encs) {
|
||
const v = e >>> 0; // two's-complement for negatives
|
||
b[o++] = (v >> 24) & 0xff;
|
||
b[o++] = (v >> 16) & 0xff;
|
||
b[o++] = (v >> 8) & 0xff;
|
||
b[o++] = v & 0xff;
|
||
}
|
||
send(b);
|
||
}
|
||
|
||
// SetDesktopSize (msg type 251): ask the server to change the desktop
|
||
// resolution. One screen at the origin, sized to the request.
|
||
function sendSetDesktopSize(w, h) {
|
||
const b = new Uint8Array(24);
|
||
b[0] = 251;
|
||
b[1] = 0; // message-type + padding
|
||
b[2] = w >> 8;
|
||
b[3] = w & 0xff;
|
||
b[4] = h >> 8;
|
||
b[5] = h & 0xff;
|
||
b[6] = 1;
|
||
b[7] = 0; // number-of-screens + padding
|
||
// screen: id(4) x(2) y(2) width(2) height(2) flags(4)
|
||
b[8] = (screenId >>> 24) & 0xff;
|
||
b[9] = (screenId >>> 16) & 0xff;
|
||
b[10] = (screenId >>> 8) & 0xff;
|
||
b[11] = screenId & 0xff;
|
||
b[12] = 0;
|
||
b[13] = 0; // x-position
|
||
b[14] = 0;
|
||
b[15] = 0; // y-position
|
||
b[16] = w >> 8;
|
||
b[17] = w & 0xff;
|
||
b[18] = h >> 8;
|
||
b[19] = h & 0xff;
|
||
b[20] = 0;
|
||
b[21] = 0;
|
||
b[22] = 0;
|
||
b[23] = 0; // flags
|
||
send(b);
|
||
}
|
||
|
||
// Peek the first unconsumed byte without draining it. -1 when empty.
|
||
function peekByte() {
|
||
for (const c of chunks) {
|
||
if (c.length) return c[0];
|
||
}
|
||
return -1;
|
||
}
|
||
|
||
// --- Input forwarding ---
|
||
canvas.addEventListener("mousemove", sendPointer);
|
||
canvas.addEventListener("mousedown", sendPointer);
|
||
canvas.addEventListener("mouseup", sendPointer);
|
||
|
||
function sendPointer(ev) {
|
||
const r = canvas.getBoundingClientRect();
|
||
// In fit mode the canvas is CSS-scaled, so the rendered rect differs
|
||
// from the intrinsic resolution — map client coords back to fb pixels.
|
||
const sx = r.width ? canvas.width / r.width : 1;
|
||
const sy = r.height ? canvas.height / r.height : 1;
|
||
const x = Math.max(
|
||
0,
|
||
Math.min(fbW - 1, Math.round((ev.clientX - r.left) * sx)),
|
||
);
|
||
const y = Math.max(
|
||
0,
|
||
Math.min(fbH - 1, Math.round((ev.clientY - r.top) * sy)),
|
||
);
|
||
let mask = 0;
|
||
if (ev.buttons & 1) mask |= 1;
|
||
if (ev.buttons & 4) mask |= 2;
|
||
if (ev.buttons & 2) mask |= 4;
|
||
const b = new Uint8Array(6);
|
||
b[0] = 5;
|
||
b[1] = mask;
|
||
b[2] = x >> 8;
|
||
b[3] = x & 0xff;
|
||
b[4] = y >> 8;
|
||
b[5] = y & 0xff;
|
||
send(b);
|
||
}
|
||
|
||
document.addEventListener("keydown", (ev) => sendKey(ev, 1));
|
||
document.addEventListener("keyup", (ev) => sendKey(ev, 0));
|
||
|
||
function sendKey(ev, down) {
|
||
ev.preventDefault();
|
||
const key = rfbKeysym(ev);
|
||
const b = new Uint8Array(8);
|
||
b[0] = 4;
|
||
b[1] = down;
|
||
b[2] = 0;
|
||
b[3] = 0;
|
||
b[4] = key >> 24;
|
||
b[5] = (key >> 16) & 0xff;
|
||
b[6] = (key >> 8) & 0xff;
|
||
b[7] = key & 0xff;
|
||
send(b);
|
||
}
|
||
|
||
function rfbKeysym(ev) {
|
||
// Map common keys to X11 keysym values
|
||
const map = {
|
||
BackSpace: 0xff08,
|
||
Tab: 0xff09,
|
||
Enter: 0xff0d,
|
||
Escape: 0xff1b,
|
||
Delete: 0xffff,
|
||
Home: 0xff50,
|
||
End: 0xff57,
|
||
PageUp: 0xff55,
|
||
PageDown: 0xff56,
|
||
ArrowLeft: 0xff51,
|
||
ArrowUp: 0xff52,
|
||
ArrowRight: 0xff53,
|
||
ArrowDown: 0xff54,
|
||
Shift: 0xffe1,
|
||
Control: 0xffe3,
|
||
Alt: 0xffe9,
|
||
Meta: 0xffe7,
|
||
F1: 0xffbe,
|
||
F2: 0xffbf,
|
||
F3: 0xffc0,
|
||
F4: 0xffc1,
|
||
F5: 0xffc2,
|
||
F6: 0xffc3,
|
||
F7: 0xffc4,
|
||
F8: 0xffc5,
|
||
F9: 0xffc6,
|
||
F10: 0xffc7,
|
||
F11: 0xffc8,
|
||
F12: 0xffc9,
|
||
};
|
||
if (map[ev.key]) return map[ev.key];
|
||
if (ev.key.length === 1) return ev.key.codePointAt(0);
|
||
return 0;
|
||
}
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|