Fold doctor into status, and stop id taking the lock
`status` asked a running agent and failed without one; `doctor` checked
the host and ignored the agent. Between them they answered one question
in two halves. They are now one command: device, agent, networks, host
capability, local addresses, all graded and aligned the same way.
`id` was worse than either. It spawned a whole agent to print three
facts, which took the directory lock and so failed with "owned by
another running agent instance" exactly when the answer was most wanted.
The lock exists to keep one writer over the mandatory state; reading who
this device is needs no such thing.
So both commands now prefer the running agent, which is live and
authoritative, and fall back to the state store, which takes no lock.
StateStore gains device_identity(), which reads and never writes:
load_or_create had the side effect of deciding an identity as a
consequence of asking about one.
Presentation moved out of the library. StatusReport::render is gone and
the CLI renders the structured data, so there is one renderer rather than
two that would drift. Health gains an Info level for rows that are facts
rather than checks — an endpoint id is neither good nor bad, and a column
of green next to plain data teaches the eye to ignore the column. A
report with no checks in it now ends without a summary instead of
claiming that everything checked out.
PathAddr and TransportKind grew Display impls; both were reaching the
user through {:?}, which is how "Direct via Ip(88.198.17.44:49792)" got
printed. The transport grading compares without regard to case, because
the agent answering can be a different build from the client asking and
this field's spelling has now changed once.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -71,7 +71,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 secret # prints tsn1...; share it privately
|
||||||
./target/release/tsunagi doctor # what this host can and cannot do
|
./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
|
||||||
```
|
```
|
||||||
@@ -169,12 +169,17 @@ allocation changes. Everything else, including every byte from the network,
|
|||||||
is handled with it lowered. `+ep` works too; the agent lowers it on the way
|
is handled with it lowered. `+ep` works too; the agent lowers it on the way
|
||||||
in.
|
in.
|
||||||
|
|
||||||
`tsunagi doctor` says which of these applies on the host it runs on. It
|
`tsunagi status` says which of these applies on the host it runs on, along
|
||||||
grades each finding: **ok** for what works, **warn** for what the agent runs
|
with what the agent is doing. It grades each finding: **ok** for what works,
|
||||||
without and you can fix from the line it prints, **FAIL** for what it cannot
|
**warn** for what the agent runs without and you can fix from the line it
|
||||||
work around. The words carry the grade as well as the colour, so the report
|
prints, **FAIL** for what it cannot work around. The words carry the grade as
|
||||||
reads the same piped to a file or on a terminal without colour, and it honours
|
well as the colour, so the report reads the same piped to a file or on a
|
||||||
`NO_COLOR`.
|
terminal without colour, and it honours `NO_COLOR`.
|
||||||
|
|
||||||
|
`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.
|
||||||
|
Reading takes no directory lock, so neither has to wait for the agent it is
|
||||||
|
asking about — nor does either need one to be running.
|
||||||
|
|
||||||
### It cleans up after itself
|
### It cleans up after itself
|
||||||
|
|
||||||
|
|||||||
+429
-109
@@ -40,16 +40,14 @@ struct Cli {
|
|||||||
enum Command {
|
enum Command {
|
||||||
/// Generates a fresh network secret and prints it.
|
/// Generates a fresh network secret and prints it.
|
||||||
Secret,
|
Secret,
|
||||||
/// Reports what this machine can and cannot do.
|
|
||||||
Doctor(PathArgs),
|
|
||||||
/// Shows this device's identity without joining anything.
|
/// Shows this device's identity without joining anything.
|
||||||
Id(PathArgs),
|
Id(StatusArgs),
|
||||||
/// 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
|
||||||
// `///` one, or clap would print it as help.
|
// `///` one, or clap would print it as help.
|
||||||
Up(Box<UpArgs>),
|
Up(Box<UpArgs>),
|
||||||
/// Asks a running agent what it is doing.
|
/// Reports this device, what the agent is doing, and what this host can do.
|
||||||
Status(StatusArgs),
|
Status(StatusArgs),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,8 +292,7 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Command::Doctor(paths) => doctor(paths).await,
|
Command::Id(args) => show_id(args).await,
|
||||||
Command::Id(paths) => show_id(paths).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,
|
||||||
}
|
}
|
||||||
@@ -309,111 +306,378 @@ fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> Path
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
/// What could be learned about this device, and from where.
|
||||||
|
///
|
||||||
|
/// A running agent is authoritative and live, so it is asked first. With no
|
||||||
|
/// agent there is still plenty to say: the mandatory state store holds the
|
||||||
|
/// identity and the configured networks, and reading it takes no directory
|
||||||
|
/// lock — so asking who this device is never collides with the agent that is
|
||||||
|
/// being asked about, and never needs one to be running.
|
||||||
|
enum Observed {
|
||||||
|
/// A running agent answered over the control socket.
|
||||||
|
Agent(Box<tsunagi::ipc::StatusReport>),
|
||||||
|
/// Read from the state store, with no agent running.
|
||||||
|
Stored {
|
||||||
|
endpoint_id: Option<String>,
|
||||||
|
hostname: Option<String>,
|
||||||
|
networks: Vec<(String, String, bool)>,
|
||||||
|
/// Why there was no agent to ask.
|
||||||
|
why: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Asks the agent, and falls back to the state store.
|
||||||
|
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
|
||||||
|
let why = if socket.exists() {
|
||||||
|
match tsunagi::ipc::unix::request_status(socket).await {
|
||||||
|
Ok(report) => return Observed::Agent(Box::new(report)),
|
||||||
|
Err(err) => format!("cannot reach the agent at {}: {err}", socket.display()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
"no agent is running for this state directory".to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read-only, and deliberately tolerant: a state directory that has never
|
||||||
|
// been used is not an error, it just has nothing to report yet.
|
||||||
|
let (endpoint_id, hostname, networks) =
|
||||||
|
match tsunagi::storage::StateStore::open(paths.state_db()) {
|
||||||
|
Ok(store) => (
|
||||||
|
store
|
||||||
|
.device_identity()
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.map(|identity| identity.endpoint_id().to_string()),
|
||||||
|
store.hostname().ok().flatten(),
|
||||||
|
store
|
||||||
|
.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 {
|
||||||
|
endpoint_id,
|
||||||
|
hostname,
|
||||||
|
networks,
|
||||||
|
why,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `device` section: who this is and where it keeps things.
|
||||||
|
fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section {
|
||||||
|
use report::{Health, Row, Section};
|
||||||
|
|
||||||
|
let mut device = Section::new("device");
|
||||||
|
let (endpoint_id, hostname) = match observed {
|
||||||
|
Observed::Agent(report) => (
|
||||||
|
Some(report.endpoint_id.clone()),
|
||||||
|
Some(report.hostname.clone()),
|
||||||
|
),
|
||||||
|
Observed::Stored {
|
||||||
|
endpoint_id,
|
||||||
|
hostname,
|
||||||
|
..
|
||||||
|
} => (endpoint_id.clone(), hostname.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
device.push(match endpoint_id {
|
||||||
|
Some(id) => Row::new(Health::Info, "endpoint id", id),
|
||||||
|
None => Row::new(Health::Info, "endpoint id", "not created yet")
|
||||||
|
.with_note("generated the first time an agent starts here"),
|
||||||
|
});
|
||||||
|
if let Some(hostname) = hostname {
|
||||||
|
device.push(Row::new(Health::Info, "hostname", hostname));
|
||||||
|
}
|
||||||
|
device.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
|
"state directory",
|
||||||
|
paths.state_dir.display().to_string(),
|
||||||
|
));
|
||||||
|
device.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
|
"cache directory",
|
||||||
|
paths.cache_dir.display().to_string(),
|
||||||
|
));
|
||||||
|
device
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `networks` section, as the state store knows them.
|
||||||
|
fn stored_networks_section(networks: &[(String, String, bool)]) -> report::Section {
|
||||||
|
use report::{Health, Row, Section};
|
||||||
|
|
||||||
|
let mut section = Section::new("networks");
|
||||||
|
if networks.is_empty() {
|
||||||
|
section.push(Row::new(Health::Info, "none", "no network has been joined"));
|
||||||
|
}
|
||||||
|
for (name, id, auto_start) in networks {
|
||||||
|
section.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
|
name,
|
||||||
|
format!("{id}{}", if *auto_start { " (auto-start)" } else { "" }),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
section
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn show_id(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use report::{Health, Report, Row, Section};
|
||||||
|
|
||||||
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());
|
||||||
if !socket.exists() {
|
let observed = observe(&paths, &socket).await;
|
||||||
return Err(format!(
|
|
||||||
"no agent is running for {} (no control socket at {})",
|
let mut out = Report::new();
|
||||||
paths.state_dir.display(),
|
out.push(device_section(&paths, &observed));
|
||||||
socket.display()
|
match &observed {
|
||||||
)
|
Observed::Agent(report) => {
|
||||||
.into());
|
let mut section = Section::new("networks");
|
||||||
|
if report.networks.is_empty() {
|
||||||
|
section.push(Row::new(Health::Info, "none", "no network has been joined"));
|
||||||
}
|
}
|
||||||
let report = tsunagi::ipc::unix::request_status(&socket)
|
for network in &report.networks {
|
||||||
.await
|
section.push(Row::new(
|
||||||
.map_err(|err| format!("cannot reach the agent at {}: {err}", socket.display()))?;
|
Health::Info,
|
||||||
print!("{}", report.render());
|
&network.name,
|
||||||
Ok(())
|
format!(
|
||||||
|
"{} ({})",
|
||||||
|
network.network_id,
|
||||||
|
if network.active { "active" } else { "inactive" }
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
out.push(section);
|
||||||
|
}
|
||||||
|
Observed::Stored { networks, .. } => out.push(stored_networks_section(networks)),
|
||||||
|
}
|
||||||
|
print_report("tsunagi id", &out)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn show_id(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
/// Reports this device, what the agent is doing, and what this host can do.
|
||||||
let paths = paths.resolve()?;
|
|
||||||
println!("state directory {}", paths.state_dir.display());
|
|
||||||
println!("cache directory {}", paths.cache_dir.display());
|
|
||||||
|
|
||||||
let agent =
|
|
||||||
Agent::spawn(AgentConfig::new(paths).with_transport(TransportPolicy::LocalOnly)).await?;
|
|
||||||
println!("endpoint id {}", agent.endpoint_id());
|
|
||||||
println!("hostname {}", agent.hostname());
|
|
||||||
for network in agent.list_networks().await? {
|
|
||||||
println!(
|
|
||||||
"network {} ({}) auto-start={}",
|
|
||||||
network.name, network.network_id, network.auto_start
|
|
||||||
);
|
|
||||||
}
|
|
||||||
agent.shutdown().await;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reports what this machine can and cannot do, and how badly it matters.
|
|
||||||
///
|
///
|
||||||
/// Three levels, and the distinction between the middle two is deliberate:
|
/// Three levels, and the distinction between the middle two is deliberate:
|
||||||
/// *degraded* is something the agent runs without and that the user can fix
|
/// *degraded* is something the agent runs without and that the user can fix
|
||||||
/// from a stated one-liner, *broken* is something it cannot work around.
|
/// from a stated one-liner, *broken* is something it cannot work around.
|
||||||
/// Getting those the wrong way round makes a diagnostic tool useless, so
|
/// Getting those the wrong way round makes a diagnostic tool useless, so
|
||||||
/// each check below says which it is and why.
|
/// each check below says which it is and why.
|
||||||
async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use report::{Health, Report, Row, Section};
|
use report::{Health, Report, Row, Section};
|
||||||
|
|
||||||
let paths = paths.resolve()?;
|
let paths = args.paths.resolve()?;
|
||||||
let mut doctor = Report::new();
|
let socket = control_socket(&paths, args.control_socket.as_ref());
|
||||||
|
let observed = observe(&paths, &socket).await;
|
||||||
|
|
||||||
// Storage. The asymmetry here is the point: state is mandatory and cache
|
let mut out = Report::new();
|
||||||
// is disposable, so the same failure means different things.
|
out.push(device_section(&paths, &observed));
|
||||||
let mut storage = Section::new("storage");
|
|
||||||
storage.push(match std::fs::create_dir_all(&paths.state_dir) {
|
|
||||||
Ok(()) => Row::new(
|
|
||||||
Health::Good,
|
|
||||||
"state directory",
|
|
||||||
format!("{} (writable)", paths.state_dir.display()),
|
|
||||||
),
|
|
||||||
Err(err) => Row::new(
|
|
||||||
Health::Broken,
|
|
||||||
"state directory",
|
|
||||||
format!("{}: {err}", paths.state_dir.display()),
|
|
||||||
)
|
|
||||||
.with_note("mandatory: the agent will not start without it"),
|
|
||||||
});
|
|
||||||
storage.push(match std::fs::create_dir_all(&paths.cache_dir) {
|
|
||||||
Ok(()) => Row::new(
|
|
||||||
Health::Good,
|
|
||||||
"cache directory",
|
|
||||||
format!("{} (writable)", paths.cache_dir.display()),
|
|
||||||
),
|
|
||||||
Err(err) => Row::new(
|
|
||||||
Health::Degraded,
|
|
||||||
"cache directory",
|
|
||||||
format!("{}: {err}", paths.cache_dir.display()),
|
|
||||||
)
|
|
||||||
.with_note("disposable: the agent runs, rediscovering what it cached"),
|
|
||||||
});
|
|
||||||
doctor.push(storage);
|
|
||||||
|
|
||||||
// Control plane. Binding a socket is a real check rather than a claim.
|
let mut agent = Section::new("agent");
|
||||||
let mut control = Section::new("control plane");
|
match &observed {
|
||||||
control.push(
|
Observed::Agent(report) => {
|
||||||
match std::net::UdpSocket::bind((std::net::Ipv6Addr::UNSPECIFIED, 0))
|
agent.push(Row::new(
|
||||||
.or_else(|_| std::net::UdpSocket::bind((std::net::Ipv4Addr::UNSPECIFIED, 0)))
|
Health::Good,
|
||||||
{
|
"running",
|
||||||
Ok(_) => Row::new(Health::Good, "udp socket", "can bind; no privileges needed"),
|
format!("reachable at {}", socket.display()),
|
||||||
Err(err) => Row::new(Health::Broken, "udp socket", format!("cannot bind: {err}"))
|
));
|
||||||
.with_note("nothing will reach any peer"),
|
if !report.bound_sockets.is_empty() {
|
||||||
},
|
agent.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
|
"bound",
|
||||||
|
report.bound_sockets.join(", "),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
agent.push(if report.cache_healthy {
|
||||||
|
Row::new(Health::Good, "cache", "usable")
|
||||||
|
} else {
|
||||||
|
Row::new(Health::Degraded, "cache", "unavailable")
|
||||||
|
.with_note("disposable: the agent runs, rediscovering what it cached")
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Observed::Stored { why, .. } => {
|
||||||
|
agent.push(
|
||||||
|
Row::new(Health::Degraded, "running", "no")
|
||||||
|
.with_note(format!("{why}; everything below was read from the store")),
|
||||||
);
|
);
|
||||||
doctor.push(control);
|
}
|
||||||
|
}
|
||||||
|
out.push(agent);
|
||||||
|
|
||||||
let mut data = Section::new("data plane (WireGuard)");
|
if let Observed::Agent(report) = &observed {
|
||||||
data.push(Row::new(
|
for network in &report.networks {
|
||||||
|
out.push(network_section(network));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
out.push(host_section());
|
||||||
|
out.push(addresses_section().await);
|
||||||
|
print_report("tsunagi status", &out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One network's control plane and overlay.
|
||||||
|
fn network_section(network: &tsunagi::ipc::NetworkReport) -> report::Section {
|
||||||
|
use report::{Health, Row, Section};
|
||||||
|
|
||||||
|
let mut section = Section::new(format!("network {}", network.name));
|
||||||
|
section.push(if network.active {
|
||||||
|
Row::new(
|
||||||
Health::Good,
|
Health::Good,
|
||||||
|
"state",
|
||||||
|
format!("active {}", network.network_id),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Row::new(
|
||||||
|
Health::Degraded,
|
||||||
|
"state",
|
||||||
|
format!("inactive {}", network.network_id),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
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 {
|
||||||
|
section.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
|
"overlay",
|
||||||
|
format!(
|
||||||
|
"{} {}/{}{} mtu {}",
|
||||||
|
overlay.interface,
|
||||||
|
overlay.address,
|
||||||
|
overlay.prefix_len,
|
||||||
|
match &overlay.address_v4 {
|
||||||
|
Some(v4) => format!(" and {v4}"),
|
||||||
|
None => String::new(),
|
||||||
|
},
|
||||||
|
overlay.mtu
|
||||||
|
),
|
||||||
|
));
|
||||||
|
for peer in &overlay.peers {
|
||||||
|
let row = match peer.handshake_secs_ago {
|
||||||
|
Some(secs) => Row::new(
|
||||||
|
Health::Good,
|
||||||
|
format!("tunnel {}", short(&peer.public_key, 8)),
|
||||||
|
format!(
|
||||||
|
"{}{} handshake {secs}s ago tx {} rx {} {}",
|
||||||
|
peer.address,
|
||||||
|
match &peer.address_v4 {
|
||||||
|
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 {
|
||||||
|
row
|
||||||
|
};
|
||||||
|
section.push(row);
|
||||||
|
}
|
||||||
|
if overlay.unroutable_packets > 0 {
|
||||||
|
section.push(
|
||||||
|
Row::new(
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shortens an identifier for a column, with an ellipsis when it was cut.
|
||||||
|
fn short(text: &str, len: usize) -> String {
|
||||||
|
if text.chars().count() <= len {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
format!("{}…", text.chars().take(len).collect::<String>())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What this host can and cannot do for the data plane.
|
||||||
|
fn host_section() -> report::Section {
|
||||||
|
use report::{Health, Row, Section};
|
||||||
|
|
||||||
|
let mut host = Section::new("host");
|
||||||
|
host.push(Row::new(
|
||||||
|
Health::Info,
|
||||||
"implementation",
|
"implementation",
|
||||||
"userspace (boringtun); no kernel module needed",
|
"userspace WireGuard (boringtun); no kernel module needed",
|
||||||
));
|
));
|
||||||
#[cfg(feature = "tun-device")]
|
#[cfg(feature = "tun-device")]
|
||||||
{
|
{
|
||||||
if cfg!(target_os = "linux") {
|
if cfg!(target_os = "linux") {
|
||||||
let tun_path = std::path::Path::new("/dev/net/tun");
|
let tun_path = std::path::Path::new("/dev/net/tun");
|
||||||
data.push(if !tun_path.exists() {
|
host.push(if !tun_path.exists() {
|
||||||
Row::new(Health::Broken, "/dev/net/tun", "missing")
|
Row::new(Health::Broken, "/dev/net/tun", "missing")
|
||||||
.with_note("load the `tun` module; without it there can be no interface")
|
.with_note("load the `tun` module; without it there can be no interface")
|
||||||
} else {
|
} else {
|
||||||
@@ -436,8 +700,8 @@ async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin};
|
use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin};
|
||||||
match probe_net_admin() {
|
match probe_net_admin() {
|
||||||
Privilege::Available => {
|
Privilege::Available => {
|
||||||
data.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held"));
|
host.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held"));
|
||||||
data.push(Row::new(
|
host.push(Row::new(
|
||||||
Health::Good,
|
Health::Good,
|
||||||
"interface",
|
"interface",
|
||||||
"managed by the agent: created on start, removed on exit",
|
"managed by the agent: created on start, removed on exit",
|
||||||
@@ -447,18 +711,18 @@ async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// The note is the command and nothing else: a paragraph of
|
// The note is the command and nothing else: a paragraph of
|
||||||
// explanation belongs in the runtime error, not in a column
|
// explanation belongs in the runtime error, not in a column
|
||||||
// the eye is meant to scan.
|
// the eye is meant to scan.
|
||||||
data.push(
|
host.push(
|
||||||
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held")
|
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held")
|
||||||
.with_note(format!("sudo setcap cap_net_admin+p {}", program_path())),
|
.with_note(format!("sudo setcap cap_net_admin+p {}", program_path())),
|
||||||
);
|
);
|
||||||
data.push(Row::new(
|
host.push(Row::new(
|
||||||
Health::Degraded,
|
Health::Degraded,
|
||||||
"interface",
|
"interface",
|
||||||
"cannot be created; run with `--no-tun` meanwhile",
|
"cannot be created; run with `--no-tun` meanwhile",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Privilege::Unsupported => {
|
Privilege::Unsupported => {
|
||||||
data.push(Row::new(
|
host.push(Row::new(
|
||||||
Health::Degraded,
|
Health::Degraded,
|
||||||
"privileges",
|
"privileges",
|
||||||
format!(
|
format!(
|
||||||
@@ -466,7 +730,7 @@ async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
std::env::consts::OS
|
std::env::consts::OS
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
data.push(Row::new(
|
host.push(Row::new(
|
||||||
Health::Degraded,
|
Health::Degraded,
|
||||||
"interface",
|
"interface",
|
||||||
"cannot be created; run with `--no-tun`",
|
"cannot be created; run with `--no-tun`",
|
||||||
@@ -475,7 +739,7 @@ async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
#[cfg(not(feature = "tun-device"))]
|
#[cfg(not(feature = "tun-device"))]
|
||||||
data.push(
|
host.push(
|
||||||
Row::new(
|
Row::new(
|
||||||
Health::Degraded,
|
Health::Degraded,
|
||||||
"interface",
|
"interface",
|
||||||
@@ -483,7 +747,12 @@ async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
)
|
)
|
||||||
.with_note("rebuild with the `tun-device` feature, or run with `--no-tun`"),
|
.with_note("rebuild with the `tun-device` feature, or run with `--no-tun`"),
|
||||||
);
|
);
|
||||||
doctor.push(data);
|
host
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The addresses this host could reach a peer from.
|
||||||
|
async fn addresses_section() -> report::Section {
|
||||||
|
use report::{Health, Row, Section};
|
||||||
|
|
||||||
let mut addresses = Section::new("local addresses");
|
let mut addresses = Section::new("local addresses");
|
||||||
let found = netwatch_addresses().await;
|
let found = netwatch_addresses().await;
|
||||||
@@ -501,21 +770,25 @@ async fn doctor(paths: PathArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
(false, true) => "ipv4",
|
(false, true) => "ipv4",
|
||||||
(false, false) => "ipv6",
|
(false, false) => "ipv6",
|
||||||
};
|
};
|
||||||
addresses.push(Row::new(Health::Good, kind, addr.to_string()));
|
addresses.push(Row::new(Health::Info, kind, addr.to_string()));
|
||||||
|
}
|
||||||
|
addresses
|
||||||
}
|
}
|
||||||
doctor.push(addresses);
|
|
||||||
|
|
||||||
// `anstream` decides whether the escapes survive: they are stripped when
|
/// Writes a report to stdout under a title.
|
||||||
// stdout is not a terminal, when NO_COLOR is set, and on a Windows console
|
///
|
||||||
// that cannot render them.
|
/// `anstream` decides whether the escapes survive: they are stripped when
|
||||||
|
/// stdout is not a terminal, when `NO_COLOR` is set, and on a Windows console
|
||||||
|
/// that cannot render them.
|
||||||
|
fn print_report(title: &str, out: &report::Report) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
let mut out = anstream::stdout().lock();
|
let mut stdout = anstream::stdout().lock();
|
||||||
writeln!(out, "tsunagi doctor\n")?;
|
writeln!(stdout, "{title}\n")?;
|
||||||
write!(out, "{}", doctor.render(true))?;
|
write!(stdout, "{}", out.render(true))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The shape of what `tsunagi doctor` reports.
|
/// The shape of what `tsunagi status` reports.
|
||||||
///
|
///
|
||||||
/// Findings are built first and rendered second, so what is reported is
|
/// Findings are built first and rendered second, so what is reported is
|
||||||
/// decided separately from how it looks and can be tested without a
|
/// decided separately from how it looks and can be tested without a
|
||||||
@@ -529,6 +802,12 @@ mod report {
|
|||||||
/// How healthy one finding is.
|
/// How healthy one finding is.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub enum Health {
|
pub enum Health {
|
||||||
|
/// Not a check at all: a fact, such as an identifier or a path.
|
||||||
|
///
|
||||||
|
/// Grading these would be noise — an endpoint id is neither good nor
|
||||||
|
/// bad — and a column of green `ok` next to plain data teaches the
|
||||||
|
/// eye to ignore the column, which is the opposite of the point.
|
||||||
|
Info,
|
||||||
/// Works, nothing to do.
|
/// Works, nothing to do.
|
||||||
Good,
|
Good,
|
||||||
/// The agent runs, but something it could do it cannot, and there is
|
/// The agent runs, but something it could do it cannot, and there is
|
||||||
@@ -543,6 +822,7 @@ mod report {
|
|||||||
/// The word printed in the margin. Four characters, so rows line up.
|
/// The word printed in the margin. Four characters, so rows line up.
|
||||||
fn word(self) -> &'static str {
|
fn word(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
|
Health::Info => " ",
|
||||||
Health::Good => "ok ",
|
Health::Good => "ok ",
|
||||||
Health::Degraded => "warn",
|
Health::Degraded => "warn",
|
||||||
Health::Broken => "FAIL",
|
Health::Broken => "FAIL",
|
||||||
@@ -551,6 +831,7 @@ mod report {
|
|||||||
|
|
||||||
fn style(self) -> Style {
|
fn style(self) -> Style {
|
||||||
let colour = match self {
|
let colour = match self {
|
||||||
|
Health::Info => return Style::new(),
|
||||||
Health::Good => AnsiColor::Green,
|
Health::Good => AnsiColor::Green,
|
||||||
Health::Degraded => AnsiColor::Yellow,
|
Health::Degraded => AnsiColor::Yellow,
|
||||||
Health::Broken => AnsiColor::Red,
|
Health::Broken => AnsiColor::Red,
|
||||||
@@ -647,6 +928,18 @@ mod report {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether anything in the report was graded at all.
|
||||||
|
///
|
||||||
|
/// A report of plain facts — `tsunagi id` — has nothing to summarise,
|
||||||
|
/// and "everything checked out" under a list of identifiers would be
|
||||||
|
/// claiming something that was never checked.
|
||||||
|
fn has_checks(&self) -> bool {
|
||||||
|
self.sections
|
||||||
|
.iter()
|
||||||
|
.flat_map(|section| §ion.rows)
|
||||||
|
.any(|row| row.health != Health::Info)
|
||||||
|
}
|
||||||
|
|
||||||
/// The closing line.
|
/// The closing line.
|
||||||
fn summary(&self) -> String {
|
fn summary(&self) -> String {
|
||||||
fn checks(count: usize) -> String {
|
fn checks(count: usize) -> String {
|
||||||
@@ -695,12 +988,17 @@ mod report {
|
|||||||
out.push_str(&paint(bold, §ion.title));
|
out.push_str(&paint(bold, §ion.title));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
for row in §ion.rows {
|
for row in §ion.rows {
|
||||||
|
let label = format!("{:width$}", row.label, width = width);
|
||||||
|
let label = if row.health == Health::Info {
|
||||||
|
paint(dim, &label)
|
||||||
|
} else {
|
||||||
|
label
|
||||||
|
};
|
||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
" {} {:width$} {}\n",
|
" {} {} {}\n",
|
||||||
paint(row.health.style(), row.health.word()),
|
paint(row.health.style(), row.health.word()),
|
||||||
row.label,
|
label,
|
||||||
row.detail,
|
row.detail
|
||||||
width = width
|
|
||||||
));
|
));
|
||||||
if let Some(note) = &row.note {
|
if let Some(note) = &row.note {
|
||||||
// Indented under the row it belongs to, and dimmed so
|
// Indented under the row it belongs to, and dimmed so
|
||||||
@@ -717,9 +1015,16 @@ mod report {
|
|||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.has_checks() {
|
||||||
let worst = self.worst();
|
let worst = self.worst();
|
||||||
out.push_str(&paint(worst.style(), &self.summary()));
|
out.push_str(&paint(worst.style(), &self.summary()));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
|
} else {
|
||||||
|
// Trim the blank line the last section left behind.
|
||||||
|
while out.ends_with("\n\n") {
|
||||||
|
out.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -829,6 +1134,21 @@ mod report {
|
|||||||
assert!(clean.render(false).contains("everything checked out"));
|
assert!(clean.render(false).contains("everything checked out"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_report_of_plain_facts_claims_nothing_at_the_end() {
|
||||||
|
// `tsunagi id` reports identifiers, not checks. Summarising them
|
||||||
|
// as fine would assert something that was never tested.
|
||||||
|
let mut report = Report::new();
|
||||||
|
let mut section = Section::new("device");
|
||||||
|
section.push(Row::new(Health::Info, "endpoint id", "abc123"));
|
||||||
|
report.push(section);
|
||||||
|
|
||||||
|
let text = report.render(false);
|
||||||
|
assert!(!text.contains("everything checked out"), "{text:?}");
|
||||||
|
assert!(!text.contains("degraded") && !text.contains("broken"));
|
||||||
|
assert!(text.ends_with("abc123\n"), "{text:?}");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_section_is_left_out_rather_than_printed_bare() {
|
fn an_empty_section_is_left_out_rather_than_printed_bare() {
|
||||||
let mut report = Report::new();
|
let mut report = Report::new();
|
||||||
@@ -1066,7 +1386,7 @@ async fn build_report(
|
|||||||
.map(|peer| PeerReport {
|
.map(|peer| PeerReport {
|
||||||
endpoint_id: peer.endpoint_id.to_string(),
|
endpoint_id: peer.endpoint_id.to_string(),
|
||||||
hostname: peer.hostname.clone(),
|
hostname: peer.hostname.clone(),
|
||||||
transport: format!("{:?}", peer.transport),
|
transport: peer.transport.to_string(),
|
||||||
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
||||||
})
|
})
|
||||||
.collect(),
|
.collect(),
|
||||||
|
|||||||
@@ -137,8 +137,8 @@ impl PacketLink for IrohLink {
|
|||||||
// Report what iroh actually knows, never a guess.
|
// Report what iroh actually knows, never a guess.
|
||||||
let snapshot = crate::net::snapshot_connection(&self.conn);
|
let snapshot = crate::net::snapshot_connection(&self.conn);
|
||||||
match snapshot.paths.iter().find(|path| path.is_selected) {
|
match snapshot.paths.iter().find(|path| path.is_selected) {
|
||||||
Some(path) => format!("{:?} via {:?}", snapshot.transport, path.remote),
|
Some(path) => format!("{} via {}", snapshot.transport, path.remote),
|
||||||
None => format!("{:?}, no selected path yet", snapshot.transport),
|
None => format!("{}, no selected path yet", snapshot.transport),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-102
@@ -121,7 +121,8 @@ pub struct PeerReport {
|
|||||||
pub endpoint_id: String,
|
pub endpoint_id: String,
|
||||||
/// Hostname it announced, if any.
|
/// Hostname it announced, if any.
|
||||||
pub hostname: Option<String>,
|
pub hostname: Option<String>,
|
||||||
/// `Direct`, `Relay` or `Unknown`, as the transport reports it.
|
/// How the connection reaches the peer: `direct`, `relay` or `unknown`,
|
||||||
|
/// as the transport reports it.
|
||||||
pub transport: String,
|
pub transport: String,
|
||||||
/// Round-trip time in milliseconds, when a path is selected.
|
/// Round-trip time in milliseconds, when a path is selected.
|
||||||
pub rtt_ms: Option<u64>,
|
pub rtt_ms: Option<u64>,
|
||||||
@@ -186,104 +187,3 @@ impl OverlayPeerReport {
|
|||||||
self.handshake_secs_ago.is_some()
|
self.handshake_secs_ago.is_some()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StatusReport {
|
|
||||||
/// Renders the report the way the command line prints it.
|
|
||||||
pub fn render(&self) -> String {
|
|
||||||
use std::fmt::Write as _;
|
|
||||||
let mut out = String::new();
|
|
||||||
let _ = writeln!(out, "endpoint {}", self.endpoint_id);
|
|
||||||
let _ = writeln!(out, "hostname {}", self.hostname);
|
|
||||||
let _ = writeln!(out, "bound {}", self.bound_sockets.join(", "));
|
|
||||||
if !self.cache_healthy {
|
|
||||||
let _ = writeln!(out, "cache UNAVAILABLE");
|
|
||||||
}
|
|
||||||
|
|
||||||
for network in &self.networks {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
"\nnetwork {} ({}) {}",
|
|
||||||
network.name,
|
|
||||||
network.network_id,
|
|
||||||
if network.active { "active" } else { "inactive" }
|
|
||||||
);
|
|
||||||
if network.peers.is_empty() {
|
|
||||||
let _ = writeln!(out, " no peers");
|
|
||||||
}
|
|
||||||
for peer in &network.peers {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
" peer {} {} {}{}",
|
|
||||||
&peer.endpoint_id[..10.min(peer.endpoint_id.len())],
|
|
||||||
peer.hostname.as_deref().unwrap_or("?"),
|
|
||||||
peer.transport,
|
|
||||||
match peer.rtt_ms {
|
|
||||||
Some(rtt) => format!(" rtt {rtt}ms"),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if network.dial_failures > 0 || network.handshake_failures > 0 {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
" {} dial failure(s), {} handshake failure(s)",
|
|
||||||
network.dial_failures, network.handshake_failures
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(overlay) = &network.overlay {
|
|
||||||
let up = overlay.peers.iter().filter(|peer| peer.is_up()).count();
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
" overlay {} {}/{}{} mtu {} {}/{} tunnel(s) up",
|
|
||||||
overlay.interface,
|
|
||||||
overlay.address,
|
|
||||||
overlay.prefix_len,
|
|
||||||
match &overlay.address_v4 {
|
|
||||||
Some(v4) => format!(" and {v4}"),
|
|
||||||
None => String::new(),
|
|
||||||
},
|
|
||||||
overlay.mtu,
|
|
||||||
up,
|
|
||||||
overlay.peers.len()
|
|
||||||
);
|
|
||||||
for peer in &overlay.peers {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
" {} {}{} {} tx {} rx {}{} {}",
|
|
||||||
&peer.public_key[..8.min(peer.public_key.len())],
|
|
||||||
peer.address,
|
|
||||||
match &peer.address_v4 {
|
|
||||||
Some(v4) => format!(" / {v4}"),
|
|
||||||
None => String::new(),
|
|
||||||
},
|
|
||||||
match peer.handshake_secs_ago {
|
|
||||||
Some(secs) => format!("handshake {secs}s ago"),
|
|
||||||
None => "NOT HANDSHAKEN".to_string(),
|
|
||||||
},
|
|
||||||
peer.tx_packets,
|
|
||||||
peer.rx_packets,
|
|
||||||
if peer.dropped > 0 {
|
|
||||||
format!(" DROPPED {}", peer.dropped)
|
|
||||||
} else {
|
|
||||||
String::new()
|
|
||||||
},
|
|
||||||
peer.path
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if overlay.unroutable_packets > 0 {
|
|
||||||
let _ = writeln!(
|
|
||||||
out,
|
|
||||||
" {} packet(s) to addresses nobody owns{}",
|
|
||||||
overlay.unroutable_packets,
|
|
||||||
match &overlay.unroutable_sample {
|
|
||||||
Some(sample) => format!(", most recently {sample}"),
|
|
||||||
None => String::new(),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+21
@@ -45,6 +45,16 @@ pub enum PathAddr {
|
|||||||
Other(String),
|
Other(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for PathAddr {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
PathAddr::Ip(addr) => write!(f, "{addr}"),
|
||||||
|
PathAddr::Relay(url) => write!(f, "relay {url}"),
|
||||||
|
PathAddr::Other(what) => write!(f, "{what}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// One verified network path of a connection.
|
/// One verified network path of a connection.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct PathInfo {
|
pub struct PathInfo {
|
||||||
@@ -81,6 +91,17 @@ pub enum TransportKind {
|
|||||||
Unknown,
|
Unknown,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for TransportKind {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
let word = match self {
|
||||||
|
TransportKind::Direct => "direct",
|
||||||
|
TransportKind::Relay => "relay",
|
||||||
|
TransportKind::Unknown => "unknown",
|
||||||
|
};
|
||||||
|
f.write_str(word)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Counters for one connection.
|
/// Counters for one connection.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||||
pub struct ConnectionCounters {
|
pub struct ConnectionCounters {
|
||||||
|
|||||||
+18
-6
@@ -175,11 +175,15 @@ impl StateStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Loads the stored device identity, creating one on first use.
|
/// Loads the stored device identity, if there is one.
|
||||||
///
|
///
|
||||||
/// A stored key of the wrong length is a corruption error, never a reason to
|
/// Reads and never writes, so asking who this device is does not decide
|
||||||
/// silently mint a new identity.
|
/// it. A store that has never run an agent has no identity yet, which is
|
||||||
pub fn load_or_create_device_identity(&self) -> Result<DeviceIdentity> {
|
/// `None` rather than an error.
|
||||||
|
///
|
||||||
|
/// A stored key of the wrong length is a corruption error, never a reason
|
||||||
|
/// to silently mint a new identity.
|
||||||
|
pub fn device_identity(&self) -> Result<Option<DeviceIdentity>> {
|
||||||
let stored: Option<Vec<u8>> = self
|
let stored: Option<Vec<u8>> = self
|
||||||
.conn
|
.conn
|
||||||
.query_row(
|
.query_row(
|
||||||
@@ -190,14 +194,22 @@ impl StateStore {
|
|||||||
.optional()
|
.optional()
|
||||||
.map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?;
|
.map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?;
|
||||||
|
|
||||||
if let Some(bytes) = stored {
|
let Some(bytes) = stored else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
|
let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
|
||||||
self.corrupt(format!(
|
self.corrupt(format!(
|
||||||
"stored device key has {} bytes, expected 32; refusing to replace it",
|
"stored device key has {} bytes, expected 32; refusing to replace it",
|
||||||
bytes.len()
|
bytes.len()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
return Ok(DeviceIdentity::from_secret_bytes(&bytes));
|
Ok(Some(DeviceIdentity::from_secret_bytes(&bytes)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads the stored device identity, creating one on first use.
|
||||||
|
pub fn load_or_create_device_identity(&self) -> Result<DeviceIdentity> {
|
||||||
|
if let Some(identity) = self.device_identity()? {
|
||||||
|
return Ok(identity);
|
||||||
}
|
}
|
||||||
|
|
||||||
let identity = DeviceIdentity::generate();
|
let identity = DeviceIdentity::generate();
|
||||||
|
|||||||
+53
-5
@@ -158,11 +158,16 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
|||||||
assert_eq!(overlay.mtu, 1280);
|
assert_eq!(overlay.mtu, 1280);
|
||||||
assert_eq!(overlay.peers.len(), 1);
|
assert_eq!(overlay.peers.len(), 1);
|
||||||
|
|
||||||
// The rendering a user actually sees mentions the important parts.
|
// The report carries what a reader needs, in structured form: how it is
|
||||||
let rendered = report.render();
|
// laid out is the CLI's business and is tested there.
|
||||||
assert!(rendered.contains(&agent.endpoint_id().to_string()));
|
assert_eq!(report.endpoint_id, agent.endpoint_id().to_string());
|
||||||
assert!(rendered.contains("1/1 tunnel(s) up"), "{rendered}");
|
assert_eq!(
|
||||||
assert!(rendered.contains(&overlay.address));
|
overlay.peers.iter().filter(|peer| peer.is_up()).count(),
|
||||||
|
1,
|
||||||
|
"the tunnel is up: {:?}",
|
||||||
|
overlay.peers
|
||||||
|
);
|
||||||
|
assert!(!overlay.address.is_empty());
|
||||||
|
|
||||||
control.shutdown().await;
|
control.shutdown().await;
|
||||||
assert!(!socket_path.exists(), "the socket is removed on shutdown");
|
assert!(!socket_path.exists(), "the socket is removed on shutdown");
|
||||||
@@ -226,3 +231,46 @@ fn the_socket_path_is_derived_and_short_enough() {
|
|||||||
control_socket_path(&std::path::PathBuf::from("/somewhere/else"))
|
control_socket_path(&std::path::PathBuf::from("/somewhere/else"))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Asking who this device is must not need the directory lock.
|
||||||
|
///
|
||||||
|
/// The lock belongs to the one agent allowed to *write* the state. `id` only
|
||||||
|
/// reads, so making it take the lock would mean the question could never be
|
||||||
|
/// answered while an agent was running — which is exactly when you want to
|
||||||
|
/// ask it.
|
||||||
|
#[cfg(feature = "cli")]
|
||||||
|
#[tokio::test]
|
||||||
|
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let discovery = SharedMemoryDiscovery::new();
|
||||||
|
|
||||||
|
// Hold the directory the way a running agent does.
|
||||||
|
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let expected = agent.endpoint_id().to_string();
|
||||||
|
|
||||||
|
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||||
|
.arg("id")
|
||||||
|
// The same layout `StoragePaths::under` gives the agent above.
|
||||||
|
.arg("--state-dir")
|
||||||
|
.arg(dir.path().join("state"))
|
||||||
|
.arg("--cache-dir")
|
||||||
|
.arg(dir.path().join("cache"))
|
||||||
|
.output()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"`id` failed while an agent was running:\n{stderr}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains(&expected),
|
||||||
|
"expected {expected} in:\n{stdout}"
|
||||||
|
);
|
||||||
|
|
||||||
|
agent.shutdown().await;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user