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