use crate::bootlog::{BootLog, MAX_READ};
use crate::door::{parse_request, Request, Response};
pub const MAX_FEED_BYTES: usize = 16 * 1024 * 1024;
use crate::join::{JoinReply, JoinRequest};
use crate::mtls;
use crate::signal::Signal;
use std::collections::BTreeMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::sync::Arc;
use std::time::Duration;
pub const PORT: u16 = 8474;
pub const HELLO_PATH: &str = "/install/hello";
pub const LOG_PATH: &str = "/install/log";
pub const FEED_PATH: &str = "/install/feed";
pub const STATUS_PATH: &str = "/install/status";
pub const CLOSE_PATH: &str = "/install/close";
pub const OFFER_PATH: &str = "/install/offer";
pub const SECRET_OFFER_PATH: &str = "/install/secret-offer";
#[derive(Clone, Default, PartialEq, Eq)]
pub struct SecretOffer(BTreeMap<String, String>);
impl SecretOffer {
pub fn new(values: BTreeMap<String, String>) -> SecretOffer {
SecretOffer(values)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn keys(&self) -> Vec<String> {
self.0.keys().cloned().collect()
}
pub fn expose(&self) -> &BTreeMap<String, String> {
&self.0
}
pub fn into_inner(self) -> BTreeMap<String, String> {
self.0
}
pub fn render(&self) -> String {
toml::to_string(&self.0).unwrap_or_default()
}
pub fn parse(text: &str) -> Result<SecretOffer, String> {
toml::from_str(text).map(SecretOffer).map_err(|_| "not a secret offer (a TOML table of strings); the body is not quoted".to_string())
}
}
impl std::fmt::Debug for SecretOffer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "SecretOffer({:?} — values withheld)", self.keys())
}
}
pub fn fed_offer_key(vm: &str, key: &str) -> String {
format!("offer.{vm}.{key}")
}
pub fn offers_in(feed: &BTreeMap<String, String>, vm: &str) -> BTreeMap<String, String> {
let prefix = format!("offer.{vm}.");
feed.iter().filter_map(|(k, v)| k.strip_prefix(&prefix).map(|k| (k.to_string(), v.clone()))).collect()
}
pub fn render_offer(offer: &BTreeMap<String, String>) -> String {
toml::to_string(offer).unwrap_or_default()
}
pub fn parse_offer(text: &str) -> Result<BTreeMap<String, String>, String> {
toml::from_str(text).map_err(|e| format!("not an offer: {e}"))
}
pub trait Vm: Send + Sync {
fn hello(&self) -> Result<JoinRequest, String>;
fn fed(&self, reply: &JoinReply) -> Result<(), String>;
fn status(&self) -> Signal;
fn close(&self) -> Result<(), String>;
fn identity(&self) -> mtls::Issued;
fn offer(&self) -> BTreeMap<String, String> {
BTreeMap::new()
}
fn secret_offer(&self) -> SecretOffer {
SecretOffer::default()
}
}
pub fn installer_only(cn: &str) -> String {
format!(
"REFUSED [installer-only] this is a VM's bootstrap listener; it serves the installer's own certificate (CN `{}`) and nothing else — `{cn}` was presented",
mtls::INSTALLER_CN
)
}
fn query_from(path: &str) -> u64 {
path.split_once('?')
.map(|(_, q)| q)
.unwrap_or("")
.split('&')
.find_map(|kv| kv.strip_prefix("from="))
.and_then(|v| v.parse().ok())
.unwrap_or(0)
}
pub fn respond(vm: &dyn Vm, log: &BootLog, req: &Request) -> (Response, bool) {
let path = req.path.split('?').next().unwrap_or("");
let ok = |body: String| Response { status: 200, body };
match (req.method.as_str(), path) {
("GET", HELLO_PATH) => match vm.hello().and_then(|h| h.render().map_err(|e| e.to_string())) {
Ok(t) => (ok(t), false),
Err(e) => (Response { status: 503, body: format!("{e}\n") }, false),
},
("GET", LOG_PATH) => (ok(log.since(query_from(&req.path), MAX_READ).render()), false),
("POST", FEED_PATH) => match JoinReply::parse(&String::from_utf8_lossy(&req.body)) {
Err(e) => (Response { status: 400, body: format!("{e}\n") }, false),
Ok(reply) => match vm.fed(&reply) {
Ok(()) => (ok(format!("fed `{}`\n", reply.me.name)), false),
Err(e) => (Response { status: 503, body: format!("{e}\n") }, false),
},
},
("GET", STATUS_PATH) => (ok(vm.status().render().unwrap_or_default()), false),
("GET", OFFER_PATH) => (ok(render_offer(&vm.offer())), false),
("GET", SECRET_OFFER_PATH) => (ok(vm.secret_offer().render()), false),
("POST", CLOSE_PATH) => match vm.close() {
Ok(()) => (ok("closed: the bootstrap certificate is dead on this VM\n".into()), true),
Err(e) => (Response { status: 503, body: format!("{e}\n") }, false),
},
(_, HELLO_PATH | LOG_PATH | FEED_PATH | STATUS_PATH | OFFER_PATH | SECRET_OFFER_PATH | CLOSE_PATH) => (Response { status: 405, body: "REFUSED [install-method] wrong method for this path\n".into() }, false),
_ => (Response { status: 404, body: format!("REFUSED [install-path] this listener has {HELLO_PATH}, {LOG_PATH}, {FEED_PATH}, {STATUS_PATH}, {OFFER_PATH}, {SECRET_OFFER_PATH} and {CLOSE_PATH}\n") }, false),
}
}
pub fn serve_one(vm: &dyn Vm, log: &BootLog, cfg: Arc<rustls::ServerConfig>, stream: TcpStream) -> Result<bool, String> {
let from = stream.peer_addr().map(|a| a.to_string()).unwrap_or_else(|_| "?".into());
let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(20)));
let conn = rustls::ServerConnection::new(cfg).map_err(|e| e.to_string())?;
let mut tls = rustls::StreamOwned::new(conn, stream);
let mut bytes = Vec::new();
let mut chunk = [0u8; 4096];
let req = loop {
let n = tls.read(&mut chunk).map_err(|e| format!("{from}: {e}"))?;
if n == 0 {
return Err(format!("{from}: closed before a request"));
}
bytes.extend_from_slice(&chunk[..n]);
if bytes.len() > MAX_FEED_BYTES {
return Err(format!("{from}: request too large"));
}
if let Some(r) = parse_request(&bytes) {
break r?;
}
};
let cn = mtls::peer_cn(&tls.conn).unwrap_or_default();
let (resp, closed) = if cn == mtls::INSTALLER_CN {
respond(vm, log, &req)
} else {
(Response { status: 403, body: format!("{}\n", installer_only(&cn)) }, false)
};
tls.write_all(&resp.bytes()).map_err(|e| e.to_string())?;
tls.conn.send_close_notify();
let _ = tls.flush();
if cn != mtls::INSTALLER_CN {
return Err(format!("{from}: {}", installer_only(&cn)));
}
Ok(closed)
}
pub fn serve(vm: &dyn Vm, log: &BootLog, ca_pem: &str, listener: TcpListener, say: &dyn Fn(&str)) {
let mut current: Option<(String, Arc<rustls::ServerConfig>)> = None;
for stream in listener.incoming() {
let Ok(s) = stream else { continue };
let id = vm.identity();
let cfg = match ¤t {
Some((pem, cfg)) if *pem == id.cert_pem => cfg.clone(),
_ => match mtls::server_config(ca_pem, &id) {
Ok(cfg) => {
current = Some((id.cert_pem.clone(), cfg.clone()));
cfg
}
Err(e) => {
say(&format!("the bootstrap listener cannot show its certificate: {e}"));
continue;
}
},
};
match serve_one(vm, log, cfg, s) {
Ok(true) => {
say("the installer closed the bootstrap listener: the bootstrap certificate is dead on this VM");
return;
}
Ok(false) => {}
Err(e) => say(&format!("a connection to the bootstrap listener was refused: {e}")),
}
}
}
pub fn bind(port: u16) -> std::io::Result<TcpListener> {
TcpListener::bind(SocketAddr::from(([0, 0, 0, 0], port)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::elect::Capacity;
use crate::seed::{Kind, TunnelKind};
use std::sync::Mutex;
struct FakeVm {
fed: Mutex<Vec<JoinReply>>,
closed: Mutex<bool>,
}
impl Vm for FakeVm {
fn hello(&self) -> Result<JoinRequest, String> {
Ok(JoinRequest { estate: "e".into(), kind: Kind::Gunnar, tunnel_pubkey: "K".into(), capacity: Some(Capacity { cores: 2, ram_bytes: 1, data_bytes: 1, disk_mib_s: 1 }), csr_pem: String::new() })
}
fn fed(&self, reply: &JoinReply) -> Result<(), String> {
self.fed.lock().unwrap().push(reply.clone());
Ok(())
}
fn status(&self) -> Signal {
Signal::of("main", TunnelKind::Wireguard, &crate::pair::Machine::default())
}
fn close(&self) -> Result<(), String> {
*self.closed.lock().unwrap() = true;
Ok(())
}
fn identity(&self) -> crate::mtls::Issued {
crate::mtls::Issued { cert_pem: String::new(), key_pem: String::new() }
}
fn offer(&self) -> BTreeMap<String, String> {
BTreeMap::from([("ticket_pubkey".to_string(), "ab12".to_string())])
}
}
fn get(path: &str) -> Request {
Request { method: "GET".into(), path: path.into(), body: Vec::new() }
}
#[test]
fn the_listener_answers_hello_log_feed_status_and_close_and_nothing_else() {
let vm = FakeVm { fed: Mutex::new(Vec::new()), closed: Mutex::new(false) };
let log = BootLog::new(10);
log.push("korp-installer: pass began\ngunnar: listening");
let (r, c) = respond(&vm, &log, &get(HELLO_PATH));
assert_eq!((r.status, c), (200, false));
assert_eq!(JoinRequest::parse(&r.body).unwrap().tunnel_pubkey, "K");
let (r, _) = respond(&vm, &log, &get(&format!("{LOG_PATH}?from=1")));
let chunk = crate::bootlog::Chunk::parse(&r.body).unwrap();
assert_eq!((chunk.lines, chunk.next), (vec!["gunnar: listening".to_string()], 2));
let reply = JoinReply { estate: "e".into(), me: crate::join::Assigned { name: "main".into(), overlay: crate::OVERLAY_BOX_A }, cert_pem: None, role: None, primary_overlay: None, primary_utility: None, peers: vec![], feed: Default::default() };
let (r, _) = respond(&vm, &log, &Request { method: "POST".into(), path: FEED_PATH.into(), body: reply.render().unwrap().into_bytes() });
assert_eq!(r.status, 200, "{}", r.body);
assert_eq!(vm.fed.lock().unwrap().len(), 1);
let (r, _) = respond(&vm, &log, &get(STATUS_PATH));
assert!(Signal::parse(&r.body).is_ok(), "{}", r.body);
let (r, _) = respond(&vm, &log, &get(OFFER_PATH));
assert_eq!(parse_offer(&r.body).unwrap()["ticket_pubkey"], "ab12");
assert_eq!(parse_offer("").unwrap(), BTreeMap::new(), "an empty body is an empty offer");
let feed = BTreeMap::from([(fed_offer_key("main", "ticket_pubkey"), "ab12".to_string()), (fed_offer_key("twin", "twin_pubkey"), "ssh-ed25519 X".to_string()), ("monetize_pubkey".to_string(), "m".to_string())]);
assert_eq!(offers_in(&feed, "main"), BTreeMap::from([("ticket_pubkey".to_string(), "ab12".to_string())]));
assert_eq!(offers_in(&feed, "twin")["twin_pubkey"], "ssh-ed25519 X");
assert_eq!(respond(&vm, &log, &get(CLOSE_PATH)).0.status, 405);
assert_eq!(respond(&vm, &log, &get("/pair/join")).0.status, 404);
let (r, closed) = respond(&vm, &log, &Request { method: "POST".into(), path: CLOSE_PATH.into(), body: Vec::new() });
assert!(r.status == 200 && closed && *vm.closed.lock().unwrap());
}
#[test]
fn the_secret_offer_is_its_own_path_and_never_shows_a_value() {
struct Front;
impl Vm for Front {
fn hello(&self) -> Result<JoinRequest, String> {
Err("n/a".into())
}
fn fed(&self, _r: &JoinReply) -> Result<(), String> {
Ok(())
}
fn status(&self) -> Signal {
Signal::of("front", TunnelKind::Wireguard, &crate::pair::Machine::default())
}
fn close(&self) -> Result<(), String> {
Ok(())
}
fn identity(&self) -> crate::mtls::Issued {
crate::mtls::Issued { cert_pem: String::new(), key_pem: String::new() }
}
fn secret_offer(&self) -> SecretOffer {
SecretOffer::new(BTreeMap::from([("tls.gunnar.rs.key".to_string(), "-----BEGIN PRIVATE KEY-----\nK\n".to_string())]))
}
}
let log = BootLog::new(4);
let (r, _) = respond(&Front, &log, &get(SECRET_OFFER_PATH));
assert_eq!(r.status, 200);
let s = SecretOffer::parse(&r.body).unwrap();
assert_eq!(s.expose()["tls.gunnar.rs.key"], "-----BEGIN PRIVATE KEY-----\nK\n");
assert_eq!(format!("{s:?}"), "SecretOffer([\"tls.gunnar.rs.key\"] — values withheld)");
assert!(parse_offer(&respond(&Front, &log, &get(OFFER_PATH)).0.body).unwrap().is_empty(), "the public offer does not carry it");
let box_vm = FakeVm { fed: Mutex::new(Vec::new()), closed: Mutex::new(false) };
assert!(SecretOffer::parse(&respond(&box_vm, &log, &get(SECRET_OFFER_PATH)).0.body).unwrap().is_empty(), "a box hands over nothing");
assert!(!SecretOffer::parse("= nope").unwrap_err().contains("nope"), "the error never quotes the body");
}
#[test]
fn the_from_query_is_read_and_a_missing_one_is_the_start() {
assert_eq!(query_from("/install/log?from=42"), 42);
assert_eq!(query_from("/install/log?x=1&from=7"), 7);
assert_eq!(query_from("/install/log"), 0);
assert!(installer_only("bootstrap").starts_with("REFUSED [installer-only]"));
}
}