Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e178a6f9d | ||
|
|
f4dcdec662 | ||
|
|
69310108f4 | ||
|
|
e7dff34ec2 | ||
|
|
a55ea883a1 | ||
|
|
3e066f991f | ||
|
|
3752419075 |
No files matched your search
@@ -45,7 +45,6 @@ jobs:
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
pkg_arch: aarch64
|
||||
gnu_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
@@ -111,11 +110,19 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
rustup target add "${{ matrix.gnu_target }}"
|
||||
cargo zigbuild --release --locked \
|
||||
--target "${{ matrix.gnu_target }}.2.28" --bin tsunagi-tray
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
# tray-icon links GTK and libappindicator through pkg-config, which
|
||||
# has no cross story under zig, so the Linux GUI is built natively
|
||||
# (x86_64 only) against the runner's glibc.
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
|
||||
cargo build --release --locked \
|
||||
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
|
||||
else
|
||||
echo "no GUI build for ${{ matrix.target }}"
|
||||
fi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
|
||||
fi
|
||||
@@ -133,11 +140,13 @@ jobs:
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The tray GUI alongside it: glibc build on Linux, native elsewhere.
|
||||
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
|
||||
tray="tsunagi-tray"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
else
|
||||
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
@@ -201,7 +210,7 @@ jobs:
|
||||
# The GUI variants: the same agent plus the tray binary and a desktop
|
||||
# launcher, named tsunagi-gui (conflicting with the headless packages).
|
||||
- name: Build the GUI packages
|
||||
if: matrix.deb
|
||||
if: matrix.gnu_target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -47,7 +47,6 @@ jobs:
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
pkg_arch: aarch64
|
||||
gnu_target: aarch64-unknown-linux-gnu
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
@@ -113,11 +112,19 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
rustup target add "${{ matrix.gnu_target }}"
|
||||
cargo zigbuild --release --locked \
|
||||
--target "${{ matrix.gnu_target }}.2.28" --bin tsunagi-tray
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
# tray-icon links GTK and libappindicator through pkg-config, which
|
||||
# has no cross story under zig, so the Linux GUI is built natively
|
||||
# (x86_64 only) against the runner's glibc.
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
libgtk-3-dev libayatana-appindicator3-dev libxdo-dev
|
||||
cargo build --release --locked \
|
||||
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
|
||||
else
|
||||
echo "no GUI build for ${{ matrix.target }}"
|
||||
fi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
|
||||
fi
|
||||
@@ -135,11 +142,13 @@ jobs:
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# The tray GUI alongside it: glibc build on Linux, native elsewhere.
|
||||
# The tray GUI alongside it, where it was built (Linux: x86_64 only).
|
||||
tray="tsunagi-tray"
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && tray="tsunagi-tray.exe"
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
else
|
||||
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
@@ -203,7 +212,7 @@ jobs:
|
||||
# The GUI variants: the same agent plus the tray binary and a desktop
|
||||
# launcher, named tsunagi-gui (conflicting with the headless packages).
|
||||
- name: Build the GUI packages
|
||||
if: matrix.deb
|
||||
if: matrix.gnu_target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
Generated
+1
@@ -6329,6 +6329,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui-phosphor",
|
||||
"gtk",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ mainline = { path = "vendor/mainline" }
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
rust-version = "1.91"
|
||||
license = "MIT OR Apache-2.0"
|
||||
license = "WTFPL"
|
||||
repository = "https://github.com/tsunagi-net/tsunagi"
|
||||
|
||||
[workspace.lints.rust]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
Version 2, December 2004
|
||||
|
||||
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
|
||||
|
||||
Everyone is permitted to copy and distribute verbatim or modified
|
||||
copies of this license document, and changing it is allowed as long
|
||||
as the name is changed.
|
||||
|
||||
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. You just DO WHAT THE FUCK YOU WANT TO.
|
||||
@@ -763,4 +763,4 @@ any of this.
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0.
|
||||
[WTFPL](LICENSE): do what the fuck you want to.
|
||||
+115
-17
@@ -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)
|
||||
}
|
||||
@@ -2631,10 +2677,8 @@ impl MemberRow<'_> {
|
||||
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.
|
||||
/// What to call it: the name it announced — the last one, when it is
|
||||
/// away — or a short form of its id.
|
||||
fn label(&self) -> String {
|
||||
match self.hostname {
|
||||
Some(hostname) => hostname.to_string(),
|
||||
@@ -2667,10 +2711,11 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve
|
||||
let row = entry(&mut rows, &member.endpoint_id);
|
||||
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
|
||||
row.failed_dials = member.failed_dials;
|
||||
row.hostname = member.hostname.as_deref();
|
||||
}
|
||||
for peer in &network.peers {
|
||||
let row = entry(&mut rows, &peer.endpoint_id);
|
||||
row.hostname = peer.hostname.as_deref();
|
||||
row.hostname = peer.hostname.as_deref().or(row.hostname);
|
||||
row.transport = Some(&peer.transport);
|
||||
row.rtt_ms = peer.rtt_ms;
|
||||
}
|
||||
@@ -3001,7 +3046,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 +3090,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 +3123,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 +4048,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,
|
||||
@@ -4250,6 +4309,10 @@ async fn build_report(
|
||||
.iter()
|
||||
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
|
||||
.map_or(0, |candidate| candidate.consecutive_failures),
|
||||
hostname: member
|
||||
.last_hostname
|
||||
.clone()
|
||||
.or_else(|| member.hostname.clone()),
|
||||
})
|
||||
.collect(),
|
||||
range: network.range.map(|range| range.to_string()),
|
||||
@@ -4276,6 +4339,21 @@ 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(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
protocols: PROTOCOLS
|
||||
.iter()
|
||||
.map(|spec| format!("{} v{}", spec.name, spec.version))
|
||||
.collect(),
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4577,11 +4655,13 @@ mod status_tests {
|
||||
endpoint_id: OWN.into(),
|
||||
overlay_address_v4: Some("10.13.37.69".into()),
|
||||
failed_dials: 0,
|
||||
hostname: None,
|
||||
},
|
||||
MemberReport {
|
||||
endpoint_id: ONLINE.into(),
|
||||
overlay_address_v4: Some("10.13.37.237".into()),
|
||||
failed_dials: 0,
|
||||
hostname: None,
|
||||
},
|
||||
],
|
||||
range: Some("10.13.37.0/24".into()),
|
||||
@@ -4822,6 +4902,7 @@ mod status_tests {
|
||||
endpoint_id: AWAY.into(),
|
||||
overlay_address_v4: Some("10.13.37.99".into()),
|
||||
failed_dials: 9,
|
||||
hostname: None,
|
||||
});
|
||||
|
||||
let rows = member_rows(&network, OWN);
|
||||
@@ -4831,6 +4912,22 @@ mod status_tests {
|
||||
assert_eq!(rows[1].endpoint_id, AWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_member_keeps_the_last_name_it_announced() {
|
||||
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: 0,
|
||||
hostname: Some("laptop".into()),
|
||||
});
|
||||
|
||||
let rows = member_rows(&network, OWN);
|
||||
let away = rows.iter().find(|row| row.endpoint_id == AWAY).unwrap();
|
||||
assert!(!away.online());
|
||||
assert_eq!(away.label(), "laptop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_that_is_away_is_stated_rather_than_flagged() {
|
||||
// In a mesh of laptops a member being away is the ordinary
|
||||
@@ -4842,6 +4939,7 @@ mod status_tests {
|
||||
endpoint_id: AWAY.into(),
|
||||
overlay_address_v4: Some("10.13.37.99".into()),
|
||||
failed_dials: 9,
|
||||
hostname: None,
|
||||
});
|
||||
|
||||
let mut out = report::Report::new();
|
||||
|
||||
@@ -26,9 +26,15 @@ tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
egui-phosphor = "0.9"
|
||||
|
||||
# macOS only: set the app's activation policy to Accessory so it lives in the
|
||||
# menu bar with no Dock icon. Pinned to eframe's winit so the types unify.
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
# Linux only: tray-icon (already GTK-based there) needs the app to initialise
|
||||
# GTK and run its main loop.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
# Windows and macOS: the tray process has no GUI toolkit, only an event loop to
|
||||
# serve the icon (and, on macOS, the Accessory activation policy that keeps it
|
||||
# out of the Dock). Pinned to eframe's winit so the types unify.
|
||||
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
|
||||
winit = "0.30"
|
||||
|
||||
[lints]
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! The About window: what this software is, and what it is talking to.
|
||||
//!
|
||||
//! Compact on purpose — one table for the client, one for the agent. The agent
|
||||
//! half is whatever the agent says about itself, so a client and an agent that
|
||||
//! are not the same build show up as such.
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use tsunagi::ipc::{PrivilegeReport, StatusReport};
|
||||
|
||||
use crate::agent::Snapshot;
|
||||
use crate::format;
|
||||
|
||||
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
|
||||
const AUTHOR: &str = "AB <tsunagi@hexor.cy>";
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Draws the window body into its viewport.
|
||||
pub(crate) fn show(ctx: &egui::Context, socket: &std::path::Path, snapshot: &Snapshot) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
// Long paths and a taller agent table must scroll rather than be cut.
|
||||
egui::ScrollArea::both()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| body(ui, socket, snapshot));
|
||||
});
|
||||
}
|
||||
|
||||
fn body(ui: &mut egui::Ui, socket: &std::path::Path, snapshot: &Snapshot) {
|
||||
{
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("tsunagi");
|
||||
ui.label(egui::RichText::new(VERSION).small().weak());
|
||||
});
|
||||
ui.label("Peer-to-peer mesh network with no server.");
|
||||
ui.add_space(6.0);
|
||||
|
||||
section(ui, "client");
|
||||
grid(ui, "about client", |ui| {
|
||||
row(ui, "repository", |ui| {
|
||||
ui.hyperlink(REPOSITORY);
|
||||
});
|
||||
row(ui, "author", |ui| {
|
||||
format::copy_field(ui, AUTHOR, AUTHOR);
|
||||
});
|
||||
row(ui, "license", |ui| {
|
||||
ui.label(env!("CARGO_PKG_LICENSE"));
|
||||
});
|
||||
row(ui, "build", |ui| {
|
||||
ui.label(format!(
|
||||
"{} {} · {}",
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
}
|
||||
));
|
||||
});
|
||||
});
|
||||
|
||||
ui.add_space(8.0);
|
||||
section(ui, "agent");
|
||||
let socket = socket.display().to_string();
|
||||
match &snapshot.status {
|
||||
None => {
|
||||
grid(ui, "about agent", |ui| {
|
||||
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
|
||||
row(ui, "state", |ui| {
|
||||
ui.label("connecting…");
|
||||
});
|
||||
});
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
grid(ui, "about agent", |ui| {
|
||||
row(ui, "socket", |ui| format::copy_field(ui, &socket, &socket));
|
||||
row(ui, "state", |ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x4a, 0x3a), "unreachable");
|
||||
});
|
||||
row(ui, "reason", |ui| {
|
||||
ui.add(egui::Label::new(error).wrap());
|
||||
});
|
||||
});
|
||||
}
|
||||
Some(Ok(report)) => agent_table(ui, &socket, report),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn agent_table(ui: &mut egui::Ui, socket: &str, report: &StatusReport) {
|
||||
grid(ui, "about agent", |ui| {
|
||||
row(ui, "socket", |ui| format::copy_field(ui, socket, socket));
|
||||
row(ui, "state", |ui| {
|
||||
ui.colored_label(egui::Color32::from_rgb(0x3c, 0xb0, 0x4a), "connected");
|
||||
});
|
||||
row(ui, "version", |ui| {
|
||||
if report.version.is_empty() {
|
||||
ui.weak("unknown");
|
||||
} else if report.version == VERSION {
|
||||
ui.label(&report.version);
|
||||
} else {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(0xd8, 0x9a, 0x2a),
|
||||
format!("{} (client is {VERSION})", report.version),
|
||||
);
|
||||
}
|
||||
});
|
||||
if !report.protocols.is_empty() {
|
||||
row(ui, "protocols", |ui| {
|
||||
ui.label(report.protocols.join(" · "));
|
||||
});
|
||||
}
|
||||
row(ui, "endpoint id", |ui| {
|
||||
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
|
||||
});
|
||||
row(ui, "hostname", |ui| {
|
||||
ui.label(&report.hostname);
|
||||
});
|
||||
if !report.program.is_empty() {
|
||||
row(ui, "binary", |ui| {
|
||||
format::copy_field(ui, &report.program, &report.program);
|
||||
});
|
||||
}
|
||||
if !report.state_dir.is_empty() {
|
||||
row(ui, "state dir", |ui| {
|
||||
format::copy_field(ui, &report.state_dir, &report.state_dir);
|
||||
});
|
||||
row(ui, "cache dir", |ui| {
|
||||
format::copy_field(ui, &report.cache_dir, &report.cache_dir);
|
||||
});
|
||||
}
|
||||
row(ui, "interface", |ui| match &report.privilege {
|
||||
PrivilegeReport::Unknown => {
|
||||
ui.weak("unknown");
|
||||
}
|
||||
PrivilegeReport::Available => {
|
||||
ui.label("can be managed (CAP_NET_ADMIN)");
|
||||
}
|
||||
PrivilegeReport::Missing(_) => {
|
||||
ui.colored_label(egui::Color32::from_rgb(0xd8, 0x9a, 0x2a), "no privileges");
|
||||
}
|
||||
PrivilegeReport::Unsupported => {
|
||||
ui.label("not supported on this platform");
|
||||
}
|
||||
});
|
||||
row(ui, "cache", |ui| {
|
||||
ui.label(if report.cache_healthy {
|
||||
"usable"
|
||||
} else {
|
||||
"unavailable"
|
||||
});
|
||||
});
|
||||
row(ui, "listening", |ui| {
|
||||
ui.label(report.bound_sockets.join(", "));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn section(ui: &mut egui::Ui, title: &str) {
|
||||
ui.label(egui::RichText::new(title).strong());
|
||||
}
|
||||
|
||||
fn grid(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut egui::Ui)) {
|
||||
egui::Grid::new(id)
|
||||
.num_columns(2)
|
||||
.spacing([12.0, 3.0])
|
||||
.show(ui, add);
|
||||
}
|
||||
|
||||
/// One label/value line of a table.
|
||||
fn row(ui: &mut egui::Ui, label: &str, value: impl FnOnce(&mut egui::Ui)) {
|
||||
ui.weak(label);
|
||||
value(ui);
|
||||
ui.end_row();
|
||||
}
|
||||
@@ -21,8 +21,6 @@ const POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
|
||||
/// A request from the UI to the agent.
|
||||
pub(crate) enum Command {
|
||||
/// Re-read status now.
|
||||
Refresh,
|
||||
/// Stop serving a network, or start serving it again.
|
||||
SetActive { network_id: String, active: bool },
|
||||
/// Turn local broadcast participation on or off for a network.
|
||||
@@ -170,7 +168,6 @@ async fn run(
|
||||
ctx.request_repaint();
|
||||
|
||||
let outcome: Result<String, String> = match command {
|
||||
Command::Refresh => Ok(String::new()),
|
||||
Command::SetActive { network_id, active } => ipc::set_active(socket, &network_id, active)
|
||||
.await
|
||||
.map(|report| {
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
//! The eframe application: wires the tray, the main window, the per-network
|
||||
//! The eframe application behind the window: the main view, the per-network
|
||||
//! devices windows, and the agent worker.
|
||||
//!
|
||||
//! It runs in its own process, started by the tray when the user chooses Open
|
||||
//! and gone when the window is closed. The tray, which has to outlive the
|
||||
//! window, lives in the other process; see [`crate::tray`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::about;
|
||||
use crate::agent::{AgentClient, resolve_socket};
|
||||
use crate::devices;
|
||||
use crate::stats::Traffic;
|
||||
use crate::tray::{Action, Tray};
|
||||
use crate::ui::{self, UiState};
|
||||
|
||||
/// The tray application state.
|
||||
/// The window's state.
|
||||
pub(crate) struct App {
|
||||
agent: AgentClient,
|
||||
tray: Tray,
|
||||
state: UiState,
|
||||
/// Traffic rates derived from successive snapshots.
|
||||
traffic: Traffic,
|
||||
/// The snapshot generation last folded into `traffic`.
|
||||
sampled: u64,
|
||||
/// Set when the user chose Quit, so the next close exits rather than hides.
|
||||
quitting: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Builds the app: resolves the socket, starts the worker, creates the tray.
|
||||
/// Builds the app: resolves the socket and starts the worker.
|
||||
pub(crate) fn new(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
@@ -37,40 +38,18 @@ impl App {
|
||||
egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular);
|
||||
ctx.set_fonts(fonts);
|
||||
|
||||
let agent = AgentClient::spawn(&runtime, ctx.clone(), resolve_socket());
|
||||
let tray = Tray::new(ctx)?;
|
||||
let agent = AgentClient::spawn(&runtime, ctx, resolve_socket());
|
||||
Ok(Self {
|
||||
agent,
|
||||
tray,
|
||||
state: UiState::default(),
|
||||
traffic: Traffic::default(),
|
||||
sampled: 0,
|
||||
quitting: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
for action in self.tray.poll() {
|
||||
match action {
|
||||
Action::Open => {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(true));
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Focus);
|
||||
}
|
||||
Action::Quit => {
|
||||
self.quitting = true;
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Closing the main window hides it to the tray; only Quit truly exits.
|
||||
if ctx.input(|i| i.viewport().close_requested()) && !self.quitting {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Visible(false));
|
||||
}
|
||||
|
||||
let snapshot = self.agent.snapshot();
|
||||
if snapshot.generation != self.sampled
|
||||
&& let (Some(Ok(report)), Some(at)) = (&snapshot.status, snapshot.at)
|
||||
@@ -81,6 +60,7 @@ impl eframe::App for App {
|
||||
|
||||
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
|
||||
self.show_devices_windows(ctx, &snapshot);
|
||||
self.show_about_window(ctx, &snapshot);
|
||||
|
||||
// A secret the worker fetched for the clipboard: copy it here, on the
|
||||
// UI thread that owns the clipboard, exactly once.
|
||||
@@ -88,12 +68,35 @@ impl eframe::App for App {
|
||||
ctx.copy_text(secret);
|
||||
}
|
||||
|
||||
// Keep ticking so fresh status and tray actions are picked up.
|
||||
// Keep ticking so fresh status is picked up.
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Renders the About window while it is open.
|
||||
fn show_about_window(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
if !self.state.about_open {
|
||||
return;
|
||||
}
|
||||
let builder = egui::ViewportBuilder::default()
|
||||
.with_title("About tsunagi")
|
||||
.with_inner_size([560.0, 520.0])
|
||||
.with_min_inner_size([380.0, 280.0]);
|
||||
let socket = self.agent.socket();
|
||||
let keep = ctx.show_viewport_immediate(
|
||||
egui::ViewportId::from_hash_of("about"),
|
||||
builder,
|
||||
|vctx, _class| {
|
||||
about::show(vctx, socket, snapshot);
|
||||
!vctx.input(|i| i.viewport().close_requested())
|
||||
},
|
||||
);
|
||||
if !keep {
|
||||
self.state.about_open = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders one separate window per open network-devices view.
|
||||
fn show_devices_windows(&mut self, ctx: &egui::Context, snapshot: &crate::agent::Snapshot) {
|
||||
let Some(Ok(report)) = &snapshot.status else {
|
||||
@@ -117,7 +120,7 @@ impl App {
|
||||
// main window's stay in step.
|
||||
let unit = &mut self.state.unit;
|
||||
let keep = ctx.show_viewport_immediate(viewport_id, builder, |vctx, _class| {
|
||||
devices::show(vctx, agent, unit, network, traffic);
|
||||
devices::show(vctx, agent, unit, network, &report.endpoint_id, traffic);
|
||||
!vctx.input(|i| i.viewport().close_requested())
|
||||
});
|
||||
if !keep {
|
||||
|
||||
@@ -23,22 +23,51 @@ pub(crate) fn title(network: &NetworkReport) -> String {
|
||||
/// One row of the device table, built from a tunnel and/or a signed member.
|
||||
struct Device<'a> {
|
||||
online: bool,
|
||||
name: String,
|
||||
/// What clicking the name copies: `hostname.network` so it resolves, or
|
||||
/// the short id when there is no hostname.
|
||||
copy: String,
|
||||
/// The last hostname seen for it, kept while it is offline.
|
||||
hostname: Option<&'a str>,
|
||||
/// What clicking the hostname copies: `hostname.network`, so it resolves.
|
||||
copy: Option<String>,
|
||||
/// The full endpoint id, which clicking the short form copies.
|
||||
id: &'a str,
|
||||
proto: &'a str,
|
||||
address: Option<String>,
|
||||
handshake_secs: Option<u64>,
|
||||
overlay: Option<&'a OverlayPeerReport>,
|
||||
}
|
||||
|
||||
impl Device<'_> {
|
||||
fn sort_key(&self) -> String {
|
||||
self.hostname.unwrap_or(self.id).to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// The first characters of an endpoint id, enough to tell devices apart.
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(5).collect()
|
||||
}
|
||||
|
||||
/// The device cell: the hostname and, in brackets, the start of the id.
|
||||
/// Clicking either copies its own full value.
|
||||
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
match (device.hostname, &device.copy) {
|
||||
(Some(hostname), Some(copy)) => {
|
||||
format::copy_field(ui, hostname, copy);
|
||||
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
|
||||
}
|
||||
_ => format::copy_field(ui, &short_id(device.id), device.id),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the window body into its viewport.
|
||||
pub(crate) fn show(
|
||||
ctx: &egui::Context,
|
||||
_agent: &AgentClient,
|
||||
unit: &mut Unit,
|
||||
network: &NetworkReport,
|
||||
own_id: &str,
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
@@ -60,7 +89,7 @@ pub(crate) fn show(
|
||||
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
|
||||
ui.add_space(8.0);
|
||||
|
||||
let devices = devices(network);
|
||||
let devices = devices(network, own_id);
|
||||
let online = devices.iter().filter(|d| d.online).count();
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
@@ -123,31 +152,56 @@ fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
|
||||
});
|
||||
}
|
||||
|
||||
/// Every other device this network knows of, from a live tunnel or the signed
|
||||
/// membership. This device is in the roster too but is not one of the others.
|
||||
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
|
||||
let overlay_peers = network
|
||||
.overlay
|
||||
.as_ref()
|
||||
.map(|o| o.peers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let mut ids: Vec<&str> = Vec::new();
|
||||
let candidates = overlay_peers
|
||||
.iter()
|
||||
.map(|p| p.endpoint_id.as_str())
|
||||
.chain(network.peers.iter().map(|p| p.endpoint_id.as_str()))
|
||||
.chain(network.members.iter().map(|m| m.endpoint_id.as_str()));
|
||||
for id in candidates {
|
||||
if id != own_id && !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// How many other devices this network knows of, connected or not.
|
||||
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
|
||||
known_ids(network, own_id).len()
|
||||
}
|
||||
|
||||
/// Builds the device list: every endpoint known from a live tunnel or the
|
||||
/// signed membership, online ones first.
|
||||
fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
||||
let hostnames: HashMap<&str, &str> = network
|
||||
.peers
|
||||
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
|
||||
// The signed members carry the last name seen, so a device that is away
|
||||
// is still called by it; a live announcement is fresher and wins.
|
||||
let mut hostnames: HashMap<&str, &str> = network
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h)))
|
||||
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
|
||||
.collect();
|
||||
hostnames.extend(
|
||||
network
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
|
||||
);
|
||||
let overlay_peers = network
|
||||
.overlay
|
||||
.as_ref()
|
||||
.map(|o| o.peers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let mut ids: Vec<&str> = Vec::new();
|
||||
for peer in overlay_peers {
|
||||
if !ids.contains(&peer.endpoint_id.as_str()) {
|
||||
ids.push(&peer.endpoint_id);
|
||||
}
|
||||
}
|
||||
for member in &network.members {
|
||||
if !ids.contains(&member.endpoint_id.as_str()) {
|
||||
ids.push(&member.endpoint_id);
|
||||
}
|
||||
}
|
||||
let ids = known_ids(network, own_id);
|
||||
|
||||
let mut devices: Vec<Device> = ids
|
||||
.into_iter()
|
||||
@@ -156,15 +210,11 @@ fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
||||
let member = network.members.iter().find(|m| m.endpoint_id == id);
|
||||
let online = overlay.is_some_and(|p| p.handshake_secs_ago.is_some());
|
||||
let hostname = hostnames.get(id).copied();
|
||||
let name = hostname.map_or_else(
|
||||
|| format::short(overlay.map_or(id, |p| p.public_key.as_str())),
|
||||
str::to_string,
|
||||
);
|
||||
let copy = hostname.map_or_else(|| name.clone(), |h| format!("{h}.{}", network.name));
|
||||
Device {
|
||||
online,
|
||||
name,
|
||||
copy,
|
||||
hostname,
|
||||
copy: hostname.map(|h| format!("{h}.{}", network.name)),
|
||||
id,
|
||||
proto: overlay.map_or("—", |p| p.protocol.as_str()),
|
||||
address: overlay
|
||||
.and_then(|p| p.address.clone())
|
||||
@@ -178,11 +228,59 @@ fn devices(network: &NetworkReport) -> Vec<Device<'_>> {
|
||||
devices.sort_by(|a, b| {
|
||||
b.online
|
||||
.cmp(&a.online)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
.then_with(|| a.sort_key().cmp(&b.sort_key()))
|
||||
});
|
||||
devices
|
||||
}
|
||||
|
||||
/// The widest value each column is expected to hold, so the table starts out
|
||||
/// wide enough and does not reflow as numbers grow.
|
||||
const COLUMN_SAMPLES: [&str; 8] = [
|
||||
"",
|
||||
"tcp-tls",
|
||||
"255.255.255.255",
|
||||
"23h 59m",
|
||||
"99999/99999",
|
||||
"1023.9 MB / 1023.9 MB",
|
||||
"1023.9 kB/s",
|
||||
"",
|
||||
];
|
||||
|
||||
/// Column widths that only ever grow, kept across frames.
|
||||
///
|
||||
/// A grid sizes each column to its widest cell *this frame*, so a value that
|
||||
/// gets shorter — `14.4 MB/s` giving way to `382 pkt/s` — would pull the
|
||||
/// column in and push every cell after it. Remembering the widest seen keeps
|
||||
/// the table where it is.
|
||||
fn column_widths(ui: &egui::Ui, id: egui::Id) -> [f32; 8] {
|
||||
let stored: Option<[f32; 8]> = ui.memory(|m| m.data.get_temp(id));
|
||||
stored.unwrap_or_else(|| {
|
||||
let font = egui::TextStyle::Body.resolve(ui.style());
|
||||
let mut widths = [0.0; 8];
|
||||
for (width, sample) in widths.iter_mut().zip(COLUMN_SAMPLES) {
|
||||
if !sample.is_empty() {
|
||||
*width = ui.fonts(|fonts| {
|
||||
fonts
|
||||
.layout_no_wrap(sample.to_owned(), font.clone(), egui::Color32::WHITE)
|
||||
.size()
|
||||
.x
|
||||
});
|
||||
}
|
||||
}
|
||||
widths
|
||||
})
|
||||
}
|
||||
|
||||
/// One cell, at least as wide as its column has ever been.
|
||||
fn cell(ui: &mut egui::Ui, column: usize, widths: &mut [f32; 8], add: impl FnOnce(&mut egui::Ui)) {
|
||||
let floor = widths[column];
|
||||
let response = ui.scope(|ui| {
|
||||
ui.set_min_width(floor);
|
||||
add(ui);
|
||||
});
|
||||
widths[column] = widths[column].max(response.response.rect.width());
|
||||
}
|
||||
|
||||
/// One table of all devices: online rows carry live counters and a plot,
|
||||
/// offline rows show what is known (name and address) and dashes.
|
||||
fn device_table(
|
||||
@@ -196,12 +294,14 @@ fn device_table(
|
||||
ui.weak("none");
|
||||
return;
|
||||
}
|
||||
let widths_id = egui::Id::new(("devices column widths", network_id));
|
||||
let mut widths = column_widths(ui, widths_id);
|
||||
egui::Grid::new("devices")
|
||||
.num_columns(8)
|
||||
.striped(true)
|
||||
.spacing([10.0, 4.0])
|
||||
.show(ui, |ui| {
|
||||
for header in [
|
||||
for (column, header) in [
|
||||
"device",
|
||||
"proto",
|
||||
"address",
|
||||
@@ -210,49 +310,112 @@ fn device_table(
|
||||
"bytes",
|
||||
"rate",
|
||||
"plot",
|
||||
] {
|
||||
ui.weak(header);
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
cell(ui, column, &mut widths, |ui| {
|
||||
ui.weak(header);
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
|
||||
for device in devices {
|
||||
format::copy_field(ui, &device.name, &device.copy);
|
||||
ui.label(device.proto);
|
||||
match &device.address {
|
||||
cell(ui, 0, &mut widths, |ui| name_cell(ui, device));
|
||||
cell(ui, 1, &mut widths, |ui| {
|
||||
ui.label(device.proto);
|
||||
});
|
||||
cell(ui, 2, &mut widths, |ui| match &device.address {
|
||||
Some(address) => format::copy_field(ui, address, address),
|
||||
None => {
|
||||
ui.weak("—");
|
||||
}
|
||||
}
|
||||
ui.label(
|
||||
device
|
||||
.handshake_secs
|
||||
.map_or_else(|| "offline".to_string(), format::age),
|
||||
);
|
||||
});
|
||||
cell(ui, 3, &mut widths, |ui| {
|
||||
ui.label(
|
||||
device
|
||||
.handshake_secs
|
||||
.map_or_else(|| "offline".to_string(), format::age),
|
||||
);
|
||||
});
|
||||
|
||||
match device.overlay.filter(|_| device.online) {
|
||||
Some(peer) => {
|
||||
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
|
||||
ui.label(format!(
|
||||
"{} / {}",
|
||||
format::bytes(peer.tx_bytes as f64),
|
||||
format::bytes(peer.rx_bytes as f64)
|
||||
));
|
||||
let series = traffic.peer(network_id, &peer.public_key);
|
||||
ui.add(egui::Label::new(format::series_rate(series, unit)).truncate());
|
||||
cell(ui, 4, &mut widths, |ui| {
|
||||
ui.label(format!("{}/{}", peer.tx_packets, peer.rx_packets));
|
||||
});
|
||||
cell(ui, 5, &mut widths, |ui| {
|
||||
ui.label(format!(
|
||||
"{} / {}",
|
||||
format::bytes(peer.tx_bytes as f64),
|
||||
format::bytes(peer.rx_bytes as f64)
|
||||
));
|
||||
});
|
||||
cell(ui, 6, &mut widths, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(format::series_rate(series, unit))
|
||||
.wrap_mode(egui::TextWrapMode::Extend),
|
||||
);
|
||||
});
|
||||
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
|
||||
format::sparkline(ui, series, unit, 18.0);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
for _ in 0..4 {
|
||||
ui.weak("—");
|
||||
for column in 4..7 {
|
||||
cell(ui, column, &mut widths, |ui| {
|
||||
ui.weak("—");
|
||||
});
|
||||
}
|
||||
ui.weak("—");
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
// Everything this agent has moved through the overlay, including
|
||||
// devices that have since gone away.
|
||||
let (mut tx_packets, mut rx_packets, mut tx_bytes, mut rx_bytes) = (0u64, 0, 0, 0);
|
||||
for peer in devices.iter().filter_map(|d| d.overlay) {
|
||||
tx_packets += peer.tx_packets;
|
||||
rx_packets += peer.rx_packets;
|
||||
tx_bytes += peer.tx_bytes;
|
||||
rx_bytes += peer.rx_bytes;
|
||||
}
|
||||
cell(ui, 0, &mut widths, |ui| {
|
||||
ui.strong("total");
|
||||
});
|
||||
for column in 1..4 {
|
||||
cell(ui, column, &mut widths, |ui| {
|
||||
ui.label("");
|
||||
});
|
||||
}
|
||||
cell(ui, 4, &mut widths, |ui| {
|
||||
ui.strong(format!("{tx_packets}/{rx_packets}"));
|
||||
});
|
||||
cell(ui, 5, &mut widths, |ui| {
|
||||
ui.strong(format!(
|
||||
"{} / {}",
|
||||
format::bytes(tx_bytes as f64),
|
||||
format::bytes(rx_bytes as f64)
|
||||
));
|
||||
});
|
||||
cell(ui, 6, &mut widths, |ui| {
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
egui::RichText::new(format::series_rate(traffic.network(network_id), unit))
|
||||
.strong(),
|
||||
)
|
||||
.wrap_mode(egui::TextWrapMode::Extend),
|
||||
);
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
ui.memory_mut(|m| {
|
||||
m.data
|
||||
.insert_temp(egui::Id::new("devices column widths"), widths)
|
||||
});
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
//! Tray application for managing a running tsunagi agent.
|
||||
//!
|
||||
//! A small window (networks, their peers and addresses, join/leave, per-network
|
||||
//! active and broadcast switches) plus a tray menu (Open, Quit). It is a thin
|
||||
//! client over the agent's local control socket — the same interface the CLI
|
||||
//! uses — and owns its own runtime, logger and event loop, like the CLI binary.
|
||||
//! A tray icon and menu (Open, Quit) that opens a small window (networks, their
|
||||
//! peers and addresses, join/leave, per-network active and broadcast switches).
|
||||
//! There is no window until Open, and closing it leaves the icon. The window
|
||||
//! is a thin client over the agent's local control socket — the same interface
|
||||
//! the CLI uses.
|
||||
|
||||
// The window is created hidden-to-tray on close rather than exiting; the binary
|
||||
// itself may exit on Quit. Nothing here is a library.
|
||||
// Nothing here is a library.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod about;
|
||||
mod agent;
|
||||
mod app;
|
||||
mod devices;
|
||||
@@ -21,38 +22,42 @@ use eframe::egui;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
/// The argument that makes this binary the window rather than the tray.
|
||||
const WINDOW_ARG: &str = "--window";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
|
||||
// Two processes from one binary. The tray is what stays; the window is
|
||||
// started by it and ends when it is closed. A window that merely hides is
|
||||
// not something every platform can do — Wayland has no such thing — and a
|
||||
// hidden one that stays is worse than none.
|
||||
if std::env::args().any(|arg| arg == WINDOW_ARG) {
|
||||
window()
|
||||
} else {
|
||||
tray::run()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the window until it is closed.
|
||||
fn window() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
#[cfg_attr(not(target_os = "macos"), expect(unused_mut))]
|
||||
let mut options = eframe::NativeOptions {
|
||||
let options = eframe::NativeOptions {
|
||||
// A fixed, small window; the whole layout targets this size.
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("tsunagi")
|
||||
.with_app_id("tsunagi")
|
||||
.with_inner_size([360.0, 600.0])
|
||||
.with_resizable(false)
|
||||
// Start hidden: the app lives in the tray and the window opens from
|
||||
// the menu.
|
||||
.with_visible(false),
|
||||
.with_resizable(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// macOS: live in the menu bar with no Dock icon (tray-only).
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
options.event_loop_builder = Some(Box::new(|builder| {
|
||||
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
|
||||
builder.with_activation_policy(ActivationPolicy::Accessory);
|
||||
}));
|
||||
}
|
||||
|
||||
eframe::run_native(
|
||||
"tsunagi",
|
||||
options,
|
||||
@@ -63,7 +68,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}),
|
||||
)?;
|
||||
|
||||
// The window loop has ended (Quit); let the runtime wind down.
|
||||
// The window loop has ended; let the runtime wind down.
|
||||
drop(runtime);
|
||||
Ok(())
|
||||
}
|
||||
+156
-68
@@ -1,85 +1,173 @@
|
||||
//! The tray icon and its menu.
|
||||
//! The tray: the icon, its menu, and starting the window.
|
||||
//!
|
||||
//! The menu is just Open and Quit; everything else (per-network switches,
|
||||
//! join/leave) lives in the window. Menu clicks are read on a background
|
||||
//! thread and turned into [`Action`]s, so they are handled even while the
|
||||
//! window is hidden and egui would otherwise not be repainting.
|
||||
//! This is the process that stays. It holds no window and no GUI toolkit of
|
||||
//! its own; choosing Open starts this same binary again as the window, which
|
||||
//! is a perfectly ordinary program that ends when it is closed.
|
||||
|
||||
use std::sync::mpsc;
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use eframe::egui;
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
/// What the user picked from the tray menu.
|
||||
pub(crate) enum Action {
|
||||
/// Show (and focus) the window.
|
||||
Open,
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
/// The window process, when there is one.
|
||||
struct Window {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
/// The live tray icon plus the channel of menu actions.
|
||||
pub(crate) struct Tray {
|
||||
/// Kept alive for as long as the app runs; dropping it removes the icon.
|
||||
_icon: TrayIcon,
|
||||
actions: mpsc::Receiver<Action>,
|
||||
}
|
||||
|
||||
impl Tray {
|
||||
/// Builds the tray and starts reading its menu events.
|
||||
impl Window {
|
||||
/// Starts the window unless one is already open.
|
||||
///
|
||||
/// Created on the main thread (required on macOS); `ctx` is used to wake the
|
||||
/// UI when a menu item is chosen.
|
||||
pub(crate) fn new(ctx: egui::Context) -> Result<Self, String> {
|
||||
let open = MenuItem::new("Open tsunagi", true, None);
|
||||
let quit = MenuItem::new("Quit", true, None);
|
||||
let menu = Menu::new();
|
||||
menu.append(&open).map_err(|err| err.to_string())?;
|
||||
menu.append(&PredefinedMenuItem::separator())
|
||||
.map_err(|err| err.to_string())?;
|
||||
menu.append(&quit).map_err(|err| err.to_string())?;
|
||||
|
||||
let icon = make_icon()?;
|
||||
let tray = TrayIconBuilder::new()
|
||||
.with_tooltip("tsunagi")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(icon)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
let open_id = open.id().clone();
|
||||
let quit_id = quit.id().clone();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
// The global menu-event channel is fed by the OS event loop.
|
||||
while let Ok(event) = MenuEvent::receiver().recv() {
|
||||
let action = if event.id == open_id {
|
||||
Action::Open
|
||||
} else if event.id == quit_id {
|
||||
Action::Quit
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if tx.send(action).is_err() {
|
||||
break;
|
||||
}
|
||||
ctx.request_repaint();
|
||||
/// Raising an existing one is not something a process can ask for on
|
||||
/// every platform, so choosing Open while it is open does nothing.
|
||||
fn open(&self) {
|
||||
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(child) = slot.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(None) => return,
|
||||
_ => *slot = None,
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_icon: tray,
|
||||
actions: rx,
|
||||
})
|
||||
}
|
||||
let started = std::env::current_exe()
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|exe| {
|
||||
Command::new(exe)
|
||||
.arg(crate::WINDOW_ARG)
|
||||
.spawn()
|
||||
.map_err(|err| err.to_string())
|
||||
});
|
||||
match started {
|
||||
Ok(child) => *slot = Some(child),
|
||||
Err(err) => eprintln!("cannot open the window: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The menu actions since the last call.
|
||||
pub(crate) fn poll(&self) -> impl Iterator<Item = Action> + '_ {
|
||||
self.actions.try_iter()
|
||||
/// Closes the window if it is open.
|
||||
fn close(&self) {
|
||||
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(mut child) = slot.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A built tray icon and the ids of its menu items.
|
||||
struct Built {
|
||||
icon: TrayIcon,
|
||||
open: MenuId,
|
||||
quit: MenuId,
|
||||
}
|
||||
|
||||
fn build() -> Result<Built, String> {
|
||||
let open = MenuItem::new("Open tsunagi", true, None);
|
||||
let quit = MenuItem::new("Quit", true, None);
|
||||
let menu = Menu::new();
|
||||
menu.append(&open).map_err(|err| err.to_string())?;
|
||||
menu.append(&PredefinedMenuItem::separator())
|
||||
.map_err(|err| err.to_string())?;
|
||||
menu.append(&quit).map_err(|err| err.to_string())?;
|
||||
|
||||
let icon = TrayIconBuilder::new()
|
||||
.with_tooltip("tsunagi")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(make_icon()?)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(Built {
|
||||
icon,
|
||||
open: open.id().clone(),
|
||||
quit: quit.id().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Acts on menu choices as the platform's loop delivers them.
|
||||
fn handle_menu(built: &Built, window: Arc<Window>) {
|
||||
let (open, quit) = (built.open.clone(), built.quit.clone());
|
||||
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
|
||||
if event.id == open {
|
||||
window.open();
|
||||
} else if event.id == quit {
|
||||
window.close();
|
||||
std::process::exit(0);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Shows the icon and runs until Quit.
|
||||
///
|
||||
/// Linux: the tray needs GTK, which nothing else here initialises, so this
|
||||
/// thread does and runs its main loop.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?;
|
||||
let built = build()?;
|
||||
handle_menu(
|
||||
&built,
|
||||
Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
gtk::main();
|
||||
drop(built.icon);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the icon and runs until Quit.
|
||||
///
|
||||
/// Windows and macOS: the icon needs an event loop on the main thread to be
|
||||
/// served, and macOS wants it created once that loop is running. There are no
|
||||
/// windows in it; it exists to be pumped.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::window::WindowId;
|
||||
|
||||
struct Pump {
|
||||
window: Arc<Window>,
|
||||
icon: Option<TrayIcon>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Pump {
|
||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
if self.icon.is_some() {
|
||||
return;
|
||||
}
|
||||
match build() {
|
||||
Ok(built) => {
|
||||
handle_menu(&built, Arc::clone(&self.window));
|
||||
self.icon = Some(built.icon);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("cannot create the tray icon: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
|
||||
let mut builder = EventLoop::builder();
|
||||
// macOS: live in the menu bar with no Dock icon.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
|
||||
builder.with_activation_policy(ActivationPolicy::Accessory);
|
||||
}
|
||||
let event_loop = builder.build()?;
|
||||
let mut pump = Pump {
|
||||
window: Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
icon: None,
|
||||
};
|
||||
event_loop.run_app(&mut pump)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A small round icon, drawn in code so there is no asset to ship yet.
|
||||
fn make_icon() -> Result<Icon, String> {
|
||||
const SIZE: u32 = 32;
|
||||
|
||||
@@ -15,6 +15,7 @@ use egui_phosphor::regular as icon;
|
||||
use tsunagi::ipc::{NetworkReport, StatusReport};
|
||||
|
||||
use crate::agent::{AgentClient, Command, Snapshot};
|
||||
use crate::devices;
|
||||
use crate::format;
|
||||
use crate::stats::{Traffic, Unit};
|
||||
|
||||
@@ -39,6 +40,8 @@ pub(crate) struct UiState {
|
||||
pub(crate) host_edit: Option<String>,
|
||||
/// Network ids whose devices window is open.
|
||||
pub(crate) open_devices: BTreeSet<String>,
|
||||
/// Whether the About window is open.
|
||||
pub(crate) about_open: bool,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
@@ -48,6 +51,7 @@ impl Default for UiState {
|
||||
unit: Unit::Packets,
|
||||
host_edit: None,
|
||||
open_devices: BTreeSet::new(),
|
||||
about_open: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -64,13 +68,14 @@ pub(crate) fn draw(
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("tsunagi");
|
||||
ui.label(
|
||||
egui::RichText::new(env!("CARGO_PKG_VERSION"))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui
|
||||
.button(icon::ARROWS_CLOCKWISE)
|
||||
.on_hover_text("Refresh")
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::Refresh);
|
||||
if ui.button(icon::INFO).on_hover_text("About").clicked() {
|
||||
state.about_open = true;
|
||||
}
|
||||
if snapshot.busy {
|
||||
ui.add(egui::Spinner::new());
|
||||
@@ -114,7 +119,7 @@ pub(crate) fn draw(
|
||||
ui.weak("No networks yet — join one below.");
|
||||
}
|
||||
for network in &report.networks {
|
||||
draw_network(ui, agent, state, traffic, network);
|
||||
draw_network(ui, agent, state, traffic, network, &report.endpoint_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -191,6 +196,7 @@ fn draw_network(
|
||||
state: &mut UiState,
|
||||
traffic: &Traffic,
|
||||
network: &NetworkReport,
|
||||
own_id: &str,
|
||||
) {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
@@ -233,7 +239,7 @@ fn draw_network(
|
||||
ui.label(format!(
|
||||
"{} connected · {} known",
|
||||
network.peers.len(),
|
||||
network.members.len()
|
||||
devices::known_count(network, own_id)
|
||||
));
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -331,6 +331,9 @@ struct Runtime {
|
||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||
/// The highest version this agent has ever published for this network.
|
||||
own_version: u64,
|
||||
/// The last hostname each peer announced, kept across restarts so a
|
||||
/// member that is away can still be called by name.
|
||||
known_hostnames: HashMap<EndpointId, String>,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
@@ -382,6 +385,7 @@ impl Runtime {
|
||||
state: StateSet::new(),
|
||||
pending_state: Vec::new(),
|
||||
own_version: 0,
|
||||
known_hostnames: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +848,17 @@ impl Runtime {
|
||||
.own_record_version(self.network_id, self.local_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
for (id, hostname) in self
|
||||
.params
|
||||
.storage
|
||||
.peer_hostnames(self.network_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Ok(id) = EndpointId::from_bytes(&id) {
|
||||
self.known_hostnames.insert(id, hostname);
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_own_claim().await;
|
||||
self.publish_allocations().await;
|
||||
@@ -1885,6 +1900,33 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the name a peer announced, on disk too when it changed.
|
||||
///
|
||||
/// Written off the loop: a name that is not stored this time is stored
|
||||
/// the next time the peer announces it.
|
||||
fn remember_hostname(&mut self, peer: EndpointId, hostname: &str) {
|
||||
if hostname.is_empty()
|
||||
|| self
|
||||
.known_hostnames
|
||||
.get(&peer)
|
||||
.is_some_and(|h| h == hostname)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.known_hostnames.insert(peer, hostname.to_string());
|
||||
let storage = self.params.storage.clone();
|
||||
let network_id = self.network_id;
|
||||
let hostname = hostname.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = storage
|
||||
.remember_peer_hostname(network_id, *peer.as_bytes(), hostname)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%err, "could not remember a peer's hostname");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
||||
match &message {
|
||||
ControlMessage::Announce(announcement) => {
|
||||
@@ -1894,6 +1936,7 @@ impl Runtime {
|
||||
session.broadcast = announcement.broadcast;
|
||||
session.capabilities = capabilities.clone();
|
||||
}
|
||||
self.remember_hostname(peer, &announcement.hostname);
|
||||
self.update_broadcast();
|
||||
self.dispatch_capabilities(peer, &capabilities);
|
||||
self.ensure_links();
|
||||
@@ -1994,7 +2037,10 @@ impl Runtime {
|
||||
broadcast: session.broadcast,
|
||||
endpoint_id: session.peer,
|
||||
role: session.role,
|
||||
hostname: session.hostname.clone(),
|
||||
hostname: session
|
||||
.hostname
|
||||
.clone()
|
||||
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
|
||||
protocols: session
|
||||
.capabilities
|
||||
.iter()
|
||||
@@ -2052,6 +2098,7 @@ impl Runtime {
|
||||
// both hold it would be untrue on one of them.
|
||||
overlay_address_v4: self.state.address_of(&endpoint_id),
|
||||
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
||||
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -80,6 +80,11 @@ pub struct MemberStatus {
|
||||
/// Signed, so it is still known while the member is away — which is what
|
||||
/// lets an absent member be named rather than shown as a bare id.
|
||||
pub hostname: Option<String>,
|
||||
/// The last name this agent saw it announce, whether or not it is here.
|
||||
///
|
||||
/// Not signed, so it is a convenience for naming a member that is away
|
||||
/// and never a claim: the zone is served from `hostname`.
|
||||
pub last_hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Status of one authenticated session.
|
||||
|
||||
@@ -352,6 +352,38 @@ 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,
|
||||
/// The version of the agent's software.
|
||||
pub version: String,
|
||||
/// The data plane protocols it has, each as `name vN` (wire version).
|
||||
pub protocols: Vec<String>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -436,6 +468,8 @@ pub struct MemberReport {
|
||||
pub overlay_address_v4: Option<String>,
|
||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||
pub failed_dials: u32,
|
||||
/// The last hostname this agent saw it announce, kept while it is away.
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// One control plane peer.
|
||||
|
||||
@@ -264,7 +264,8 @@ impl Storage {
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)
|
||||
state.forget_signed_records(network_id)?;
|
||||
state.forget_peer_hostnames(network_id)
|
||||
})
|
||||
.await?;
|
||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||
@@ -283,6 +284,26 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member announced, so it can still be named
|
||||
/// once it is away.
|
||||
pub async fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: [u8; 32],
|
||||
hostname: String,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The hostnames members of a network last announced.
|
||||
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
self.with_state(move |state| state.peer_hostnames(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records an address hint. Failures are non-fatal.
|
||||
pub async fn record_hint(
|
||||
&self,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{RecordBody, SignedRecord};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 4;
|
||||
pub const SCHEMA_VERSION: i64 = 5;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -191,13 +191,34 @@ impl StateStore {
|
||||
PRAGMA user_version = 4;
|
||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
||||
}
|
||||
if found < 5 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS peer_hostnames (
|
||||
network_id BLOB NOT NULL,
|
||||
endpoint_id BLOB NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
PRIMARY KEY (network_id, endpoint_id)
|
||||
);
|
||||
PRAGMA user_version = 5;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||
/// database is reported rather than used.
|
||||
fn verify_shape(&self) -> Result<()> {
|
||||
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
||||
for table in [
|
||||
"device_identity",
|
||||
"networks",
|
||||
"settings",
|
||||
"signed_records",
|
||||
"peer_hostnames",
|
||||
] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
@@ -635,6 +656,61 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member last announced.
|
||||
pub fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: &[u8; 32],
|
||||
hostname: &str,
|
||||
) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
endpoint_id.as_slice(),
|
||||
hostname
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last hostname every member of a network announced.
|
||||
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, hostname) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
|
||||
out.push((id, hostname));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Forgets every remembered hostname of a network.
|
||||
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM peer_hostnames WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an arbitrary setting.
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||
self.conn
|
||||
@@ -698,6 +774,29 @@ mod broadcast_storage_tests {
|
||||
use super::*;
|
||||
use crate::identity::NetworkKeys;
|
||||
|
||||
#[test]
|
||||
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let name = NetworkName::new("hostnames").unwrap();
|
||||
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let peer = [3u8; 32];
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
|
||||
drop(store);
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.peer_hostnames(id).unwrap(),
|
||||
vec![(peer, "laptop-2".to_string())]
|
||||
);
|
||||
store.forget_peer_hostnames(id).unwrap();
|
||||
assert!(store.peer_hostnames(id).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Vendored
+1
-2
@@ -69,8 +69,7 @@ builddate = $(date +%s)
|
||||
packager = AB <ab@hexor.cy>
|
||||
size = ${size}
|
||||
arch = ${arch}
|
||||
${relations}license = MIT
|
||||
license = Apache
|
||||
${relations}license = WTFPL
|
||||
PKGINFO
|
||||
|
||||
# Scriptlet: create the dedicated system user the service runs as, keep systemd
|
||||
|
||||
Reference in new issue
Block a user