Every first network previously proposed 10.13.37.0/24. Independently created networks therefore collided when a device joined more than one of them. Use the existing stable NetworkId-derived /24 in 10/8 for every new network, independent of activation order or peer availability. Legacy and explicit signed ranges outrank the derived default; remaining range disagreements still use author-id ordering. The derivation, identity labels, handshake and signed record encoding stay unchanged. There is no creator, timestamp or vote. Reserve ranges before starting network actors and serialise start, stop and switching through the agent lifecycle lock. Refuse equal or nested overlaps without changing a network's subnet locally. A late authenticated range that collides suspends only its network before updating plugins or the interface. Coalesced watch notifications let the agent clean up outside the network actor and reconsider suspended networks when ranges change. An explicit switch stops every blocker before activating the chosen network. Suspended networks retain their startup request and resume whenever their ranges become free, including switching from a wide range to a narrow one. A manual stop cancels that request even for an already suspended network. Persist preference order and enabling together in an SQLite transaction so the user's selection survives restart; no database schema migration is needed. CLI status and network listing show blocker names, ids, ranges and the start command. Network start switches explicitly and reports suspended networks; joining an overlapping network reports it as configured but inactive. The tray adds conflict details, an amber icon, Switch to this network and Keep stopped actions. Local IPC wire version is 19 for the extended reports, so restart the agent and tray together. Release versions and peer wire formats are unchanged. Update the user documentation and compatibility tests. Validation on Linux through nix develop: - cargo fmt --all -- --check - cargo clippy --locked --workspace --all-targets -- -D warnings - cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET removed from the test environment: 486 passed, 2 pre-existing ignored tests - final focused range_conflicts integration suite after making discovery fixtures deterministic: 6 passed Earlier validation attempts caught outdated diagnostic assertions, a new test helper scope error, and the inherited system control socket override. Those were corrected before the successful runs. Plain cargo was unavailable in the host shell, so validation used the Nix development toolchain. macOS, Windows, privileged host TUN operations and manual tray interaction were not exercised.
175 lines
5.5 KiB
Rust
175 lines
5.5 KiB
Rust
//! What the tray icon shows: whether the agent has anybody to talk to, and
|
|
//! whether this device sends its traffic through an exit node.
|
|
//!
|
|
//! The tray process has no window and no event loop of its own to poll from,
|
|
//! so a thread asks the agent on an interval and hands each answer to a
|
|
//! callback, which puts it where the platform wants it.
|
|
|
|
use std::time::Duration;
|
|
|
|
use tsunagi::ipc::{self, StatusReport};
|
|
|
|
use crate::agent::resolve_socket;
|
|
|
|
/// How often the agent is asked.
|
|
const INTERVAL: Duration = Duration::from_secs(3);
|
|
|
|
/// What the icon says.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub(crate) enum Health {
|
|
/// Nobody is connected, or the agent cannot be reached, or the exit node
|
|
/// this device uses is gone, which blocks its traffic.
|
|
Disconnected,
|
|
/// Connected, nothing out of the ordinary.
|
|
Connected,
|
|
/// At least one configured network is blocked by an overlapping active range.
|
|
RangeConflict,
|
|
/// This device's traffic leaves through an exit node, and that works.
|
|
ExitNode,
|
|
}
|
|
|
|
impl Health {
|
|
/// The state a status report amounts to; `None` is an agent that did not
|
|
/// answer.
|
|
pub(crate) fn of(report: Option<&StatusReport>) -> Self {
|
|
let Some(report) = report else {
|
|
return Self::Disconnected;
|
|
};
|
|
if report
|
|
.networks
|
|
.iter()
|
|
.any(|network| network.auto_start && !network.range_conflicts.is_empty())
|
|
{
|
|
return Self::RangeConflict;
|
|
}
|
|
if !report
|
|
.networks
|
|
.iter()
|
|
.any(|network| !network.peers.is_empty())
|
|
{
|
|
return Self::Disconnected;
|
|
}
|
|
match report
|
|
.networks
|
|
.iter()
|
|
.find(|network| network.exit.via.is_some())
|
|
{
|
|
Some(network) if network.exit.via_online => Self::ExitNode,
|
|
Some(_) => Self::Disconnected,
|
|
None => Self::Connected,
|
|
}
|
|
}
|
|
|
|
/// The tooltip of the icon.
|
|
pub(crate) fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Disconnected => "tsunagi: not connected",
|
|
Self::Connected => "tsunagi: connected",
|
|
Self::RangeConflict => "tsunagi: network range conflict; open to switch networks",
|
|
Self::ExitNode => "tsunagi: using an exit node",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Starts asking the agent, and calls `notify` with every answer.
|
|
pub(crate) fn spawn(notify: impl Fn(Health) + Send + 'static) {
|
|
let spawned = std::thread::Builder::new()
|
|
.name("tray-status".into())
|
|
.spawn(move || {
|
|
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
|
|
.enable_all()
|
|
.build()
|
|
else {
|
|
return;
|
|
};
|
|
loop {
|
|
let report = runtime.block_on(async {
|
|
tokio::time::timeout(INTERVAL, ipc::request_status(resolve_socket()))
|
|
.await
|
|
.ok()
|
|
.and_then(Result::ok)
|
|
});
|
|
notify(Health::of(report.as_ref()));
|
|
std::thread::sleep(INTERVAL);
|
|
}
|
|
});
|
|
if let Err(err) = spawned {
|
|
eprintln!("cannot watch the agent for the tray icon: {err}");
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tsunagi::ipc::{ExitReport, NetworkReport, PeerReport};
|
|
|
|
fn network(peers: usize, exit: ExitReport) -> NetworkReport {
|
|
NetworkReport {
|
|
peers: vec![PeerReport::default(); peers],
|
|
exit,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
fn report(networks: Vec<NetworkReport>) -> StatusReport {
|
|
StatusReport {
|
|
networks,
|
|
..Default::default()
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn overlapping_networks_are_visible_even_while_other_peers_are_connected() {
|
|
let mut blocked = NetworkReport {
|
|
auto_start: true,
|
|
..Default::default()
|
|
};
|
|
blocked
|
|
.range_conflicts
|
|
.push(tsunagi::ipc::RangeConflictReport {
|
|
name: "other".into(),
|
|
network_id: "other-id".into(),
|
|
range: "10.90.0.0/16".into(),
|
|
});
|
|
let status = report(vec![network(2, ExitReport::default()), blocked]);
|
|
assert_eq!(Health::of(Some(&status)), Health::RangeConflict);
|
|
assert!(Health::RangeConflict.label().contains("switch networks"));
|
|
}
|
|
|
|
#[test]
|
|
fn no_agent_or_nobody_connected_is_the_red_state() {
|
|
assert_eq!(Health::of(None), Health::Disconnected);
|
|
assert_eq!(Health::of(Some(&report(vec![]))), Health::Disconnected);
|
|
assert_eq!(
|
|
Health::of(Some(&report(vec![network(0, ExitReport::default())]))),
|
|
Health::Disconnected
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_connection_in_any_network_is_the_blue_state() {
|
|
let status = report(vec![
|
|
network(0, ExitReport::default()),
|
|
network(2, ExitReport::default()),
|
|
]);
|
|
assert_eq!(Health::of(Some(&status)), Health::Connected);
|
|
}
|
|
|
|
#[test]
|
|
fn an_exit_node_in_use_is_green_while_it_works_and_red_once_it_is_gone() {
|
|
let using = |online| ExitReport {
|
|
via: Some("abc".into()),
|
|
via_online: online,
|
|
..Default::default()
|
|
};
|
|
assert_eq!(
|
|
Health::of(Some(&report(vec![network(1, using(true))]))),
|
|
Health::ExitNode
|
|
);
|
|
assert_eq!(
|
|
Health::of(Some(&report(vec![network(1, using(false))]))),
|
|
Health::Disconnected
|
|
);
|
|
}
|
|
}
|