Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3752419075 | ||
|
|
f63343a6a0 | ||
|
|
d13bb00623 | ||
|
|
2a0a8abd60 | ||
|
|
db01c58a6f | ||
|
|
de56258eb1 | ||
|
|
8f5ce21ce9 | ||
|
|
0a344c1b4f | ||
|
|
2cda56e1f8 | ||
|
|
d1de61ba29 | ||
|
|
19509dc833 | ||
|
|
de8e48bee7 | ||
|
|
8c812e6874 |
No files matched your search
@@ -36,6 +36,7 @@ jobs:
|
||||
deb: true
|
||||
deb_arch: amd64
|
||||
pkg_arch: x86_64
|
||||
gnu_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
@@ -102,6 +103,30 @@ jobs:
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
# The tray GUI. On Linux it is built against glibc (an old baseline for
|
||||
# portability), not musl: a GUI must dlopen libGL/X11/Wayland at runtime,
|
||||
# which a static musl binary cannot. macOS and Windows build it natively.
|
||||
- name: Build the tray GUI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
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
|
||||
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
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -115,6 +140,17 @@ jobs:
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# 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
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
else
|
||||
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
@@ -171,6 +207,25 @@ jobs:
|
||||
"$version" \
|
||||
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
|
||||
|
||||
# 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.gnu_target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" "$version" \
|
||||
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" "$version" \
|
||||
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
|
||||
|
||||
# @v3 on purpose: Gitea does not support the v4 artifact backend. This is
|
||||
# what makes the archive (and the .deb, where built) show up under the run
|
||||
# and hands it to `publish`.
|
||||
|
||||
@@ -2,13 +2,14 @@ name: Release
|
||||
|
||||
# GitHub version of the release pipeline. The build half mirrors the Gitea one
|
||||
# in .gitea/workflows/release.yml (static musl Linux via cargo-zigbuild, native
|
||||
# macOS and Windows); only the publish half differs: GitHub needs the v4
|
||||
# artifact backend and fills the release with `gh`.
|
||||
# macOS and Windows); only the publish half differs: it uses the current
|
||||
# artifact actions (Node 24) and fills the release with `gh`.
|
||||
#
|
||||
# Every job is gated on `github.server_url == 'https://github.com'`, so this file
|
||||
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows; should a
|
||||
# Gitea instance also read .github/workflows, the guard skips these jobs there
|
||||
# so they never fail on the @v4 artifact backend or `gh`.
|
||||
# runs only on GitHub. Gitea runs its own copy in .gitea/workflows (which pins
|
||||
# the older @v3 artifact actions it supports); should a Gitea instance also read
|
||||
# .github/workflows, the guard skips these jobs there so they never fail on the
|
||||
# artifact backend or `gh`.
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
@@ -37,6 +38,7 @@ jobs:
|
||||
deb: true
|
||||
deb_arch: amd64
|
||||
pkg_arch: x86_64
|
||||
gnu_target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-musl
|
||||
asset: tsunagi-linux-aarch64
|
||||
@@ -57,7 +59,7 @@ jobs:
|
||||
zig: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
@@ -103,6 +105,30 @@ jobs:
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
fi
|
||||
|
||||
# The tray GUI. On Linux it is built against glibc (an old baseline for
|
||||
# portability), not musl: a GUI must dlopen libGL/X11/Wayland at runtime,
|
||||
# which a static musl binary cannot. macOS and Windows build it natively.
|
||||
- name: Build the tray GUI
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ runner.os }}" == "Linux" ]]; then
|
||||
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
|
||||
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
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -116,6 +142,17 @@ jobs:
|
||||
[[ "${{ runner.os }}" == "Windows" ]] && bin="tsunagi.exe"
|
||||
cp "target/${{ matrix.target }}/release/${bin}" "$stage/"
|
||||
|
||||
# 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
|
||||
if [[ -n "${{ matrix.gnu_target }}" ]]; then
|
||||
cp "target/${{ matrix.gnu_target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
else
|
||||
cp "target/${{ matrix.target }}/release/${tray}" "$stage/"
|
||||
fi
|
||||
|
||||
# The top-level README, plus the per-OS install notes and integration
|
||||
# files, so the archive carries what the platform needs to set up.
|
||||
cp README.md "$stage/"
|
||||
@@ -172,8 +209,27 @@ jobs:
|
||||
"$version" \
|
||||
"tsunagi-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst"
|
||||
|
||||
# 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.gnu_target
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
tray="target/${{ matrix.gnu_target }}/release/tsunagi-tray"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" "$version" \
|
||||
"tsunagi-gui_${version}_${{ matrix.deb_arch }}.deb" "$tray"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" "$version" \
|
||||
"tsunagi-gui-${pkgver}-1-${{ matrix.pkg_arch }}.pkg.tar.zst" "$tray"
|
||||
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: |
|
||||
@@ -191,7 +247,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download every build artifact
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
@@ -206,7 +262,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# download-artifact@v4 nests each artifact under its own directory, so
|
||||
# download-artifact nests each artifact under its own directory, so
|
||||
# the archives are artifacts/<asset>/<file>.
|
||||
mapfile -t files < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \))
|
||||
printf 'asset: %s\n' "${files[@]}"
|
||||
|
||||
Generated
+2739
-89
File diff suppressed because it is too large.
Load diff
@@ -33,6 +33,11 @@ anstyle = "1.0"
|
||||
# Local interface addresses, for the diagnostics in `status`.
|
||||
netwatch = "0.19.3"
|
||||
|
||||
# Resolving a group name to its gid for `up --control-group`, through a safe
|
||||
# wrapper (no unsafe of our own). Unix only.
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["user"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -482,6 +482,17 @@ struct UpArgs {
|
||||
/// Control socket to serve. Derived from the state directory by default.
|
||||
#[arg(long, env = "TSUNAGI_CONTROL_SOCKET")]
|
||||
control_socket: Option<PathBuf>,
|
||||
|
||||
/// Group whose members may use the control socket (Unix, mode 0660), so a
|
||||
/// GUI or CLI running as another user can reach the agent. Without it the
|
||||
/// socket is owner-only (0600). The group must already exist.
|
||||
#[arg(
|
||||
long,
|
||||
value_name = "GROUP",
|
||||
env = "TSUNAGI_CONTROL_GROUP",
|
||||
help_heading = "System"
|
||||
)]
|
||||
control_group: Option<String>,
|
||||
}
|
||||
|
||||
/// Reads the shared secret from an argument or a file.
|
||||
@@ -748,6 +759,31 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the control-socket access policy from an optional group name.
|
||||
///
|
||||
/// The group must already exist (packaging/post-install creates it); a name
|
||||
/// that cannot be resolved is an error the caller reports rather than a silent
|
||||
/// fallback that would leave a GUI unable to connect without explanation.
|
||||
#[cfg(unix)]
|
||||
fn control_socket_access(group: Option<&str>) -> Result<tsunagi::ipc::ControlSocketAccess, String> {
|
||||
match group {
|
||||
None => Ok(tsunagi::ipc::ControlSocketAccess::Private),
|
||||
Some(name) => {
|
||||
let group = nix::unistd::Group::from_name(name)
|
||||
.map_err(|err| format!("cannot look up group `{name}`: {err}"))?
|
||||
.ok_or_else(|| format!("group `{name}` does not exist; create it first"))?;
|
||||
Ok(tsunagi::ipc::ControlSocketAccess::Group(group.gid.as_raw()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn control_socket_access(
|
||||
_group: Option<&str>,
|
||||
) -> Result<tsunagi::ipc::ControlSocketAccess, String> {
|
||||
Ok(tsunagi::ipc::ControlSocketAccess::Private)
|
||||
}
|
||||
|
||||
/// Path of the local control socket for a state directory.
|
||||
fn control_socket(
|
||||
paths: &StoragePaths,
|
||||
@@ -757,7 +793,10 @@ fn control_socket(
|
||||
if let Some(path) = override_path {
|
||||
return path.clone();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
// On Windows and macOS the agent runs as a system service and serves at a
|
||||
// fixed system path (a runtime directory the GUI can reach), not inside the
|
||||
// owner-only state directory.
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
if !_is_custom_state_dir {
|
||||
return tsunagi::ipc::default_control_socket_path();
|
||||
}
|
||||
@@ -1409,6 +1448,17 @@ impl tsunagi::ipc::ReportSource for AgentControl {
|
||||
})
|
||||
}
|
||||
|
||||
fn network_secret(&self, network_id: String) -> tsunagi::BoxFuture<'_, Result<String, String>> {
|
||||
Box::pin(async move {
|
||||
let networks = stored_networks(&self.paths);
|
||||
let network = networks
|
||||
.iter()
|
||||
.find(|network| network.network_id.to_string() == network_id)
|
||||
.ok_or_else(|| format!("no configured network with id {network_id}"))?;
|
||||
Ok(network.secret.encode().as_str().to_owned())
|
||||
})
|
||||
}
|
||||
|
||||
fn join(
|
||||
&self,
|
||||
name: String,
|
||||
@@ -3859,7 +3909,17 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let path = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
match tsunagi::ipc::ControlSocket::bind(path, source).await {
|
||||
let access = match control_socket_access(args.control_group.as_deref()) {
|
||||
Ok(access) => access,
|
||||
Err(reason) => {
|
||||
eprintln!(
|
||||
"warning: cannot grant the control socket to a group ({reason}); \
|
||||
it will be owner-only"
|
||||
);
|
||||
tsunagi::ipc::ControlSocketAccess::Private
|
||||
}
|
||||
};
|
||||
match tsunagi::ipc::ControlSocket::bind(path, source, access).await {
|
||||
Ok(socket) => {
|
||||
println!(" control {}", socket.path().display());
|
||||
Some(socket)
|
||||
@@ -4029,6 +4089,8 @@ async fn build_report(
|
||||
handshake_secs_ago: tunnel.health.since_handshake.map(|s| s.as_secs()),
|
||||
tx_packets: tunnel.stats.tx_packets,
|
||||
rx_packets: tunnel.stats.rx_packets,
|
||||
tx_bytes: tunnel.stats.tx_bytes,
|
||||
rx_bytes: tunnel.stats.rx_bytes,
|
||||
dropped: tunnel.stats.dropped_wrong_source
|
||||
+ tunnel.stats.dropped_oversize,
|
||||
protocol_errors: tunnel.stats.protocol_errors,
|
||||
@@ -4047,6 +4109,8 @@ async fn build_report(
|
||||
handshake_secs_ago: Some(tcp_tls.uptime_secs),
|
||||
tx_packets: tcp_tls.tx_packets,
|
||||
rx_packets: tcp_tls.rx_packets,
|
||||
tx_bytes: tcp_tls.tx_bytes,
|
||||
rx_bytes: tcp_tls.rx_bytes,
|
||||
dropped: tcp_tls.dropped,
|
||||
protocol_errors: 0,
|
||||
path: tcp_tls.path.clone(),
|
||||
@@ -4065,6 +4129,8 @@ async fn build_report(
|
||||
handshake_secs_ago: tunnel.health.since_handshake.map(|s| s.as_secs()),
|
||||
tx_packets: tunnel.stats.tx_packets,
|
||||
rx_packets: tunnel.stats.rx_packets,
|
||||
tx_bytes: tunnel.stats.tx_bytes,
|
||||
rx_bytes: tunnel.stats.rx_bytes,
|
||||
dropped: tunnel.stats.dropped_wrong_source
|
||||
+ tunnel.stats.dropped_oversize,
|
||||
protocol_errors: tunnel.stats.protocol_errors,
|
||||
@@ -4082,6 +4148,8 @@ async fn build_report(
|
||||
handshake_secs_ago: None,
|
||||
tx_packets: 0,
|
||||
rx_packets: 0,
|
||||
tx_bytes: 0,
|
||||
rx_bytes: 0,
|
||||
dropped: 0,
|
||||
protocol_errors: 0,
|
||||
path: "no data link".into(),
|
||||
@@ -4098,6 +4166,8 @@ async fn build_report(
|
||||
handshake_secs_ago: None,
|
||||
tx_packets: 0,
|
||||
rx_packets: 0,
|
||||
tx_bytes: 0,
|
||||
rx_bytes: 0,
|
||||
dropped: 0,
|
||||
protocol_errors: 0,
|
||||
path: "no data link".into(),
|
||||
@@ -4386,7 +4456,12 @@ fn log_event(event: &Event) {
|
||||
let peer_str = peer
|
||||
.map(|p| p.fmt_short().to_string())
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
tracing::warn!(
|
||||
// Routine and remotely triggerable: a peer dialing a network this
|
||||
// agent does not serve — one it never had, or has left and the peer
|
||||
// has not learned yet — a stranger, or an old client. Rejecting is
|
||||
// correct and contained (one session), so this is debug rather than
|
||||
// a warning any peer could spam into the log.
|
||||
tracing::debug!(
|
||||
network = ?network.map(|n| n.fmt_short().to_string()),
|
||||
peer = %peer_str,
|
||||
"Handshake rejected: {reason}"
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "tsunagi-tray"
|
||||
version = "0.1.0"
|
||||
description = "Tray application to manage a running tsunagi agent."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi-tray"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# The agent control client and the report types: the GUI is a thin client over
|
||||
# the same local control interface the CLI uses, so it reuses these directly
|
||||
# and never re-implements the protocol.
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0" }
|
||||
# Native, lightweight GUI — no web engine. eframe re-exports egui.
|
||||
eframe = { version = "0.31", default-features = false, features = ["default_fonts", "glow", "wayland", "x11"] }
|
||||
# Cross-platform tray icon + menu (Windows tray, macOS menu bar, Linux SNI).
|
||||
tray-icon = "0.21"
|
||||
# Background worker that talks to the agent off the UI thread.
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
|
||||
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]
|
||||
winit = "0.30"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,232 @@
|
||||
//! The client side: a background worker that talks to the agent over the local
|
||||
//! control socket, off the UI thread.
|
||||
//!
|
||||
//! The UI never blocks on IO. A worker on a tokio runtime polls the agent for
|
||||
//! status on an interval and runs the commands the UI sends (join, leave,
|
||||
//! activate, broadcast), writing the latest snapshot into shared state and
|
||||
//! asking egui to repaint. The whole thing is a thin layer over
|
||||
//! [`tsunagi::ipc`]; no protocol is re-implemented here.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use tsunagi::ipc::{self, StatusReport};
|
||||
|
||||
/// How often the worker refreshes status while idle.
|
||||
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.
|
||||
SetBroadcast { network_id: String, enabled: bool },
|
||||
/// Join a network, or start one already configured.
|
||||
Join { name: String, secret: String },
|
||||
/// Leave a network entirely.
|
||||
Leave { network_id: String },
|
||||
/// Change the hostname this device announces.
|
||||
SetHostname(String),
|
||||
/// Fetch a network's shared secret and place it on the clipboard.
|
||||
CopySecret { network_id: String },
|
||||
}
|
||||
|
||||
/// The latest the worker knows, read by the UI each frame.
|
||||
#[derive(Clone, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
/// The last status, or the reason it could not be read (agent down, no
|
||||
/// permission on the socket, …).
|
||||
pub status: Option<Result<StatusReport, String>>,
|
||||
/// The outcome of the most recent command, for a transient banner.
|
||||
pub last_action: Option<Result<String, String>>,
|
||||
/// Whether a command is currently being executed.
|
||||
pub busy: bool,
|
||||
/// Bumped each time `status` is refreshed, so the UI can tell new data from
|
||||
/// a repaint of the same data and sample traffic rates only on change.
|
||||
pub generation: u64,
|
||||
/// When this status was read, for computing rates between refreshes.
|
||||
pub at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// the clipboard; kept out of [`Snapshot`] so it is copied once, not every
|
||||
/// frame, and never travels in the cloned snapshot.
|
||||
pending_copy: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
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 {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
let shared = Arc::new(Mutex::new(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,
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes any secret the worker fetched for the clipboard.
|
||||
pub(crate) fn take_pending_copy(&self) -> Option<String> {
|
||||
lock(&self.pending_copy).take()
|
||||
}
|
||||
|
||||
/// The control socket this client talks to.
|
||||
pub(crate) fn socket(&self) -> &std::path::Path {
|
||||
&self.socket
|
||||
}
|
||||
|
||||
/// Sends a command; a dropped worker simply means nothing happens.
|
||||
pub(crate) fn send(&self, command: Command) {
|
||||
let _ = self.tx.send(command);
|
||||
}
|
||||
|
||||
/// A copy of the latest snapshot.
|
||||
pub(crate) fn snapshot(&self) -> Snapshot {
|
||||
lock(&self.shared).clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
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;
|
||||
}
|
||||
command = rx.recv() => {
|
||||
let Some(command) = command else { return };
|
||||
run(&socket, command, &shared, &pending_copy, &ctx).await;
|
||||
refresh(&socket, &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)
|
||||
.await
|
||||
.map_err(|err| err.to_string());
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.status = Some(status);
|
||||
guard.generation = guard.generation.wrapping_add(1);
|
||||
guard.at = Some(Instant::now());
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Runs one command and records its outcome.
|
||||
async fn run(
|
||||
socket: &std::path::Path,
|
||||
command: Command,
|
||||
shared: &Arc<Mutex<Snapshot>>,
|
||||
pending_copy: &Arc<Mutex<Option<String>>>,
|
||||
ctx: &egui::Context,
|
||||
) {
|
||||
lock(shared).busy = true;
|
||||
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| {
|
||||
let state = if report.active { "started" } else { "stopped" };
|
||||
format!("{} {}", state, report.name)
|
||||
})
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetBroadcast {
|
||||
network_id,
|
||||
enabled,
|
||||
} => ipc::set_broadcast(socket, &network_id, enabled)
|
||||
.await
|
||||
.map(|on| format!("broadcast {}", if on { "on" } else { "off" }))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::Join { name, secret } => ipc::join_network(socket, &name, &secret)
|
||||
.await
|
||||
.map(|report| format!("joined {}", report.name))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::Leave { network_id } => ipc::leave_network(socket, &network_id)
|
||||
.await
|
||||
.map(|report| format!("left {}", report.name))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::SetHostname(name) => ipc::set_hostname(socket, &name)
|
||||
.await
|
||||
.map(|accepted| format!("hostname: {accepted}"))
|
||||
.map_err(|err| err.to_string()),
|
||||
Command::CopySecret { network_id } => {
|
||||
match ipc::network_secret(socket, &network_id).await {
|
||||
// The secret goes only to the clipboard slot, never into the
|
||||
// banner text or any log line.
|
||||
Ok(secret) => {
|
||||
*lock(pending_copy) = Some(secret);
|
||||
Ok("secret copied to clipboard".to_string())
|
||||
}
|
||||
Err(err) => Err(err.to_string()),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut guard = lock(shared);
|
||||
guard.busy = false;
|
||||
guard.last_action = if matches!(&outcome, Ok(message) if message.is_empty()) {
|
||||
None
|
||||
} else {
|
||||
Some(outcome)
|
||||
};
|
||||
}
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Where the GUI looks for the agent: `TSUNAGI_CONTROL_SOCKET` if set, else the
|
||||
/// platform default (the system service's path).
|
||||
pub(crate) fn resolve_socket() -> PathBuf {
|
||||
if let Some(path) = std::env::var_os("TSUNAGI_CONTROL_SOCKET") {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
ipc::default_control_socket_path()
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
//! The eframe application: wires the tray, the main window, the per-network
|
||||
//! devices windows, and the agent worker.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
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.
|
||||
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.
|
||||
pub(crate) fn new(
|
||||
cc: &eframe::CreationContext<'_>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
) -> Result<Self, String> {
|
||||
let ctx = cc.egui_ctx.clone();
|
||||
// Add the Phosphor icon glyphs to the font set so the icon buttons
|
||||
// render instead of showing as tofu.
|
||||
let mut fonts = egui::FontDefinitions::default();
|
||||
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)?;
|
||||
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)
|
||||
{
|
||||
self.traffic.observe(report, at);
|
||||
self.sampled = snapshot.generation;
|
||||
}
|
||||
|
||||
ui::draw(ctx, &self.agent, &mut self.state, &snapshot, &self.traffic);
|
||||
self.show_devices_windows(ctx, &snapshot);
|
||||
|
||||
// A secret the worker fetched for the clipboard: copy it here, on the
|
||||
// UI thread that owns the clipboard, exactly once.
|
||||
if let Some(secret) = self.agent.take_pending_copy() {
|
||||
ctx.copy_text(secret);
|
||||
}
|
||||
|
||||
// Keep ticking so fresh status and tray actions are picked up.
|
||||
ctx.request_repaint_after(Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// 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 {
|
||||
return;
|
||||
};
|
||||
let open: Vec<String> = self.state.open_devices.iter().cloned().collect();
|
||||
for id in open {
|
||||
let Some(network) = report.networks.iter().find(|n| n.network_id == id) else {
|
||||
// The network is gone (left/forgotten); close its window.
|
||||
self.state.open_devices.remove(&id);
|
||||
continue;
|
||||
};
|
||||
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_min_inner_size([480.0, 320.0]);
|
||||
let agent = &self.agent;
|
||||
let traffic = &self.traffic;
|
||||
// The window shares the single unit setting, so its toggle and the
|
||||
// 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);
|
||||
!vctx.input(|i| i.viewport().close_requested())
|
||||
});
|
||||
if !keep {
|
||||
self.state.open_devices.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! The per-network devices window: full detail about one network — its
|
||||
//! settings and counters, and one table of every device, online first, with
|
||||
//! inline traffic plots for the connected ones.
|
||||
//!
|
||||
//! Rendered in a separate OS window (an egui viewport) opened from a tile in
|
||||
//! the main window.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use tsunagi::ipc::{NetworkReport, OverlayPeerReport};
|
||||
|
||||
use crate::agent::AgentClient;
|
||||
use crate::format;
|
||||
use crate::stats::{Traffic, Unit};
|
||||
|
||||
/// A readable title for a network's window.
|
||||
pub(crate) fn title(network: &NetworkReport) -> String {
|
||||
format!("tsunagi · {}", network.name)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
proto: &'a str,
|
||||
address: Option<String>,
|
||||
handshake_secs: Option<u64>,
|
||||
overlay: Option<&'a OverlayPeerReport>,
|
||||
}
|
||||
|
||||
/// Draws the window body into its viewport.
|
||||
pub(crate) fn show(
|
||||
ctx: &egui::Context,
|
||||
_agent: &AgentClient,
|
||||
unit: &mut Unit,
|
||||
network: &NetworkReport,
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading(&network.name);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.selectable_value(unit, Unit::Bytes, "bytes");
|
||||
ui.selectable_value(unit, Unit::Packets, "pkts");
|
||||
ui.label("show:");
|
||||
});
|
||||
});
|
||||
// A copy of the chosen unit for the read-only rendering below.
|
||||
let unit = *unit;
|
||||
|
||||
summary(ui, network);
|
||||
ui.add_space(6.0);
|
||||
|
||||
ui.label(egui::RichText::new("traffic").strong());
|
||||
format::sparkline(ui, traffic.network(&network.network_id), unit, 48.0);
|
||||
ui.add_space(8.0);
|
||||
|
||||
let devices = devices(network);
|
||||
let online = devices.iter().filter(|d| d.online).count();
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"devices ({} online / {} known)",
|
||||
online,
|
||||
devices.len()
|
||||
))
|
||||
.strong(),
|
||||
);
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
device_table(ui, unit, &network.network_id, &devices, traffic);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The network's own settings and counters.
|
||||
fn summary(ui: &mut egui::Ui, network: &NetworkReport) {
|
||||
egui::Grid::new("summary")
|
||||
.num_columns(2)
|
||||
.spacing([12.0, 3.0])
|
||||
.show(ui, |ui| {
|
||||
ui.weak("id");
|
||||
format::copy_field(ui, &network.network_id, &network.network_id);
|
||||
ui.end_row();
|
||||
|
||||
ui.weak("active");
|
||||
ui.label(yes_no(network.active));
|
||||
ui.end_row();
|
||||
ui.weak("broadcast");
|
||||
ui.label(yes_no(network.broadcast));
|
||||
ui.end_row();
|
||||
|
||||
if let Some(range) = &network.range {
|
||||
ui.weak("range");
|
||||
format::copy_field(ui, range, range);
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(address) = network.overlay.as_ref().and_then(|o| o.address.as_ref()) {
|
||||
ui.weak("this device");
|
||||
format::copy_field(ui, address, address);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.weak("candidates");
|
||||
ui.label(network.candidates.to_string());
|
||||
ui.end_row();
|
||||
ui.weak("relay fwd/via/recv");
|
||||
ui.label(format!(
|
||||
"{} / {} / {}",
|
||||
network.relay_forwarded, network.relay_sent_via, network.relay_received_via
|
||||
));
|
||||
ui.end_row();
|
||||
ui.weak("dial/handshake fails");
|
||||
ui.label(format!(
|
||||
"{} / {}",
|
||||
network.dial_failures, network.handshake_failures
|
||||
));
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
|
||||
/// 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
|
||||
.iter()
|
||||
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h)))
|
||||
.collect();
|
||||
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 mut devices: Vec<Device> = ids
|
||||
.into_iter()
|
||||
.map(|id| {
|
||||
let overlay = overlay_peers.iter().find(|p| p.endpoint_id == id);
|
||||
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,
|
||||
proto: overlay.map_or("—", |p| p.protocol.as_str()),
|
||||
address: overlay
|
||||
.and_then(|p| p.address.clone())
|
||||
.or_else(|| member.and_then(|m| m.overlay_address_v4.clone())),
|
||||
handshake_secs: overlay.and_then(|p| p.handshake_secs_ago),
|
||||
overlay,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
devices.sort_by(|a, b| {
|
||||
b.online
|
||||
.cmp(&a.online)
|
||||
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
devices
|
||||
}
|
||||
|
||||
/// 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(
|
||||
ui: &mut egui::Ui,
|
||||
unit: Unit,
|
||||
network_id: &str,
|
||||
devices: &[Device<'_>],
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
if devices.is_empty() {
|
||||
ui.weak("none");
|
||||
return;
|
||||
}
|
||||
egui::Grid::new("devices")
|
||||
.num_columns(8)
|
||||
.striped(true)
|
||||
.spacing([10.0, 4.0])
|
||||
.show(ui, |ui| {
|
||||
for header in [
|
||||
"device",
|
||||
"proto",
|
||||
"address",
|
||||
"handshake",
|
||||
"pkts",
|
||||
"bytes",
|
||||
"rate",
|
||||
"plot",
|
||||
] {
|
||||
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 {
|
||||
Some(address) => format::copy_field(ui, address, address),
|
||||
None => {
|
||||
ui.weak("—");
|
||||
}
|
||||
}
|
||||
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());
|
||||
ui.allocate_ui(egui::vec2(100.0, 20.0), |ui| {
|
||||
format::sparkline(ui, series, unit, 18.0);
|
||||
});
|
||||
}
|
||||
None => {
|
||||
for _ in 0..4 {
|
||||
ui.weak("—");
|
||||
}
|
||||
}
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn yes_no(value: bool) -> &'static str {
|
||||
if value { "yes" } else { "no" }
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Formatting and small shared widgets: humanized ages, byte sizes and rates,
|
||||
//! a fixed-size sparkline, and a click-to-copy field with a green-flash feedback.
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::stats::{Series, Unit};
|
||||
|
||||
/// Sparkline accent.
|
||||
const ACCENT: egui::Color32 = egui::Color32::from_rgb(0x2f, 0x80, 0xd8);
|
||||
/// The green a field flashes to when its value is copied.
|
||||
const COPIED_GREEN: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
|
||||
/// How long the copy flash takes to fade back to normal.
|
||||
const FLASH: Duration = Duration::from_secs(1);
|
||||
|
||||
/// A short, readable prefix of a long identifier (char-safe, no panic).
|
||||
pub(crate) fn short(id: &str) -> String {
|
||||
const KEEP: usize = 12;
|
||||
if id.chars().count() <= KEEP {
|
||||
id.to_string()
|
||||
} else {
|
||||
format!("{}…", id.chars().take(KEEP).collect::<String>())
|
||||
}
|
||||
}
|
||||
|
||||
/// A duration in seconds as a compact age (`5s`, `12m`, `3h 20m`, `2d 4h`).
|
||||
pub(crate) fn age(secs: u64) -> String {
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3_600 {
|
||||
format!("{}m", secs / 60)
|
||||
} else if secs < 86_400 {
|
||||
format!("{}h {}m", secs / 3_600, (secs % 3_600) / 60)
|
||||
} else {
|
||||
format!("{}d {}h", secs / 86_400, (secs % 86_400) / 3_600)
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte count in binary units.
|
||||
pub(crate) fn bytes(n: f64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "kB", "MB", "GB", "TB"];
|
||||
let mut value = n.max(0.0);
|
||||
let mut unit = 0;
|
||||
while value >= 1024.0 && unit < UNITS.len() - 1 {
|
||||
value /= 1024.0;
|
||||
unit += 1;
|
||||
}
|
||||
if unit == 0 {
|
||||
format!("{} B", value as u64)
|
||||
} else {
|
||||
format!("{value:.1} {}", UNITS[unit])
|
||||
}
|
||||
}
|
||||
|
||||
/// A rate in the chosen unit, per second.
|
||||
pub(crate) fn rate(value: f32, unit: Unit) -> String {
|
||||
match unit {
|
||||
Unit::Packets => {
|
||||
if value >= 1000.0 {
|
||||
format!("{:.1}k pkt/s", value / 1000.0)
|
||||
} else {
|
||||
format!("{value:.0} pkt/s")
|
||||
}
|
||||
}
|
||||
Unit::Bytes => format!("{}/s", bytes(f64::from(value))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current rate of a series as a string, or a dash when unsampled.
|
||||
pub(crate) fn series_rate(series: Option<&Series>, unit: Unit) -> String {
|
||||
series.map_or_else(|| "—".to_string(), |s| rate(s.rate(unit), unit))
|
||||
}
|
||||
|
||||
/// A label whose text is `display` and which copies `value` to the clipboard
|
||||
/// when clicked. Feedback is a green flash that fades back over a second — the
|
||||
/// text itself never changes, so a dense table does not reflow on a copy.
|
||||
pub(crate) fn copy_field(ui: &mut egui::Ui, display: &str, value: &str) {
|
||||
let id = ui.make_persistent_id(("copy", value, display));
|
||||
let copied_at: Option<Instant> = ui.memory(|memory| memory.data.get_temp(id));
|
||||
// 1.0 right after the click, fading to 0.0 a second later.
|
||||
let flash = copied_at.and_then(|at| {
|
||||
let elapsed = at.elapsed().as_secs_f32();
|
||||
let span = FLASH.as_secs_f32();
|
||||
(elapsed < span).then(|| 1.0 - elapsed / span)
|
||||
});
|
||||
|
||||
let text = match flash {
|
||||
Some(factor) => {
|
||||
let base = ui.visuals().text_color();
|
||||
egui::RichText::new(display).color(lerp_color(base, COPIED_GREEN, factor))
|
||||
}
|
||||
None => egui::RichText::new(display),
|
||||
};
|
||||
let response = ui
|
||||
.add(egui::Label::new(text).sense(egui::Sense::click()))
|
||||
.on_hover_cursor(egui::CursorIcon::PointingHand)
|
||||
.on_hover_text("click to copy");
|
||||
if response.clicked() {
|
||||
ui.ctx().copy_text(value.to_owned());
|
||||
ui.memory_mut(|memory| memory.data.insert_temp(id, Instant::now()));
|
||||
}
|
||||
if flash.is_some() {
|
||||
// Animate the fade smoothly until it completes.
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear blend between two colours, `t` in `0.0..=1.0` toward `b`.
|
||||
fn lerp_color(a: egui::Color32, b: egui::Color32, t: f32) -> egui::Color32 {
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let mix = |x: u8, y: u8| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round() as u8;
|
||||
egui::Color32::from_rgb(mix(a.r(), b.r()), mix(a.g(), b.g()), mix(a.b(), b.b()))
|
||||
}
|
||||
|
||||
/// A fixed-height sparkline of a series' recent rate, with the peak (top-left)
|
||||
/// and current value (bottom-right) overlaid. The space is always reserved —
|
||||
/// even with no data — so the layout never jumps when data arrives.
|
||||
pub(crate) fn sparkline(ui: &mut egui::Ui, series: Option<&Series>, unit: Unit, height: f32) {
|
||||
let width = ui.available_width();
|
||||
let (rect, _) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
let visuals = ui.visuals();
|
||||
painter.rect_filled(rect, 2.0, visuals.extreme_bg_color);
|
||||
|
||||
let Some(series) = series else {
|
||||
painter.text(
|
||||
rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"no data yet",
|
||||
egui::FontId::proportional(10.0),
|
||||
visuals.weak_text_color(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
let history = series.history(unit);
|
||||
let peak = history.iter().copied().fold(1.0_f32, f32::max);
|
||||
if history.len() >= 2 {
|
||||
let count = history.len();
|
||||
let points: Vec<egui::Pos2> = history
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, value)| {
|
||||
let x = rect.left() + rect.width() * (i as f32 / (count - 1) as f32);
|
||||
let y = rect.bottom() - 2.0 - (rect.height() - 12.0) * (value / peak);
|
||||
egui::pos2(x, y)
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(
|
||||
points,
|
||||
egui::Stroke::new(1.5_f32, ACCENT),
|
||||
));
|
||||
}
|
||||
|
||||
painter.text(
|
||||
rect.left_top() + egui::vec2(4.0, 2.0),
|
||||
egui::Align2::LEFT_TOP,
|
||||
format!("peak {}", rate(peak, unit)),
|
||||
egui::FontId::proportional(9.0),
|
||||
visuals.weak_text_color(),
|
||||
);
|
||||
painter.text(
|
||||
rect.right_bottom() + egui::vec2(-4.0, -2.0),
|
||||
egui::Align2::RIGHT_BOTTOM,
|
||||
rate(series.rate(unit), unit),
|
||||
egui::FontId::proportional(10.0),
|
||||
visuals.strong_text_color(),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! 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.
|
||||
|
||||
// The window is created hidden-to-tray on close rather than exiting; the binary
|
||||
// itself may exit on Quit. Nothing here is a library.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod agent;
|
||||
mod app;
|
||||
mod devices;
|
||||
mod format;
|
||||
mod stats;
|
||||
mod tray;
|
||||
mod ui;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
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();
|
||||
|
||||
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 {
|
||||
// A fixed, small window; the whole layout targets this size.
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("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),
|
||||
..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,
|
||||
Box::new(move |cc| {
|
||||
App::new(cc, handle.clone())
|
||||
.map(|app| Box::new(app) as Box<dyn eframe::App>)
|
||||
.map_err(std::convert::Into::into)
|
||||
}),
|
||||
)?;
|
||||
|
||||
// The window loop has ended (Quit); let the runtime wind down.
|
||||
drop(runtime);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Traffic rates derived from successive status snapshots.
|
||||
//!
|
||||
//! The agent reports cumulative packet and byte counters, not rates. The GUI
|
||||
//! samples them each time a fresh snapshot arrives and turns the deltas into
|
||||
//! per-second rates plus short rolling histories for sparklines. One series per
|
||||
//! key: a network total, and each peer within it. Both packets and bytes are
|
||||
//! tracked so the UI can switch between them.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::time::Instant;
|
||||
|
||||
use tsunagi::ipc::StatusReport;
|
||||
|
||||
/// How many rate samples to keep for a sparkline.
|
||||
const HISTORY: usize = 60;
|
||||
|
||||
/// Which quantity a rate or history is of.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Unit {
|
||||
/// Packets per second.
|
||||
Packets,
|
||||
/// Bytes per second.
|
||||
Bytes,
|
||||
}
|
||||
|
||||
/// One series' rolling packet and byte rates.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Series {
|
||||
last: Option<(Instant, u64, u64)>,
|
||||
packets_rate: f32,
|
||||
bytes_rate: f32,
|
||||
packets_history: VecDeque<f32>,
|
||||
bytes_history: VecDeque<f32>,
|
||||
}
|
||||
|
||||
impl Series {
|
||||
fn sample(&mut self, now: Instant, packets: u64, bytes: u64) {
|
||||
if let Some((then, prev_packets, prev_bytes)) = self.last {
|
||||
let dt = now.saturating_duration_since(then).as_secs_f32();
|
||||
if dt > 0.0 {
|
||||
self.packets_rate = packets.saturating_sub(prev_packets) as f32 / dt;
|
||||
self.bytes_rate = bytes.saturating_sub(prev_bytes) as f32 / dt;
|
||||
push(&mut self.packets_history, self.packets_rate);
|
||||
push(&mut self.bytes_history, self.bytes_rate);
|
||||
}
|
||||
}
|
||||
self.last = Some((now, packets, bytes));
|
||||
}
|
||||
|
||||
/// The current rate in the chosen unit.
|
||||
pub(crate) fn rate(&self, unit: Unit) -> f32 {
|
||||
match unit {
|
||||
Unit::Packets => self.packets_rate,
|
||||
Unit::Bytes => self.bytes_rate,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rolling history in the chosen unit.
|
||||
pub(crate) fn history(&self, unit: Unit) -> &VecDeque<f32> {
|
||||
match unit {
|
||||
Unit::Packets => &self.packets_history,
|
||||
Unit::Bytes => &self.bytes_history,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push(history: &mut VecDeque<f32>, value: f32) {
|
||||
history.push_back(value);
|
||||
while history.len() > HISTORY {
|
||||
history.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// All traffic series, keyed by a stable string.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Traffic {
|
||||
series: HashMap<String, Series>,
|
||||
}
|
||||
|
||||
impl Traffic {
|
||||
/// Folds a fresh snapshot into the rates. Call once per new generation.
|
||||
pub(crate) fn observe(&mut self, report: &StatusReport, now: Instant) {
|
||||
for network in &report.networks {
|
||||
let Some(overlay) = &network.overlay else {
|
||||
continue;
|
||||
};
|
||||
let (mut net_packets, mut net_bytes) = (0u64, 0u64);
|
||||
for peer in &overlay.peers {
|
||||
let packets = peer.tx_packets.saturating_add(peer.rx_packets);
|
||||
let bytes = peer.tx_bytes.saturating_add(peer.rx_bytes);
|
||||
net_packets = net_packets.saturating_add(packets);
|
||||
net_bytes = net_bytes.saturating_add(bytes);
|
||||
self.series
|
||||
.entry(peer_key(&network.network_id, &peer.public_key))
|
||||
.or_default()
|
||||
.sample(now, packets, bytes);
|
||||
}
|
||||
self.series
|
||||
.entry(net_key(&network.network_id))
|
||||
.or_default()
|
||||
.sample(now, net_packets, net_bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// The network-total series, if sampled.
|
||||
pub(crate) fn network(&self, network_id: &str) -> Option<&Series> {
|
||||
self.series.get(&net_key(network_id))
|
||||
}
|
||||
|
||||
/// One peer's series, if sampled.
|
||||
pub(crate) fn peer(&self, network_id: &str, public_key: &str) -> Option<&Series> {
|
||||
self.series.get(&peer_key(network_id, public_key))
|
||||
}
|
||||
}
|
||||
|
||||
fn net_key(network_id: &str) -> String {
|
||||
format!("net:{network_id}")
|
||||
}
|
||||
|
||||
fn peer_key(network_id: &str, public_key: &str) -> String {
|
||||
format!("peer:{network_id}:{public_key}")
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! The tray icon and its menu.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
use std::sync::mpsc;
|
||||
|
||||
use eframe::egui;
|
||||
use tray_icon::menu::{Menu, MenuEvent, 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 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.
|
||||
///
|
||||
/// 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();
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
_icon: tray,
|
||||
actions: rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// The menu actions since the last call.
|
||||
pub(crate) fn poll(&self) -> impl Iterator<Item = Action> + '_ {
|
||||
self.actions.try_iter()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
let centre = (SIZE as f32 - 1.0) / 2.0;
|
||||
let radius = SIZE as f32 * 0.46;
|
||||
let mut rgba = vec![0u8; (SIZE * SIZE * 4) as usize];
|
||||
for y in 0..SIZE {
|
||||
for x in 0..SIZE {
|
||||
let (dx, dy) = (x as f32 - centre, y as f32 - centre);
|
||||
if dx * dx + dy * dy <= radius * radius {
|
||||
let i = ((y * SIZE + x) * 4) as usize;
|
||||
rgba[i] = 0x2f;
|
||||
rgba[i + 1] = 0x80;
|
||||
rgba[i + 2] = 0xd8;
|
||||
rgba[i + 3] = 0xff;
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon::from_rgba(rgba, SIZE, SIZE).map_err(|err| err.to_string())
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
//! The main window: a compact overview. Per-network detail (the full peer
|
||||
//! table with inline plots) lives in a separate devices window (see
|
||||
//! [`crate::devices`]), opened from each tile.
|
||||
//!
|
||||
//! Pure rendering: state comes from the snapshot and the traffic sampler, a
|
||||
//! change to a switch or button sends a [`Command`] to the worker, and local UI
|
||||
//! state (the join form, the unit toggle, which devices windows are open, an
|
||||
//! in-progress hostname edit) lives in [`UiState`].
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use eframe::egui;
|
||||
use egui_phosphor::regular as icon;
|
||||
|
||||
use tsunagi::ipc::{NetworkReport, StatusReport};
|
||||
|
||||
use crate::agent::{AgentClient, Command, Snapshot};
|
||||
use crate::format;
|
||||
use crate::stats::{Traffic, Unit};
|
||||
|
||||
const OK: egui::Color32 = egui::Color32::from_rgb(0x3c, 0xb0, 0x4a);
|
||||
const BAD: egui::Color32 = egui::Color32::from_rgb(0xd0, 0x4a, 0x3c);
|
||||
/// A neutral, not-garish red for the Leave button.
|
||||
const LEAVE_RED: egui::Color32 = egui::Color32::from_rgb(0xa8, 0x3a, 0x3a);
|
||||
|
||||
/// Join form fields.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct JoinForm {
|
||||
pub(crate) name: String,
|
||||
pub(crate) secret: String,
|
||||
}
|
||||
|
||||
/// Local UI state kept across frames.
|
||||
pub(crate) struct UiState {
|
||||
pub(crate) join: JoinForm,
|
||||
/// Whether rates and sparklines are shown in packets or bytes.
|
||||
pub(crate) unit: Unit,
|
||||
/// An in-progress hostname edit (`None` when not editing).
|
||||
pub(crate) host_edit: Option<String>,
|
||||
/// Network ids whose devices window is open.
|
||||
pub(crate) open_devices: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl Default for UiState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
join: JoinForm::default(),
|
||||
unit: Unit::Packets,
|
||||
host_edit: None,
|
||||
open_devices: BTreeSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws the main window.
|
||||
pub(crate) fn draw(
|
||||
ctx: &egui::Context,
|
||||
agent: &AgentClient,
|
||||
state: &mut UiState,
|
||||
snapshot: &Snapshot,
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
egui::TopBottomPanel::top("top").show(ctx, |ui| {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.heading("tsunagi");
|
||||
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 snapshot.busy {
|
||||
ui.add(egui::Spinner::new());
|
||||
}
|
||||
ui.separator();
|
||||
ui.selectable_value(&mut state.unit, Unit::Bytes, "bytes");
|
||||
ui.selectable_value(&mut state.unit, Unit::Packets, "pkts");
|
||||
ui.label("show:");
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::TopBottomPanel::bottom("bottom").show(ctx, |ui| {
|
||||
if let Some(action) = &snapshot.last_action {
|
||||
ui.add_space(4.0);
|
||||
match action {
|
||||
Ok(message) => ui.colored_label(OK, message),
|
||||
Err(error) => ui.colored_label(BAD, error),
|
||||
};
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
draw_join(ui, agent, &mut state.join);
|
||||
ui.add_space(4.0);
|
||||
});
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| match &snapshot.status {
|
||||
None => {
|
||||
ui.add_space(8.0);
|
||||
ui.label("Connecting to the agent…");
|
||||
}
|
||||
Some(Err(error)) => draw_unreachable(ui, agent, error),
|
||||
Some(Ok(report)) => {
|
||||
draw_header(ui, agent, state, report);
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical()
|
||||
.auto_shrink([false, false])
|
||||
.show(ui, |ui| {
|
||||
if report.networks.is_empty() {
|
||||
ui.add_space(6.0);
|
||||
ui.weak("No networks yet — join one below.");
|
||||
}
|
||||
for network in &report.networks {
|
||||
draw_network(ui, agent, state, traffic, network);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The "agent cannot be reached" state.
|
||||
fn draw_unreachable(ui: &mut egui::Ui, agent: &AgentClient, error: &str) {
|
||||
ui.add_space(8.0);
|
||||
ui.colored_label(BAD, "The agent is not reachable.");
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("socket");
|
||||
ui.monospace(agent.socket().display().to_string());
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.weak(error);
|
||||
ui.add_space(6.0);
|
||||
ui.weak(
|
||||
"Start the agent, or check that this user may open its control socket \
|
||||
(its group, mode 0660).",
|
||||
);
|
||||
}
|
||||
|
||||
/// The device header: id (with a copy button) and an editable hostname.
|
||||
fn draw_header(ui: &mut egui::Ui, agent: &AgentClient, state: &mut UiState, report: &StatusReport) {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("device");
|
||||
// Clicking the shortened id copies the full one.
|
||||
format::copy_field(ui, &format::short(&report.endpoint_id), &report.endpoint_id);
|
||||
});
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
ui.weak("host");
|
||||
match &mut state.host_edit {
|
||||
None => {
|
||||
ui.label(&report.hostname);
|
||||
if ui
|
||||
.small_button(icon::PENCIL_SIMPLE)
|
||||
.on_hover_text("rename")
|
||||
.clicked()
|
||||
{
|
||||
state.host_edit = Some(report.hostname.clone());
|
||||
}
|
||||
}
|
||||
Some(buffer) => {
|
||||
let response = ui.add(egui::TextEdit::singleline(buffer).desired_width(150.0));
|
||||
let submit = (response.lost_focus()
|
||||
&& ui.input(|i| i.key_pressed(egui::Key::Enter)))
|
||||
|| ui.small_button(icon::CHECK).on_hover_text("save").clicked();
|
||||
if submit {
|
||||
agent.send(Command::SetHostname(buffer.trim().to_string()));
|
||||
state.host_edit = None;
|
||||
} else if ui.small_button(icon::X).on_hover_text("cancel").clicked() {
|
||||
state.host_edit = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if !report.cache_healthy {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(0xd9, 0x9a, 0x00),
|
||||
"cache unavailable",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// One compact network tile.
|
||||
fn draw_network(
|
||||
ui: &mut egui::Ui,
|
||||
agent: &AgentClient,
|
||||
state: &mut UiState,
|
||||
traffic: &Traffic,
|
||||
network: &NetworkReport,
|
||||
) {
|
||||
ui.add_space(6.0);
|
||||
egui::Frame::group(ui.style()).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
format::copy_field(ui, network.name.as_str(), &network.name);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let leave =
|
||||
egui::Button::new(egui::RichText::new("Leave").color(egui::Color32::WHITE))
|
||||
.fill(LEAVE_RED);
|
||||
if ui.add(leave).clicked() {
|
||||
agent.send(Command::Leave {
|
||||
network_id: network.network_id.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let address = network.overlay.as_ref().and_then(|o| o.address.as_ref());
|
||||
let series = traffic.network(&network.network_id);
|
||||
|
||||
egui::Grid::new(("net", &network.network_id))
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 3.0])
|
||||
.show(ui, |ui| {
|
||||
ui.weak("id");
|
||||
format::copy_field(ui, &format::short(&network.network_id), &network.network_id);
|
||||
ui.end_row();
|
||||
|
||||
if let Some(range) = &network.range {
|
||||
ui.weak("range");
|
||||
format::copy_field(ui, range, range);
|
||||
ui.end_row();
|
||||
}
|
||||
if let Some(address) = address {
|
||||
ui.weak("addr");
|
||||
format::copy_field(ui, address, address);
|
||||
ui.end_row();
|
||||
}
|
||||
ui.weak("peers");
|
||||
ui.label(format!(
|
||||
"{} connected · {} known",
|
||||
network.peers.len(),
|
||||
network.members.len()
|
||||
));
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
format::sparkline(ui, series, state.unit, 34.0);
|
||||
|
||||
ui.horizontal(|ui| {
|
||||
let mut active = network.active;
|
||||
if ui.checkbox(&mut active, "active").changed() {
|
||||
agent.send(Command::SetActive {
|
||||
network_id: network.network_id.clone(),
|
||||
active,
|
||||
});
|
||||
}
|
||||
let mut broadcast = network.broadcast;
|
||||
if ui.checkbox(&mut broadcast, "broadcast").changed() {
|
||||
agent.send(Command::SetBroadcast {
|
||||
network_id: network.network_id.clone(),
|
||||
enabled: broadcast,
|
||||
});
|
||||
}
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if ui.button("Show devices").clicked() {
|
||||
state.open_devices.insert(network.network_id.clone());
|
||||
}
|
||||
if ui
|
||||
.button(format!("{} Copy secret", icon::COPY))
|
||||
.on_hover_text("copy this network's secret to the clipboard")
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::CopySecret {
|
||||
network_id: network.network_id.clone(),
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// The join form: aligned name and secret, a generate button, and Join.
|
||||
fn draw_join(ui: &mut egui::Ui, agent: &AgentClient, join: &mut JoinForm) {
|
||||
const INPUT_WIDTH: f32 = 208.0;
|
||||
ui.label("Join or create a network");
|
||||
egui::Grid::new("join")
|
||||
.num_columns(2)
|
||||
.spacing([8.0, 6.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("name");
|
||||
ui.add(egui::TextEdit::singleline(&mut join.name).desired_width(INPUT_WIDTH));
|
||||
ui.end_row();
|
||||
|
||||
ui.label("secret");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut join.secret)
|
||||
.desired_width(INPUT_WIDTH - 26.0)
|
||||
.password(true),
|
||||
);
|
||||
if ui
|
||||
.button(icon::SHUFFLE)
|
||||
.on_hover_text("Generate a new random secret")
|
||||
.clicked()
|
||||
{
|
||||
join.secret = tsunagi::identity::NetworkSecret::generate()
|
||||
.encode()
|
||||
.as_str()
|
||||
.to_owned();
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
let ready = !join.name.trim().is_empty() && !join.secret.trim().is_empty();
|
||||
if ui
|
||||
.add_enabled(ready, egui::Button::new("Join / create"))
|
||||
.clicked()
|
||||
{
|
||||
agent.send(Command::Join {
|
||||
name: join.name.trim().to_string(),
|
||||
secret: join.secret.trim().to_string(),
|
||||
});
|
||||
join.secret.clear();
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::dataplane::IpPlugin;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::ipc::{ControlSocket, request_status};
|
||||
use tsunagi::ipc::{ControlSocket, ControlSocketAccess, request_status};
|
||||
use tsunagi::ipc::{StatusReport, control_socket_path};
|
||||
use tsunagi::overlay::MemoryTunFactory;
|
||||
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
|
||||
@@ -130,9 +130,13 @@ async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
|
||||
// A short path: a Unix socket address is limited to about 100 bytes.
|
||||
let socket_path = dir_a.path().join("agent.sock");
|
||||
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
source(agent.clone(), plugin.clone()),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = wait_until("the overlay is reported as up", || {
|
||||
let socket_path = socket_path.clone();
|
||||
@@ -193,13 +197,13 @@ async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
|
||||
|
||||
// A file with nobody listening is a leftover from a crash.
|
||||
std::fs::write(&path, b"stale").unwrap();
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty))
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(request_status(&path).await.is_ok());
|
||||
|
||||
// A live socket is not stolen from the agent that owns it.
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty)).await;
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty), ControlSocketAccess::Private).await;
|
||||
assert!(
|
||||
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
|
||||
"a second agent must not take over a live control socket"
|
||||
@@ -321,9 +325,13 @@ async fn a_client_can_leave_a_network_through_the_running_agent() {
|
||||
wait_for_peers(&agent, network_id, 1).await;
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
Arc::new(Control(agent.clone())),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::leave_network(&socket_path, &network_id.to_string())
|
||||
.await
|
||||
@@ -361,9 +369,13 @@ async fn a_client_can_add_a_network_to_a_running_agent() {
|
||||
agent.join_network(&first, &first_secret).await.unwrap();
|
||||
|
||||
let socket_path = dir.path().join("control.sock");
|
||||
let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
let control = ControlSocket::bind(
|
||||
&socket_path,
|
||||
Arc::new(Control(agent.clone())),
|
||||
ControlSocketAccess::Private,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = tsunagi::ipc::join_network(
|
||||
&socket_path,
|
||||
|
||||
@@ -83,6 +83,8 @@ struct TunnelPeerState {
|
||||
link: SharedLink,
|
||||
tx_packets: AtomicU64,
|
||||
rx_packets: AtomicU64,
|
||||
tx_bytes: AtomicU64,
|
||||
rx_bytes: AtomicU64,
|
||||
dropped: AtomicU64,
|
||||
established_at: std::time::Instant,
|
||||
}
|
||||
@@ -100,6 +102,10 @@ pub struct TunnelPeerReport {
|
||||
pub tx_packets: u64,
|
||||
/// Packets successfully received and decrypted.
|
||||
pub rx_packets: u64,
|
||||
/// Plaintext bytes successfully encrypted and sent.
|
||||
pub tx_bytes: u64,
|
||||
/// Plaintext bytes successfully received and decrypted.
|
||||
pub rx_bytes: u64,
|
||||
/// Packets dropped due to encryption/decryption/send failures.
|
||||
pub dropped: u64,
|
||||
/// Uptime in seconds since the data link was established.
|
||||
@@ -145,6 +151,8 @@ impl<C: TunnelCodec> GenericTunnelPlugin<C> {
|
||||
path: state.link.path_description(),
|
||||
tx_packets: state.tx_packets.load(Ordering::Relaxed),
|
||||
rx_packets: state.rx_packets.load(Ordering::Relaxed),
|
||||
tx_bytes: state.tx_bytes.load(Ordering::Relaxed),
|
||||
rx_bytes: state.rx_bytes.load(Ordering::Relaxed),
|
||||
dropped: state.dropped.load(Ordering::Relaxed),
|
||||
uptime_secs: state.established_at.elapsed().as_secs(),
|
||||
})
|
||||
@@ -223,6 +231,9 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||
Ok(ciphertext) => {
|
||||
if state.link.send(ciphertext).is_ok() {
|
||||
state.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
state
|
||||
.tx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
true
|
||||
} else {
|
||||
state.dropped.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -242,6 +253,8 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||
link: Arc::clone(&link),
|
||||
tx_packets: AtomicU64::new(0),
|
||||
rx_packets: AtomicU64::new(0),
|
||||
tx_bytes: AtomicU64::new(0),
|
||||
rx_bytes: AtomicU64::new(0),
|
||||
dropped: AtomicU64::new(0),
|
||||
established_at: std::time::Instant::now(),
|
||||
});
|
||||
@@ -268,6 +281,9 @@ impl<C: TunnelCodec> IpPlugin for GenericTunnelPlugin<C> {
|
||||
match codec.decrypt(network, peer, &payload) {
|
||||
Ok(packet) => {
|
||||
rx_state.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
rx_state
|
||||
.rx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
if let Some(ref sink) = sink {
|
||||
sink.deliver(network, peer, packet).await;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,21 @@ use windows as transport;
|
||||
#[cfg(any(unix, windows))]
|
||||
pub use transport::ControlSocket;
|
||||
|
||||
/// Who may open the local control socket.
|
||||
///
|
||||
/// The default is owner-only. A system service shares its socket with a GUI or
|
||||
/// CLI running as another user by granting a group; the agent sets that on the
|
||||
/// socket when it binds it, so the access is correct from creation rather than
|
||||
/// patched afterwards.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ControlSocketAccess {
|
||||
/// Owner only — mode `0600`. The per-user agent's default.
|
||||
Private,
|
||||
/// The owner and members of this group — the socket's group is set to this
|
||||
/// gid and the mode to `0660`. The caller resolves a group name to its gid.
|
||||
Group(u32),
|
||||
}
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -58,6 +73,31 @@ use crate::error::{Error, Result};
|
||||
/// Largest accepted local control message.
|
||||
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
||||
|
||||
/// A string the control protocol must carry but that must never appear in a
|
||||
/// log: it serialises transparently (the text crosses the socket intact) but
|
||||
/// its `Debug` is redacted. Used for a network secret handed back to a local
|
||||
/// client that asked to copy it.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RedactedText(String);
|
||||
|
||||
impl RedactedText {
|
||||
/// Wraps a string.
|
||||
pub fn new(text: impl Into<String>) -> Self {
|
||||
Self(text.into())
|
||||
}
|
||||
|
||||
/// Unwraps to the plain string.
|
||||
pub fn into_string(self) -> String {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RedactedText {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("<redacted>")
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the control socket for a state directory lives.
|
||||
///
|
||||
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
||||
@@ -108,10 +148,17 @@ pub fn default_control_socket_path() -> PathBuf {
|
||||
}
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
{
|
||||
// Prefer a running system service's socket when it is present, so a
|
||||
// GUI or CLI reaches the packaged daemon with no configuration; fall
|
||||
// back to the per-user location otherwise.
|
||||
let system = PathBuf::from("/run/tsunagi/agent.sock");
|
||||
if system.exists() {
|
||||
return system;
|
||||
}
|
||||
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
||||
control_socket_path(&paths.state_dir)
|
||||
} else {
|
||||
PathBuf::from("/run/tsunagi/agent.sock")
|
||||
system
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +227,13 @@ pub enum Request {
|
||||
#[serde(default)]
|
||||
broadcast: Option<bool>,
|
||||
},
|
||||
/// Ask for a configured network's shared secret, in its `tsn1…` text form,
|
||||
/// so a local GUI or script can copy it to re-share. The reply travels only
|
||||
/// over the owner/group socket and the secret is never logged.
|
||||
Secret {
|
||||
/// Which network, by the id text `status` prints.
|
||||
network_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Request {
|
||||
@@ -200,6 +254,7 @@ impl std::fmt::Debug for Request {
|
||||
write!(f, "Dns {{ enable: {enable}, port: {port:?} }}")
|
||||
}
|
||||
Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: <redacted> }}"),
|
||||
Request::Secret { network_id } => write!(f, "Secret {{ {network_id} }}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +279,8 @@ pub enum Response {
|
||||
///
|
||||
/// `None` means it is not serving at all.
|
||||
Dns(Option<DnsReport>),
|
||||
/// A network's shared secret, redacted in `Debug`.
|
||||
Secret(RedactedText),
|
||||
/// The request could not be served.
|
||||
Error(String),
|
||||
}
|
||||
@@ -457,6 +514,10 @@ pub struct OverlayPeerReport {
|
||||
pub tx_packets: u64,
|
||||
/// Packets decrypted from this peer.
|
||||
pub rx_packets: u64,
|
||||
/// Plaintext bytes encrypted and sent to this peer.
|
||||
pub tx_bytes: u64,
|
||||
/// Plaintext bytes decrypted from this peer.
|
||||
pub rx_bytes: u64,
|
||||
/// Data packets dropped: wrong source address, or too large for the path.
|
||||
pub dropped: u64,
|
||||
/// WireGuard protocol errors.
|
||||
@@ -560,6 +621,17 @@ pub trait ReportSource: Send + Sync + 'static {
|
||||
) -> BoxFuture<'_, std::result::Result<Option<DnsReport>, String>> {
|
||||
Box::pin(async move { Err("this agent cannot serve DNS".to_string()) })
|
||||
}
|
||||
|
||||
/// Returns a configured network's shared secret in its `tsn1…` text form.
|
||||
///
|
||||
/// Defaulted to a refusal, like the others. An implementor reads it from
|
||||
/// local state; it must never be logged.
|
||||
fn network_secret(
|
||||
&self,
|
||||
_network_id: String,
|
||||
) -> BoxFuture<'_, std::result::Result<String, String>> {
|
||||
Box::pin(async move { Err("this agent cannot read secrets".to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> ReportSource for F
|
||||
@@ -590,7 +662,7 @@ pub const EXCHANGE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 15]);
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 17]);
|
||||
|
||||
/// Reads one request off an accepted stream, answers it, writes the response.
|
||||
///
|
||||
@@ -648,6 +720,10 @@ where
|
||||
Ok(report) => Response::Dns(report),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
Request::Secret { network_id } => match source.network_secret(network_id).await {
|
||||
Ok(secret) => Response::Secret(RedactedText::new(secret)),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
};
|
||||
write_message(&mut stream, &response).await
|
||||
}
|
||||
@@ -802,6 +878,21 @@ pub async fn set_dns(
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent for a configured network's shared secret, in its
|
||||
/// `tsn1…` text form, so a local client can copy it. The secret is never
|
||||
/// logged by this path.
|
||||
pub async fn network_secret(path: impl AsRef<Path>, network_id: &str) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
let request = Request::Secret {
|
||||
network_id: network_id.to_string(),
|
||||
};
|
||||
match exchange(path, &request, EXCHANGE_TIMEOUT).await? {
|
||||
Response::Secret(secret) => Ok(secret.into_string()),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn write_message<S, T>(stream: &mut S, value: &T) -> Result<()>
|
||||
where
|
||||
S: AsyncWrite + Unpin,
|
||||
|
||||
@@ -22,8 +22,8 @@ use crate::error::{Error, Result};
|
||||
// The transport-agnostic surface, re-exported so this module is a complete
|
||||
// view of the local control interface on its own.
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
@@ -39,11 +39,15 @@ impl ControlSocket {
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)?;
|
||||
create_parent(parent, &access)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
@@ -61,7 +65,7 @@ impl ControlSocket {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict(&path)?;
|
||||
apply_access(&path, &access)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
@@ -94,9 +98,48 @@ impl Drop for ControlSocket {
|
||||
}
|
||||
}
|
||||
|
||||
fn restrict(path: &Path) -> Result<()> {
|
||||
/// Creates the socket's parent directory.
|
||||
///
|
||||
/// A group-shared socket must sit in a directory the group can traverse, so it
|
||||
/// is `0755`; a private socket keeps the owner-only state-directory default.
|
||||
fn create_parent(parent: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => crate::storage::create_dir(parent),
|
||||
ControlSocketAccess::Group(_) => {
|
||||
std::fs::create_dir_all(parent).map_err(|source| Error::Io {
|
||||
path: parent.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
set_mode(parent, 0o755)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the access policy to the bound socket.
|
||||
///
|
||||
/// Private is owner-only `0600`. Group sets the socket's group to the gid and
|
||||
/// the mode to `0660`; if the group cannot be set — the agent is not a member,
|
||||
/// say — it falls back to owner-only rather than leaving the group wrong, so
|
||||
/// the socket is never more open than intended.
|
||||
fn apply_access(path: &Path, access: &ControlSocketAccess) -> Result<()> {
|
||||
match access {
|
||||
ControlSocketAccess::Private => set_mode(path, 0o600),
|
||||
ControlSocketAccess::Group(gid) => {
|
||||
if let Err(source) = std::os::unix::fs::chown(path, None, Some(*gid)) {
|
||||
tracing::warn!(
|
||||
path = %path.display(), gid, %source,
|
||||
"cannot set the control socket group; keeping it owner-only"
|
||||
);
|
||||
return set_mode(path, 0o600);
|
||||
}
|
||||
set_mode(path, 0o660)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_mode(path: &Path, mode: u32) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
@@ -170,7 +213,9 @@ mod tests {
|
||||
let source: Arc<dyn ReportSource> = Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
let control = ControlSocket::bind(&path, source).await.unwrap();
|
||||
let control = ControlSocket::bind(&path, source, ControlSocketAccess::Private)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let answer = exchange(&path, &Request::Status, EXCHANGE_TIMEOUT)
|
||||
.await
|
||||
|
||||
@@ -40,8 +40,8 @@ use tokio::task::JoinHandle;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
pub use super::{
|
||||
CONTROL_PROTOCOL, EXCHANGE_TIMEOUT, ReportSource, is_serving, join_network, leave_network,
|
||||
request_status, set_active, set_dns, set_hostname,
|
||||
CONTROL_PROTOCOL, ControlSocketAccess, EXCHANGE_TIMEOUT, ReportSource, is_serving,
|
||||
join_network, leave_network, request_status, set_active, set_dns, set_hostname,
|
||||
};
|
||||
|
||||
/// `ERROR_ACCESS_DENIED`: creating a first instance finds an existing pipe,
|
||||
@@ -149,7 +149,16 @@ impl ControlSocket {
|
||||
/// Claiming the first instance of the name is what detects a second agent:
|
||||
/// if the name already exists, the create is refused and that is reported
|
||||
/// as the state being locked, so two live agents never share one pipe.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
///
|
||||
/// `_access` is accepted for parity with the Unix socket; granting a group
|
||||
/// here means a pipe security descriptor and is a separate follow-up, so for
|
||||
/// now the pipe keeps its default (creator plus the built-in access the
|
||||
/// `PipeSecurityAttributes` set).
|
||||
pub async fn bind(
|
||||
path: impl AsRef<Path>,
|
||||
source: Arc<dyn ReportSource>,
|
||||
_access: ControlSocketAccess,
|
||||
) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let name = pipe_name(&path);
|
||||
|
||||
|
||||
Vendored
+34
-14
@@ -1,13 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble an Arch Linux package (.pkg.tar.zst) from a prebuilt static tsunagi
|
||||
# binary, using only bsdtar + zstd on a non-Arch host — no makepkg, no Arch
|
||||
# container. The binary is static, so the package has no dependencies.
|
||||
# Assemble an Arch Linux package (.pkg.tar.zst) from prebuilt tsunagi binaries,
|
||||
# using only bsdtar + zstd on a non-Arch host — no makepkg, no Arch container.
|
||||
#
|
||||
# Usage: build-pkg.sh <binary> <arch> <version> <output.pkg.tar.zst>
|
||||
# <binary> path to the built tsunagi executable
|
||||
# <arch> pacman architecture: x86_64 or aarch64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the package to
|
||||
# Usage: build-pkg.sh <binary> <arch> <version> <output.pkg.tar.zst> [tray_binary]
|
||||
# <binary> path to the built tsunagi (agent/CLI) executable
|
||||
# <arch> pacman architecture: x86_64 or aarch64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the package to
|
||||
# [tray_binary] optional tsunagi-tray GUI executable; when given, the package
|
||||
# also installs the tray and a desktop launcher and is named
|
||||
# tsunagi-gui (conflicting with the headless tsunagi).
|
||||
#
|
||||
# Needs bsdtar (libarchive-tools) and zstd on the builder.
|
||||
set -euo pipefail
|
||||
@@ -16,6 +18,7 @@ binary="$1"
|
||||
arch="$2"
|
||||
version="$3"
|
||||
output="$4"
|
||||
tray="${5:-}"
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(mktemp -d)"
|
||||
@@ -40,31 +43,48 @@ install -D -m 0644 "$here/../linux/50-tsunagi-resolved.rules" \
|
||||
install -D -m 0644 "$here/../../README.md" "$root/usr/share/doc/tsunagi/README.md"
|
||||
install -D -m 0644 "$here/../linux/README.md" "$root/usr/share/doc/tsunagi/INSTALL.md"
|
||||
|
||||
pkgname=tsunagi
|
||||
pkgdesc="Serverless private mesh networking agent"
|
||||
relations=""
|
||||
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'
|
||||
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"
|
||||
install -D -m 0644 "$here/../linux/tsunagi.svg" \
|
||||
"$root/usr/share/icons/hicolor/scalable/apps/tsunagi.svg"
|
||||
fi
|
||||
|
||||
# Installed size (payload only), before the metadata files are written.
|
||||
size="$(du -sb "$root" | cut -f1)"
|
||||
|
||||
cat > "$root/.PKGINFO" <<PKGINFO
|
||||
pkgname = tsunagi
|
||||
pkgname = ${pkgname}
|
||||
pkgver = ${pkgver}-${pkgrel}
|
||||
pkgdesc = Serverless private mesh networking agent
|
||||
pkgdesc = ${pkgdesc}
|
||||
url = https://github.com/Ultradesu/tsunagi
|
||||
builddate = $(date +%s)
|
||||
packager = AB <ab@hexor.cy>
|
||||
size = ${size}
|
||||
arch = ${arch}
|
||||
license = MIT
|
||||
${relations}license = MIT
|
||||
license = Apache
|
||||
PKGINFO
|
||||
|
||||
# Scriptlet: create the dedicated system user the service runs as, and keep
|
||||
# systemd in step. The "tsunagi" user is in the "tsunagi" group, which the
|
||||
# polkit rule grants the resolver actions.
|
||||
# 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).
|
||||
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
|
||||
}
|
||||
post_upgrade() {
|
||||
post_install
|
||||
|
||||
Vendored
+43
-13
@@ -1,19 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# Assemble a dependency-free Debian package from a prebuilt static tsunagi
|
||||
# binary, using only dpkg-deb (no cargo-deb, no nfpm). The binary is statically
|
||||
# linked, so the package declares no Depends and runs on any Debian/Ubuntu.
|
||||
# Assemble a dependency-free Debian package from prebuilt tsunagi binaries,
|
||||
# using only dpkg-deb (no cargo-deb, no nfpm).
|
||||
#
|
||||
# Usage: build-deb.sh <binary> <deb_arch> <version> <output.deb>
|
||||
# <binary> path to the built tsunagi executable
|
||||
# <deb_arch> Debian architecture: amd64 or arm64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the .deb to
|
||||
# Usage: build-deb.sh <binary> <deb_arch> <version> <output.deb> [tray_binary]
|
||||
# <binary> path to the built tsunagi (agent/CLI) executable
|
||||
# <deb_arch> Debian architecture: amd64 or arm64
|
||||
# <version> package version (the tag without its leading v)
|
||||
# <output> path to write the .deb to
|
||||
# [tray_binary] optional path to the tsunagi-tray GUI executable; when given,
|
||||
# the package also installs the tray and a desktop launcher and
|
||||
# is named tsunagi-gui (conflicting with the headless tsunagi).
|
||||
set -euo pipefail
|
||||
|
||||
binary="$1"
|
||||
arch="$2"
|
||||
version="$3"
|
||||
output="$4"
|
||||
tray="${5:-}"
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(mktemp -d)"
|
||||
@@ -34,22 +37,38 @@ install -D -m 0644 "$here/../linux/50-tsunagi-resolved.rules" \
|
||||
install -D -m 0644 "$here/../../README.md" "$root/usr/share/doc/tsunagi/README.md"
|
||||
install -D -m 0644 "$here/../linux/README.md" "$root/usr/share/doc/tsunagi/INSTALL.md"
|
||||
|
||||
pkgname=tsunagi
|
||||
extra_control=""
|
||||
desc_gui=""
|
||||
if [ -n "$tray" ]; then
|
||||
pkgname=tsunagi-gui
|
||||
# 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'
|
||||
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" \
|
||||
"$root/usr/share/applications/tsunagi-tray.desktop"
|
||||
install -D -m 0644 "$here/../linux/tsunagi.svg" \
|
||||
"$root/usr/share/icons/hicolor/scalable/apps/tsunagi.svg"
|
||||
fi
|
||||
|
||||
mkdir -p "$root/DEBIAN"
|
||||
|
||||
cat > "$root/DEBIAN/control" <<CTRL
|
||||
Package: tsunagi
|
||||
Package: ${pkgname}
|
||||
Version: ${version}
|
||||
Architecture: ${arch}
|
||||
Maintainer: AB <ab@hexor.cy>
|
||||
Section: net
|
||||
Priority: optional
|
||||
Homepage: https://github.com/Ultradesu/tsunagi
|
||||
Description: Serverless private mesh networking agent
|
||||
${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,
|
||||
dependency-free binary, a systemd service that runs the agent as a dedicated
|
||||
dependency-free agent, a systemd service that runs it as a dedicated
|
||||
unprivileged "tsunagi" user with only CAP_NET_ADMIN and CAP_NET_BIND_SERVICE,
|
||||
and a polkit rule for the local DNS resolver.
|
||||
and a polkit rule for the local DNS resolver.${desc_gui}
|
||||
CTRL
|
||||
|
||||
# The dedicated system user the service runs as. It is in the "tsunagi" group,
|
||||
@@ -68,9 +87,20 @@ fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
POSTINST
|
||||
|
||||
if [ -n "$tray" ]; then
|
||||
cat >> "$root/DEBIAN/postinst" <<'POSTINST_GUI'
|
||||
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||
update-desktop-database -q /usr/share/applications >/dev/null 2>&1 || true
|
||||
fi
|
||||
if command -v gtk-update-icon-cache >/dev/null 2>&1; then
|
||||
gtk-update-icon-cache -q -t /usr/share/icons/hicolor >/dev/null 2>&1 || true
|
||||
fi
|
||||
POSTINST_GUI
|
||||
fi
|
||||
echo "exit 0" >> "$root/DEBIAN/postinst"
|
||||
|
||||
cat > "$root/DEBIAN/prerm" <<'PRERM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=tsunagi
|
||||
GenericName=Mesh network manager
|
||||
Comment=Manage tsunagi networks from the tray
|
||||
Exec=tsunagi-tray
|
||||
Icon=tsunagi
|
||||
Terminal=false
|
||||
Categories=Network;Utility;
|
||||
Keywords=mesh;vpn;overlay;network;
|
||||
StartupNotify=false
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
|
||||
<circle cx="64" cy="64" r="56" fill="#2f80d8"/>
|
||||
<circle cx="64" cy="64" r="30" fill="none" stroke="#ffffff" stroke-width="7" opacity="0.9"/>
|
||||
<circle cx="64" cy="64" r="9" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 328 B |
Reference in new issue
Block a user