Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

deploy/upcloud/src/vnc.rs

//! **The framebuffer, captured.** A zero-dependency RFB 3.8 client: connect,
//! VNC-authenticate, ask for one Raw full-screen update, write it out as a PNG.
//!
//! [`crate::console`] ends at a *verified `host:port`* and says so in its own
//! header: "Capturing pixels is a job of its own, and it starts here rather
//! than from nothing." This is that job. It was written on 2026-09-13, when the
//! re-imaged appliance answered ICMP and nothing else, and the only place the
//! reason could appear was the screen.
//!
//! # Why no dependency
//!
//! Every RFB crate on the registry brings a decoder stack (zlib, tight, jpeg)
//! for encodings this module *refuses to negotiate*. Asking for **Raw only**
//! makes the wire format a rectangle of pixels, and then the whole client is
//! the handshake plus a copy loop. The two non-obvious pieces are written out
//! below rather than pulled in:
//!
//! * **DES**, for VNC authentication — ~150 lines of FIPS 46-3 tables, with the
//!   published known-answer vector as a unit test. The quirk that defeats most
//!   hand-written clients is in [`vnc_key`]: the key is the password padded to
//!   8 bytes with **every byte's bits reversed**.
//! * **PNG**, with a zlib stream of *stored* (uncompressed) deflate blocks and
//!   a hand-rolled CRC-32. A console screenshot is ~2 MB uncompressed and is
//!   read once by a human; a deflate implementation would be the larger risk.
//!
//! `nornir-hash` is the constellation's SHA-256 and is reused for uploads
//! (see this crate's manifest); it has no CRC-32 or Adler-32, which is why
//! those two are local.
//!
//! # The port dies on every stop/start
//!
//! Same trap as [`crate::console`], and this module does not work around it:
//! point it at an address that has been *verified* to answer `RFB 003.008`,
//! which is exactly what `console::open` returns.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;

// ───────────────────────────── DES ─────────────────────────────
// Straight FIPS 46-3, bit-vector style. Two blocks per capture; clarity over speed.

const PC1: [usize; 56] = [
    57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60,
    52, 44, 36, 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29,
    21, 13, 5, 28, 20, 12, 4,
];
const PC2: [usize; 48] = [
    14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2, 41, 52,
    31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
];
const SHIFTS: [usize; 16] = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
const IP: [usize; 64] = [
    58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4, 62, 54, 46, 38, 30, 22, 14, 6,
    64, 56, 48, 40, 32, 24, 16, 8, 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3, 61,
    53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
];
const FP: [usize; 64] = [
    40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31, 38, 6, 46, 14, 54, 22, 62, 30,
    37, 5, 45, 13, 53, 21, 61, 29, 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
    34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
];
const E: [usize; 48] = [
    32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17, 16, 17, 18,
    19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
];
const P: [usize; 32] = [
    16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10, 2, 8, 24, 14, 32, 27, 3, 9, 19,
    13, 30, 6, 22, 11, 4, 25,
];
const S: [[u8; 64]; 8] = [
    [
        14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12,
        11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9,
        1, 7, 5, 11, 3, 14, 10, 0, 6, 13,
    ],
    [
        15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1,
        10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15,
        4, 2, 11, 6, 7, 12, 0, 5, 14, 9,
    ],
    [
        10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5,
        14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6,
        9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12,
    ],
    [
        7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2,
        12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 1,
        13, 8, 9, 4, 5, 11, 12, 7, 2, 14,
    ],
    [
        2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15,
        10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14,
        2, 13, 6, 15, 0, 9, 10, 4, 5, 3,
    ],
    [
        12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13,
        14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5,
        15, 10, 11, 14, 1, 7, 6, 0, 8, 13,
    ],
    [
        4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5,
        12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4,
        10, 7, 9, 5, 0, 15, 14, 2, 3, 12,
    ],
    [
        13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6,
        11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10,
        8, 13, 15, 12, 9, 0, 3, 5, 6, 11,
    ],
];

fn bytes_to_bits(b: &[u8]) -> Vec<u8> {
    let mut v = Vec::with_capacity(b.len() * 8);
    for byte in b {
        for i in (0..8).rev() {
            v.push((byte >> i) & 1);
        }
    }
    v
}

fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
    bits.chunks(8)
        .map(|c| c.iter().fold(0u8, |a, &b| (a << 1) | b))
        .collect()
}

fn permute(input: &[u8], table: &[usize]) -> Vec<u8> {
    table.iter().map(|&p| input[p - 1]).collect()
}

fn des_subkeys(key: &[u8; 8]) -> Vec<Vec<u8>> {
    let kb = bytes_to_bits(key);
    let pc1 = permute(&kb, &PC1);
    let (mut c, mut d) = (pc1[..28].to_vec(), pc1[28..].to_vec());
    let mut out = Vec::with_capacity(16);
    for &s in SHIFTS.iter() {
        c.rotate_left(s);
        d.rotate_left(s);
        let mut cd = c.clone();
        cd.extend_from_slice(&d);
        out.push(permute(&cd, &PC2));
    }
    out
}

pub fn des_encrypt_block(key: &[u8; 8], block: &[u8]) -> Vec<u8> {
    let keys = des_subkeys(key);
    let bits = bytes_to_bits(block);
    let ip = permute(&bits, &IP);
    let (mut l, mut r) = (ip[..32].to_vec(), ip[32..].to_vec());
    for k in keys.iter() {
        let er = permute(&r, &E);
        let x: Vec<u8> = er.iter().zip(k.iter()).map(|(a, b)| a ^ b).collect();
        let mut sout = Vec::with_capacity(32);
        for (i, ch) in x.chunks(6).enumerate() {
            let row = ((ch[0] << 1) | ch[5]) as usize;
            let col = ((ch[1] << 3) | (ch[2] << 2) | (ch[3] << 1) | ch[4]) as usize;
            let val = S[i][row * 16 + col];
            for j in (0..4).rev() {
                sout.push((val >> j) & 1);
            }
        }
        let f = permute(&sout, &P);
        let new_r: Vec<u8> = l.iter().zip(f.iter()).map(|(a, b)| a ^ b).collect();
        l = r;
        r = new_r;
    }
    let mut rl = r;
    rl.extend_from_slice(&l);
    bits_to_bytes(&permute(&rl, &FP))
}

/// The VNC quirk: the key is the password padded/truncated to 8 bytes, with the
/// bits of every byte reversed.
pub fn vnc_key(password: &str) -> [u8; 8] {
    let mut k = [0u8; 8];
    for (i, b) in password.bytes().take(8).enumerate() {
        k[i] = b.reverse_bits();
    }
    k
}

// ───────────────────────────── PNG ─────────────────────────────

fn crc32(data: &[u8]) -> u32 {
    let mut table = [0u32; 256];
    for i in 0..256u32 {
        let mut c = i;
        for _ in 0..8 {
            c = if c & 1 != 0 {
                0xEDB8_8320 ^ (c >> 1)
            } else {
                c >> 1
            };
        }
        table[i as usize] = c;
    }
    let mut c = 0xFFFF_FFFFu32;
    for &b in data {
        c = table[((c ^ b as u32) & 0xFF) as usize] ^ (c >> 8);
    }
    c ^ 0xFFFF_FFFF
}

fn adler32(data: &[u8]) -> u32 {
    let (mut a, mut b) = (1u32, 0u32);
    for &x in data {
        a = (a + x as u32) % 65521;
        b = (b + a) % 65521;
    }
    (b << 16) | a
}

fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], payload: &[u8]) {
    out.extend_from_slice(&(payload.len() as u32).to_be_bytes());
    let mut body = kind.to_vec();
    body.extend_from_slice(payload);
    out.extend_from_slice(&body);
    out.extend_from_slice(&crc32(&body).to_be_bytes());
}

/// RGB8 -> PNG, zlib with stored (uncompressed) deflate blocks. No compression,
/// no dependency; a few MB on disk is not the problem today.
pub fn write_png(path: &str, w: u32, h: u32, rgb: &[u8]) -> std::io::Result<()> {
    let mut raw = Vec::with_capacity((w as usize * 3 + 1) * h as usize);
    for y in 0..h as usize {
        raw.push(0u8); // filter: None
        raw.extend_from_slice(&rgb[y * w as usize * 3..(y + 1) * w as usize * 3]);
    }
    let mut z = vec![0x78u8, 0x01];
    let mut off = 0usize;
    while off < raw.len() {
        let n = std::cmp::min(65535, raw.len() - off);
        let last = if off + n == raw.len() { 1u8 } else { 0u8 };
        z.push(last);
        z.extend_from_slice(&(n as u16).to_le_bytes());
        z.extend_from_slice(&(!(n as u16)).to_le_bytes());
        z.extend_from_slice(&raw[off..off + n]);
        off += n;
    }
    z.extend_from_slice(&adler32(&raw).to_be_bytes());

    let mut png = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
    let mut ihdr = Vec::new();
    ihdr.extend_from_slice(&w.to_be_bytes());
    ihdr.extend_from_slice(&h.to_be_bytes());
    ihdr.extend_from_slice(&[8, 2, 0, 0, 0]); // 8-bit, truecolour RGB
    chunk(&mut png, b"IHDR", &ihdr);
    chunk(&mut png, b"IDAT", &z);
    chunk(&mut png, b"IEND", &[]);
    std::fs::write(path, png)
}

// ───────────────────────────── RFB ─────────────────────────────

fn rd(s: &mut TcpStream, n: usize) -> std::io::Result<Vec<u8>> {
    let mut b = vec![0u8; n];
    s.read_exact(&mut b)?;
    Ok(b)
}

pub struct Shot {
    pub w: u16,
    pub h: u16,
    pub name: String,
    pub rgb: Vec<u8>,
}

pub fn capture(addr: &str, password: &str) -> Result<Shot, String> {
    let mut s = TcpStream::connect(addr).map_err(|e| format!("connect: {e}"))?;
    s.set_read_timeout(Some(Duration::from_secs(45))).ok();
    s.set_write_timeout(Some(Duration::from_secs(20))).ok();
    s.set_nodelay(true).ok();

    // Handshake.
    let greet = rd(&mut s, 12).map_err(|e| format!("greeting: {e}"))?;
    let greet_s = String::from_utf8_lossy(&greet).trim_end().to_string();
    eprintln!("  server greeting: {greet_s}");
    if !greet.starts_with(b"RFB 003.") {
        return Err(format!("not RFB: {greet_s}"));
    }
    s.write_all(b"RFB 003.008\n")
        .map_err(|e| format!("version: {e}"))?;

    // Security types.
    let n = rd(&mut s, 1).map_err(|e| format!("sectypes count: {e}"))?[0];
    if n == 0 {
        let len = u32::from_be_bytes(rd(&mut s, 4).unwrap_or(vec![0; 4]).try_into().unwrap());
        let msg = rd(&mut s, len as usize).unwrap_or_default();
        return Err(format!("server refused: {}", String::from_utf8_lossy(&msg)));
    }
    let types = rd(&mut s, n as usize).map_err(|e| format!("sectypes: {e}"))?;
    eprintln!("  security types offered: {types:?}");
    if !types.contains(&2) {
        return Err(format!("no VNC-Auth (2) offered, only {types:?}"));
    }
    s.write_all(&[2u8])
        .map_err(|e| format!("pick sectype: {e}"))?;

    // VNC auth.
    let challenge = rd(&mut s, 16).map_err(|e| format!("challenge: {e}"))?;
    let key = vnc_key(password);
    let mut resp = des_encrypt_block(&key, &challenge[..8]);
    resp.extend_from_slice(&des_encrypt_block(&key, &challenge[8..]));
    s.write_all(&resp)
        .map_err(|e| format!("auth response: {e}"))?;
    let res = u32::from_be_bytes(
        rd(&mut s, 4)
            .map_err(|e| format!("auth result: {e}"))?
            .try_into()
            .unwrap(),
    );
    if res != 0 {
        let mut why = String::new();
        if let Ok(l) = rd(&mut s, 4) {
            let len = u32::from_be_bytes(l.try_into().unwrap());
            if len > 0 && len < 4096 {
                if let Ok(m) = rd(&mut s, len as usize) {
                    why = String::from_utf8_lossy(&m).to_string();
                }
            }
        }
        return Err(format!("VNC auth FAILED, result={res} {why}"));
    }
    eprintln!("  VNC auth ok");

    // ClientInit / ServerInit.
    s.write_all(&[1u8])
        .map_err(|e| format!("clientinit: {e}"))?;
    let si = rd(&mut s, 24).map_err(|e| format!("serverinit: {e}"))?;
    let w = u16::from_be_bytes([si[0], si[1]]);
    let h = u16::from_be_bytes([si[2], si[3]]);
    eprintln!(
        "  serverinit: {w}x{h} bpp={} depth={} big_endian={} true_colour={}",
        si[4], si[5], si[6], si[7]
    );
    let nlen = u32::from_be_bytes([si[20], si[21], si[22], si[23]]) as usize;
    let name =
        String::from_utf8_lossy(&rd(&mut s, nlen).map_err(|e| format!("name: {e}"))?).to_string();
    eprintln!("  desktop name: {name:?}");

    // SetPixelFormat: 32bpp, depth 24, little-endian, true colour, R<<16 G<<8 B.
    let mut spf = vec![0u8, 0, 0, 0];
    spf.extend_from_slice(&[32, 24, 0, 1]);
    spf.extend_from_slice(&255u16.to_be_bytes());
    spf.extend_from_slice(&255u16.to_be_bytes());
    spf.extend_from_slice(&255u16.to_be_bytes());
    spf.extend_from_slice(&[16, 8, 0, 0, 0, 0]);
    s.write_all(&spf)
        .map_err(|e| format!("setpixelformat: {e}"))?;

    // SetEncodings: Raw only.
    let mut se = vec![2u8, 0];
    se.extend_from_slice(&1u16.to_be_bytes());
    se.extend_from_slice(&0i32.to_be_bytes());
    s.write_all(&se).map_err(|e| format!("setencodings: {e}"))?;

    // FramebufferUpdateRequest, non-incremental, whole screen.
    let mut fur = vec![3u8, 0];
    fur.extend_from_slice(&0u16.to_be_bytes());
    fur.extend_from_slice(&0u16.to_be_bytes());
    fur.extend_from_slice(&w.to_be_bytes());
    fur.extend_from_slice(&h.to_be_bytes());
    s.write_all(&fur)
        .map_err(|e| format!("fbupdaterequest: {e}"))?;

    let mut rgb = vec![0u8; w as usize * h as usize * 3];
    let mut painted: u64 = 0;
    let target = w as u64 * h as u64;
    let mut guard = 0;
    while painted < target {
        guard += 1;
        if guard > 400 {
            return Err(format!(
                "gave up after {guard} messages, painted {painted}/{target}"
            ));
        }
        let mt = rd(&mut s, 1).map_err(|e| format!("msg type: {e}"))?[0];
        match mt {
            0 => {
                let hdr = rd(&mut s, 3).map_err(|e| format!("fbu hdr: {e}"))?;
                let nrects = u16::from_be_bytes([hdr[1], hdr[2]]);
                for _ in 0..nrects {
                    let r = rd(&mut s, 12).map_err(|e| format!("rect hdr: {e}"))?;
                    let rx = u16::from_be_bytes([r[0], r[1]]) as usize;
                    let ry = u16::from_be_bytes([r[2], r[3]]) as usize;
                    let rw = u16::from_be_bytes([r[4], r[5]]) as usize;
                    let rh = u16::from_be_bytes([r[6], r[7]]) as usize;
                    let enc = i32::from_be_bytes([r[8], r[9], r[10], r[11]]);
                    if enc != 0 {
                        return Err(format!("rect encoding {enc} is not Raw"));
                    }
                    let data = rd(&mut s, rw * rh * 4)
                        .map_err(|e| format!("rect pixels {rw}x{rh}: {e}"))?;
                    for yy in 0..rh {
                        for xx in 0..rw {
                            let p = (yy * rw + xx) * 4;
                            let v = u32::from_le_bytes([
                                data[p],
                                data[p + 1],
                                data[p + 2],
                                data[p + 3],
                            ]);
                            let (dx, dy) = (rx + xx, ry + yy);
                            if dx >= w as usize || dy >= h as usize {
                                continue;
                            }
                            let o = (dy * w as usize + dx) * 3;
                            rgb[o] = ((v >> 16) & 0xFF) as u8;
                            rgb[o + 1] = ((v >> 8) & 0xFF) as u8;
                            rgb[o + 2] = (v & 0xFF) as u8;
                        }
                    }
                    painted += (rw * rh) as u64;
                }
            }
            1 => {
                let hd = rd(&mut s, 5).map_err(|e| format!("colourmap: {e}"))?;
                let cnt = u16::from_be_bytes([hd[3], hd[4]]) as usize;
                rd(&mut s, cnt * 6).map_err(|e| format!("colourmap body: {e}"))?;
            }
            2 => {}
            3 => {
                let hd = rd(&mut s, 7).map_err(|e| format!("cuttext: {e}"))?;
                let l = u32::from_be_bytes([hd[3], hd[4], hd[5], hd[6]]) as usize;
                rd(&mut s, l).map_err(|e| format!("cuttext body: {e}"))?;
            }
            other => return Err(format!("unknown server message type {other}")),
        }
    }
    Ok(Shot { w, h, name, rgb })
}

pub fn describe(rgb: &[u8]) -> String {
    use std::collections::HashMap;
    let mut hist: HashMap<(u8, u8, u8), u64> = HashMap::new();
    for px in rgb.chunks(3) {
        *hist.entry((px[0], px[1], px[2])).or_insert(0) += 1;
    }
    let total = (rgb.len() / 3) as u64;
    let mut v: Vec<_> = hist.iter().collect();
    v.sort_by(|a, b| b.1.cmp(a.1));
    let nonblack = total - *hist.get(&(0, 0, 0)).unwrap_or(&0);
    let top: Vec<String> = v
        .iter()
        .take(4)
        .map(|(c, n)| {
            format!(
                "#{:02x}{:02x}{:02x}={:.1}%",
                c.0,
                c.1,
                c.2,
                **n as f64 * 100.0 / total as f64
            )
        })
        .collect();
    // Cheap content fingerprint so frames can be compared.
    let mut fp = 0xcbf2_9ce4_8422_2325u64;
    for &b in rgb {
        fp ^= b as u64;
        fp = fp.wrapping_mul(0x1000_0000_01b3);
    }
    format!(
        "distinct_colours={} nonblack_px={} ({:.2}%) top=[{}] fnv1a={:016x}",
        hist.len(),
        nonblack,
        nonblack as f64 * 100.0 / total as f64,
        top.join(" "),
        fp
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    #[test]
    fn des_known_answer() {
        // FIPS: key 133457799BBCDFF1, plaintext 0123456789ABCDEF -> 85E813540F0AB405
        let key = [0x13, 0x34, 0x57, 0x79, 0x9B, 0xBC, 0xDF, 0xF1];
        let pt = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF];
        assert_eq!(
            des_encrypt_block(&key, &pt),
            vec![0x85, 0xE8, 0x13, 0x54, 0x0F, 0x0A, 0xB4, 0x05]
        );
    }
}