Report members, not symptoms
A peer going away showed up as three unrelated yellow rows: no peers authenticated, N dial failures, one packet to an address nobody owns. Each was true and none of them said the actual thing, which is that a member we know about is offline. Worse, they are cumulative, so after the peer came back the report still looked broken. Two changes behind that. Members are now a list, joined from the three sources that each know part of the answer: signed state says who belongs and keeps saying it while they are away, the session list says who is here, the overlay says whose tunnel is up. Online first, then away, this agent left out because the device section already covers it. An absent member is stated rather than flagged — in a mesh of laptops being away is the ordinary condition — and its failed dials are attributed to it instead of floating free as a network-wide number. Counters are history and no longer grade anything. Grading them is what kept the report red long after the cause had gone. The one exception is context-sensitive rather than cumulative: handshake failures with nobody connected is the signature of a mismatched secret, so that is called out. NetworkStatus grows a member roster from the signed records, and the overlay report carries the endpoint id so a tunnel can be matched to its session. Note what the roster cannot do: a member is in signed state only once it has claimed something, which today means an IPv4 address, so an IPv6-only network still has no durable roster. Hostnames are not persisted either, so an absent member is named by its id. The control socket gained a version word. Adding these fields changed how postcard parses the bytes, and without it a client one build ahead of its agent reported "Found an Option discriminant that wasn't 0 or 1". The check names a mismatch for whichever side is newer; an older agent reading a newer request just drops the connection, so the CLI offers that as a possibility rather than asserting it. It also now separates an agent that is absent, which is an ordinary answer, from one that is there and will not answer, which is a fault. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,14 @@ prints, **FAIL** for what it cannot work around. The words carry the grade as
|
|||||||
well as the colour, so the report reads the same piped to a file or on a
|
well as the colour, so the report reads the same piped to a file or on a
|
||||||
terminal without colour, and it honours `NO_COLOR`.
|
terminal without colour, and it honours `NO_COLOR`.
|
||||||
|
|
||||||
|
Members are listed online first, then the ones that are away. A member that
|
||||||
|
is away is reported plainly rather than flagged: in a mesh of laptops it is
|
||||||
|
the ordinary condition, not a fault. The signed state is what makes that
|
||||||
|
sayable — it remembers who belongs while they are gone, so the report can say
|
||||||
|
"offline, 10.13.37.99 still reserved for it" instead of leaving a
|
||||||
|
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.
|
||||||
|
|
||||||
`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
|
||||||
|
|||||||
+5
-1
@@ -26,7 +26,8 @@ mod status;
|
|||||||
|
|
||||||
pub use events::Event;
|
pub use events::Event;
|
||||||
pub use status::{
|
pub use status::{
|
||||||
AgentStatus, CandidateStatus, NetworkMetrics, NetworkState, NetworkStatus, PeerStatus,
|
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
|
||||||
|
PeerStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
@@ -451,6 +452,9 @@ impl Agent {
|
|||||||
state: NetworkState::Inactive,
|
state: NetworkState::Inactive,
|
||||||
peers: Vec::new(),
|
peers: Vec::new(),
|
||||||
candidates: Vec::new(),
|
candidates: Vec::new(),
|
||||||
|
// An inactive network has no runtime to ask; the roster comes
|
||||||
|
// from one. Empty, not invented.
|
||||||
|
members: Vec::new(),
|
||||||
metrics: NetworkMetrics::default(),
|
metrics: NetworkMetrics::default(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-1
@@ -30,7 +30,9 @@ use crate::storage::Storage;
|
|||||||
use super::events::Event;
|
use super::events::Event;
|
||||||
use super::session::{self, Session, SessionEvent};
|
use super::session::{self, Session, SessionEvent};
|
||||||
use super::shutdown::Shutdown;
|
use super::shutdown::Shutdown;
|
||||||
use super::status::{CandidateStatus, NetworkMetrics, NetworkState, NetworkStatus, PeerStatus};
|
use super::status::{
|
||||||
|
CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus, PeerStatus,
|
||||||
|
};
|
||||||
|
|
||||||
/// An inbound connection that already passed the handshake.
|
/// An inbound connection that already passed the handshake.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -1224,6 +1226,23 @@ impl Runtime {
|
|||||||
.collect();
|
.collect();
|
||||||
candidates.sort_by(|a, b| a.endpoint_id.as_bytes().cmp(b.endpoint_id.as_bytes()));
|
candidates.sort_by(|a, b| a.endpoint_id.as_bytes().cmp(b.endpoint_id.as_bytes()));
|
||||||
|
|
||||||
|
// The durable roster. Every author of a signed record is a member,
|
||||||
|
// including this agent and including members that are not here.
|
||||||
|
let mut members: Vec<MemberStatus> = self
|
||||||
|
.state
|
||||||
|
.records()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|record| {
|
||||||
|
let endpoint_id = record.author_id().ok()?;
|
||||||
|
Some(MemberStatus {
|
||||||
|
endpoint_id,
|
||||||
|
overlay_address_v4: record.body.claimed_address(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
members.sort_by(|a, b| a.endpoint_id.as_bytes().cmp(b.endpoint_id.as_bytes()));
|
||||||
|
members.dedup_by(|a, b| a.endpoint_id == b.endpoint_id);
|
||||||
|
|
||||||
NetworkStatus {
|
NetworkStatus {
|
||||||
descriptor: self.params.keys.descriptor(),
|
descriptor: self.params.keys.descriptor(),
|
||||||
name: self.params.keys.name().clone(),
|
name: self.params.keys.name().clone(),
|
||||||
@@ -1231,6 +1250,7 @@ impl Runtime {
|
|||||||
state: NetworkState::Active,
|
state: NetworkState::Active,
|
||||||
peers,
|
peers,
|
||||||
candidates,
|
candidates,
|
||||||
|
members,
|
||||||
metrics: self.metrics.clone(),
|
metrics: self.metrics.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,24 @@ pub struct CandidateStatus {
|
|||||||
pub consecutive_failures: u32,
|
pub consecutive_failures: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A member the signed state knows about, connected or not.
|
||||||
|
///
|
||||||
|
/// This is the durable roster: it comes from signed records, so a member that
|
||||||
|
/// went away last month is still here. That is what makes it possible to say
|
||||||
|
/// "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
|
||||||
|
/// state once it has claimed something — today that means an IPv4 overlay
|
||||||
|
/// address. In an IPv6-only network nothing is claimed, so members are
|
||||||
|
/// visible only while they are connected.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct MemberStatus {
|
||||||
|
/// The member's device identity.
|
||||||
|
pub endpoint_id: EndpointId,
|
||||||
|
/// The IPv4 overlay address it claimed and signed for.
|
||||||
|
pub overlay_address_v4: Option<std::net::Ipv4Addr>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Status of one authenticated session.
|
/// Status of one authenticated session.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PeerStatus {
|
pub struct PeerStatus {
|
||||||
@@ -115,6 +133,8 @@ pub struct NetworkStatus {
|
|||||||
pub peers: Vec<PeerStatus>,
|
pub peers: Vec<PeerStatus>,
|
||||||
/// Unverified candidates currently known. Not peers.
|
/// Unverified candidates currently known. Not peers.
|
||||||
pub candidates: Vec<CandidateStatus>,
|
pub candidates: Vec<CandidateStatus>,
|
||||||
|
/// Members the signed state knows about, whether connected or not.
|
||||||
|
pub members: Vec<MemberStatus>,
|
||||||
/// Per-network counters.
|
/// Per-network counters.
|
||||||
pub metrics: NetworkMetrics,
|
pub metrics: NetworkMetrics,
|
||||||
}
|
}
|
||||||
|
|||||||
+453
-109
@@ -316,25 +316,31 @@ fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> Path
|
|||||||
enum Observed {
|
enum Observed {
|
||||||
/// A running agent answered over the control socket.
|
/// A running agent answered over the control socket.
|
||||||
Agent(Box<tsunagi::ipc::StatusReport>),
|
Agent(Box<tsunagi::ipc::StatusReport>),
|
||||||
/// Read from the state store, with no agent running.
|
/// Read from the state store, because the agent could not be asked.
|
||||||
Stored {
|
Stored {
|
||||||
endpoint_id: Option<String>,
|
endpoint_id: Option<String>,
|
||||||
hostname: Option<String>,
|
hostname: Option<String>,
|
||||||
networks: Vec<(String, String, bool)>,
|
networks: Vec<(String, String, bool)>,
|
||||||
/// Why there was no agent to ask.
|
/// Why the agent could not be asked.
|
||||||
why: String,
|
why: String,
|
||||||
|
/// Whether a socket was there at all.
|
||||||
|
///
|
||||||
|
/// Nothing running is an ordinary state and gets said plainly. A
|
||||||
|
/// socket that is there and will not answer is a fault.
|
||||||
|
socket_present: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Asks the agent, and falls back to the state store.
|
/// Asks the agent, and falls back to the state store.
|
||||||
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
|
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
|
||||||
let why = if socket.exists() {
|
let socket_present = socket.exists();
|
||||||
|
let why = if socket_present {
|
||||||
match tsunagi::ipc::unix::request_status(socket).await {
|
match tsunagi::ipc::unix::request_status(socket).await {
|
||||||
Ok(report) => return Observed::Agent(Box::new(report)),
|
Ok(report) => return Observed::Agent(Box::new(report)),
|
||||||
Err(err) => format!("cannot reach the agent at {}: {err}", socket.display()),
|
Err(err) => format!("{err}"),
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
"no agent is running for this state directory".to_string()
|
"no control socket for this state directory".to_string()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Read-only, and deliberately tolerant: a state directory that has never
|
// Read-only, and deliberately tolerant: a state directory that has never
|
||||||
@@ -368,6 +374,7 @@ async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
|
|||||||
hostname,
|
hostname,
|
||||||
networks,
|
networks,
|
||||||
why,
|
why,
|
||||||
|
socket_present,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,18 +506,33 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_note("disposable: the agent runs, rediscovering what it cached")
|
.with_note("disposable: the agent runs, rediscovering what it cached")
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Observed::Stored { why, .. } => {
|
Observed::Stored {
|
||||||
agent.push(
|
why,
|
||||||
Row::new(Health::Degraded, "running", "no")
|
socket_present,
|
||||||
.with_note(format!("{why}; everything below was read from the store")),
|
..
|
||||||
);
|
} => {
|
||||||
|
// Nothing running is an ordinary answer to "what is running", not
|
||||||
|
// a fault; a socket that will not answer is a fault.
|
||||||
|
agent.push(if *socket_present {
|
||||||
|
// The version check in the framing names a mismatch only for
|
||||||
|
// whichever side is newer. An older agent reading a newer
|
||||||
|
// request just drops the connection, so the hint has to be
|
||||||
|
// offered rather than asserted.
|
||||||
|
Row::new(Health::Degraded, "running", "not answering").with_note(format!(
|
||||||
|
"{why} · it may be an older build: restart it with this binary. \
|
||||||
|
The rest was read from the store"
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
Row::new(Health::Info, "running", "no")
|
||||||
|
.with_note(format!("{why} · the rest was read from the store"))
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push(agent);
|
out.push(agent);
|
||||||
|
|
||||||
if let Observed::Agent(report) = &observed {
|
if let Observed::Agent(report) = &observed {
|
||||||
for network in &report.networks {
|
for network in &report.networks {
|
||||||
out.push(network_section(network));
|
out.push(network_section(network, &report.endpoint_id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -519,8 +541,100 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
print_report("tsunagi status", &out)
|
print_report("tsunagi status", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One network's control plane and overlay.
|
/// One member of a network, from every source that knows something about it.
|
||||||
fn network_section(network: &tsunagi::ipc::NetworkReport) -> report::Section {
|
///
|
||||||
|
/// The three sources answer different questions and none of them answers the
|
||||||
|
/// whole one. The signed state says who belongs, and keeps saying it while
|
||||||
|
/// they are away. The session list says who is here. The overlay says whose
|
||||||
|
/// tunnel is up. Reporting them as three lists is what made a peer being
|
||||||
|
/// offline look like three unrelated faults.
|
||||||
|
struct MemberRow<'a> {
|
||||||
|
endpoint_id: &'a str,
|
||||||
|
hostname: Option<&'a str>,
|
||||||
|
/// `Some` exactly when there is an authenticated session right now.
|
||||||
|
transport: Option<&'a str>,
|
||||||
|
rtt_ms: Option<u64>,
|
||||||
|
overlay_address_v4: Option<&'a str>,
|
||||||
|
tunnel: Option<&'a tsunagi::ipc::OverlayPeerReport>,
|
||||||
|
failed_dials: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MemberRow<'_> {
|
||||||
|
fn online(&self) -> bool {
|
||||||
|
self.transport.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to call it: the name it announced, or a short form of its id.
|
||||||
|
///
|
||||||
|
/// A member that is away has no hostname, because nothing durable records
|
||||||
|
/// one — only the signed claim survives, and that carries an address.
|
||||||
|
fn label(&self) -> String {
|
||||||
|
match self.hostname {
|
||||||
|
Some(hostname) => hostname.to_string(),
|
||||||
|
None => short(self.endpoint_id, 12),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Joins the three views of a network into one list, online members first.
|
||||||
|
fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Vec<MemberRow<'a>> {
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn entry<'a, 'm>(
|
||||||
|
rows: &'m mut BTreeMap<&'a str, MemberRow<'a>>,
|
||||||
|
id: &'a str,
|
||||||
|
) -> &'m mut MemberRow<'a> {
|
||||||
|
rows.entry(id).or_insert_with(|| MemberRow {
|
||||||
|
endpoint_id: id,
|
||||||
|
hostname: None,
|
||||||
|
transport: None,
|
||||||
|
rtt_ms: None,
|
||||||
|
overlay_address_v4: None,
|
||||||
|
tunnel: None,
|
||||||
|
failed_dials: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut rows: BTreeMap<&'a str, MemberRow<'a>> = BTreeMap::new();
|
||||||
|
for member in &network.members {
|
||||||
|
let row = entry(&mut rows, &member.endpoint_id);
|
||||||
|
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
|
||||||
|
row.failed_dials = member.failed_dials;
|
||||||
|
}
|
||||||
|
for peer in &network.peers {
|
||||||
|
let row = entry(&mut rows, &peer.endpoint_id);
|
||||||
|
row.hostname = peer.hostname.as_deref();
|
||||||
|
row.transport = Some(&peer.transport);
|
||||||
|
row.rtt_ms = peer.rtt_ms;
|
||||||
|
}
|
||||||
|
if let Some(overlay) = &network.overlay {
|
||||||
|
for peer in &overlay.peers {
|
||||||
|
let row = entry(&mut rows, &peer.endpoint_id);
|
||||||
|
row.tunnel = Some(peer);
|
||||||
|
if row.overlay_address_v4.is_none() {
|
||||||
|
row.overlay_address_v4 = peer.address_v4.as_deref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This agent is in the roster too — it signs claims like everyone else —
|
||||||
|
// but it is already the subject of the `device` section.
|
||||||
|
let mut rows: Vec<MemberRow<'a>> = rows
|
||||||
|
.into_values()
|
||||||
|
.filter(|row| row.endpoint_id != own_id)
|
||||||
|
.collect();
|
||||||
|
// Online first, as asked, then by name so the order is stable between
|
||||||
|
// runs rather than following whatever the map happened to hold.
|
||||||
|
rows.sort_by(|a, b| {
|
||||||
|
b.online()
|
||||||
|
.cmp(&a.online())
|
||||||
|
.then_with(|| a.label().cmp(&b.label()))
|
||||||
|
});
|
||||||
|
rows
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One network: what it is, who is in it, and what has happened since start.
|
||||||
|
fn network_section(network: &tsunagi::ipc::NetworkReport, own_id: &str) -> report::Section {
|
||||||
use report::{Health, Row, Section};
|
use report::{Health, Row, Section};
|
||||||
|
|
||||||
let mut section = Section::new(format!("network {}", network.name));
|
let mut section = Section::new(format!("network {}", network.name));
|
||||||
@@ -538,52 +652,6 @@ fn network_section(network: &tsunagi::ipc::NetworkReport) -> report::Section {
|
|||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
if network.peers.is_empty() {
|
|
||||||
section.push(Row::new(Health::Degraded, "peers", "none authenticated"));
|
|
||||||
}
|
|
||||||
for peer in &network.peers {
|
|
||||||
// A relayed path works but goes through somebody else's machine and
|
|
||||||
// costs latency, so it is the middle grade rather than a good one.
|
|
||||||
// Compared without regard to case: the agent answering may be a
|
|
||||||
// different build from the client asking, and the spelling of this
|
|
||||||
// field has already changed once.
|
|
||||||
let health = if peer.transport.eq_ignore_ascii_case("direct") {
|
|
||||||
Health::Good
|
|
||||||
} else {
|
|
||||||
Health::Degraded
|
|
||||||
};
|
|
||||||
section.push(Row::new(
|
|
||||||
health,
|
|
||||||
format!("peer {}", short(&peer.endpoint_id, 10)),
|
|
||||||
format!(
|
|
||||||
"{} {}{}",
|
|
||||||
peer.hostname.as_deref().unwrap_or("unnamed"),
|
|
||||||
peer.transport.to_lowercase(),
|
|
||||||
match peer.rtt_ms {
|
|
||||||
Some(rtt) => format!(" rtt {rtt}ms"),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let (sent, received) = network.control_messages;
|
|
||||||
section.push(Row::new(
|
|
||||||
Health::Info,
|
|
||||||
"control messages",
|
|
||||||
format!("{sent} sent, {received} received"),
|
|
||||||
));
|
|
||||||
if network.dial_failures > 0 || network.handshake_failures > 0 {
|
|
||||||
section.push(Row::new(
|
|
||||||
Health::Degraded,
|
|
||||||
"failures",
|
|
||||||
format!(
|
|
||||||
"{} dial, {} handshake",
|
|
||||||
network.dial_failures, network.handshake_failures
|
|
||||||
),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(overlay) = &network.overlay {
|
if let Some(overlay) = &network.overlay {
|
||||||
section.push(Row::new(
|
section.push(Row::new(
|
||||||
Health::Info,
|
Health::Info,
|
||||||
@@ -600,60 +668,134 @@ fn network_section(network: &tsunagi::ipc::NetworkReport) -> report::Section {
|
|||||||
overlay.mtu
|
overlay.mtu
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
for peer in &overlay.peers {
|
}
|
||||||
let row = match peer.handshake_secs_ago {
|
|
||||||
Some(secs) => Row::new(
|
let rows = member_rows(network, own_id);
|
||||||
Health::Good,
|
let online = rows.iter().filter(|row| row.online()).count();
|
||||||
format!("tunnel {}", short(&peer.public_key, 8)),
|
if rows.is_empty() {
|
||||||
format!(
|
section.push(Row::new(
|
||||||
"{}{} handshake {secs}s ago tx {} rx {} {}",
|
Health::Info,
|
||||||
peer.address,
|
"members",
|
||||||
match &peer.address_v4 {
|
"none known yet; nobody else has joined",
|
||||||
Some(v4) => format!(" / {v4}"),
|
));
|
||||||
None => String::new(),
|
|
||||||
},
|
|
||||||
peer.tx_packets,
|
|
||||||
peer.rx_packets,
|
|
||||||
peer.path
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// A snapshot cannot tell a tunnel that is still coming up
|
|
||||||
// from one that is stuck, so this is the middle grade with
|
|
||||||
// the consequence spelled out rather than an alarm.
|
|
||||||
None => Row::new(
|
|
||||||
Health::Degraded,
|
|
||||||
format!("tunnel {}", short(&peer.public_key, 8)),
|
|
||||||
format!("{} no handshake yet", peer.address),
|
|
||||||
)
|
|
||||||
.with_note("the tunnel cannot carry traffic until it handshakes"),
|
|
||||||
};
|
|
||||||
let row = if peer.dropped > 0 {
|
|
||||||
row.with_note(format!("{} packet(s) dropped", peer.dropped))
|
|
||||||
} else {
|
} else {
|
||||||
row
|
section.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
|
"members",
|
||||||
|
format!("{online} of {} online", rows.len()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in &rows {
|
||||||
|
section.push(member_row(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counters are history, not health. Grading them keeps a report red long
|
||||||
|
// after whatever caused them has gone away — which is exactly how a peer
|
||||||
|
// coming back still looked like three problems.
|
||||||
|
let (sent, received) = network.control_messages;
|
||||||
|
let mut totals = vec![format!("{sent} sent, {received} received")];
|
||||||
|
if network.dial_failures > 0 {
|
||||||
|
totals.push(format!("{} dial failure(s)", network.dial_failures));
|
||||||
|
}
|
||||||
|
if network.handshake_failures > 0 {
|
||||||
|
totals.push(format!(
|
||||||
|
"{} handshake failure(s)",
|
||||||
|
network.handshake_failures
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Some(overlay) = &network.overlay
|
||||||
|
&& overlay.unroutable_packets > 0
|
||||||
|
{
|
||||||
|
totals.push(format!(
|
||||||
|
"{} packet(s) to an address nobody owns{}",
|
||||||
|
overlay.unroutable_packets,
|
||||||
|
match &overlay.unroutable_sample {
|
||||||
|
Some(sample) => format!(" ({sample})"),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Repeated handshake failures with nobody connected is the signature of a
|
||||||
|
// mismatched secret, and that *is* a present-tense problem rather than a
|
||||||
|
// number from the past.
|
||||||
|
let health = if network.handshake_failures > 0 && online == 0 {
|
||||||
|
Health::Degraded
|
||||||
|
} else {
|
||||||
|
Health::Info
|
||||||
};
|
};
|
||||||
section.push(row);
|
let totals_row = Row::new(health, "since start", totals.join(", "));
|
||||||
}
|
section.push(if health == Health::Degraded {
|
||||||
if overlay.unroutable_packets > 0 {
|
totals_row.with_note("handshakes are failing and nobody is connected: check that every member was given the same secret")
|
||||||
section.push(
|
} else {
|
||||||
Row::new(
|
totals_row
|
||||||
Health::Degraded,
|
});
|
||||||
"unroutable",
|
|
||||||
format!(
|
|
||||||
"{} packet(s) sent to an address no peer owns",
|
|
||||||
overlay.unroutable_packets
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.with_note(match &overlay.unroutable_sample {
|
|
||||||
Some(sample) => format!("for example {sample}"),
|
|
||||||
None => "no sample recorded".to_string(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
section
|
section
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One member: connected or not, and what is known either way.
|
||||||
|
fn member_row(row: &MemberRow<'_>) -> report::Row {
|
||||||
|
use report::{Health, Row};
|
||||||
|
|
||||||
|
let Some(transport) = row.transport else {
|
||||||
|
// Away. Not a fault of this agent, and in a mesh of laptops it is the
|
||||||
|
// ordinary condition, so it is stated rather than flagged.
|
||||||
|
let mut detail = "offline".to_string();
|
||||||
|
if let Some(v4) = row.overlay_address_v4 {
|
||||||
|
detail.push_str(&format!(" · {v4} still reserved for it"));
|
||||||
|
}
|
||||||
|
let out = Row::new(Health::Info, row.label(), detail);
|
||||||
|
return if row.failed_dials > 0 {
|
||||||
|
// Attributed to the member it concerns, rather than left as a
|
||||||
|
// network-wide counter with no explanation attached.
|
||||||
|
out.with_note(format!(
|
||||||
|
"{} dial attempt(s) failed since it was last reachable",
|
||||||
|
row.failed_dials
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
out
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
let direct = transport.eq_ignore_ascii_case("direct");
|
||||||
|
let tunnel_up = row.tunnel.is_some_and(|tunnel| tunnel.is_up());
|
||||||
|
let has_overlay = row.tunnel.is_some();
|
||||||
|
|
||||||
|
let mut detail = transport.to_lowercase();
|
||||||
|
if let Some(rtt) = row.rtt_ms {
|
||||||
|
detail.push_str(&format!(" rtt {rtt}ms"));
|
||||||
|
}
|
||||||
|
if let Some(v4) = row.overlay_address_v4 {
|
||||||
|
detail.push_str(&format!(" · {v4}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let health = if !direct || (has_overlay && !tunnel_up) {
|
||||||
|
Health::Degraded
|
||||||
|
} else {
|
||||||
|
Health::Good
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut out = Row::new(health, row.label(), detail);
|
||||||
|
if let Some(tunnel) = row.tunnel {
|
||||||
|
out = out.with_note(match tunnel.handshake_secs_ago {
|
||||||
|
Some(secs) => format!(
|
||||||
|
"tunnel up, handshake {secs}s ago, tx {} rx {}{} · {}",
|
||||||
|
tunnel.tx_packets,
|
||||||
|
tunnel.rx_packets,
|
||||||
|
if tunnel.dropped > 0 {
|
||||||
|
format!(", {} dropped", tunnel.dropped)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
},
|
||||||
|
tunnel.path
|
||||||
|
),
|
||||||
|
None => "no WireGuard handshake yet; the tunnel cannot carry traffic".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Shortens an identifier for a column, with an ellipsis when it was cut.
|
/// Shortens an identifier for a column, with an ellipsis when it was cut.
|
||||||
fn short(text: &str, len: usize) -> String {
|
fn short(text: &str, len: usize) -> String {
|
||||||
if text.chars().count() <= len {
|
if text.chars().count() <= len {
|
||||||
@@ -1318,7 +1460,9 @@ async fn build_report(
|
|||||||
agent: &Agent,
|
agent: &Agent,
|
||||||
wireguard: Option<&WireguardPlugin>,
|
wireguard: Option<&WireguardPlugin>,
|
||||||
) -> tsunagi::ipc::StatusReport {
|
) -> tsunagi::ipc::StatusReport {
|
||||||
use tsunagi::ipc::{NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport};
|
use tsunagi::ipc::{
|
||||||
|
MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport,
|
||||||
|
};
|
||||||
|
|
||||||
let Ok(status) = agent.status().await else {
|
let Ok(status) = agent.status().await else {
|
||||||
return StatusReport::default();
|
return StatusReport::default();
|
||||||
@@ -1341,6 +1485,7 @@ async fn build_report(
|
|||||||
.peers
|
.peers
|
||||||
.iter()
|
.iter()
|
||||||
.map(|peer| OverlayPeerReport {
|
.map(|peer| OverlayPeerReport {
|
||||||
|
endpoint_id: peer.endpoint_id.to_string(),
|
||||||
public_key: peer.public_key.to_string(),
|
public_key: peer.public_key.to_string(),
|
||||||
address: peer.overlay_address.to_string(),
|
address: peer.overlay_address.to_string(),
|
||||||
address_v4: peer.overlay_address_v4.map(|addr| addr.to_string()),
|
address_v4: peer.overlay_address_v4.map(|addr| addr.to_string()),
|
||||||
@@ -1390,6 +1535,23 @@ async fn build_report(
|
|||||||
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
members: network
|
||||||
|
.members
|
||||||
|
.iter()
|
||||||
|
.map(|member| MemberReport {
|
||||||
|
endpoint_id: member.endpoint_id.to_string(),
|
||||||
|
overlay_address_v4: member.overlay_address_v4.map(|addr| addr.to_string()),
|
||||||
|
// What this agent is currently experiencing trying to
|
||||||
|
// reach it, so a dial-failure count can be attributed
|
||||||
|
// to the member it belongs to instead of floating
|
||||||
|
// free as a network-wide number.
|
||||||
|
failed_dials: network
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
|
||||||
|
.map_or(0, |candidate| candidate.consecutive_failures),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
dial_failures: network.metrics.dial_failures,
|
dial_failures: network.metrics.dial_failures,
|
||||||
handshake_failures: network.metrics.handshake_failures,
|
handshake_failures: network.metrics.handshake_failures,
|
||||||
control_messages: (
|
control_messages: (
|
||||||
@@ -1584,3 +1746,185 @@ async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&Wire
|
|||||||
}
|
}
|
||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod status_tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use super::report::Health;
|
||||||
|
use super::*;
|
||||||
|
use tsunagi::ipc::{MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport};
|
||||||
|
|
||||||
|
const OWN: &str = "aaaa0000";
|
||||||
|
const ONLINE: &str = "bbbb1111";
|
||||||
|
const AWAY: &str = "cccc2222";
|
||||||
|
|
||||||
|
fn overlay(peers: Vec<OverlayPeerReport>) -> OverlayReport {
|
||||||
|
OverlayReport {
|
||||||
|
interface: "tsundemo".into(),
|
||||||
|
mtu: 1280,
|
||||||
|
address: "fd55::1".into(),
|
||||||
|
address_v4: Some("10.13.37.69".into()),
|
||||||
|
prefix: "fd55::".into(),
|
||||||
|
prefix_len: 64,
|
||||||
|
peers,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tunnel(endpoint_id: &str, handshake: Option<u64>) -> OverlayPeerReport {
|
||||||
|
OverlayPeerReport {
|
||||||
|
endpoint_id: endpoint_id.into(),
|
||||||
|
public_key: "keykeykey".into(),
|
||||||
|
address: "fd55::2".into(),
|
||||||
|
address_v4: Some("10.13.37.237".into()),
|
||||||
|
handshake_secs_ago: handshake,
|
||||||
|
tx_packets: 32,
|
||||||
|
rx_packets: 887,
|
||||||
|
path: "direct via 192.0.2.1:50303".into(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The situation that prompted this: one peer left and came back.
|
||||||
|
fn network_after_a_peer_returned() -> NetworkReport {
|
||||||
|
NetworkReport {
|
||||||
|
name: "LAB".into(),
|
||||||
|
network_id: "xa7gyz".into(),
|
||||||
|
active: true,
|
||||||
|
peers: vec![PeerReport {
|
||||||
|
endpoint_id: ONLINE.into(),
|
||||||
|
hostname: Some("music".into()),
|
||||||
|
transport: "direct".into(),
|
||||||
|
rtt_ms: Some(24),
|
||||||
|
}],
|
||||||
|
members: vec![
|
||||||
|
MemberReport {
|
||||||
|
endpoint_id: OWN.into(),
|
||||||
|
overlay_address_v4: Some("10.13.37.69".into()),
|
||||||
|
failed_dials: 0,
|
||||||
|
},
|
||||||
|
MemberReport {
|
||||||
|
endpoint_id: ONLINE.into(),
|
||||||
|
overlay_address_v4: Some("10.13.37.237".into()),
|
||||||
|
failed_dials: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Everything below happened while the peer was away.
|
||||||
|
dial_failures: 9,
|
||||||
|
handshake_failures: 0,
|
||||||
|
control_messages: (2, 2),
|
||||||
|
overlay: Some(OverlayReport {
|
||||||
|
unroutable_packets: 1,
|
||||||
|
unroutable_sample: Some("10.13.37.237".into()),
|
||||||
|
..overlay(vec![tunnel(ONLINE, Some(29))])
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn counters_from_the_past_do_not_grade_the_present() {
|
||||||
|
// A peer that left and returned leaves dial failures and a packet
|
||||||
|
// sent to an address nobody owned behind it. Once it is back, those
|
||||||
|
// are history: reporting them as current faults made a working
|
||||||
|
// network look broken.
|
||||||
|
let network = network_after_a_peer_returned();
|
||||||
|
let mut out = report::Report::new();
|
||||||
|
out.push(network_section(&network, OWN));
|
||||||
|
|
||||||
|
assert_eq!(out.worst(), Health::Good, "{}", out.render(false));
|
||||||
|
let text = out.render(false);
|
||||||
|
assert!(text.contains("since start"), "{text}");
|
||||||
|
assert!(
|
||||||
|
text.contains("9 dial failure(s)"),
|
||||||
|
"the history is still shown: {text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn this_agent_is_not_listed_among_its_own_peers() {
|
||||||
|
let network = network_after_a_peer_returned();
|
||||||
|
let rows = member_rows(&network, OWN);
|
||||||
|
assert_eq!(rows.len(), 1, "only the other member");
|
||||||
|
assert_eq!(rows[0].endpoint_id, ONLINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn offline_members_are_listed_after_online_ones() {
|
||||||
|
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: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
let rows = member_rows(&network, OWN);
|
||||||
|
assert_eq!(rows.len(), 2);
|
||||||
|
assert!(rows[0].online(), "the connected member comes first");
|
||||||
|
assert!(!rows[1].online());
|
||||||
|
assert_eq!(rows[1].endpoint_id, AWAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_member_that_is_away_is_stated_rather_than_flagged() {
|
||||||
|
// In a mesh of laptops a member being away is the ordinary
|
||||||
|
// condition, not a fault of this agent. It is said plainly, with
|
||||||
|
// what the signed state still knows about it, and the failed dials
|
||||||
|
// are attributed to it instead of floating free as a counter.
|
||||||
|
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: 9,
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut out = report::Report::new();
|
||||||
|
out.push(network_section(&network, OWN));
|
||||||
|
let text = out.render(false);
|
||||||
|
|
||||||
|
assert_eq!(out.worst(), Health::Good, "{text}");
|
||||||
|
assert!(text.contains("offline"), "{text}");
|
||||||
|
assert!(text.contains("10.13.37.99 still reserved for it"), "{text}");
|
||||||
|
assert!(text.contains("9 dial attempt(s) failed"), "{text}");
|
||||||
|
assert!(text.contains("1 of 2 online"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_relayed_peer_is_graded_as_degraded_quality() {
|
||||||
|
let mut network = network_after_a_peer_returned();
|
||||||
|
network.peers[0].transport = "relay".into();
|
||||||
|
|
||||||
|
let mut out = report::Report::new();
|
||||||
|
out.push(network_section(&network, OWN));
|
||||||
|
assert_eq!(out.worst(), Health::Degraded, "{}", out.render(false));
|
||||||
|
assert!(out.render(false).contains("relay"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tunnel_that_never_handshook_is_flagged_while_the_peer_is_connected() {
|
||||||
|
let mut network = network_after_a_peer_returned();
|
||||||
|
network.overlay = Some(overlay(vec![tunnel(ONLINE, None)]));
|
||||||
|
|
||||||
|
let mut out = report::Report::new();
|
||||||
|
out.push(network_section(&network, OWN));
|
||||||
|
let text = out.render(false);
|
||||||
|
assert_eq!(out.worst(), Health::Degraded, "{text}");
|
||||||
|
assert!(text.contains("no WireGuard handshake yet"), "{text}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handshake_failures_with_nobody_connected_point_at_the_secret() {
|
||||||
|
// The classic symptom of one member being given a different secret.
|
||||||
|
// With a peer connected the same counter is just history.
|
||||||
|
let mut network = network_after_a_peer_returned();
|
||||||
|
network.peers.clear();
|
||||||
|
network.overlay = Some(overlay(Vec::new()));
|
||||||
|
network.handshake_failures = 4;
|
||||||
|
|
||||||
|
let mut out = report::Report::new();
|
||||||
|
out.push(network_section(&network, OWN));
|
||||||
|
let text = out.render(false);
|
||||||
|
assert_eq!(out.worst(), Health::Degraded, "{text}");
|
||||||
|
assert!(text.contains("same secret"), "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,6 +104,13 @@ pub struct NetworkReport {
|
|||||||
pub active: bool,
|
pub active: bool,
|
||||||
/// Authenticated control plane peers.
|
/// Authenticated control plane peers.
|
||||||
pub peers: Vec<PeerReport>,
|
pub peers: Vec<PeerReport>,
|
||||||
|
/// Members the signed state knows about, connected or not.
|
||||||
|
///
|
||||||
|
/// This is what makes "offline" sayable. Without it a member that is away
|
||||||
|
/// is indistinguishable from one that never existed, and the only thing
|
||||||
|
/// left to report is a dial-failure counter — which describes the symptom
|
||||||
|
/// and not the cause.
|
||||||
|
pub members: Vec<MemberReport>,
|
||||||
/// Outbound dials that failed.
|
/// Outbound dials that failed.
|
||||||
pub dial_failures: u64,
|
pub dial_failures: u64,
|
||||||
/// Handshakes rejected in either direction.
|
/// Handshakes rejected in either direction.
|
||||||
@@ -114,6 +121,17 @@ pub struct NetworkReport {
|
|||||||
pub overlay: Option<OverlayReport>,
|
pub overlay: Option<OverlayReport>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One member of the network, from signed state.
|
||||||
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct MemberReport {
|
||||||
|
/// The member's device identity.
|
||||||
|
pub endpoint_id: String,
|
||||||
|
/// The IPv4 overlay address it claimed and signed for.
|
||||||
|
pub overlay_address_v4: Option<String>,
|
||||||
|
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||||
|
pub failed_dials: u32,
|
||||||
|
}
|
||||||
|
|
||||||
/// One control plane peer.
|
/// One control plane peer.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PeerReport {
|
pub struct PeerReport {
|
||||||
@@ -156,6 +174,9 @@ pub struct OverlayReport {
|
|||||||
/// One overlay peer.
|
/// One overlay peer.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct OverlayPeerReport {
|
pub struct OverlayPeerReport {
|
||||||
|
/// The peer's control plane identity, so a tunnel can be matched to the
|
||||||
|
/// session and the member it belongs to.
|
||||||
|
pub endpoint_id: String,
|
||||||
/// The peer's WireGuard public key.
|
/// The peer's WireGuard public key.
|
||||||
pub public_key: String,
|
pub public_key: String,
|
||||||
/// Its overlay address.
|
/// Its overlay address.
|
||||||
|
|||||||
@@ -157,6 +157,19 @@ pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks the wire format of the local control socket.
|
||||||
|
///
|
||||||
|
/// `b"TSN"` followed by the version, so a mismatch is recognised as one
|
||||||
|
/// instead of being read as a length. The encoding is postcard, which is not
|
||||||
|
/// self-describing: adding a field to a report changes how the bytes parse,
|
||||||
|
/// and without this a client one build ahead of its agent reports something
|
||||||
|
/// like "Found an Option discriminant that wasn't 0 or 1" — which says
|
||||||
|
/// nothing about the actual problem, that the two are different builds.
|
||||||
|
///
|
||||||
|
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||||
|
/// changes shape.
|
||||||
|
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 2]);
|
||||||
|
|
||||||
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)
|
||||||
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
|
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
|
||||||
@@ -164,6 +177,10 @@ async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T)
|
|||||||
return Err(Error::Storage("control message is too large".into()));
|
return Err(Error::Storage("control message is too large".into()));
|
||||||
}
|
}
|
||||||
let len = encoded.len() as u32;
|
let len = encoded.len() as u32;
|
||||||
|
stream
|
||||||
|
.write_all(&CONTROL_PROTOCOL.to_be_bytes())
|
||||||
|
.await
|
||||||
|
.map_err(io_error)?;
|
||||||
stream
|
stream
|
||||||
.write_all(&len.to_be_bytes())
|
.write_all(&len.to_be_bytes())
|
||||||
.await
|
.await
|
||||||
@@ -174,6 +191,16 @@ async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T)
|
|||||||
|
|
||||||
async fn read_message<T: for<'de> serde::Deserialize<'de>>(stream: &mut UnixStream) -> Result<T> {
|
async fn read_message<T: for<'de> serde::Deserialize<'de>>(stream: &mut UnixStream) -> Result<T> {
|
||||||
let mut header = [0u8; 4];
|
let mut header = [0u8; 4];
|
||||||
|
stream.read_exact(&mut header).await.map_err(io_error)?;
|
||||||
|
let version = u32::from_be_bytes(header);
|
||||||
|
if version != CONTROL_PROTOCOL {
|
||||||
|
return Err(Error::Storage(format!(
|
||||||
|
"the other end speaks control protocol {version:#010x} and this build speaks \
|
||||||
|
{CONTROL_PROTOCOL:#010x}; they are different builds of tsunagi, so restart the \
|
||||||
|
agent with the binary you are running now"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
stream.read_exact(&mut header).await.map_err(io_error)?;
|
stream.read_exact(&mut header).await.map_err(io_error)?;
|
||||||
let len = u32::from_be_bytes(header) as usize;
|
let len = u32::from_be_bytes(header) as usize;
|
||||||
// Checked before allocating, exactly as on the network.
|
// Checked before allocating, exactly as on the network.
|
||||||
|
|||||||
Reference in New Issue
Block a user