diff --git a/crates/tsunagi-tray/src/agent.rs b/crates/tsunagi-tray/src/agent.rs index 8156647..3912a93 100644 --- a/crates/tsunagi-tray/src/agent.rs +++ b/crates/tsunagi-tray/src/agent.rs @@ -50,11 +50,12 @@ pub(crate) struct Snapshot { pub generation: u64, /// When this status was read, for computing rates between refreshes. pub at: Option, + /// 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, shared: Arc>, /// A secret fetched for the clipboard, taken by the UI thread which owns @@ -65,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, @@ -93,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. @@ -118,33 +116,37 @@ fn lock(mutex: &Mutex) -> 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, shared: Arc>, pending_copy: Arc>>, 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>, 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>, ctx: &egui::Context) { + let socket = resolve_socket(); + let status = ipc::request_status(&socket) .await .map_err(|err| err.to_string()); { @@ -152,6 +154,7 @@ async fn refresh(socket: &std::path::Path, shared: &Arc>, ctx: & guard.status = Some(status); guard.generation = guard.generation.wrapping_add(1); guard.at = Some(Instant::now()); + guard.socket = socket; } ctx.request_repaint(); } diff --git a/crates/tsunagi-tray/src/app.rs b/crates/tsunagi-tray/src/app.rs index a016a3d..da6947d 100644 --- a/crates/tsunagi-tray/src/app.rs +++ b/crates/tsunagi-tray/src/app.rs @@ -10,7 +10,7 @@ use std::time::Duration; use eframe::egui; use crate::about; -use crate::agent::{AgentClient, resolve_socket}; +use crate::agent::AgentClient; use crate::devices; use crate::stats::Traffic; use crate::ui::{self, UiState}; @@ -38,7 +38,7 @@ impl App { egui_phosphor::add_to_fonts(&mut fonts, egui_phosphor::Variant::Regular); ctx.set_fonts(fonts); - let agent = AgentClient::spawn(&runtime, ctx, resolve_socket()); + let agent = AgentClient::spawn(&runtime, ctx); Ok(Self { agent, state: UiState::default(), @@ -88,7 +88,7 @@ impl App { egui::ViewportId::from_hash_of("about"), builder, |vctx, _class| { - about::show(vctx, socket, snapshot); + about::show(vctx, &socket, snapshot); !vctx.input(|i| i.viewport().close_requested()) }, ); diff --git a/crates/tsunagi-tray/src/tray.rs b/crates/tsunagi-tray/src/tray.rs index cd75dcf..9c5f0bf 100644 --- a/crates/tsunagi-tray/src/tray.rs +++ b/crates/tsunagi-tray/src/tray.rs @@ -81,6 +81,26 @@ fn build() -> Result { }) } +/// [`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 { + 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) { let (open, quit) = (built.open.clone(), built.quit.clone()); @@ -101,7 +121,7 @@ fn handle_menu(built: &Built, window: Arc) { #[cfg(target_os = "linux")] pub(crate) fn run() -> Result<(), Box> { gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?; - let built = build()?; + let built = build_checked()?; handle_menu( &built, Arc::new(Window { diff --git a/dist/arch/README.md b/dist/arch/README.md index b67edd1..c0a22c6 100644 --- a/dist/arch/README.md +++ b/dist/arch/README.md @@ -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 diff --git a/dist/arch/build-pkg.sh b/dist/arch/build-pkg.sh index ed56847..a5572b2 100755 --- a/dist/arch/build-pkg.sh +++ b/dist/arch/build-pkg.sh @@ -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,7 +69,7 @@ cat > "$root/.PKGINFO" < size = ${size} @@ -72,21 +77,50 @@ arch = ${arch} ${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 diff --git a/dist/deb/build-deb.sh b/dist/deb/build-deb.sh index 87ea3b2..cdc2572 100755 --- a/dist/deb/build-deb.sh +++ b/dist/deb/build-deb.sh @@ -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 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, diff --git a/dist/linux/README.md b/dist/linux/README.md index 2a77bb6..8510fac 100644 --- a/dist/linux/README.md +++ b/dist/linux/README.md @@ -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 diff --git a/dist/linux/tsunagi.service b/dist/linux/tsunagi.service index 57b5a8c..eb77968 100644 --- a/dist/linux/tsunagi.service +++ b/dist/linux/tsunagi.service @@ -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.