Compare commits

..
Author SHA1 Message Date
Aleksandr BogomiakovandClaude c62d29fa14 Arch package: start the service and let the GUI reach the agent
The control socket is now opened to the tsunagi group (unit), the Arch
package enables and starts the service on install, adds the installing user
to the group, restarts a running agent on upgrade, and depends on the
libraries the tray loads at run time. The tray looks for the agent's socket
again on every poll instead of once at start, and a missing indicator
library is reported instead of panicking. Repository URLs unified.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:29:51 +01:00
Aleksandr BogomiakovandClaude a6d9edabee Point the project repository at github.com/house-of-vanity/tsunagi
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:06:48 +01:00
Aleksandr BogomiakovandClaude 2f7f9980c7 Tray: open the devices window wider and let its table scroll sideways
Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 23:05:08 +01:00
Aleksandr BogomiakovandClaude 2e178a6f9d Tray: About window, fixed-width device table with totals; WTFPL
Release / tsunagi-linux-aarch64 (push) Successful in 2m19s
Release / tsunagi-linux-x86_64 (push) Successful in 3m56s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
Add an About window (client and agent details, protocol versions), pin the
device table's column widths and add a total row, show the version in the
header and drop the refresh button. Relicense under WTFPL.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 20:16:01 +01:00
Aleksandr BogomiakovandClaude f4dcdec662 Tray: separate tray and window processes; device list without self
Release / tsunagi-linux-aarch64 (push) Successful in 2m44s
Release / tsunagi-linux-x86_64 (push) Successful in 4m37s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The tray process holds only the icon and starts the same binary with
--window on Open; the window ends when closed, which works on Wayland where
a window cannot be hidden. The devices table now names devices as
hostname (id5), leaves out this device and counts the others.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 19:23:06 +01:00
Aleksandr BogomiakovandClaude 69310108f4 Remember the last hostname each peer announced
Persist announced hostnames per network (state schema 5) and report them for
members, so a device that is away is still named in `status` and the tray.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 19:23:06 +01:00
Aleksandr BogomiakovandClaude e7dff34ec2 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>
2026-10-02 18:31:01 +01:00
Aleksandr BogomiakovandClaude a55ea883a1 Tray: initialise GTK and run its main loop on Linux
Release / tsunagi-linux-aarch64 (push) Successful in 3m14s
Release / tsunagi-linux-x86_64 (push) Successful in 5m21s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
tray-icon needs gtk::init() and a running GTK loop on Linux, which
eframe/winit never provide, so the tray panicked at startup with "GTK has
not been initialized". Build the tray on a dedicated GTK thread instead.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-02 17:54:38 +01:00
UltradesuandClaude Opus 4.8 3e066f991f CI: install libxdo-dev for the Linux tray link
Release / tsunagi-linux-aarch64 (push) Successful in 3m23s
Release / tsunagi-linux-x86_64 (push) Successful in 6m58s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The x86_64 Linux tray failed at link with "unable to find library -lxdo":
tray-icon pulls libxdo, whose .so was not present. Add libxdo-dev to the GUI
build's apt install, alongside libgtk-3-dev and libayatana-appindicator3-dev.
Everything else (aarch64 agent, macOS, Windows) already built.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:51:53 +01:00
UltradesuandClaude Opus 4.8 3752419075 CI: build the Linux tray natively with GTK; drop the aarch64 GUI
Release / tsunagi-linux-aarch64 (push) Successful in 4m14s
Release / tsunagi-linux-x86_64 (push) Failing after 4m29s
Release / tsunagi-macos-aarch64 (push) Canceled after 0s
Release / tsunagi-windows-x86_64 (push) Canceled after 0s
Release / Publish release (push) Canceled after 0s
The aarch64 GUI cross-build failed: tray-icon links GTK and libappindicator
through pkg-config, which zig cross-compilation cannot satisfy without a target
sysroot. Rather than set one up, the Linux tray is now built natively on the
x86_64 runner with libgtk-3-dev and libayatana-appindicator3-dev installed, so
pkg-config just works. The agent is still static musl for both arches.

The aarch64 Linux GUI is dropped for now: that archive stays agent-only and no
aarch64 tsunagi-gui package is produced. x86_64 gets the tray in its archive
and the tsunagi-gui .deb/.pkg.tar.zst. macOS and Windows build the tray
natively as before. Guarded on matrix.gnu_target (set only on x86_64 Linux).

Note: the native build links the runner's glibc, so the Linux GUI binary needs a
reasonably recent glibc; fine for current desktops. A lower baseline would need
a GTK sysroot or an older runner image.

YAML validated; the Linux GUI build is exercised only by a real CI run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-02 16:38:58 +01:00
26 changed files with 1109 additions and 262 deletions

No files matched your search

+17 -8
View File
@@ -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
+17 -8
View File
@@ -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
View File
@@ -6329,6 +6329,7 @@ version = "0.1.0"
dependencies = [
"eframe",
"egui-phosphor",
"gtk",
"tokio",
"tracing",
"tracing-subscriber",
+2 -2
View File
@@ -14,8 +14,8 @@ mainline = { path = "vendor/mainline" }
[workspace.package]
edition = "2024"
rust-version = "1.91"
license = "MIT OR Apache-2.0"
repository = "https://github.com/tsunagi-net/tsunagi"
license = "WTFPL"
repository = "https://github.com/house-of-vanity/tsunagi"
[workspace.lints.rust]
missing_docs = "warn"
+13
View File
@@ -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.
+1 -1
View File
@@ -763,4 +763,4 @@ any of this.
## Licence
MIT OR Apache-2.0.
[WTFPL](LICENSE): do what the fuck you want to.
+115 -17
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)
}
@@ -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();
+9 -3
View File
@@ -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]
+175
View File
@@ -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();
}
+22 -22
View File
@@ -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.
@@ -52,11 +50,12 @@ pub(crate) struct Snapshot {
pub generation: u64,
/// When this status was read, for computing rates between refreshes.
pub at: Option<Instant>,
/// The socket the last request went to.
pub socket: PathBuf,
}
/// Handle the UI holds: send commands, read the latest snapshot.
pub(crate) struct AgentClient {
socket: PathBuf,
tx: mpsc::UnboundedSender<Command>,
shared: Arc<Mutex<Snapshot>>,
/// A secret fetched for the clipboard, taken by the UI thread which owns
@@ -67,23 +66,20 @@ pub(crate) struct AgentClient {
impl AgentClient {
/// Starts the worker on `runtime`, reporting to `ctx` (for repaints).
pub(crate) fn spawn(
runtime: &tokio::runtime::Handle,
ctx: egui::Context,
socket: PathBuf,
) -> Self {
pub(crate) fn spawn(runtime: &tokio::runtime::Handle, ctx: egui::Context) -> Self {
let (tx, rx) = mpsc::unbounded_channel();
let shared = Arc::new(Mutex::new(Snapshot::default()));
let shared = Arc::new(Mutex::new(Snapshot {
socket: resolve_socket(),
..Snapshot::default()
}));
let pending_copy = Arc::new(Mutex::new(None));
runtime.spawn(worker(
socket.clone(),
rx,
Arc::clone(&shared),
Arc::clone(&pending_copy),
ctx,
));
Self {
socket,
tx,
shared,
pending_copy,
@@ -95,9 +91,9 @@ impl AgentClient {
lock(&self.pending_copy).take()
}
/// The control socket this client talks to.
pub(crate) fn socket(&self) -> &std::path::Path {
&self.socket
/// The control socket this client talks to, as last resolved.
pub(crate) fn socket(&self) -> PathBuf {
lock(&self.shared).socket.clone()
}
/// Sends a command; a dropped worker simply means nothing happens.
@@ -120,33 +116,37 @@ fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
/// The worker loop: refresh on an interval and whenever a command runs.
async fn worker(
socket: PathBuf,
mut rx: mpsc::UnboundedReceiver<Command>,
shared: Arc<Mutex<Snapshot>>,
pending_copy: Arc<Mutex<Option<String>>>,
ctx: egui::Context,
) {
refresh(&socket, &shared, &ctx).await;
refresh(&shared, &ctx).await;
let mut tick = tokio::time::interval(POLL_INTERVAL);
tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
_ = tick.tick() => {
refresh(&socket, &shared, &ctx).await;
refresh(&shared, &ctx).await;
}
command = rx.recv() => {
let Some(command) = command else { return };
run(&socket, command, &shared, &pending_copy, &ctx).await;
refresh(&socket, &shared, &ctx).await;
run(&resolve_socket(), command, &shared, &pending_copy, &ctx).await;
refresh(&shared, &ctx).await;
}
}
}
}
/// Reads status and stores it, mapping any failure to a human string.
async fn refresh(socket: &std::path::Path, shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
let status = ipc::request_status(socket)
///
/// The socket is looked for again each time: a window opened before the agent
/// was started would otherwise keep asking the per-user path for ever, while
/// the service came up at the system one.
async fn refresh(shared: &Arc<Mutex<Snapshot>>, ctx: &egui::Context) {
let socket = resolve_socket();
let status = ipc::request_status(&socket)
.await
.map_err(|err| err.to_string());
{
@@ -154,6 +154,7 @@ async fn refresh(socket: &std::path::Path, shared: &Arc<Mutex<Snapshot>>, ctx: &
guard.status = Some(status);
guard.generation = guard.generation.wrapping_add(1);
guard.at = Some(Instant::now());
guard.socket = socket;
}
ctx.request_repaint();
}
@@ -170,7 +171,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| {
+37 -34
View File
@@ -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::agent::{AgentClient, resolve_socket};
use crate::about;
use crate::agent::AgentClient;
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);
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 {
@@ -109,7 +112,7 @@ impl App {
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
let builder = egui::ViewportBuilder::default()
.with_title(devices::title(network))
.with_inner_size([720.0, 540.0])
.with_inner_size([900.0, 540.0])
.with_min_inner_size([480.0, 320.0]);
let agent = &self.agent;
let traffic = &self.traffic;
@@ -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 {
+213 -50
View File
@@ -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!(
@@ -70,7 +99,7 @@ pub(crate) fn show(
))
.strong(),
);
egui::ScrollArea::vertical()
egui::ScrollArea::both()
.auto_shrink([false, false])
.show(ui, |ui| {
device_table(ui, unit, &network.network_id, &devices, traffic);
@@ -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 {
+27 -22
View File
@@ -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(())
}
+176 -68
View File
@@ -1,85 +1,193 @@
//! 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(),
})
}
/// [`build`], with a missing indicator library reported instead of panicking.
///
/// The library is loaded on first use, and its binding panics when it is not
/// installed, which reads as a bug in this program rather than a package that
/// is not there.
#[cfg(target_os = "linux")]
fn build_checked() -> Result<Built, String> {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let built = std::panic::catch_unwind(std::panic::AssertUnwindSafe(build));
std::panic::set_hook(hook);
built.unwrap_or_else(|_| {
Err(
"the tray needs libayatana-appindicator (Arch: libayatana-appindicator, \
Debian/Ubuntu: libayatana-appindicator3-1), which is not installed"
.to_string(),
)
})
}
/// 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_checked()?;
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;
+14 -8
View File
@@ -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()
}
})
})
+48 -1
View File
@@ -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();
+5
View File
@@ -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.
+34
View File
@@ -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.
+22 -1
View File
@@ -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,
+101 -2
View File
@@ -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();
+9 -4
View File
@@ -37,10 +37,15 @@ creates and owns its directories:
| cache | `/var/cache/tsunagi` |
| control socket | `/run/tsunagi/agent.sock` |
The polkit rule lets anyone in the `tsunagi` group configure systemd-resolved
for the overlay zones (the service user is in that group). To run the agent as
your own user as well, add yourself to the group: `sudo usermod -aG tsunagi
"$USER"`.
Installing the package enables and starts the service, and adds the user who ran
pacman to the `tsunagi` group (log out and in again for it to apply). The
control socket is open to that group, so members can run `tsunagi status`, the
CLI and the tray GUI without sudo; anyone else adds themselves with `sudo
usermod -aG tsunagi "$USER"`. The group also lets its members configure
systemd-resolved for the overlay zones through the polkit rule.
The GUI package depends on `gtk3`, `libayatana-appindicator` (the tray icon),
`xdotool` (its `libxdo`), and the usual windowing libraries.
## Removal
+40 -7
View File
@@ -50,6 +50,11 @@ if [ -n "$tray" ]; then
pkgname=tsunagi-gui
pkgdesc="Serverless private mesh networking agent, with the tray GUI"
relations=$'conflict = tsunagi\nprovides = tsunagi\nreplaces = tsunagi\n'
# What the tray and its window load at run time; none of it is linked in
# statically, so pacman has to be told.
for dep in gtk3 libayatana-appindicator xdotool libxkbcommon wayland libx11 libglvnd; do
relations+="depend = ${dep}"$'\n'
done
install -D -m 0755 "$tray" "$root/usr/bin/tsunagi-tray"
install -D -m 0644 "$here/../linux/tsunagi-tray.desktop" \
"$root/usr/share/applications/tsunagi-tray.desktop"
@@ -64,30 +69,58 @@ cat > "$root/.PKGINFO" <<PKGINFO
pkgname = ${pkgname}
pkgver = ${pkgver}-${pkgrel}
pkgdesc = ${pkgdesc}
url = https://github.com/Ultradesu/tsunagi
url = https://github.com/house-of-vanity/tsunagi
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
# in step, and refresh the desktop/icon caches (a no-op without a GUI payload).
# Scriptlet: create the dedicated system user the service runs as, start the
# service, and let whoever ran pacman use it. The control socket belongs to the
# `tsunagi` group, so the tray and the CLI work for any member of it; the
# person installing is added, since the package cannot know anyone else.
cat > "$root/.INSTALL" <<'INSTALL'
post_install() {
getent group tsunagi >/dev/null 2>&1 || groupadd -r tsunagi
getent passwd tsunagi >/dev/null 2>&1 || \
useradd -r -g tsunagi -d /var/lib/tsunagi -s /usr/bin/nologin -c "tsunagi agent" tsunagi
systemctl daemon-reload >/dev/null 2>&1 || true
command -v update-desktop-database >/dev/null 2>&1 && \
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
command -v gtk-update-icon-cache >/dev/null 2>&1 && \
gtk-update-icon-cache -q -t /usr/share/icons/hicolor >/dev/null 2>&1 || true
# Whoever ran pacman through sudo or pkexec (an AUR helper does too).
installer="${SUDO_USER:-}"
if [ -z "$installer" ] && [ -n "${PKEXEC_UID:-}" ]; then
installer="$(getent passwd "$PKEXEC_UID" | cut -d: -f1)"
fi
if [ -n "$installer" ] && [ "$installer" != root ] && id "$installer" >/dev/null 2>&1; then
usermod -aG tsunagi "$installer" >/dev/null 2>&1 || true
echo ":: $installer was added to the tsunagi group; log out and in again so the"
echo " tray and the CLI can reach the agent without sudo."
else
echo ":: add yourself to the tsunagi group to use the tray and the CLI without"
echo " sudo: sudo usermod -aG tsunagi \$USER, then log out and in again."
fi
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl enable --now tsunagi.service >/dev/null 2>&1 || \
echo ":: could not start tsunagi.service; see: systemctl status tsunagi"
fi
}
post_upgrade() {
post_install
# Not enabled again: it may have been turned off on purpose. A running
# agent is restarted, because the control protocol changes between builds
# and a new client cannot talk to an old agent.
command -v update-desktop-database >/dev/null 2>&1 && \
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
if [ -d /run/systemd/system ]; then
systemctl daemon-reload >/dev/null 2>&1 || true
systemctl try-restart tsunagi.service >/dev/null 2>&1 || true
fi
}
pre_remove() {
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
+3 -1
View File
@@ -45,6 +45,8 @@ if [ -n "$tray" ]; then
# The GUI package is a superset of the headless one and must not coexist
# with it (both ship /usr/bin/tsunagi).
extra_control=$'Conflicts: tsunagi\nReplaces: tsunagi\nProvides: tsunagi\n'
# What the tray and its window load at run time.
extra_control+=$'Depends: libgtk-3-0 | libgtk-3-0t64, libayatana-appindicator3-1 | libappindicator3-1, libxdo3, libxkbcommon0, libwayland-client0, libx11-6, libgl1\n'
desc_gui=" It also installs the tray GUI (tsunagi-tray) and a desktop launcher so it can be started from the applications menu."
install -D -m 0755 "$tray" "$root/usr/bin/tsunagi-tray"
install -D -m 0644 "$here/../linux/tsunagi-tray.desktop" \
@@ -62,7 +64,7 @@ Architecture: ${arch}
Maintainer: AB <ab@hexor.cy>
Section: net
Priority: optional
Homepage: https://github.com/Ultradesu/tsunagi
Homepage: https://github.com/house-of-vanity/tsunagi
${extra_control}Description: Serverless private mesh networking agent
tsunagi forms small private mesh IP networks between peers, with NAT
traversal and no central server. This package installs a static,
+3 -1
View File
@@ -22,7 +22,9 @@ and `CAP_NET_BIND_SERVICE` (granted by systemd), not root. systemd creates its
state (`/var/lib/tsunagi`), cache (`/var/cache/tsunagi`) and runtime
(`/run/tsunagi`) directories, and the polkit rule lets it configure the
resolver. Control it with `sudo` — the CLI finds the running service's socket at
`/run/tsunagi/agent.sock` with no flags:
`/run/tsunagi/agent.sock` with no flags. The socket is open to the `tsunagi`
group, so after `sudo usermod -aG tsunagi "$USER"` (and logging in again) no
`sudo` is needed:
```sh
sudo tsunagi status
+4 -2
View File
@@ -1,6 +1,6 @@
[Unit]
Description=tsunagi mesh agent
Documentation=https://github.com/Ultradesu/tsunagi
Documentation=https://github.com/house-of-vanity/tsunagi
After=network-online.target
Wants=network-online.target
@@ -16,7 +16,8 @@ CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
# systemd creates and owns these for the service user, and the agent is pointed
# at them: state in /var/lib/tsunagi, cache in /var/cache/tsunagi, and the
# control socket in /run/tsunagi, which stays private to the service user.
# control socket in /run/tsunagi. The socket is opened to the `tsunagi` group,
# so a user who joins it can run the CLI and the tray without sudo.
StateDirectory=tsunagi
StateDirectoryMode=0700
CacheDirectory=tsunagi
@@ -25,6 +26,7 @@ RuntimeDirectoryMode=0755
Environment=TSUNAGI_STATE_DIR=%S/tsunagi
Environment=TSUNAGI_CACHE_DIR=%C/tsunagi
Environment=TSUNAGI_CONTROL_SOCKET=%t/tsunagi/agent.sock
Environment=TSUNAGI_CONTROL_GROUP=tsunagi
# The tarball installer puts the binary here; the deb and the Arch package
# rewrite this to /usr/bin where they install it.