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]
);
}
}