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/console.rs

//! The VNC console — optional, and the reason the virtio trap was ever found.
//!
//! The install is opaque from the API side: `state == "started"` is all the
//! hypervisor will say while `korp-installer` is printing the reason it cannot
//! find its payload. The console is where that reason appears, and it is how
//! `INSTALLER: volume: no block device carries the ISO-9660 magic (CD001)` was
//! read at all.
//!
//! # ⚠ THE PORT CHANGES ON EVERY STOP/START, AND THE API SERVES A STALE ONE
//!
//! `remote_access_port` on `GET /server/{uuid}` is not to be believed after a
//! stop/start: UpCloud hands out a new listener and the old number keeps being
//! reported. A `vncviewer` pointed at the stale port hangs on a connect that
//! will never be accepted, which reads exactly like a machine that has not
//! booted.
//!
//! The toggle is the fix, and it is two calls, in this order:
//!
//! ```text
//! PUT /server/{uuid} {"server":{"remote_access_enabled":"no"}}
//! PUT /server/{uuid} {"server":{"remote_access_enabled":"yes",
//!                               "remote_access_type":"vnc",
//!                               "remote_access_password":"…"}}
//! ```
//!
//! The second reply carries `remote_access_host` and `remote_access_port`, and
//! **even that is verified before it is printed**: [`open`] opens the port and
//! reads the RFB greeting (`RFB 003.008`) off it. A host:port that has not
//! answered `RFB` is not reported as a console.
//!
//! # What this does NOT do, deliberately
//!
//! It does not capture the framebuffer. Decoding RFB into pixels means a VNC
//! client, and the honest options were a dependency that would be carried for
//! one screenshot or a hand-rolled protocol implementation in a deploy tool.
//! **This verb prints a verified `host:port` and the password, for a human to
//! attach to** — with the guarantee that the address is live, which is the half
//! an operator cannot check quickly by hand. Capturing pixels is a job of its
//! own, and it starts here rather than from nothing.

use crate::api::{field_str, Client};
use crate::banner::BannerProbe;
use crate::wait::Clock;
use upcloud_api::UpCloudApi;

/// A console that has been proved to answer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Console {
    pub host: String,
    pub port: String,
    pub password: String,
    /// The greeting actually read off the socket, e.g. `RFB 003.008`.
    pub greeting: String,
}

impl Console {
    pub fn address(&self) -> String {
        format!("{}:{}", self.host, self.port)
    }
}

/// Does this look like a VNC server?
pub fn is_rfb(greeting: &str) -> bool {
    greeting.starts_with("RFB ")
}

/// A console password when the operator gave none.
///
/// Derived from the server uuid and the clock, hex, twelve characters — inside
/// UpCloud's 8–32 alphanumeric rule. It is not a secret worth protecting: it
/// guards a console for the length of one install and it is printed on the
/// operator's own terminal. It is derived rather than constant so that two
/// re-images do not share one.
pub fn derive_password(server_uuid: &str, now_ms: u64) -> String {
    nornir_hash::sha256_hex(format!("{server_uuid}:{now_ms}").as_bytes())[..12].to_string()
}

/// Toggle the console off and on, then prove the port answers before believing
/// it.
pub fn open<A: UpCloudApi, C: Clock, B: BannerProbe>(
    client: &Client<A>,
    clock: &C,
    probe: &B,
    server_uuid: &str,
    password: &str,
    settle_ms: u64,
    attempts: u32,
    log: &mut dyn FnMut(&str),
) -> Result<Console, String> {
    Client::<A>::must(
        "PUT /server/{uuid} (console off)",
        client
            .api
            .set_console(server_uuid, upcloud_api::Console::Off),
    )?;
    // The API needs a moment between the two halves; without it the "yes" is
    // accepted and the OLD port comes back, which is the exact staleness this
    // toggle exists to defeat.
    clock.sleep_ms(settle_ms);
    let v = Client::<A>::must(
        "PUT /server/{uuid} (console on)",
        client
            .api
            .set_console(server_uuid, upcloud_api::Console::Vnc { password }),
    )?;
    let host = field_str(&v, &["server", "remote_access_host"])?;
    let port = field_str(&v, &["server", "remote_access_port"])?;
    let addr = format!("{host}:{port}");

    let mut last = String::from("nothing yet");
    for attempt in 1..=attempts {
        match probe.banner(&addr) {
            Ok(g) if is_rfb(&g) => {
                return Ok(Console {
                    host,
                    port,
                    password: password.to_string(),
                    greeting: g.trim().to_string(),
                })
            }
            Ok(g) => last = format!("something answered and it is not VNC: {g:?}"),
            Err(e) => last = e,
        }
        log(&format!(
            "   console {addr}: not answering yet ({last}), attempt {attempt}/{attempts}"
        ));
        clock.sleep_ms(settle_ms);
    }
    Err(format!(
        "the API says the console is at {addr}, and nothing there speaks RFB: {last}. The port \
         changes on every stop/start and the API serves a stale one, so this is most likely \
         that — the no/yes toggle has already been done once; try the verb again once the \
         server has settled."
    ))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn an_rfb_greeting_is_recognised_and_an_ssh_one_is_not() {
        assert!(is_rfb("RFB 003.008"));
        assert!(!is_rfb("SSH-2.0-gunnar_1.3.0 v1.3.0-48-g426c938"));
        assert!(!is_rfb(""));
    }

    #[test]
    fn two_re_images_do_not_share_a_console_password() {
        let a = derive_password("11111111-2222-3333-4444-555555555555", 1);
        let b = derive_password("11111111-2222-3333-4444-555555555555", 2);
        assert_ne!(a, b);
        assert_eq!(a.len(), 12);
        assert!(a.chars().all(|c| c.is_ascii_alphanumeric()), "{a}");
    }
}