Make identity something you can look at and change

`id` now shows what this device is — the key it signs with, the name it
answers to, the secret of every network it has joined — and changes all
of it. One shape throughout: name a thing to see it, name it with a value
to change it. `secret` folded in as `id secret generate`, and the path
flags became global so they work either side of a subcommand.

There is no separate signing certificate to show: the endpoint key is
what signs records, and the report says so rather than leaving it to be
guessed.

Secrets appear in `id`, which is where you go to ask for one, and stay
out of `status`, logs, `Debug` and anything sent to a peer.

The hostname is now a signed claim, which is what makes changing it a
revocation. Records are one per author, so a new version replaces the
whole claim and no replica can keep the old name standing. RecordBody
generalised to Claim { address, range, hostname } + Release for that,
with the signing domain bumped; a name is bounded and canonicalised, and
a non-canonical one is rejected rather than repaired, because a repaired
version is not what its author signed. Two members claiming one name
resolve it like an address: lowest id wins, computed identically
everywhere. A member with only a name now has a record too, so an
IPv6-only network finally has a durable roster and an absent member can
be named rather than shown as a bare id.

Replacing the signing key is allowed and does not break the store. The
outgoing key signs a release for every network first, so the address and
name it held are freed rather than reserved forever to a key nobody has
— nothing can sign for a retired author, and by design no authority
could overrule one. Identity and releases commit together: a crash
between them would leave the old key gone and unable to sign what it
owed. It refuses while an agent holds the directory, rather than failing
on the lock with a message that says nothing about what to do.

The version counter is keyed by author as well as network, so a
replacement key starts its own sequence. The migration drops records
written under the previous signing domain instead of carrying rows that
every read must reject and that look exactly like corruption.

The hostname defaults to the machine's own name. Also fixed a
pre-existing flaky test: 40 random authors in a /24 collide by the
birthday problem often enough that its threshold failed about one run in
six, so the authors are fixed now and it tests a property rather than a
coin flip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 15:59:39 +01:00
co-authored by Claude Opus 5
parent 776eedc669
commit 43e8ac8159
16 changed files with 1441 additions and 213 deletions
Generated
+11
View File
@@ -1072,6 +1072,16 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "gethostname"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [
"rustix",
"windows-link",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -3666,6 +3676,7 @@ dependencies = [
"directories", "directories",
"fs4", "fs4",
"futures-util", "futures-util",
"gethostname",
"hex", "hex",
"hkdf", "hkdf",
"hmac 0.13.0", "hmac 0.13.0",
+3
View File
@@ -49,6 +49,9 @@ thiserror = "2.0"
tracing = "0.1" tracing = "0.1"
fs4 = { version = "1.1", features = ["sync"] } fs4 = { version = "1.1", features = ["sync"] }
directories = "6.0" directories = "6.0"
# The real system hostname, without a libc call of our own: this crate is
# forbidden `unsafe` and will not make one.
gethostname = "1.1"
netwatch = "0.19.3" netwatch = "0.19.3"
bytes = "1.12.1" bytes = "1.12.1"
boringtun = { version = "0.7.1", default-features = false } boringtun = { version = "0.7.1", default-features = false }
+31 -1
View File
@@ -70,7 +70,7 @@ On the first machine:
```bash ```bash
cargo build --release cargo build --release
./target/release/tsunagi secret # prints tsn1...; share it privately ./target/release/tsunagi id secret generate # prints tsn1...; share it privately
./target/release/tsunagi status # this device, the agent, and this host ./target/release/tsunagi status # this device, the agent, and this host
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard ./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard
@@ -184,6 +184,36 @@ sayable — it remembers who belongs while they are gone, so the report can say
dial-failure counter to imply it. Counters are history and are never graded: dial-failure counter to imply it. Counters are history and are never graded:
a peer that left and came back should not leave the report looking broken. a peer that left and came back should not leave the report looking broken.
`id` is the other half: it shows what this device is — its signing key, the
name it answers to, and the secret of every network it has joined — and
changes those. Every item takes the same shape, so there is nothing to
remember: name it to see it, name it with a value to change it.
```
tsunagi id everything about this device
tsunagi id hostname the name it answers to
tsunagi id hostname mango change it
tsunagi id key the key it signs with
tsunagi id key rotate replace that key
tsunagi id secret the secret of each joined network
tsunagi id secret generate a fresh secret for a network that does not exist yet
```
Secrets appear in `id`, which is where you go to ask for one, and never in
`status`, in a log, in a `Debug` rendering or in anything sent to a peer.
The name is part of the signed state, so changing it revokes the previous
one: there is one record per author, a new version replaces the whole claim,
and no replica can keep the old name standing. Changing it while the agent
runs goes through the agent, which republishes and tells its peers straight
away.
Replacing the signing key makes this device a different member, and it loses
the address and name the old key held — nothing can sign on a retired key's
behalf, and by design there is no authority that could overrule an author. So
the outgoing key signs a release for every network on its way out, which
frees them for whoever wants them next, and the whole thing commits at once.
`status` and `id` both prefer a running agent, which is live and `status` and `id` both prefer a running agent, which is live and
authoritative, and fall back to reading the state store when there is none. authoritative, and fall back to reading the state store when there is none.
Reading takes no directory lock, so neither has to wait for the agent it is Reading takes no directory lock, so neither has to wait for the agent it is
+86 -17
View File
@@ -79,7 +79,8 @@ struct Inner {
storage: Storage, storage: Storage,
identity: DeviceIdentity, identity: DeviceIdentity,
adapter: EndpointAdapter, adapter: EndpointAdapter,
hostname: String, /// Behind a lock because it can be changed while the agent runs.
hostname: std::sync::RwLock<String>,
events: broadcast::Sender<Event>, events: broadcast::Sender<Event>,
networks: RwLock<HashMap<NetworkId, NetworkHandle>>, networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
shutdown: Shutdown, shutdown: Shutdown,
@@ -88,6 +89,24 @@ struct Inner {
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>, transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
} }
impl Inner {
/// The name this agent currently answers to.
fn read_hostname(&self) -> String {
match self.hostname.read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
/// Replaces it. Only [`Agent::set_hostname`] does this.
fn write_hostname(&self, hostname: String) {
match self.hostname.write() {
Ok(mut guard) => *guard = hostname,
Err(poisoned) => *poisoned.into_inner() = hostname,
}
}
}
/// Answers the data plane transport's questions about the agent. /// Answers the data plane transport's questions about the agent.
/// ///
/// Holds a weak reference on purpose: the transport lives inside the agent, so /// Holds a weak reference on purpose: the transport lives inside the agent, so
@@ -142,6 +161,7 @@ impl Agent {
let hostname = resolve_hostname(&config, &storage, identity.endpoint_id())?; let hostname = resolve_hostname(&config, &storage, identity.endpoint_id())?;
storage.set_hostname(hostname.clone()).await?; storage.set_hostname(hostname.clone()).await?;
let hostname = std::sync::RwLock::new(hostname);
let (events, _) = broadcast::channel(config.limits.event_buffer); let (events, _) = broadcast::channel(config.limits.event_buffer);
let limits = Arc::new(config.limits.clone()); let limits = Arc::new(config.limits.clone());
@@ -226,8 +246,45 @@ impl Agent {
} }
/// The hostname announced to peers. /// The hostname announced to peers.
pub fn hostname(&self) -> &str { pub fn hostname(&self) -> String {
&self.inner.hostname self.inner.read_hostname()
}
/// Changes the name this agent answers to, and tells everyone.
///
/// The name is reduced to a canonical form first, so what is stored is
/// what every peer will compare against; the accepted form is returned.
///
/// Every running network publishes a fresh signed claim, which is what
/// gives up the previous name: there is one record per author, so a new
/// version replaces the whole claim and no replica can keep the old name
/// standing. A network that is not running picks it up when it starts.
pub async fn set_hostname(&self, hostname: &str) -> Result<String> {
let hostname = crate::state::sanitise_hostname(hostname);
if hostname.is_empty() {
return Err(Error::InvalidEncoding {
kind: "hostname",
reason: "must contain at least one letter, digit, `-`, `.` or `_`",
});
}
// Stored first: if the process dies here, the next start uses the new
// name rather than silently reverting to the old one.
self.inner.storage.set_hostname(hostname.clone()).await?;
self.inner.write_hostname(hostname.clone());
let senders: Vec<mpsc::Sender<NetCommand>> = self
.inner
.networks
.read()
.await
.values()
.map(|handle| handle.commands.clone())
.collect();
for sender in senders {
let _ = sender.send(NetCommand::SetHostname(hostname.clone())).await;
}
Ok(hostname)
} }
/// The underlying iroh endpoint, for callers that need more detail. /// The underlying iroh endpoint, for callers that need more detail.
@@ -310,7 +367,7 @@ impl Agent {
discovery: self.inner.config.discovery.clone(), discovery: self.inner.config.discovery.clone(),
discovery_interval: self.inner.config.discovery_interval, discovery_interval: self.inner.config.discovery_interval,
plugins: self.inner.config.plugins.clone(), plugins: self.inner.config.plugins.clone(),
hostname: self.inner.hostname.clone(), hostname: self.inner.read_hostname(),
transport: self.inner.transport.get().cloned(), transport: self.inner.transport.get().cloned(),
device_secret: self.inner.identity.signing_key(), device_secret: self.inner.identity.signing_key(),
ipv4_range: self.inner.config.overlay_ipv4_range, ipv4_range: self.inner.config.overlay_ipv4_range,
@@ -461,7 +518,7 @@ impl Agent {
Ok(AgentStatus { Ok(AgentStatus {
endpoint_id: endpoint.endpoint_id, endpoint_id: endpoint.endpoint_id,
hostname: self.inner.hostname.clone(), hostname: self.inner.read_hostname(),
bound_sockets: endpoint.bound_sockets, bound_sockets: endpoint.bound_sockets,
observed_addrs: endpoint.observed_addrs, observed_addrs: endpoint.observed_addrs,
endpoint_addr: self.inner.adapter.addr(), endpoint_addr: self.inner.adapter.addr(),
@@ -803,29 +860,41 @@ async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection
/// Picks the hostname to announce. /// Picks the hostname to announce.
/// ///
/// Order: explicit configuration, then what the state store already holds, then /// Order: an explicit choice, then one the user set earlier and the store
/// a best-effort environment variable, then a stable fallback derived from the /// kept, then the machine's own name, then a fallback derived from the
/// endpoint id. The library does not shell out to discover a hostname. /// endpoint id for the rare host that has no usable name.
///
/// No shell is involved at any step: the system name comes from the platform
/// call, not from running `hostname`.
fn resolve_hostname( fn resolve_hostname(
config: &AgentConfig, config: &AgentConfig,
storage: &Storage, storage: &Storage,
endpoint_id: EndpointId, endpoint_id: EndpointId,
) -> Result<String> { ) -> Result<String> {
if let Some(hostname) = &config.hostname { if let Some(hostname) = &config.hostname {
return Ok(hostname.clone()); return Ok(crate::state::sanitise_hostname(hostname));
} }
if let Some(stored) = storage.hostname_blocking()? if let Some(stored) = storage.hostname_blocking()?
&& !stored.is_empty() && !stored.is_empty()
{ {
return Ok(stored); return Ok(crate::state::sanitise_hostname(&stored));
} }
for key in ["HOSTNAME", "COMPUTERNAME"] { if let Some(system) = system_hostname() {
if let Ok(value) = std::env::var(key) { return Ok(system);
let value = value.trim();
if !value.is_empty() {
return Ok(value.to_string());
}
}
} }
Ok(format!("tsunagi-{}", endpoint_id.fmt_short())) Ok(format!("tsunagi-{}", endpoint_id.fmt_short()))
} }
/// The machine's own name, if it has a usable one.
///
/// Some hosts answer with `localhost`, or with nothing at all. That is not a
/// name that distinguishes this device from any other, so it is treated as
/// absent and the caller falls back to something that does.
pub fn system_hostname() -> Option<String> {
let raw = gethostname::gethostname();
let name = crate::state::sanitise_hostname(&raw.to_string_lossy());
if name.is_empty() || name.eq_ignore_ascii_case("localhost") {
return None;
}
Some(name)
}
+94 -42
View File
@@ -63,6 +63,8 @@ pub(crate) enum NetCommand {
Recheck, Recheck,
/// Resend this agent's announcement to every peer of this network. /// Resend this agent's announcement to every peer of this network.
Reannounce, Reannounce,
/// Answer to a different name from now on.
SetHostname(String),
/// An IP plugin reported an error from one of its own tasks. /// An IP plugin reported an error from one of its own tasks.
PluginError { PluginError {
/// Plugin protocol id. /// Plugin protocol id.
@@ -84,6 +86,7 @@ impl std::fmt::Debug for NetCommand {
NetCommand::Status { .. } => f.write_str("Status"), NetCommand::Status { .. } => f.write_str("Status"),
NetCommand::Recheck => f.write_str("Recheck"), NetCommand::Recheck => f.write_str("Recheck"),
NetCommand::Reannounce => f.write_str("Reannounce"), NetCommand::Reannounce => f.write_str("Reannounce"),
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"), NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"),
} }
} }
@@ -339,6 +342,19 @@ impl Runtime {
} }
NetCommand::Recheck => self.discovery_round().await, NetCommand::Recheck => self.discovery_round().await,
NetCommand::Reannounce => self.reannounce(), NetCommand::Reannounce => self.reannounce(),
NetCommand::SetHostname(hostname) => {
if self.params.hostname != hostname {
self.params.hostname = hostname;
// Publishing the claim is the revocation: one record per
// author, so the new version replaces the old name
// rather than sitting beside it.
self.ensure_own_claim().await;
self.broadcast_state();
}
// Told to peers regardless, so a session that missed the
// earlier announcement is not left with a stale name.
self.reannounce();
}
NetCommand::PluginError { protocol, reason } => { NetCommand::PluginError { protocol, reason } => {
// Counted here so that the per-network metric and the event // Counted here so that the per-network metric and the event
// always agree, wherever the error came from. // always agree, wherever the error came from.
@@ -594,7 +610,7 @@ impl Runtime {
self.own_version = self self.own_version = self
.params .params
.storage .storage
.own_record_version(self.network_id) .own_record_version(self.network_id, self.local_id)
.await .await
.unwrap_or(0); .unwrap_or(0);
@@ -616,52 +632,84 @@ impl Runtime {
/// Called after anything that could change the picture: startup, and /// Called after anything that could change the picture: startup, and
/// every time another replica's records arrive. /// every time another replica's records arrive.
async fn ensure_own_claim(&mut self) { async fn ensure_own_claim(&mut self) {
let Some(range) = self.effective_range() else { let wanted_hostname = {
return; let hostname = crate::state::sanitise_hostname(&self.params.hostname);
(!hostname.is_empty()).then_some(hostname)
}; };
let holders = self.state.address_holders(); let range = self.effective_range();
let mine = self.state.address_of(&self.local_id);
// An address we still hold is kept; this is what makes a returning let wanted_address = match range {
// participant get its old address back. None => None,
if let Some(mine) = mine Some(range) => {
&& range.contains(mine) let holders = self.state.address_holders();
// An address we still hold is kept; this is what makes a
// returning participant get its old address back.
match self.state.address_of(&self.local_id) {
Some(mine) if range.contains(mine) => Some(mine),
_ => {
let taken: std::collections::HashSet<std::net::Ipv4Addr> = holders
.iter()
.filter(|(_, holder)| **holder != self.local_id)
.map(|(address, _)| *address)
.collect();
match allocate(
self.network_id,
self.local_id,
range,
&taken,
self.state
.get(&self.local_id)
.and_then(|record| record.body.claimed_address()),
) {
Ok(address) => Some(address),
Err(err) => {
self.metrics.plugin_errors += 1;
self.emit(Event::PluginError {
network: self.network_id,
protocol: "overlay".into(),
reason: err.to_string(),
});
None
}
}
}
}
}
};
let body = RecordBody::Claim {
address: wanted_address,
// The range only travels with an address, so a member of an
// IPv6-only network does not assert one.
range: wanted_address.and(range),
hostname: wanted_hostname,
};
// Nothing to say is not the same as saying nothing changed: a member
// that claims neither an address nor a name has no reason to occupy a
// record at all.
if matches!(
&body,
RecordBody::Claim {
address: None,
hostname: None,
..
}
) {
return;
}
// Republishing an unchanged claim would bump the version for no
// reason and make every replica store it again.
if self
.state
.get(&self.local_id)
.is_some_and(|record| record.body == body)
{ {
return; return;
} }
let taken: std::collections::HashSet<std::net::Ipv4Addr> = holders self.publish_record(body).await;
.iter()
.filter(|(_, holder)| **holder != self.local_id)
.map(|(address, _)| *address)
.collect();
let wanted = match allocate(
self.network_id,
self.local_id,
range,
&taken,
self.state
.get(&self.local_id)
.and_then(|record| record.body.claimed_address()),
) {
Ok(address) => address,
Err(err) => {
self.metrics.plugin_errors += 1;
self.emit(Event::PluginError {
network: self.network_id,
protocol: "overlay".into(),
reason: err.to_string(),
});
return;
}
};
self.publish_record(RecordBody::Ipv4Claim {
address: wanted,
range,
})
.await;
} }
/// Signs, stores and announces one of this agent's own records. /// Signs, stores and announces one of this agent's own records.
@@ -1236,7 +1284,11 @@ impl Runtime {
let endpoint_id = record.author_id().ok()?; let endpoint_id = record.author_id().ok()?;
Some(MemberStatus { Some(MemberStatus {
endpoint_id, endpoint_id,
overlay_address_v4: record.body.claimed_address(), // Only uncontested claims are reported as held: two
// members may have claimed the same thing, and saying
// 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),
}) })
}) })
.collect(); .collect();
+8 -3
View File
@@ -43,15 +43,20 @@ pub struct CandidateStatus {
/// "this peer is offline" rather than only "nobody is connected". /// "this peer is offline" rather than only "nobody is connected".
/// ///
/// It is not a complete membership list, and cannot be. A member is in signed /// It is not a complete membership list, and cannot be. A member is in signed
/// state once it has claimed something — today that means an IPv4 overlay /// state once it has claimed something — an address, a name, or both — so a
/// address. In an IPv6-only network nothing is claimed, so members are /// member that has joined but never published is visible only while it is
/// visible only while they are connected. /// connected.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemberStatus { pub struct MemberStatus {
/// The member's device identity. /// The member's device identity.
pub endpoint_id: EndpointId, pub endpoint_id: EndpointId,
/// The IPv4 overlay address it claimed and signed for. /// The IPv4 overlay address it claimed and signed for.
pub overlay_address_v4: Option<std::net::Ipv4Addr>, pub overlay_address_v4: Option<std::net::Ipv4Addr>,
/// The name it claimed, when it holds that name uncontested.
///
/// 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>,
} }
/// Status of one authenticated session. /// Status of one authenticated session.
+297 -81
View File
@@ -38,10 +38,11 @@ struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum Command { enum Command {
/// Generates a fresh network secret and prints it. /// Shows this device's identity and secrets, and changes them.
Secret, ///
/// Shows this device's identity without joining anything. /// Every item follows the same shape: name it to see it, name it with a
Id(StatusArgs), /// value to change it.
Id(IdArgs),
/// Joins a network and runs until interrupted. /// Joins a network and runs until interrupted.
// Boxed: it is much larger than the other variants, and every command // Boxed: it is much larger than the other variants, and every command
// but this one would otherwise pay for its size. A `//` comment, not a // but this one would otherwise pay for its size. A `//` comment, not a
@@ -51,6 +52,54 @@ enum Command {
Status(StatusArgs), Status(StatusArgs),
} }
#[derive(Debug, Args)]
struct IdArgs {
#[command(flatten)]
paths: PathArgs,
/// Control socket to talk to. Derived from the state directory by default.
#[arg(long, global = true)]
control_socket: Option<PathBuf>,
#[command(subcommand)]
action: Option<IdAction>,
}
#[derive(Debug, Subcommand)]
enum IdAction {
/// Shows the name this device answers to, or changes it.
Hostname {
/// The new name. Omit it to see the current one.
name: Option<String>,
},
/// Shows the key this device signs with.
Key {
#[command(subcommand)]
action: Option<KeyAction>,
},
/// Shows the secret of every network this device has joined.
Secret {
#[command(subcommand)]
action: Option<SecretAction>,
},
}
#[derive(Debug, Subcommand)]
enum KeyAction {
/// Replaces the signing key with a fresh one.
///
/// This device becomes a different member. The outgoing key gives up the
/// addresses and names it held on the way out, so they are freed rather
/// than reserved to a key nobody has. Requires the agent to be stopped.
Rotate,
}
#[derive(Debug, Subcommand)]
enum SecretAction {
/// Prints a fresh random secret, for a network that does not exist yet.
Generate,
}
#[derive(Debug, Args)] #[derive(Debug, Args)]
struct StatusArgs { struct StatusArgs {
#[command(flatten)] #[command(flatten)]
@@ -80,11 +129,14 @@ fn resolve_ipv4_range(
#[derive(Debug, Args, Clone)] #[derive(Debug, Args, Clone)]
struct PathArgs { struct PathArgs {
// Global, so they may be written before or after a subcommand. A
// sub-subcommand that silently rejected the flag its parent accepts is
// the kind of inconsistency that makes a tool feel arbitrary.
/// Directory for the mandatory state. Defaults to the platform location. /// Directory for the mandatory state. Defaults to the platform location.
#[arg(long, env = "TSUNAGI_STATE_DIR")] #[arg(long, env = "TSUNAGI_STATE_DIR", global = true)]
state_dir: Option<PathBuf>, state_dir: Option<PathBuf>,
/// Directory for the disposable cache. Defaults to the platform location. /// Directory for the disposable cache. Defaults to the platform location.
#[arg(long, env = "TSUNAGI_CACHE_DIR")] #[arg(long, env = "TSUNAGI_CACHE_DIR", global = true)]
cache_dir: Option<PathBuf>, cache_dir: Option<PathBuf>,
} }
@@ -283,16 +335,7 @@ fn main() -> std::process::ExitCode {
async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> { async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
match command { match command {
Command::Secret => { Command::Id(args) => id(args).await,
let secret = NetworkSecret::generate();
println!("{}", secret.encode().as_str());
eprintln!(
"\nShare this with every participant, over a channel you trust.\n\
Anyone who has it can join the network."
);
Ok(())
}
Command::Id(args) => show_id(args).await,
Command::Up(args) => up(*args).await, Command::Up(args) => up(*args).await,
Command::Status(args) => status(args).await, Command::Status(args) => status(args).await,
} }
@@ -320,7 +363,6 @@ enum Observed {
Stored { Stored {
endpoint_id: Option<String>, endpoint_id: Option<String>,
hostname: Option<String>, hostname: Option<String>,
networks: Vec<(String, String, bool)>,
/// Why the agent could not be asked. /// Why the agent could not be asked.
why: String, why: String,
/// Whether a socket was there at all. /// Whether a socket was there at all.
@@ -345,34 +387,20 @@ async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
// Read-only, and deliberately tolerant: a state directory that has never // Read-only, and deliberately tolerant: a state directory that has never
// been used is not an error, it just has nothing to report yet. // been used is not an error, it just has nothing to report yet.
let (endpoint_id, hostname, networks) = let (endpoint_id, hostname) = match tsunagi::storage::StateStore::open(paths.state_db()) {
match tsunagi::storage::StateStore::open(paths.state_db()) { Ok(store) => (
Ok(store) => ( store
store .device_identity()
.device_identity() .ok()
.ok() .flatten()
.flatten() .map(|identity| identity.endpoint_id().to_string()),
.map(|identity| identity.endpoint_id().to_string()), store.hostname().ok().flatten(),
store.hostname().ok().flatten(), ),
store Err(_) => (None, None),
.list_networks() };
.unwrap_or_default()
.into_iter()
.map(|network| {
(
network.name.to_string(),
network.network_id.to_string(),
network.auto_start,
)
})
.collect(),
),
Err(_) => (None, None, Vec::new()),
};
Observed::Stored { Observed::Stored {
endpoint_id, endpoint_id,
hostname, hostname,
networks,
why, why,
socket_present, socket_present,
} }
@@ -416,57 +444,245 @@ fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section
device device
} }
/// The `networks` section, as the state store knows them. /// The networks this device belongs to, named but not described.
fn stored_networks_section(networks: &[(String, String, bool)]) -> report::Section { ///
/// No secrets: this is part of `status`, and a status report is somewhere a
/// secret must never appear. `tsunagi id secret` is the place that shows one,
/// because asking for it there is deliberate.
fn configured_networks_section(paths: &StoragePaths) -> report::Section {
use report::{Health, Row, Section}; use report::{Health, Row, Section};
let mut section = Section::new("networks"); let mut section = Section::new("networks");
let networks = stored_networks(paths);
if networks.is_empty() { if networks.is_empty() {
section.push(Row::new(Health::Info, "none", "no network has been joined")); section.push(Row::new(Health::Info, "none", "no network has been joined"));
} }
for (name, id, auto_start) in networks { for network in &networks {
section.push(Row::new( section.push(Row::new(
Health::Info, Health::Info,
name, network.name.as_str(),
format!("{id}{}", if *auto_start { " (auto-start)" } else { "" }), format!(
"{}{}",
network.network_id,
if network.auto_start {
" (auto-start)"
} else {
""
}
),
)); ));
} }
section section
} }
async fn show_id(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> { /// Serves the local control socket from the running agent.
use report::{Health, Report, Row, Section}; ///
/// A struct rather than a closure because this end both answers questions and
/// accepts changes, and a change has to reach the agent itself: writing one
/// into the store behind its back would be overwritten by the next thing it
/// published.
struct AgentControl {
agent: Agent,
plugin: Option<Arc<WireguardPlugin>>,
}
impl tsunagi::ipc::unix::ReportSource for AgentControl {
fn report(&self) -> tsunagi::BoxFuture<'_, tsunagi::ipc::StatusReport> {
Box::pin(async move { build_report(&self.agent, self.plugin.as_deref()).await })
}
fn set_hostname(&self, hostname: String) -> tsunagi::BoxFuture<'_, Result<String, String>> {
Box::pin(async move {
self.agent
.set_hostname(&hostname)
.await
.map_err(|err| err.to_string())
})
}
}
/// The networks this device has joined, read straight from the store.
///
/// Secrets live only in the mandatory state, never in a status report and
/// never on the control socket, so they are read here rather than asked for.
fn stored_networks(paths: &StoragePaths) -> Vec<tsunagi::storage::StoredNetwork> {
tsunagi::storage::StateStore::open(paths.state_db())
.and_then(|store| store.list_networks())
.unwrap_or_default()
}
/// `tsunagi id`: what this device is, and what changes it.
async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?; let paths = args.paths.resolve()?;
let socket = control_socket(&paths, args.control_socket.as_ref()); let socket = control_socket(&paths, args.control_socket.as_ref());
let observed = observe(&paths, &socket).await;
let mut out = Report::new(); match args.action {
out.push(device_section(&paths, &observed)); None => show_identity(&paths, &socket).await,
match &observed { Some(IdAction::Hostname { name: None }) => show_hostname(&paths, &socket).await,
Observed::Agent(report) => { Some(IdAction::Hostname { name: Some(name) }) => set_hostname(&paths, &socket, &name).await,
let mut section = Section::new("networks"); Some(IdAction::Key { action: None }) => show_key(&paths, &socket).await,
if report.networks.is_empty() { Some(IdAction::Key {
section.push(Row::new(Health::Info, "none", "no network has been joined")); action: Some(KeyAction::Rotate),
} }) => rotate_key(&paths, &socket).await,
for network in &report.networks { Some(IdAction::Secret { action: None }) => show_secrets(&paths),
section.push(Row::new( Some(IdAction::Secret {
Health::Info, action: Some(SecretAction::Generate),
&network.name, }) => {
format!( let secret = NetworkSecret::generate();
"{} ({})", println!("{}", secret.encode().as_str());
network.network_id, eprintln!(
if network.active { "active" } else { "inactive" } "\nShare this with every participant, over a channel you trust.\n\
), Anyone who has it can join the network."
)); );
} Ok(())
out.push(section);
} }
Observed::Stored { networks, .. } => out.push(stored_networks_section(networks)),
} }
}
/// Everything about this device in one view.
async fn show_identity(
paths: &StoragePaths,
socket: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
use report::{Health, Report, Row, Section};
let observed = observe(paths, socket).await;
let mut out = Report::new();
let mut device = device_section(paths, &observed);
device.push(Row::new(
Health::Info,
"signs with",
"the endpoint key above; there is no separate signing certificate",
));
out.push(device);
let networks = stored_networks(paths);
let mut section = Section::new("networks");
if networks.is_empty() {
section.push(Row::new(Health::Info, "none", "no network has been joined"));
}
for network in &networks {
section.push(
Row::new(
Health::Info,
network.name.as_str(),
network.network_id.to_string(),
)
.with_note(format!("secret {}", network.secret.encode().as_str())),
);
}
out.push(section);
print_report("tsunagi id", &out) print_report("tsunagi id", &out)
} }
/// The name this device answers to.
async fn show_hostname(
paths: &StoragePaths,
socket: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
match observe(paths, socket).await {
Observed::Agent(report) => println!("{}", report.hostname),
Observed::Stored { hostname, .. } => match hostname {
Some(hostname) => println!("{hostname}"),
None => println!(
"{}",
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
),
},
}
Ok(())
}
/// Changes the name, through the agent when one is running.
///
/// Through it rather than behind its back: the agent republishes its signed
/// claim, which is what gives up the previous name, and tells its peers. A
/// write straight to the store while it ran would be overwritten by the next
/// thing the agent published.
async fn set_hostname(
paths: &StoragePaths,
socket: &std::path::Path,
name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
if socket.exists() {
return match tsunagi::ipc::unix::set_hostname(socket, name).await {
Ok(accepted) => {
println!("{accepted}");
Ok(())
}
Err(err) => {
Err(format!("the agent is running but would not accept the change: {err}").into())
}
};
}
let accepted = tsunagi::state::sanitise_hostname(name);
if accepted.is_empty() {
return Err("a hostname must contain at least one letter, digit, `-`, `.` or `_`".into());
}
let store = tsunagi::storage::StateStore::open(paths.state_db())?;
store.set_hostname(&accepted)?;
println!("{accepted}");
Ok(())
}
/// The key this device signs with.
async fn show_key(
paths: &StoragePaths,
socket: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
match observe(paths, socket).await {
Observed::Agent(report) => println!("{}", report.endpoint_id),
Observed::Stored { endpoint_id, .. } => match endpoint_id {
Some(id) => println!("{id}"),
None => return Err("this device has no identity yet; start an agent once".into()),
},
}
Ok(())
}
/// Replaces the signing key.
async fn rotate_key(
paths: &StoragePaths,
socket: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
// The rotation writes, so it needs the directory to itself. Refused up
// front rather than after the lock fails, because what a lock failure
// says does not tell the reader what to do about it.
if socket.exists() {
return Err(
"stop the agent first: replacing the signing key rewrites state it is using".into(),
);
}
let store = tsunagi::storage::StateStore::open(paths.state_db())?;
let (identity, released) = store.rotate_device_identity()?;
println!("{}", identity.endpoint_id());
if !released.is_empty() {
eprintln!(
"\nReleased what the previous key held in {} network(s). \
This device rejoins as a new member and is allocated a new address.",
released.len()
);
}
Ok(())
}
/// The secret of every network this device has joined.
fn show_secrets(paths: &StoragePaths) -> Result<(), Box<dyn std::error::Error>> {
let networks = stored_networks(paths);
if networks.is_empty() {
eprintln!("no network has been joined");
return Ok(());
}
for network in networks {
println!("{} {}", network.name, network.secret.encode().as_str());
}
Ok(())
}
/// Reports this device, what the agent is doing, and what this host can do. /// Reports this device, what the agent is doing, and what this host can do.
/// ///
/// Three levels, and the distinction between the middle two is deliberate: /// Three levels, and the distinction between the middle two is deliberate:
@@ -530,10 +746,15 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
} }
out.push(agent); out.push(agent);
if let Observed::Agent(report) = &observed { match &observed {
for network in &report.networks { Observed::Agent(report) => {
out.push(network_section(network, &report.endpoint_id)); for network in &report.networks {
out.push(network_section(network, &report.endpoint_id));
}
} }
// Without an agent there is no live view, but the store still knows
// which networks this device belongs to, which is worth saying.
Observed::Stored { .. } => out.push(configured_networks_section(&paths)),
} }
out.push(host_section()); out.push(host_section());
@@ -1395,13 +1616,8 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let control = { let control = {
let agent = agent.clone(); let agent = agent.clone();
let plugin = wireguard.clone(); let plugin = wireguard.clone();
let source: Arc<dyn tsunagi::ipc::unix::ReportSource> = Arc::new( let source: Arc<dyn tsunagi::ipc::unix::ReportSource> =
move || -> tsunagi::BoxFuture<'static, tsunagi::ipc::StatusReport> { Arc::new(AgentControl { agent, plugin });
let agent = agent.clone();
let plugin = plugin.clone();
Box::pin(async move { build_report(&agent, plugin.as_deref()).await })
},
);
let path = control_socket(&paths, args.control_socket.as_ref()); let path = control_socket(&paths, args.control_socket.as_ref());
match tsunagi::ipc::unix::ControlSocket::bind(path, source).await { match tsunagi::ipc::unix::ControlSocket::bind(path, source).await {
Ok(socket) => { Ok(socket) => {
+8
View File
@@ -66,6 +66,12 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
pub enum Request { pub enum Request {
/// Report what the agent is doing. /// Report what the agent is doing.
Status, Status,
/// Answer to a different name from now on.
///
/// Applied by the running agent rather than written behind its back, so
/// the change takes effect and reaches peers immediately instead of
/// waiting for a restart.
SetHostname(String),
} }
/// What the agent answers. /// What the agent answers.
@@ -74,6 +80,8 @@ pub enum Request {
pub enum Response { pub enum Response {
/// A status report. /// A status report.
Status(Box<StatusReport>), Status(Box<StatusReport>),
/// The name the agent now answers to, after reducing it to canonical form.
Hostname(String),
/// The request could not be served. /// The request could not be served.
Error(String), Error(String),
} }
+38 -1
View File
@@ -25,6 +25,18 @@ use super::{MAX_MESSAGE_LEN, Request, Response, StatusReport};
pub trait ReportSource: Send + Sync + 'static { pub trait ReportSource: Send + Sync + 'static {
/// Produces a fresh report. /// Produces a fresh report.
fn report(&self) -> BoxFuture<'_, StatusReport>; fn report(&self) -> BoxFuture<'_, StatusReport>;
/// Changes the name the agent answers to, returning the accepted form.
///
/// Defaulted to a refusal so that a source which only reports — the
/// closure impl below, and every test that uses it — stays valid and
/// says plainly that it cannot do this, rather than appearing to.
fn set_hostname(
&self,
_hostname: String,
) -> BoxFuture<'_, std::result::Result<String, String>> {
Box::pin(async move { Err("this agent cannot change its hostname".to_string()) })
}
} }
impl<F> ReportSource for F impl<F> ReportSource for F
@@ -137,6 +149,10 @@ async fn handle(mut stream: UnixStream, source: Arc<dyn ReportSource>) -> Result
let request: Request = read_message(&mut stream).await?; let request: Request = read_message(&mut stream).await?;
let response = match request { let response = match request {
Request::Status => Response::Status(Box::new(source.report().await)), Request::Status => Response::Status(Box::new(source.report().await)),
Request::SetHostname(hostname) => match source.set_hostname(hostname).await {
Ok(accepted) => Response::Hostname(accepted),
Err(reason) => Response::Error(reason),
},
}; };
write_message(&mut stream, &response).await write_message(&mut stream, &response).await
} }
@@ -154,6 +170,27 @@ pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
match read_message::<Response>(&mut stream).await? { match read_message::<Response>(&mut stream).await? {
Response::Status(report) => Ok(*report), Response::Status(report) => Ok(*report),
Response::Error(reason) => Err(Error::Storage(reason)), Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
}
}
/// Asks a running agent to answer to a different name.
///
/// Returns the name it accepted, which is the canonical form of what was
/// asked for and may differ from it.
pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<String> {
let path = path.as_ref();
let mut stream = UnixStream::connect(path)
.await
.map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
write_message(&mut stream, &Request::SetHostname(hostname.to_string())).await?;
match read_message::<Response>(&mut stream).await? {
Response::Hostname(accepted) => Ok(accepted),
Response::Error(reason) => Err(Error::Storage(reason)),
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
} }
} }
@@ -168,7 +205,7 @@ pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
/// ///
/// Bump it whenever [`Request`], [`Response`] or anything they contain /// Bump it whenever [`Request`], [`Response`] or anything they contain
/// changes shape. /// changes shape.
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 2]); pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 3]);
async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> { async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> {
let encoded = postcard::to_stdvec(value) let encoded = postcard::to_stdvec(value)
+6 -1
View File
@@ -196,7 +196,12 @@ mod tests {
#[test] #[test]
fn allocation_is_deterministic_and_spread_out() { fn allocation_is_deterministic_and_spread_out() {
let id = network("spread"); let id = network("spread");
let authors: Vec<_> = (0..40).map(|_| SecretKey::generate().public()).collect(); // Fixed keys, not random ones. With 40 random authors in a /24 the
// birthday problem alone makes a handful of collisions likely, so a
// threshold on the count was a coin flip rather than a property.
let authors: Vec<_> = (0..40u8)
.map(|seed| SecretKey::from_bytes(&[seed; 32]).public())
.collect();
let first: Vec<_> = authors let first: Vec<_> = authors
.iter() .iter()
+353 -50
View File
@@ -121,7 +121,14 @@ pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range {
}; };
/// Frozen domain separator for the bytes a record signature covers. /// Frozen domain separator for the bytes a record signature covers.
pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v1"; pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v2";
/// Longest hostname a record may carry.
///
/// One DNS label's worth. It bounds what arrives from the network before
/// anything is allocated for it, and keeps a name short enough to print in a
/// column.
pub const MAX_HOSTNAME_LEN: usize = 63;
/// Largest number of records accepted in one exchange. /// Largest number of records accepted in one exchange.
pub const MAX_RECORDS_PER_MESSAGE: usize = 256; pub const MAX_RECORDS_PER_MESSAGE: usize = 256;
@@ -154,59 +161,132 @@ pub enum StateError {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive] #[non_exhaustive]
pub enum RecordBody { pub enum RecordBody {
/// This author holds an IPv4 overlay address, in this range. /// Everything this author currently asserts about itself.
/// ///
/// The range travels with the claim so that a participant joining later /// One record per author, so everything it claims travels together and a
/// learns which range the network actually settled on, rather than having /// later version supersedes the lot. That is what makes changing a claim
/// to be told. /// a revocation of the previous one rather than an addition beside it:
Ipv4Claim { /// there is no way to leave the old value standing.
/// The address this author holds. Claim {
address: Ipv4Addr, /// The IPv4 overlay address this author holds, if it holds one.
/// The overlay range it was allocated from. address: Option<Ipv4Addr>,
range: Ipv4Range, /// The overlay range that address was allocated from.
///
/// The range travels with the claim so that a participant joining
/// later learns which range the network actually settled on, rather
/// than having to be told.
range: Option<Ipv4Range>,
/// The name this author answers to.
hostname: Option<String>,
}, },
/// This author gave its address up. /// This author gives up everything it claimed.
/// ///
/// A tombstone, not an absence: it is a positive statement, so it /// A tombstone, not an absence: it is a positive statement, so it
/// survives merging and cannot be undone by a replica that simply has not /// survives merging and cannot be undone by a replica that simply has not
/// heard of it. /// heard of it. Published when a device key is replaced, so the address
Ipv4Release, /// and name it held are freed for somebody else rather than reserved
/// forever to a key nobody has.
Release,
} }
impl RecordBody { impl RecordBody {
/// The address this body claims, if any. /// The address this body claims, if any.
pub fn claimed_address(&self) -> Option<Ipv4Addr> { pub fn claimed_address(&self) -> Option<Ipv4Addr> {
match self { match self {
RecordBody::Ipv4Claim { address, .. } => Some(*address), RecordBody::Claim { address, .. } => *address,
RecordBody::Ipv4Release => None, RecordBody::Release => None,
} }
} }
/// The range this body names, if any. /// The range this body names, if any.
pub fn range(&self) -> Option<Ipv4Range> { pub fn range(&self) -> Option<Ipv4Range> {
match self { match self {
RecordBody::Ipv4Claim { range, .. } => Some(*range), RecordBody::Claim { range, .. } => *range,
RecordBody::Ipv4Release => None, RecordBody::Release => None,
}
}
/// The hostname this body claims, if any.
pub fn hostname(&self) -> Option<&str> {
match self {
RecordBody::Claim { hostname, .. } => hostname.as_deref(),
RecordBody::Release => None,
} }
} }
fn canonical(&self) -> Vec<u8> { fn canonical(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(48); let mut out = Vec::with_capacity(96);
match self { match self {
RecordBody::Ipv4Claim { address, range } => { RecordBody::Claim {
push_lp(&mut out, b"ipv4-claim"); address,
push_lp(&mut out, &address.octets()); range,
push_lp(&mut out, &range.base.octets()); hostname,
push_lp(&mut out, &[range.prefix_len]); } => {
push_lp(&mut out, b"claim");
push_opt(
&mut out,
address
.map(|address| address.octets())
.as_ref()
.map(|o| &o[..]),
);
push_opt(
&mut out,
range
.map(|range| {
let mut bytes = range.base.octets().to_vec();
bytes.push(range.prefix_len);
bytes
})
.as_deref(),
);
push_opt(&mut out, hostname.as_deref().map(str::as_bytes));
} }
RecordBody::Ipv4Release => { RecordBody::Release => {
push_lp(&mut out, b"ipv4-release"); push_lp(&mut out, b"release");
} }
} }
out out
} }
} }
/// Reduces a hostname to something safe to store, compare and print.
///
/// Three jobs at once. It bounds the length, so a record from the network
/// cannot carry an unbounded string. It strips everything outside a
/// conservative set, so a name can never be mistaken for a path, an option or
/// a shell word by anything downstream — nothing here is ever executed, and
/// this keeps it that way even if some later caller is careless. And it
/// lower-cases, so that two members claiming the same name in different cases
/// are recognised as claiming the same name rather than quietly both holding
/// it.
pub fn sanitise_hostname(raw: &str) -> String {
let mut out = String::with_capacity(raw.len().min(MAX_HOSTNAME_LEN));
for ch in raw.chars() {
if out.len() >= MAX_HOSTNAME_LEN {
break;
}
let ch = ch.to_ascii_lowercase();
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '.' | '_') {
out.push(ch);
}
}
// A name that is only separators distinguishes nothing.
let trimmed = out.trim_matches(|ch| matches!(ch, '-' | '.' | '_'));
trimmed.to_string()
}
/// Writes an optional value unambiguously: a presence byte, then the value.
fn push_opt(out: &mut Vec<u8>, value: Option<&[u8]>) {
match value {
Some(bytes) => {
out.push(1);
push_lp(out, bytes);
}
None => out.push(0),
}
}
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) { fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX); let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_be_bytes()); out.extend_from_slice(&len.to_be_bytes());
@@ -278,14 +358,40 @@ impl SignedRecord {
if self.network != *network.as_bytes() { if self.network != *network.as_bytes() {
return Err(StateError::WrongNetwork); return Err(StateError::WrongNetwork);
} }
if let RecordBody::Ipv4Claim { address, range } = &self.body { if let RecordBody::Claim {
if range.prefix_len > 30 { address,
return Err(StateError::Malformed("claimed range has no room for hosts")); range,
hostname,
} = &self.body
{
// Bounds before anything is believed, because all of this came
// off the network.
if let Some(range) = range {
if range.prefix_len > 30 {
return Err(StateError::Malformed("claimed range has no room for hosts"));
}
if let Some(address) = address
&& !range.contains(*address)
{
return Err(StateError::Malformed(
"claimed address is outside its range",
));
}
} else if address.is_some() {
return Err(StateError::Malformed("claimed an address with no range"));
} }
if !range.contains(*address) { if let Some(hostname) = hostname {
return Err(StateError::Malformed( // Rejected rather than sanitised: a name that does not
"claimed address is outside its range", // survive sanitising unchanged would hash and compare
)); // differently from what the author signed, so accepting a
// repaired version would mean believing something nobody
// signed.
if hostname.len() > MAX_HOSTNAME_LEN {
return Err(StateError::Malformed("claimed hostname is too long"));
}
if *hostname != sanitise_hostname(hostname) {
return Err(StateError::Malformed("claimed hostname is not canonical"));
}
} }
} }
@@ -448,6 +554,35 @@ impl StateSet {
holders holders
} }
/// Who currently holds each claimed hostname.
///
/// The same rule as addresses, for the same reason: a name is owned, two
/// members may claim one, and every replica has to reach the same answer
/// about who has it without asking anybody.
pub fn hostname_holders(&self) -> HashMap<String, EndpointId> {
let mut holders: HashMap<String, EndpointId> = HashMap::new();
for (author, record) in &self.records {
let Some(hostname) = record.body.hostname() else {
continue;
};
holders
.entry(hostname.to_string())
.and_modify(|held| {
if author.as_bytes() < held.as_bytes() {
*held = *author;
}
})
.or_insert(*author);
}
holders
}
/// The hostname an author holds, if it holds one uncontested.
pub fn hostname_of(&self, author: &EndpointId) -> Option<&str> {
let hostname = self.records.get(author)?.body.hostname()?;
(self.hostname_holders().get(hostname) == Some(author)).then_some(hostname)
}
/// The address an author holds, if it holds one uncontested. /// The address an author holds, if it holds one uncontested.
pub fn address_of(&self, author: &EndpointId) -> Option<Ipv4Addr> { pub fn address_of(&self, author: &EndpointId) -> Option<Ipv4Addr> {
let address = self.records.get(author)?.body.claimed_address()?; let address = self.records.get(author)?.body.claimed_address()?;
@@ -493,9 +628,10 @@ mod tests {
} }
fn claim(address: &str) -> RecordBody { fn claim(address: &str) -> RecordBody {
RecordBody::Ipv4Claim { RecordBody::Claim {
address: address.parse().unwrap(), address: Some(address.parse().unwrap()),
range: range(), range: Some(range()),
hostname: None,
} }
} }
@@ -551,9 +687,10 @@ mod tests {
&secret, &secret,
id, id,
1, 1,
RecordBody::Ipv4Claim { RecordBody::Claim {
address: "10.99.0.1".parse().unwrap(), address: Some("10.99.0.1".parse().unwrap()),
range: range(), range: Some(range()),
hostname: None,
}, },
); );
assert!(matches!(outside.verify(id), Err(StateError::Malformed(_)))); assert!(matches!(outside.verify(id), Err(StateError::Malformed(_))));
@@ -562,17 +699,185 @@ mod tests {
&secret, &secret,
id, id,
1, 1,
RecordBody::Ipv4Claim { RecordBody::Claim {
address: "10.13.37.1".parse().unwrap(), address: Some("10.13.37.1".parse().unwrap()),
range: Ipv4Range { range: Some(Ipv4Range {
base: "10.13.37.0".parse().unwrap(), base: "10.13.37.0".parse().unwrap(),
prefix_len: 31, prefix_len: 31,
}, }),
hostname: None,
}, },
); );
assert!(matches!(no_hosts.verify(id), Err(StateError::Malformed(_)))); assert!(matches!(no_hosts.verify(id), Err(StateError::Malformed(_))));
} }
#[test]
fn a_hostname_is_reduced_to_something_safe_to_store_and_compare() {
// Lower-cased, so two members cannot both "own" the same name in
// different cases without noticing.
assert_eq!(sanitise_hostname("Music"), "music");
// Stripped, so nothing downstream can mistake a name for a path, an
// option or a shell word.
assert_eq!(sanitise_hostname("ab; rm -rf /"), "abrm-rf");
assert_eq!(sanitise_hostname("a/b\\c"), "abc");
assert_eq!(sanitise_hostname("host name"), "hostname");
// Bounded before anything is allocated for it.
assert_eq!(sanitise_hostname(&"x".repeat(200)).len(), MAX_HOSTNAME_LEN);
// A name of nothing but separators distinguishes nothing.
assert_eq!(sanitise_hostname("---"), "");
assert_eq!(sanitise_hostname(""), "");
// Already canonical names survive untouched, or the check in
// `verify` would reject what this produced.
for name in ["music", "ab-laptop", "host.example", "a_b.c-1"] {
assert_eq!(sanitise_hostname(name), name);
}
}
#[test]
fn a_hostname_that_is_not_canonical_is_rejected_rather_than_repaired() {
// Repairing it would mean storing something the author never signed.
let secret = SecretKey::generate();
let id = network("naming");
for bad in ["Music", "ab; rm", "x".repeat(MAX_HOSTNAME_LEN + 1).as_str()] {
let record = SignedRecord::sign(
&secret,
id,
1,
RecordBody::Claim {
address: None,
range: None,
hostname: Some(bad.to_string()),
},
);
assert!(
matches!(record.verify(id), Err(StateError::Malformed(_))),
"accepted {bad:?}"
);
}
}
#[test]
fn an_address_without_a_range_is_rejected() {
let secret = SecretKey::generate();
let id = network("naming");
let record = SignedRecord::sign(
&secret,
id,
1,
RecordBody::Claim {
address: Some("10.13.37.5".parse().unwrap()),
range: None,
hostname: None,
},
);
assert!(matches!(record.verify(id), Err(StateError::Malformed(_))));
}
#[test]
fn two_members_claiming_one_name_resolve_it_the_same_way_everywhere() {
// The same rule as addresses: a name is owned, and every replica has
// to reach the same answer about who owns it with nobody to ask.
let a = SecretKey::generate();
let b = SecretKey::generate();
let id = network("naming");
let named = |secret: &SecretKey| {
SignedRecord::sign(
secret,
id,
1,
RecordBody::Claim {
address: None,
range: None,
hostname: Some("music".into()),
},
)
};
let mut set = StateSet::new();
set.merge(id, named(&a)).unwrap();
set.merge(id, named(&b)).unwrap();
let (lower, higher) = if a.public().as_bytes() < b.public().as_bytes() {
(a.public(), b.public())
} else {
(b.public(), a.public())
};
assert_eq!(set.hostname_of(&lower), Some("music"));
assert_eq!(
set.hostname_of(&higher),
None,
"the loser does not hold the name it claimed"
);
// And the order the records arrived in cannot change the answer.
let mut reversed = StateSet::new();
reversed.merge(id, named(&b)).unwrap();
reversed.merge(id, named(&a)).unwrap();
assert_eq!(reversed.hostname_of(&lower), Some("music"));
}
#[test]
fn changing_a_name_revokes_the_old_one_everywhere() {
// There is one record per author, so a new version replaces the whole
// claim. The previous name cannot survive beside it.
let secret = SecretKey::generate();
let id = network("naming");
let claim = |version, name: &str| {
SignedRecord::sign(
&secret,
id,
version,
RecordBody::Claim {
address: None,
range: None,
hostname: Some(name.to_string()),
},
)
};
let mut set = StateSet::new();
set.merge(id, claim(1, "old")).unwrap();
set.merge(id, claim(2, "new")).unwrap();
assert_eq!(set.hostname_of(&secret.public()), Some("new"));
assert!(
!set.hostname_holders().contains_key("old"),
"the old name is gone, not merely shadowed"
);
// A replica that has not heard of the change cannot bring it back.
set.merge(id, claim(1, "old")).unwrap();
assert_eq!(set.hostname_of(&secret.public()), Some("new"));
}
#[test]
fn a_release_gives_up_the_name_as_well_as_the_address() {
let secret = SecretKey::generate();
let id = network("naming");
let mut set = StateSet::new();
set.merge(
id,
SignedRecord::sign(
&secret,
id,
1,
RecordBody::Claim {
address: Some("10.13.37.5".parse().unwrap()),
range: Some(range()),
hostname: Some("music".into()),
},
),
)
.unwrap();
set.merge(id, SignedRecord::sign(&secret, id, 2, RecordBody::Release))
.unwrap();
assert_eq!(set.address_of(&secret.public()), None);
assert_eq!(set.hostname_of(&secret.public()), None);
assert!(set.address_holders().is_empty());
assert!(set.hostname_holders().is_empty());
}
#[test] #[test]
fn a_newer_version_wins_and_an_older_one_never_rolls_back() { fn a_newer_version_wins_and_an_older_one_never_rolls_back() {
let id = network("versions"); let id = network("versions");
@@ -673,11 +978,8 @@ mod tests {
.unwrap(); .unwrap();
assert!(set.address_of(&secret.public()).is_some()); assert!(set.address_of(&secret.public()).is_some());
set.merge( set.merge(id, SignedRecord::sign(&secret, id, 2, RecordBody::Release))
id, .unwrap();
SignedRecord::sign(&secret, id, 2, RecordBody::Ipv4Release),
)
.unwrap();
assert_eq!(set.address_of(&secret.public()), None); assert_eq!(set.address_of(&secret.public()), None);
assert!(set.address_holders().is_empty()); assert!(set.address_holders().is_empty());
@@ -722,9 +1024,10 @@ mod tests {
&author, &author,
id, id,
1, 1,
RecordBody::Ipv4Claim { RecordBody::Claim {
address: "10.99.0.7".parse().unwrap(), address: Some("10.99.0.7".parse().unwrap()),
range: custom, range: Some(custom),
hostname: None,
}, },
), ),
) )
+6 -2
View File
@@ -302,8 +302,12 @@ impl Storage {
} }
/// The highest version this agent has ever published for a network. /// The highest version this agent has ever published for a network.
pub async fn own_record_version(&self, network_id: NetworkId) -> Result<u64> { pub async fn own_record_version(
self.with_state(move |state| state.own_record_version(network_id)) &self,
network_id: NetworkId,
author: iroh::EndpointId,
) -> Result<u64> {
self.with_state(move |state| state.own_record_version(network_id, author))
.await .await
} }
+172 -14
View File
@@ -18,10 +18,10 @@ use rusqlite::{Connection, OptionalExtension, params};
use crate::error::{Error, Result}; use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret}; use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
use crate::state::SignedRecord; use crate::state::{RecordBody, SignedRecord};
/// Schema version written by this build. /// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 2; pub const SCHEMA_VERSION: i64 = 3;
/// Key of the stored hostname setting. /// Key of the stored hostname setting.
const SETTING_HOSTNAME: &str = "hostname"; const SETTING_HOSTNAME: &str = "hostname";
@@ -57,6 +57,14 @@ impl StateStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self> { pub fn open(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref().to_path_buf(); let path = path.as_ref().to_path_buf();
let existed = path.exists(); let existed = path.exists();
// The database file is created on demand, so the directory holding
// it has to be too — with the same restricted permissions the agent
// would have given it, never looser.
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
super::create_dir(parent)?;
}
let conn = Connection::open(&path).map_err(|err| Error::StateCorrupted { let conn = Connection::open(&path).map_err(|err| Error::StateCorrupted {
path: path.clone(), path: path.clone(),
reason: format!("cannot open database: {err}"), reason: format!("cannot open database: {err}"),
@@ -116,6 +124,29 @@ impl StateStore {
return self.verify_shape(); return self.verify_shape();
} }
// Migration 2 -> 3: the record body gained a hostname, which changed
// the signing domain, and the version counter gained an author.
//
// The stored records are discarded rather than carried over. They
// were signed under a domain that no longer verifies, so keeping them
// would mean holding rows that every read has to reject — and one of
// those rejections could be mistaken for corruption. Each member
// re-publishes its claim on the next run, which is the one thing here
// that repairs itself.
if (2..3).contains(&found) {
self.conn
.execute_batch(
"BEGIN;
DROP TABLE IF EXISTS signed_records;
DROP TABLE IF EXISTS own_record_version;
COMMIT;",
)
.map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?;
self.conn
.execute_batch(SIGNED_RECORDS_SCHEMA)
.map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?;
}
// Migration 1 -> 2: signed records that outlive a session. // Migration 1 -> 2: signed records that outlive a session.
if (1..2).contains(&found) { if (1..2).contains(&found) {
self.conn self.conn
@@ -222,6 +253,110 @@ impl StateStore {
Ok(identity) Ok(identity)
} }
/// Replaces the device identity, giving up what the outgoing key held.
///
/// A device key is the author of every record this agent has signed, so
/// replacing it makes this a different member. The addresses and names
/// the old key claimed would otherwise stay reserved to a key nobody
/// holds, and nothing could ever free them — there is no way to sign on
/// another author's behalf, and by design there is no authority that
/// could overrule one.
///
/// So the outgoing key signs a release for every network on its way out.
/// That is the revocation: a positive statement, merged like any other,
/// which frees the address and the name for whoever wants them next.
///
/// All of it commits together. A crash part way through must not leave an
/// identity that has already been replaced beside releases that were
/// never written, because the old key would then be gone and unable to
/// sign them.
///
/// Returns the new identity and the networks a release was signed for.
pub fn rotate_device_identity(&self) -> Result<(DeviceIdentity, Vec<NetworkId>)> {
let outgoing = self.device_identity()?;
let networks = self.list_networks()?;
let replacement = DeviceIdentity::generate();
let transaction = self
.conn
.unchecked_transaction()
.map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?;
let mut released = Vec::new();
if let Some(outgoing) = &outgoing {
let author = outgoing.endpoint_id();
let signing = outgoing.signing_key();
for network in &networks {
let previous: Option<i64> = transaction
.query_row(
"SELECT version FROM own_record_version
WHERE network_id = ?1 AND author = ?2",
params![
network.network_id.as_bytes().as_slice(),
author.as_bytes().as_slice()
],
|row| row.get(0),
)
.optional()
.map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?;
// A key that never published anything has nothing to give up.
let Some(previous) = previous else { continue };
let version = (previous.max(0) as u64).saturating_add(1);
let record =
SignedRecord::sign(&signing, network.network_id, version, RecordBody::Release);
let body = postcard::to_stdvec(&record.body)
.map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?;
transaction
.execute(
"INSERT INTO signed_records (network_id, author, version, body, signature)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(network_id, author) DO UPDATE SET
version = excluded.version,
body = excluded.body,
signature = excluded.signature",
params![
record.network.as_slice(),
record.author.as_slice(),
record.version as i64,
body,
record.signature
],
)
.map_err(|err| Error::Storage(format!("cannot store a release: {err}")))?;
transaction
.execute(
"INSERT INTO own_record_version (network_id, author, version)
VALUES (?1, ?2, ?3)
ON CONFLICT(network_id, author) DO UPDATE SET
version = max(version, excluded.version)",
params![
record.network.as_slice(),
record.author.as_slice(),
record.version as i64
],
)
.map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?;
released.push(network.network_id);
}
}
transaction
.execute(
"INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)
ON CONFLICT(id) DO UPDATE SET
secret_key = excluded.secret_key,
created_at = excluded.created_at",
params![replacement.secret_bytes().as_slice(), now_unix()],
)
.map_err(|err| Error::Storage(format!("cannot store the new identity: {err}")))?;
transaction
.commit()
.map_err(|err| Error::Storage(format!("cannot commit the new identity: {err}")))?;
Ok((replacement, released))
}
/// Inserts or updates a network configuration. /// Inserts or updates a network configuration.
pub fn upsert_network( pub fn upsert_network(
&self, &self,
@@ -421,10 +556,15 @@ impl StateStore {
.map_err(|err| Error::Storage(format!("cannot store our record: {err}")))?; .map_err(|err| Error::Storage(format!("cannot store our record: {err}")))?;
transaction transaction
.execute( .execute(
"INSERT INTO own_record_version (network_id, version) VALUES (?1, ?2) "INSERT INTO own_record_version (network_id, author, version)
ON CONFLICT(network_id) DO UPDATE SET VALUES (?1, ?2, ?3)
ON CONFLICT(network_id, author) DO UPDATE SET
version = max(version, excluded.version)", version = max(version, excluded.version)",
params![record.network.as_slice(), record.version as i64], params![
record.network.as_slice(),
record.author.as_slice(),
record.version as i64
],
) )
.map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?; .map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?;
@@ -433,16 +573,26 @@ impl StateStore {
.map_err(|err| Error::Storage(format!("cannot commit our record: {err}"))) .map_err(|err| Error::Storage(format!("cannot commit our record: {err}")))
} }
/// The highest version we have ever published for a network. /// The highest version an author has ever published for a network.
/// ///
/// Monotonic even if our record is later replaced by a conflicting one, /// Monotonic even if the record is later replaced by a conflicting one,
/// so we never reuse a number. /// so a number is never reused. Keyed by author as well: a replaced
pub fn own_record_version(&self, network_id: NetworkId) -> Result<u64> { /// device key is a different author and starts its own sequence, while
/// the outgoing one keeps its place so the release it signs on the way
/// out cannot collide with something it already published.
pub fn own_record_version(
&self,
network_id: NetworkId,
author: iroh::EndpointId,
) -> Result<u64> {
let version: Option<i64> = self let version: Option<i64> = self
.conn .conn
.query_row( .query_row(
"SELECT version FROM own_record_version WHERE network_id = ?1", "SELECT version FROM own_record_version WHERE network_id = ?1 AND author = ?2",
params![network_id.as_bytes().as_slice()], params![
network_id.as_bytes().as_slice(),
author.as_bytes().as_slice()
],
|row| row.get(0), |row| row.get(0),
) )
.optional() .optional()
@@ -487,6 +637,12 @@ impl StateStore {
} }
/// Schema for the signed records described in [`crate::state`]. /// Schema for the signed records described in [`crate::state`].
///
/// The version counter is keyed by author as well as network. A device key
/// can be replaced, and the replacement is a different author: it must start
/// its own sequence rather than inherit one, and the outgoing author's last
/// version has to survive so its release record cannot collide with
/// something it already published.
const SIGNED_RECORDS_SCHEMA: &str = "BEGIN; const SIGNED_RECORDS_SCHEMA: &str = "BEGIN;
CREATE TABLE IF NOT EXISTS signed_records ( CREATE TABLE IF NOT EXISTS signed_records (
network_id BLOB NOT NULL, network_id BLOB NOT NULL,
@@ -497,10 +653,12 @@ const SIGNED_RECORDS_SCHEMA: &str = "BEGIN;
PRIMARY KEY (network_id, author) PRIMARY KEY (network_id, author)
); );
CREATE TABLE IF NOT EXISTS own_record_version ( CREATE TABLE IF NOT EXISTS own_record_version (
network_id BLOB PRIMARY KEY, network_id BLOB NOT NULL,
version INTEGER NOT NULL author BLOB NOT NULL,
version INTEGER NOT NULL,
PRIMARY KEY (network_id, author)
); );
PRAGMA user_version = 2; PRAGMA user_version = 3;
COMMIT;"; COMMIT;";
/// Seconds since the Unix epoch, saturating at 0 before it. /// Seconds since the Unix epoch, saturating at 0 before it.
+83
View File
@@ -177,6 +177,89 @@ async fn a_state_store_from_a_newer_build_is_refused() {
); );
} }
/// A store written by the previous schema must come up, not break.
///
/// The record body gained a hostname, which changed the signing domain, so
/// records written before it can never verify again. Leaving them in place
/// would mean every read rejecting rows that look exactly like corruption.
#[tokio::test]
async fn a_state_store_from_the_previous_schema_is_migrated_and_stays_usable() {
let dir = tempfile::TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
std::fs::create_dir_all(&paths.state_dir).unwrap();
// Build a schema-2 store by hand, with a record in it.
{
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
conn.execute_batch(
"BEGIN;
CREATE TABLE device_identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
secret_key BLOB NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE networks (
network_id BLOB PRIMARY KEY,
name TEXT NOT NULL,
secret BLOB NOT NULL,
auto_start INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL
);
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE signed_records (
network_id BLOB NOT NULL,
author BLOB NOT NULL,
version INTEGER NOT NULL,
body BLOB NOT NULL,
signature BLOB NOT NULL,
PRIMARY KEY (network_id, author)
);
CREATE TABLE own_record_version (
network_id BLOB PRIMARY KEY,
version INTEGER NOT NULL
);
INSERT INTO signed_records VALUES (x'00', x'11', 7, x'2222', x'3333');
INSERT INTO own_record_version VALUES (x'00', 7);
PRAGMA user_version = 2;
COMMIT;",
)
.unwrap();
}
// An agent comes up on it, which is the whole point.
let discovery = SharedMemoryDiscovery::new();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let (name, secret) = network("migrated");
let network_id = agent.join_network(&name, &secret).await.unwrap();
assert!(agent.network_status(network_id).await.is_ok());
agent.shutdown().await;
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
let version: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.unwrap();
assert_eq!(version, tsunagi::storage::SCHEMA_VERSION);
// The unverifiable record is gone rather than left to be rejected for
// ever, and the counter is keyed by author now.
let stale: i64 = conn
.query_row(
"SELECT count(*) FROM signed_records WHERE author = x'11'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(stale, 0, "records from the old signing domain are dropped");
conn.query_row(
"SELECT count(*) FROM own_record_version WHERE author IS NOT NULL",
[],
|row| row.get::<_, i64>(0),
)
.expect("the counter is keyed by author");
}
#[tokio::test] #[tokio::test]
async fn secrets_never_appear_in_status_or_debug_output() { async fn secrets_never_appear_in_status_or_debug_output() {
let discovery = SharedMemoryDiscovery::new(); let discovery = SharedMemoryDiscovery::new();
+244
View File
@@ -0,0 +1,244 @@
//! The device's own identity: the name it answers to and the key it signs
//! with, and what happens when either is changed.
//!
//! Both are things a user may reasonably change on a machine they own, and
//! neither may leave the state store in a shape the next start cannot use.
//! That is what these check: not that changing them is prevented, but that
//! the store survives it and says something true afterwards.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{config_with, network, wait_until};
use tempfile::TempDir;
use tsunagi::Agent;
use tsunagi::config::StoragePaths;
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::state::{RecordBody, StateSet};
use tsunagi::storage::StateStore;
#[tokio::test]
async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() {
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("renaming");
let a = TempDir::new().unwrap();
let b = TempDir::new().unwrap();
let agent_a = Agent::spawn(config_with(a.path(), &discovery))
.await
.unwrap();
let agent_b = Agent::spawn(config_with(b.path(), &discovery))
.await
.unwrap();
let network_id = agent_a.join_network(&name, &secret).await.unwrap();
agent_b.join_network(&name, &secret).await.unwrap();
let accepted = agent_a.set_hostname("Renamed Host").await.unwrap();
assert_eq!(accepted, "renamedhost", "reduced to a canonical form");
assert_eq!(agent_a.hostname(), "renamedhost");
// The peer is told, rather than finding out on its next restart.
wait_until("the peer learns the new name", || async {
let status = agent_b.network_status(network_id).await.ok()?;
status
.peers
.iter()
.any(|peer| peer.hostname.as_deref() == Some("renamedhost"))
.then_some(())
})
.await;
// And the signed claim says it, so the name outlives the session.
wait_until("the claim carries the new name", || async {
let status = agent_b.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|member| member.hostname.as_deref() == Some("renamedhost"))
.then_some(())
})
.await;
agent_a.shutdown().await;
agent_b.shutdown().await;
}
#[tokio::test]
async fn a_name_that_reduces_to_nothing_is_refused_rather_than_stored() {
let discovery = SharedMemoryDiscovery::new();
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
let before = agent.hostname();
assert!(agent.set_hostname("---").await.is_err());
assert!(agent.set_hostname("").await.is_err());
assert_eq!(agent.hostname(), before, "the old name still stands");
agent.shutdown().await;
}
/// Replacing the signing key must leave a store the next run can use.
///
/// The user is entitled to do this on a machine they own, and they lose the
/// address and name the old key held — there is no way to sign on a dead
/// key's behalf, and nothing here may overrule an author. What must not
/// happen is that the store is left in a shape that breaks.
#[tokio::test]
async fn rotating_the_signing_key_releases_what_it_held_and_leaves_a_usable_store() {
let dir = TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
let discovery = SharedMemoryDiscovery::new();
let (name, secret) = network("rotation");
let network_id = NetworkKeys::derive(&name, &secret).network_id();
// Run once so there is an identity, a network, and a claim to give up.
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.join_network(&name, &secret).await.unwrap();
let before = agent.endpoint_id();
wait_until("the agent claims an address", || async {
let status = agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|member| member.endpoint_id == before && member.overlay_address_v4.is_some())
.then_some(())
})
.await;
let claimed = agent
.network_status(network_id)
.await
.unwrap()
.members
.iter()
.find(|member| member.endpoint_id == before)
.and_then(|member| member.overlay_address_v4)
.expect("an address was claimed");
agent.shutdown().await;
let (replacement, released) = {
let store = StateStore::open(paths.state_db()).unwrap();
store.rotate_device_identity().unwrap()
};
assert_ne!(replacement.endpoint_id(), before, "a different author");
assert_eq!(released, vec![network_id]);
// The outgoing key signed a release, and it still verifies: a record
// whose author no longer runs is not thereby invalid.
{
let store = StateStore::open(paths.state_db()).unwrap();
assert_eq!(
store.device_identity().unwrap().map(|id| id.endpoint_id()),
Some(replacement.endpoint_id())
);
let mut set = StateSet::new();
for record in store.signed_records(network_id).unwrap() {
set.merge(network_id, record)
.expect("every record verifies");
}
let old = set.get(&before).expect("the old author is still on record");
assert!(matches!(old.body, RecordBody::Release));
assert_eq!(
set.address_of(&before),
None,
"the address it held is free again"
);
assert!(
!set.address_holders().contains_key(&claimed),
"{claimed} is no longer reserved"
);
}
// The whole point: the next run comes up on it.
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
assert_eq!(agent.endpoint_id(), replacement.endpoint_id());
let network_id = agent.join_network(&name, &secret).await.unwrap();
wait_until("the new identity claims an address of its own", || async {
let status = agent.network_status(network_id).await.ok()?;
status
.members
.iter()
.any(|member| {
member.endpoint_id == replacement.endpoint_id()
&& member.overlay_address_v4.is_some()
})
.then_some(())
})
.await;
agent.shutdown().await;
}
#[tokio::test]
async fn rotating_a_store_that_has_never_run_just_creates_an_identity() {
let dir = TempDir::new().unwrap();
let paths = StoragePaths::under(dir.path());
let store = StateStore::open(paths.state_db()).unwrap();
assert!(store.device_identity().unwrap().is_none());
let (identity, released) = store.rotate_device_identity().unwrap();
assert!(
released.is_empty(),
"nothing was held, so nothing is given up"
);
assert_eq!(
store.device_identity().unwrap().map(|id| id.endpoint_id()),
Some(identity.endpoint_id())
);
}
#[tokio::test]
async fn the_hostname_defaults_to_the_machines_own_name() {
let discovery = SharedMemoryDiscovery::new();
let dir = TempDir::new().unwrap();
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
// Whatever this machine is called, the agent uses it rather than
// inventing a name from the key — unless the host has no usable one.
match tsunagi::agent::system_hostname() {
Some(system) => assert_eq!(agent.hostname(), system),
None => assert!(agent.hostname().starts_with("tsunagi-")),
}
agent.shutdown().await;
}
/// A secret must not reach the control socket, whatever else `id` prints.
#[tokio::test]
async fn secrets_stay_out_of_the_status_report() {
let discovery = SharedMemoryDiscovery::new();
let dir = TempDir::new().unwrap();
let (name, secret) = network("no-secrets-on-the-wire");
let agent = Agent::spawn(config_with(dir.path(), &discovery))
.await
.unwrap();
agent.join_network(&name, &secret).await.unwrap();
let status = agent.status().await.unwrap();
let rendered = format!("{status:?}");
assert!(!rendered.contains(secret.encode().as_str()));
agent.shutdown().await;
}
#[test]
fn a_network_name_and_secret_are_unaffected_by_the_device_key() {
// Network identity is derived from the name and secret only. Replacing
// the device key must not move the network the device belongs to.
let name = NetworkName::new("stable").unwrap();
let secret = NetworkSecret::from_bytes(vec![9u8; 32]).unwrap();
let first = NetworkKeys::derive(&name, &secret).network_id();
let second = NetworkKeys::derive(&name, &secret).network_id();
assert_eq!(first, second);
}
+1 -1
View File
@@ -56,7 +56,7 @@ async fn two_agents_authenticate_and_exchange_messages() {
// Both sides announce a hostname over the authenticated session. // Both sides announce a hostname over the authenticated session.
let status = a.agent.network_status(network_id).await.unwrap(); let status = a.agent.network_status(network_id).await.unwrap();
let peer = &status.peers[0]; let peer = &status.peers[0];
assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname())); assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname().as_str()));
assert!(peer.transport != tsunagi::net::TransportKind::Unknown); assert!(peer.transport != tsunagi::net::TransportKind::Unknown);
assert!(peer.rtt.is_some(), "a verified path must report an RTT"); assert!(peer.rtt.is_some(), "a verified path must report an RTT");