use crate::control::json::Writer;
pub const EXIT_NODE_ROUTES: [&str; 1] = ["0.0.0.0/0"];
pub const DUAL_STACK_EXIT_NODE_ROUTES: [&str; 2] = ["0.0.0.0/0", "::/0"];
pub fn exit_node_routes(ipv6_ready: bool) -> &'static [&'static str] {
if ipv6_ready {
&DUAL_STACK_EXIT_NODE_ROUTES
} else {
&EXIT_NODE_ROUTES
}
}
#[derive(Clone, Copy)]
pub struct Hostinfo<'a> {
pub hostname: &'a str,
pub version: &'a str,
pub os: &'a str,
pub routable_ips: &'a [&'a str],
pub preferred_derp: u16,
pub report_no_derp: bool,
pub peer_api_port: Option<u16>,
pub ssh_host_keys: &'a [&'a str],
}
impl Hostinfo<'_> {
pub const DEFAULT_HOSTNAME: &'static str = "esp-gateway";
}
impl Default for Hostinfo<'_> {
fn default() -> Self {
Self {
hostname: Self::DEFAULT_HOSTNAME,
version: concat!("esp-gateway-", env!("CARGO_PKG_VERSION")),
os: "esp32",
routable_ips: &[],
preferred_derp: 0,
report_no_derp: false,
peer_api_port: None,
ssh_host_keys: &[],
}
}
}
impl Hostinfo<'_> {
pub fn write(&self, writer: &mut Writer<'_>) {
writer
.begin_object()
.field_str("IPNVersion", self.version)
.field_str("OS", self.os)
.field_str("Hostname", self.hostname);
if !self.ssh_host_keys.is_empty() {
writer.key("sshHostKeys").begin_array();
for key in self.ssh_host_keys {
writer.str(key);
}
writer.end_array();
}
if !self.routable_ips.is_empty() {
writer.key("RoutableIPs").begin_array();
for route in self.routable_ips {
writer.str(route);
}
writer.end_array();
}
if self.preferred_derp != 0 || self.report_no_derp {
writer
.key("NetInfo")
.begin_object()
.field_u64("PreferredDERP", u64::from(self.preferred_derp))
.end_object();
}
writer.key("Services").begin_array();
if let Some(port) = self.peer_api_port {
writer
.begin_object()
.field_str("Proto", "peerapi4")
.field_u64("Port", u64::from(port))
.end_object()
.begin_object()
.field_str("Proto", "peerapi-dns-proxy")
.field_u64("Port", 1)
.end_object();
}
writer.end_array();
writer.end_object();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn rendered<'a>(buffer: &'a mut [u8], hostinfo: &Hostinfo<'_>) -> &'a str {
let mut writer = Writer::new(buffer);
hostinfo.write(&mut writer);
core::str::from_utf8(writer.finish().unwrap()).unwrap()
}
#[test]
fn a_default_hostinfo_advertises_no_routes() {
let mut buffer = [0u8; 256];
let text = rendered(&mut buffer, &Hostinfo::default());
assert!(text.contains(r#""OS":"esp32""#));
assert!(text.contains(r#""Hostname":"esp-gateway""#));
assert!(text.contains(r#""IPNVersion":"esp-gateway-"#));
assert!(text.contains(r#""Services":[]"#));
assert!(
!text.contains("RoutableIPs"),
"a node that is not an exit node must not claim routes"
);
}
#[test]
fn an_ipv4_exit_node_does_not_advertise_ipv6() {
let mut buffer = [0u8; 256];
let hostinfo = Hostinfo {
routable_ips: &EXIT_NODE_ROUTES,
..Default::default()
};
let text = rendered(&mut buffer, &hostinfo);
assert!(text.contains(r#""RoutableIPs":["0.0.0.0/0"]"#), "{text}");
}
#[test]
fn ipv6_default_route_requires_explicit_runtime_capability() {
assert_eq!(exit_node_routes(false), &["0.0.0.0/0"]);
assert_eq!(exit_node_routes(true), &["0.0.0.0/0", "::/0"]);
}
#[test]
fn a_hostname_with_a_quote_in_it_cannot_break_the_document() {
let mut buffer = [0u8; 256];
let hostinfo = Hostinfo {
hostname: r#"evil","OS":"linux"#,
..Default::default()
};
let text = rendered(&mut buffer, &hostinfo);
assert!(text.contains(r#""OS":"esp32""#));
assert!(!text.contains(r#""OS":"linux""#), "injection: {text}");
}
#[test]
fn preferred_derp_is_reported_only_after_measurement() {
let mut buffer = [0u8; 256];
let text = rendered(&mut buffer, &Hostinfo::default());
assert!(!text.contains("NetInfo"));
let cleared = rendered(
&mut buffer,
&Hostinfo {
report_no_derp: true,
..Default::default()
},
);
assert!(cleared.contains(r#""NetInfo":{"PreferredDERP":0}"#));
let hostinfo = Hostinfo {
preferred_derp: 7,
..Default::default()
};
let text = rendered(&mut buffer, &hostinfo);
assert!(text.contains(r#""NetInfo":{"PreferredDERP":7}"#));
}
#[test]
fn peer_api_advertises_ipv4_listener_and_dns_capability() {
let mut buffer = [0u8; 384];
let hostinfo = Hostinfo {
peer_api_port: Some(5254),
..Default::default()
};
let text = rendered(&mut buffer, &hostinfo);
assert!(
text.contains(
r#""Services":[{"Proto":"peerapi4","Port":5254},{"Proto":"peerapi-dns-proxy","Port":1}]"#
),
"{text}"
);
}
}