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 <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-02 19:23:06 +01:00
1 parent e7dff34ec2
commit 69310108f4
6 files changed
+206 -9

No files matched your search

+28 -5
View File
@@ -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();
+48 -1
View File
@@ -331,6 +331,9 @@ struct Runtime {
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
/// 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<EndpointId, String>,
}
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();
+5
View File
@@ -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<String>,
/// 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<String>,
}
/// Status of one authenticated session.
+2
View File
@@ -464,6 +464,8 @@ pub struct MemberReport {
pub overlay_address_v4: Option<String>,
/// 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<String>,
}
/// One control plane peer.
+22 -1
View File
@@ -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<Vec<([u8; 32], String)>> {
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,
+101 -2
View File
@@ -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<String> = 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<Vec<([u8; 32], String)>> {
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<u8>>(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<Option<String>> {
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();