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()
|
self.transport.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What to call it: the name it announced, or a short form of its id.
|
/// What to call it: the name it announced — the last one, when it is
|
||||||
///
|
/// away — 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.
|
|
||||||
fn label(&self) -> String {
|
fn label(&self) -> String {
|
||||||
match self.hostname {
|
match self.hostname {
|
||||||
Some(hostname) => hostname.to_string(),
|
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);
|
let row = entry(&mut rows, &member.endpoint_id);
|
||||||
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
|
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
|
||||||
row.failed_dials = member.failed_dials;
|
row.failed_dials = member.failed_dials;
|
||||||
|
row.hostname = member.hostname.as_deref();
|
||||||
}
|
}
|
||||||
for peer in &network.peers {
|
for peer in &network.peers {
|
||||||
let row = entry(&mut rows, &peer.endpoint_id);
|
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.transport = Some(&peer.transport);
|
||||||
row.rtt_ms = peer.rtt_ms;
|
row.rtt_ms = peer.rtt_ms;
|
||||||
}
|
}
|
||||||
@@ -4310,6 +4309,10 @@ async fn build_report(
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
|
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
|
||||||
.map_or(0, |candidate| candidate.consecutive_failures),
|
.map_or(0, |candidate| candidate.consecutive_failures),
|
||||||
|
hostname: member
|
||||||
|
.last_hostname
|
||||||
|
.clone()
|
||||||
|
.or_else(|| member.hostname.clone()),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
range: network.range.map(|range| range.to_string()),
|
range: network.range.map(|range| range.to_string()),
|
||||||
@@ -4647,11 +4650,13 @@ mod status_tests {
|
|||||||
endpoint_id: OWN.into(),
|
endpoint_id: OWN.into(),
|
||||||
overlay_address_v4: Some("10.13.37.69".into()),
|
overlay_address_v4: Some("10.13.37.69".into()),
|
||||||
failed_dials: 0,
|
failed_dials: 0,
|
||||||
|
hostname: None,
|
||||||
},
|
},
|
||||||
MemberReport {
|
MemberReport {
|
||||||
endpoint_id: ONLINE.into(),
|
endpoint_id: ONLINE.into(),
|
||||||
overlay_address_v4: Some("10.13.37.237".into()),
|
overlay_address_v4: Some("10.13.37.237".into()),
|
||||||
failed_dials: 0,
|
failed_dials: 0,
|
||||||
|
hostname: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
range: Some("10.13.37.0/24".into()),
|
range: Some("10.13.37.0/24".into()),
|
||||||
@@ -4892,6 +4897,7 @@ mod status_tests {
|
|||||||
endpoint_id: AWAY.into(),
|
endpoint_id: AWAY.into(),
|
||||||
overlay_address_v4: Some("10.13.37.99".into()),
|
overlay_address_v4: Some("10.13.37.99".into()),
|
||||||
failed_dials: 9,
|
failed_dials: 9,
|
||||||
|
hostname: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let rows = member_rows(&network, OWN);
|
let rows = member_rows(&network, OWN);
|
||||||
@@ -4901,6 +4907,22 @@ mod status_tests {
|
|||||||
assert_eq!(rows[1].endpoint_id, AWAY);
|
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]
|
#[test]
|
||||||
fn a_member_that_is_away_is_stated_rather_than_flagged() {
|
fn a_member_that_is_away_is_stated_rather_than_flagged() {
|
||||||
// In a mesh of laptops a member being away is the ordinary
|
// In a mesh of laptops a member being away is the ordinary
|
||||||
@@ -4912,6 +4934,7 @@ mod status_tests {
|
|||||||
endpoint_id: AWAY.into(),
|
endpoint_id: AWAY.into(),
|
||||||
overlay_address_v4: Some("10.13.37.99".into()),
|
overlay_address_v4: Some("10.13.37.99".into()),
|
||||||
failed_dials: 9,
|
failed_dials: 9,
|
||||||
|
hostname: None,
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut out = report::Report::new();
|
let mut out = report::Report::new();
|
||||||
|
|||||||
@@ -331,6 +331,9 @@ struct Runtime {
|
|||||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||||
/// The highest version this agent has ever published for this network.
|
/// The highest version this agent has ever published for this network.
|
||||||
own_version: u64,
|
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 {
|
impl Runtime {
|
||||||
@@ -382,6 +385,7 @@ impl Runtime {
|
|||||||
state: StateSet::new(),
|
state: StateSet::new(),
|
||||||
pending_state: Vec::new(),
|
pending_state: Vec::new(),
|
||||||
own_version: 0,
|
own_version: 0,
|
||||||
|
known_hostnames: HashMap::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -844,6 +848,17 @@ impl Runtime {
|
|||||||
.own_record_version(self.network_id, self.local_id)
|
.own_record_version(self.network_id, self.local_id)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(0);
|
.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.ensure_own_claim().await;
|
||||||
self.publish_allocations().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) {
|
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
||||||
match &message {
|
match &message {
|
||||||
ControlMessage::Announce(announcement) => {
|
ControlMessage::Announce(announcement) => {
|
||||||
@@ -1894,6 +1936,7 @@ impl Runtime {
|
|||||||
session.broadcast = announcement.broadcast;
|
session.broadcast = announcement.broadcast;
|
||||||
session.capabilities = capabilities.clone();
|
session.capabilities = capabilities.clone();
|
||||||
}
|
}
|
||||||
|
self.remember_hostname(peer, &announcement.hostname);
|
||||||
self.update_broadcast();
|
self.update_broadcast();
|
||||||
self.dispatch_capabilities(peer, &capabilities);
|
self.dispatch_capabilities(peer, &capabilities);
|
||||||
self.ensure_links();
|
self.ensure_links();
|
||||||
@@ -1994,7 +2037,10 @@ impl Runtime {
|
|||||||
broadcast: session.broadcast,
|
broadcast: session.broadcast,
|
||||||
endpoint_id: session.peer,
|
endpoint_id: session.peer,
|
||||||
role: session.role,
|
role: session.role,
|
||||||
hostname: session.hostname.clone(),
|
hostname: session
|
||||||
|
.hostname
|
||||||
|
.clone()
|
||||||
|
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
|
||||||
protocols: session
|
protocols: session
|
||||||
.capabilities
|
.capabilities
|
||||||
.iter()
|
.iter()
|
||||||
@@ -2052,6 +2098,7 @@ impl Runtime {
|
|||||||
// both hold it would be untrue on one of them.
|
// both hold it would be untrue on one of them.
|
||||||
overlay_address_v4: self.state.address_of(&endpoint_id),
|
overlay_address_v4: self.state.address_of(&endpoint_id),
|
||||||
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
||||||
|
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ pub struct MemberStatus {
|
|||||||
/// Signed, so it is still known while the member is away — which is what
|
/// 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.
|
/// lets an absent member be named rather than shown as a bare id.
|
||||||
pub hostname: Option<String>,
|
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.
|
/// Status of one authenticated session.
|
||||||
|
|||||||
@@ -464,6 +464,8 @@ pub struct MemberReport {
|
|||||||
pub overlay_address_v4: Option<String>,
|
pub overlay_address_v4: Option<String>,
|
||||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||||
pub failed_dials: u32,
|
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.
|
/// One control plane peer.
|
||||||
|
|||||||
@@ -264,7 +264,8 @@ impl Storage {
|
|||||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||||
self.with_state(move |state| {
|
self.with_state(move |state| {
|
||||||
state.remove_network(network_id)?;
|
state.remove_network(network_id)?;
|
||||||
state.forget_signed_records(network_id)
|
state.forget_signed_records(network_id)?;
|
||||||
|
state.forget_peer_hostnames(network_id)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||||
@@ -283,6 +284,26 @@ impl Storage {
|
|||||||
.await
|
.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.
|
/// Records an address hint. Failures are non-fatal.
|
||||||
pub async fn record_hint(
|
pub async fn record_hint(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
|||||||
use crate::state::{RecordBody, SignedRecord};
|
use crate::state::{RecordBody, SignedRecord};
|
||||||
|
|
||||||
/// Schema version written by this build.
|
/// Schema version written by this build.
|
||||||
pub const SCHEMA_VERSION: i64 = 4;
|
pub const SCHEMA_VERSION: i64 = 5;
|
||||||
|
|
||||||
/// Key of the stored hostname setting.
|
/// Key of the stored hostname setting.
|
||||||
const SETTING_HOSTNAME: &str = "hostname";
|
const SETTING_HOSTNAME: &str = "hostname";
|
||||||
@@ -191,13 +191,34 @@ impl StateStore {
|
|||||||
PRAGMA user_version = 4;
|
PRAGMA user_version = 4;
|
||||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||||
/// database is reported rather than used.
|
/// database is reported rather than used.
|
||||||
fn verify_shape(&self) -> Result<()> {
|
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
|
let present: Option<String> = self
|
||||||
.conn
|
.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -635,6 +656,61 @@ impl StateStore {
|
|||||||
Ok(())
|
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.
|
/// Reads an arbitrary setting.
|
||||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||||
self.conn
|
self.conn
|
||||||
@@ -698,6 +774,29 @@ mod broadcast_storage_tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::identity::NetworkKeys;
|
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]
|
#[test]
|
||||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
Reference in new issue
Block a user