From 69310108f4f3af9e87a7e27ad76db07952881a3b Mon Sep 17 00:00:00 2001 From: Aleksandr Bogomiakov Date: Fri, 2 Oct 2026 19:23:06 +0100 Subject: [PATCH] Remember the last hostname each peer announced Persist announced hostnames per network (state schema 5) and report them for members, so a device that is away is still named in `status` and the tray. Co-Authored-By: Claude --- crates/tsunagi-cli/src/main.rs | 33 +++++++-- crates/tsunagi/src/agent/network.rs | 49 ++++++++++++- crates/tsunagi/src/agent/status.rs | 5 ++ crates/tsunagi/src/ipc/mod.rs | 2 + crates/tsunagi/src/storage/mod.rs | 23 ++++++- crates/tsunagi/src/storage/state.rs | 103 +++++++++++++++++++++++++++- 6 files changed, 206 insertions(+), 9 deletions(-) diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 180a92b..fb00242 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -2677,10 +2677,8 @@ impl MemberRow<'_> { self.transport.is_some() } - /// What to call it: the name it announced, or a short form of its id. - /// - /// A member that is away has no hostname, because nothing durable records - /// one — only the signed claim survives, and that carries an address. + /// What to call it: the name it announced — the last one, when it is + /// away — or a short form of its id. fn label(&self) -> String { match self.hostname { Some(hostname) => hostname.to_string(), @@ -2713,10 +2711,11 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve let row = entry(&mut rows, &member.endpoint_id); row.overlay_address_v4 = member.overlay_address_v4.as_deref(); row.failed_dials = member.failed_dials; + row.hostname = member.hostname.as_deref(); } for peer in &network.peers { let row = entry(&mut rows, &peer.endpoint_id); - row.hostname = peer.hostname.as_deref(); + row.hostname = peer.hostname.as_deref().or(row.hostname); row.transport = Some(&peer.transport); row.rtt_ms = peer.rtt_ms; } @@ -4310,6 +4309,10 @@ async fn build_report( .iter() .find(|candidate| candidate.endpoint_id == member.endpoint_id) .map_or(0, |candidate| candidate.consecutive_failures), + hostname: member + .last_hostname + .clone() + .or_else(|| member.hostname.clone()), }) .collect(), range: network.range.map(|range| range.to_string()), @@ -4647,11 +4650,13 @@ mod status_tests { endpoint_id: OWN.into(), overlay_address_v4: Some("10.13.37.69".into()), failed_dials: 0, + hostname: None, }, MemberReport { endpoint_id: ONLINE.into(), overlay_address_v4: Some("10.13.37.237".into()), failed_dials: 0, + hostname: None, }, ], range: Some("10.13.37.0/24".into()), @@ -4892,6 +4897,7 @@ mod status_tests { endpoint_id: AWAY.into(), overlay_address_v4: Some("10.13.37.99".into()), failed_dials: 9, + hostname: None, }); let rows = member_rows(&network, OWN); @@ -4901,6 +4907,22 @@ mod status_tests { assert_eq!(rows[1].endpoint_id, AWAY); } + #[test] + fn an_offline_member_keeps_the_last_name_it_announced() { + let mut network = network_after_a_peer_returned(); + network.members.push(MemberReport { + endpoint_id: AWAY.into(), + overlay_address_v4: Some("10.13.37.99".into()), + failed_dials: 0, + hostname: Some("laptop".into()), + }); + + let rows = member_rows(&network, OWN); + let away = rows.iter().find(|row| row.endpoint_id == AWAY).unwrap(); + assert!(!away.online()); + assert_eq!(away.label(), "laptop"); + } + #[test] fn a_member_that_is_away_is_stated_rather_than_flagged() { // In a mesh of laptops a member being away is the ordinary @@ -4912,6 +4934,7 @@ mod status_tests { endpoint_id: AWAY.into(), overlay_address_v4: Some("10.13.37.99".into()), failed_dials: 9, + hostname: None, }); let mut out = report::Report::new(); diff --git a/crates/tsunagi/src/agent/network.rs b/crates/tsunagi/src/agent/network.rs index b633715..2052d51 100644 --- a/crates/tsunagi/src/agent/network.rs +++ b/crates/tsunagi/src/agent/network.rs @@ -331,6 +331,9 @@ struct Runtime { pending_state: Vec<(EndpointId, Vec)>, /// The highest version this agent has ever published for this network. own_version: u64, + /// The last hostname each peer announced, kept across restarts so a + /// member that is away can still be called by name. + known_hostnames: HashMap, } impl Runtime { @@ -382,6 +385,7 @@ impl Runtime { state: StateSet::new(), pending_state: Vec::new(), own_version: 0, + known_hostnames: HashMap::new(), } } @@ -844,6 +848,17 @@ impl Runtime { .own_record_version(self.network_id, self.local_id) .await .unwrap_or(0); + for (id, hostname) in self + .params + .storage + .peer_hostnames(self.network_id) + .await + .unwrap_or_default() + { + if let Ok(id) = EndpointId::from_bytes(&id) { + self.known_hostnames.insert(id, hostname); + } + } self.ensure_own_claim().await; self.publish_allocations().await; @@ -1885,6 +1900,33 @@ impl Runtime { } } + /// Keeps the name a peer announced, on disk too when it changed. + /// + /// Written off the loop: a name that is not stored this time is stored + /// the next time the peer announces it. + fn remember_hostname(&mut self, peer: EndpointId, hostname: &str) { + if hostname.is_empty() + || self + .known_hostnames + .get(&peer) + .is_some_and(|h| h == hostname) + { + return; + } + self.known_hostnames.insert(peer, hostname.to_string()); + let storage = self.params.storage.clone(); + let network_id = self.network_id; + let hostname = hostname.to_string(); + tokio::spawn(async move { + if let Err(err) = storage + .remember_peer_hostname(network_id, *peer.as_bytes(), hostname) + .await + { + tracing::debug!(%err, "could not remember a peer's hostname"); + } + }); + } + fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) { match &message { ControlMessage::Announce(announcement) => { @@ -1894,6 +1936,7 @@ impl Runtime { session.broadcast = announcement.broadcast; session.capabilities = capabilities.clone(); } + self.remember_hostname(peer, &announcement.hostname); self.update_broadcast(); self.dispatch_capabilities(peer, &capabilities); self.ensure_links(); @@ -1994,7 +2037,10 @@ impl Runtime { broadcast: session.broadcast, endpoint_id: session.peer, role: session.role, - hostname: session.hostname.clone(), + hostname: session + .hostname + .clone() + .or_else(|| self.known_hostnames.get(&session.peer).cloned()), protocols: session .capabilities .iter() @@ -2052,6 +2098,7 @@ impl Runtime { // both hold it would be untrue on one of them. overlay_address_v4: self.state.address_of(&endpoint_id), hostname: self.state.hostname_of(&endpoint_id).map(str::to_string), + last_hostname: self.known_hostnames.get(&endpoint_id).cloned(), }) }) .collect(); diff --git a/crates/tsunagi/src/agent/status.rs b/crates/tsunagi/src/agent/status.rs index 0f02391..5a495b9 100644 --- a/crates/tsunagi/src/agent/status.rs +++ b/crates/tsunagi/src/agent/status.rs @@ -80,6 +80,11 @@ pub struct MemberStatus { /// Signed, so it is still known while the member is away — which is what /// lets an absent member be named rather than shown as a bare id. pub hostname: Option, + /// The last name this agent saw it announce, whether or not it is here. + /// + /// Not signed, so it is a convenience for naming a member that is away + /// and never a claim: the zone is served from `hostname`. + pub last_hostname: Option, } /// Status of one authenticated session. diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index d95d351..676cbc6 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -464,6 +464,8 @@ pub struct MemberReport { pub overlay_address_v4: Option, /// Consecutive failed dial attempts, when this agent is trying to reach it. pub failed_dials: u32, + /// The last hostname this agent saw it announce, kept while it is away. + pub hostname: Option, } /// One control plane peer. diff --git a/crates/tsunagi/src/storage/mod.rs b/crates/tsunagi/src/storage/mod.rs index ed405ff..1d0af3b 100644 --- a/crates/tsunagi/src/storage/mod.rs +++ b/crates/tsunagi/src/storage/mod.rs @@ -264,7 +264,8 @@ impl Storage { pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> { self.with_state(move |state| { state.remove_network(network_id)?; - state.forget_signed_records(network_id) + state.forget_signed_records(network_id)?; + state.forget_peer_hostnames(network_id) }) .await?; self.with_cache((), move |cache| cache.forget_network(network_id)) @@ -283,6 +284,26 @@ impl Storage { .await } + /// Remembers the hostname a member announced, so it can still be named + /// once it is away. + pub async fn remember_peer_hostname( + &self, + network_id: NetworkId, + endpoint_id: [u8; 32], + hostname: String, + ) -> Result<()> { + self.with_state(move |state| { + state.remember_peer_hostname(network_id, &endpoint_id, &hostname) + }) + .await + } + + /// The hostnames members of a network last announced. + pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result> { + self.with_state(move |state| state.peer_hostnames(network_id)) + .await + } + /// Records an address hint. Failures are non-fatal. pub async fn record_hint( &self, diff --git a/crates/tsunagi/src/storage/state.rs b/crates/tsunagi/src/storage/state.rs index cfc720f..94c4285 100644 --- a/crates/tsunagi/src/storage/state.rs +++ b/crates/tsunagi/src/storage/state.rs @@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; use crate::state::{RecordBody, SignedRecord}; /// Schema version written by this build. -pub const SCHEMA_VERSION: i64 = 4; +pub const SCHEMA_VERSION: i64 = 5; /// Key of the stored hostname setting. const SETTING_HOSTNAME: &str = "hostname"; @@ -191,13 +191,34 @@ impl StateStore { PRAGMA user_version = 4; COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?; } + if found < 5 { + self.conn + .execute_batch( + "BEGIN; + CREATE TABLE IF NOT EXISTS peer_hostnames ( + network_id BLOB NOT NULL, + endpoint_id BLOB NOT NULL, + hostname TEXT NOT NULL, + PRIMARY KEY (network_id, endpoint_id) + ); + PRAGMA user_version = 5; + COMMIT;", + ) + .map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?; + } Ok(()) } /// Confirms the expected tables exist, so that a truncated or foreign /// database is reported rather than used. fn verify_shape(&self) -> Result<()> { - for table in ["device_identity", "networks", "settings", "signed_records"] { + for table in [ + "device_identity", + "networks", + "settings", + "signed_records", + "peer_hostnames", + ] { let present: Option = self .conn .query_row( @@ -635,6 +656,61 @@ impl StateStore { Ok(()) } + /// Remembers the hostname a member last announced. + pub fn remember_peer_hostname( + &self, + network_id: NetworkId, + endpoint_id: &[u8; 32], + hostname: &str, + ) -> Result<()> { + self.conn + .execute( + "INSERT INTO peer_hostnames (network_id, endpoint_id, hostname) + VALUES (?1, ?2, ?3) + ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname", + params![ + network_id.as_bytes().as_slice(), + endpoint_id.as_slice(), + hostname + ], + ) + .map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?; + Ok(()) + } + + /// The last hostname every member of a network announced. + pub fn peer_hostnames(&self, network_id: NetworkId) -> Result> { + let mut stmt = self + .conn + .prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1") + .map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?; + let rows = stmt + .query_map(params![network_id.as_bytes().as_slice()], |row| { + Ok((row.get::<_, Vec>(0)?, row.get::<_, String>(1)?)) + }) + .map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?; + let mut out = Vec::new(); + for row in rows { + let (id, hostname) = + row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?; + if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) { + out.push((id, hostname)); + } + } + Ok(out) + } + + /// Forgets every remembered hostname of a network. + pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> { + self.conn + .execute( + "DELETE FROM peer_hostnames WHERE network_id = ?1", + params![network_id.as_bytes().as_slice()], + ) + .map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?; + Ok(()) + } + /// Reads an arbitrary setting. pub fn get_setting(&self, key: &str) -> Result> { self.conn @@ -698,6 +774,29 @@ mod broadcast_storage_tests { use super::*; use crate::identity::NetworkKeys; + #[test] + fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.sqlite"); + let name = NetworkName::new("hostnames").unwrap(); + let secret = NetworkSecret::from_bytes([9; 32]).unwrap(); + let id = NetworkKeys::derive(&name, &secret).network_id(); + let peer = [3u8; 32]; + + let store = StateStore::open(&path).unwrap(); + store.remember_peer_hostname(id, &peer, "laptop").unwrap(); + store.remember_peer_hostname(id, &peer, "laptop-2").unwrap(); + drop(store); + + let store = StateStore::open(&path).unwrap(); + assert_eq!( + store.peer_hostnames(id).unwrap(), + vec![(peer, "laptop-2".to_string())] + ); + store.forget_peer_hostnames(id).unwrap(); + assert!(store.peer_hostnames(id).unwrap().is_empty()); + } + #[test] fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() { let dir = tempfile::tempdir().unwrap();