use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, JsonSchema, Serialize)]
pub struct TunnelQuery {
pub port: u16,
#[serde(default)]
pub identity: bool,
}
pub const WS_MARKER_SUBPROTOCOL: &str = "lambda-microvms";
pub mod close {
pub const NORMAL: u16 = 1000;
pub const NO_LISTENER: u16 = 4502;
pub const BAD_PORT: u16 = 4400;
pub const RELAY_FAILED: u16 = 4500;
pub const NO_IDENTITY: u16 = 4401;
pub const IDENTITY_REFUSED: u16 = 4403;
pub fn explanation(code: u16, port: u16) -> Option<String> {
match code {
NORMAL => Some("the tunnel closed cleanly".to_string()),
NO_LISTENER => Some(format!(
"nothing is listening on 127.0.0.1:{port} inside the guest. The credential \
and its port scope were accepted — this is a dead server, not an auth \
problem."
)),
BAD_PORT => Some("port 0 cannot be dialled; name the guest port".to_string()),
RELAY_FAILED => Some(format!(
"the connection to 127.0.0.1:{port} failed after it had been established"
)),
NO_IDENTITY => Some(
"this VM was launched without an identity seed, so there is no key to prove \
anything with. The seed is delivered in the run-hook payload at launch and \
cannot be added to a running VM — relaunch with identity enabled, or drop \
--verify-identity to use an unverified tunnel."
.to_string(),
),
IDENTITY_REFUSED => Some(
"the identity handshake failed. Either the pinned key is not this VM's — a \
record copied from another launch would do that — or the caller does not hold \
the launching host's private key. The daemon cannot tell those apart: both \
static keys are mixed into the handshake hash, so both arrive as one \
decryption failure."
.to_string(),
),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_failure_code_is_in_the_rfc_private_range_and_distinct() {
let failures = [
close::NO_LISTENER,
close::BAD_PORT,
close::RELAY_FAILED,
close::NO_IDENTITY,
close::IDENTITY_REFUSED,
];
for code in failures {
assert!(
(4000..5000).contains(&code),
"{code} is outside the private range and could collide with a platform code"
);
}
let mut seen = std::collections::BTreeSet::new();
for code in failures {
assert!(
seen.insert(code),
"{code} is used for two different outcomes"
);
}
assert!(!failures.contains(&close::NORMAL), "1000 is not a failure");
}
#[test]
fn a_platform_close_is_not_explained() {
assert!(close::explanation(1006, 8080).is_none());
assert!(close::explanation(4999, 8080).is_none());
}
#[test]
fn the_identity_refusals_point_at_different_fixes() {
let missing = close::explanation(close::NO_IDENTITY, 5432).expect("4401 explains itself");
assert!(
missing.contains("relaunch"),
"the fix for a missing seed is at launch time: {missing}"
);
assert!(missing.contains("run-hook payload"), "{missing}");
let refused =
close::explanation(close::IDENTITY_REFUSED, 5432).expect("4403 explains itself");
assert!(
refused.contains("pinned key"),
"a failed handshake is about the key, not the launch: {refused}"
);
assert!(refused.contains("cannot tell those apart"), "{refused}");
assert_ne!(missing, refused);
}
#[test]
fn the_dead_server_explanation_names_the_port_and_clears_the_credential() {
let text = close::explanation(close::NO_LISTENER, 5432).expect("4502 explains itself");
assert!(text.contains("5432"), "{text}");
assert!(text.contains("not an auth problem"), "{text}");
}
}