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:
1 parent
e7dff34ec2
commit
69310108f4
6 files changed
+206
-9
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user