Status: ask the agent for its state directory and its own CAP_NET_ADMIN

The CLI reached a packaged service's socket but read state from its own
default directory, so `network` saw nothing next to a running network, and
`status` reported the CLI binary's capabilities instead of the agent's.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Aleksandr BogomiakovandClaude committed 2026-10-02 18:31:01 +01:00
1 parent a55ea883a1
commit e7dff34ec2
3 files changed
+111 -12

No files matched your search

+82 -12
View File
@@ -838,6 +838,33 @@ enum Observed {
},
}
/// The directories the running agent uses, unless the user named some.
///
/// The control socket of a packaged service is found without flags, but the
/// state it keeps is not in this user's default directory — reading that
/// would report a device that has joined nothing next to an agent that has
/// joined a network. The agent knows where its state is, so it is asked.
async fn agent_paths(
paths: StoragePaths,
socket: &std::path::Path,
is_custom_state: bool,
) -> StoragePaths {
if is_custom_state || !tsunagi::ipc::is_serving(socket).await {
return paths;
}
match tsunagi::ipc::request_status(socket).await {
Ok(report) if !report.state_dir.is_empty() => {
let cache = if report.cache_dir.is_empty() {
paths.cache_dir
} else {
PathBuf::from(report.cache_dir)
};
StoragePaths::new(report.state_dir, cache)
}
_ => paths,
}
}
/// Asks the agent, and falls back to the state store.
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
let socket_present = tsunagi::ipc::is_serving(socket).await;
@@ -1374,6 +1401,7 @@ impl tsunagi::ipc::ReportSource for AgentControl {
self.plugin.as_deref(),
self.tcp_tls.as_deref(),
dns,
&self.paths,
)
.await
})
@@ -1667,6 +1695,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
match args.action {
None => show_networks(&paths, &socket).await,
Some(NetworkAction::Broadcast { network, choice }) => {
@@ -1702,6 +1731,7 @@ async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>>
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
let name = NetworkName::new(args.network)?;
// A name this device already has means that network; a name nobody
// has means a new one, and no secret is needed to make a network with
@@ -1861,16 +1891,15 @@ async fn show_networks(
use report::{Health, Report, Row, Section};
let stored = stored_networks(paths);
if stored.is_empty() {
eprintln!("no network has been joined");
return Ok(());
}
let observed = observe(paths, socket).await;
let live = match &observed {
Observed::Agent(report) => report.networks.clone(),
Observed::Stored { .. } => Vec::new(),
};
if stored.is_empty() && live.is_empty() {
eprintln!("no network has been joined");
return Ok(());
}
let mut out = Report::new();
let mut section = Section::new("networks");
@@ -1921,6 +1950,20 @@ async fn show_networks(
.with_note(note),
);
}
// What the agent runs but this process could not read from the store,
// for instance when the state is not readable by this user.
for network in live.iter().filter(|live| {
!stored
.iter()
.any(|n| n.network_id.to_string() == live.network_id)
}) {
let state = if network.active { "running" } else { "stopped" };
section.push(Row::new(
Health::Info,
network.name.clone(),
format!("{} · {state}", network.network_id),
));
}
out.push(section);
print_report("tsunagi networks", &out)
}
@@ -2163,6 +2206,7 @@ async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
let enable = match args.action {
None => return show_dns(&paths, &socket).await,
@@ -2256,6 +2300,7 @@ async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
match args.action {
None => show_identity(&paths, &socket).await,
@@ -2448,6 +2493,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?;
let is_custom_state = args.paths.state_dir.is_some();
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
let paths = agent_paths(paths, &socket, is_custom_state).await;
let observed = observe(&paths, &socket).await;
let mut out = Report::new();
@@ -2519,7 +2565,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
});
}
out.push(host_section());
out.push(host_section(&observed));
out.push(addresses_section().await);
print_report("tsunagi status", &out)
}
@@ -3001,7 +3047,11 @@ fn short(text: &str, len: usize) -> String {
}
/// What this host can and cannot do for the data plane.
fn host_section() -> report::Section {
///
/// The interface is created by the agent, so when one answered, its own
/// report of whether it may do that is the answer; what this process holds
/// says nothing about it.
fn host_section(observed: &Observed) -> report::Section {
use report::{Health, Row, Section};
let mut host = Section::new("host");
@@ -3041,8 +3091,20 @@ fn host_section() -> report::Section {
});
}
use tsunagi::ipc::PrivilegeReport;
use tsunagi::overlay::{Privilege, probe_net_admin};
match probe_net_admin() {
let (privilege, program) = match observed {
Observed::Agent(report) => match &report.privilege {
PrivilegeReport::Unknown => (probe_net_admin(), program_path()),
PrivilegeReport::Available => (Privilege::Available, report.program.clone()),
PrivilegeReport::Missing(reason) => {
(Privilege::Missing(reason.clone()), report.program.clone())
}
PrivilegeReport::Unsupported => (Privilege::Unsupported, report.program.clone()),
},
Observed::Stored { .. } => (probe_net_admin(), program_path()),
};
match privilege {
Privilege::Available => {
let held = if cfg!(target_os = "windows") {
"assumes an elevated process; creation reports if not"
@@ -3062,10 +3124,7 @@ fn host_section() -> report::Section {
// the eye is meant to scan.
host.push(
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held").with_note(
format!(
"sudo setcap cap_net_admin,cap_net_bind_service+p {}",
program_path()
),
format!("sudo setcap cap_net_admin,cap_net_bind_service+p {program}"),
),
);
host.push(Row::new(
@@ -3990,6 +4049,7 @@ async fn build_report(
wireguard: Option<&WireguardPlugin>,
tcp_tls: Option<&TcpTlsPlugin>,
dns: Option<DnsState>,
paths: &StoragePaths,
) -> tsunagi::ipc::StatusReport {
use tsunagi::ipc::{
MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport,
@@ -4276,6 +4336,16 @@ async fn build_report(
cache_healthy: status.cache_healthy,
networks,
dns,
state_dir: paths.state_dir.display().to_string(),
cache_dir: paths.cache_dir.display().to_string(),
program: program_path(),
privilege: match tsunagi::overlay::probe_net_admin() {
tsunagi::overlay::Privilege::Available => tsunagi::ipc::PrivilegeReport::Available,
tsunagi::overlay::Privilege::Missing(reason) => {
tsunagi::ipc::PrivilegeReport::Missing(reason)
}
tsunagi::overlay::Privilege::Unsupported => tsunagi::ipc::PrivilegeReport::Unsupported,
},
}
}
@@ -83,6 +83,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
cache_healthy: status.cache_healthy,
networks,
dns: None,
..Default::default()
}
})
})
+28
View File
@@ -352,6 +352,34 @@ pub struct StatusReport {
pub networks: Vec<NetworkReport>,
/// The local DNS service, when one was asked for.
pub dns: Option<DnsReport>,
/// The state directory the agent is actually using.
///
/// A client that was not told one would look in its own default, which is
/// not the agent's when the agent runs as a service under another user.
pub state_dir: String,
/// The cache directory the agent is actually using.
pub cache_dir: String,
/// The executable the agent runs, which is what needs the capability.
pub program: String,
/// Whether the agent itself can manage the overlay interface.
///
/// It is the agent that creates the interface, so the client's own
/// capabilities say nothing about it.
pub privilege: PrivilegeReport,
}
/// What the agent found when it checked whether it may manage an interface.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum PrivilegeReport {
/// Not checked.
#[default]
Unknown,
/// It can.
Available,
/// It cannot, with what was found.
Missing(String),
/// This platform has no provisioner yet.
Unsupported,
}
/// The local DNS service.