pub fn system_hostname() -> Option<String> {
if let Ok(raw) = std::fs::read_to_string("/proc/sys/kernel/hostname") {
return normalize_hostname(&raw);
}
probed_hostname_at(
std::time::Instant::now(),
HOSTNAME_PROBE_TTL,
read_hostname_command,
)
}
const HOSTNAME_PROBE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
static HOSTNAME_PROBE: std::sync::Mutex<Option<(std::time::Instant, Option<String>)>> =
std::sync::Mutex::new(None);
fn probed_hostname_at(
now: std::time::Instant,
ttl: std::time::Duration,
probe: impl FnOnce() -> Option<String>,
) -> Option<String> {
probed_at(&HOSTNAME_PROBE, now, ttl, probe)
}
fn probed_at(
slot: &std::sync::Mutex<Option<(std::time::Instant, Option<String>)>>,
now: std::time::Instant,
ttl: std::time::Duration,
probe: impl FnOnce() -> Option<String>,
) -> Option<String> {
if let Ok(held) = slot.lock() {
if let Some((at, value)) = held.as_ref() {
if now.duration_since(*at) < ttl {
return value.clone();
}
}
}
let fresh = probe();
if let Ok(mut held) = slot.lock() {
*held = Some((now, fresh.clone()));
}
fresh
}
fn read_hostname_command() -> Option<String> {
let raw = std::process::Command::new("hostname")
.output()
.ok()
.and_then(|o| String::from_utf8(o.stdout).ok())?;
normalize_hostname(&raw)
}
pub fn mdns_hostname() -> Option<String> {
let published = probed_at(
&AVAHI_PROBE,
std::time::Instant::now(),
HOSTNAME_PROBE_TTL,
read_avahi_host_fqdn,
);
published.or_else(|| system_hostname().map(|h| mdns_name_from(&h)))
}
static AVAHI_PROBE: std::sync::Mutex<Option<(std::time::Instant, Option<String>)>> =
std::sync::Mutex::new(None);
fn read_avahi_host_fqdn() -> Option<String> {
let out = std::process::Command::new("busctl")
.args([
"--system",
"--timeout=1",
"call",
"org.freedesktop.Avahi",
"/",
"org.freedesktop.Avahi.Server",
"GetHostNameFqdn",
])
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_busctl_string(&String::from_utf8(out.stdout).ok()?)
}
fn parse_busctl_string(reply: &str) -> Option<String> {
let quoted = reply.trim().strip_prefix("s ")?.trim();
let name = quoted.strip_prefix('"')?.strip_suffix('"')?;
let name = normalize_hostname(name)?;
name.to_ascii_lowercase()
.ends_with(".local")
.then_some(name)
}
pub fn mdns_name_for(hostname: &str) -> Option<String> {
normalize_hostname(hostname).map(|h| mdns_name_from(&h))
}
pub fn mdns_name_from(hostname: &str) -> String {
let label = hostname.split('.').next().unwrap_or(hostname);
format!("{label}.local")
}
fn normalize_hostname(raw: &str) -> Option<String> {
let name = raw.trim().trim_end_matches('.').trim();
if name.is_empty() {
return None;
}
if name.eq_ignore_ascii_case("localhost") || name.eq_ignore_ascii_case("localhost.localdomain")
{
return None;
}
if name.starts_with("127.") {
return None;
}
Some(name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bare_hostname_becomes_the_dot_local_name_avahi_publishes() {
assert_eq!(normalize_hostname("skynode\n").as_deref(), Some("skynode"));
assert_eq!(mdns_name_from("skynode"), "skynode.local");
}
#[test]
fn a_dotted_hostname_is_named_by_its_first_label_as_mdns_publishes_it() {
assert_eq!(mdns_name_from("skynode.lan"), "skynode.local");
assert_eq!(mdns_name_from("skynode.local"), "skynode.local");
}
#[test]
fn avahis_published_name_is_read_from_the_busctl_reply() {
assert_eq!(
parse_busctl_string("s \"skynode-2.local\"\n").as_deref(),
Some("skynode-2.local")
);
for bad in [
"",
"s \"\"",
"s \"localhost\"",
"s \"skynode.lan\"",
"u 5",
"skynode.local",
] {
assert!(parse_busctl_string(bad).is_none(), "{bad:?} is not a reach");
}
}
#[test]
fn a_trailing_dot_is_trimmed_before_the_suffix_is_applied() {
assert_eq!(normalize_hostname("skynode.").as_deref(), Some("skynode"));
}
#[test]
fn a_hostname_that_cannot_be_another_machines_reach_has_no_reach_name() {
for raw in ["", " ", "localhost", "LocalHost", "localhost.localdomain"] {
assert!(
normalize_hostname(raw).is_none(),
"{raw:?} must not be advertised as a reach"
);
}
assert!(normalize_hostname("127.0.0.1").is_none());
}
#[test]
fn the_hostname_probe_expires_so_a_renamed_or_newly_named_host_is_advertised() {
let t0 = std::time::Instant::now();
let ttl = std::time::Duration::from_secs(30);
assert_eq!(
probed_hostname_at(t0, ttl, || Some("skynode".to_string())).as_deref(),
Some("skynode")
);
assert_eq!(
probed_hostname_at(t0 + std::time::Duration::from_secs(1), ttl, || panic!(
"the window was not honoured"
))
.as_deref(),
Some("skynode")
);
assert_eq!(
probed_hostname_at(t0 + ttl, ttl, || Some("skynode-2".to_string())).as_deref(),
Some("skynode-2")
);
assert!(probed_hostname_at(t0 + ttl * 2, ttl, || None).is_none());
assert_eq!(
probed_hostname_at(t0 + ttl * 3, ttl, || Some("skynode-3".to_string())).as_deref(),
Some("skynode-3")
);
}
}