Compare commits
23
Commits
No files matched your search
@@ -33,11 +33,18 @@ jobs:
|
||||
asset: tsunagi-linux-x86_64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
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
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
pkg_arch: aarch64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
@@ -63,9 +70,9 @@ jobs:
|
||||
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
|
||||
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
|
||||
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
|
||||
- name: Install musl build tooling
|
||||
- name: Install musl build and packaging tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev libarchive-tools zstd
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
@@ -96,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 libxdo-dev
|
||||
cargo build --release --locked \
|
||||
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
|
||||
else
|
||||
echo "no GUI build for ${{ matrix.target }}"
|
||||
fi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -109,12 +140,24 @@ 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/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh "$stage/"
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh \
|
||||
dist/linux/50-tsunagi-resolved.rules "$stage/"
|
||||
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
macOS)
|
||||
@@ -133,13 +176,67 @@ jobs:
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
|
||||
|
||||
# A dependency-free Arch package (.pkg.tar.zst), assembled with bsdtar and
|
||||
# zstd (no makepkg); same binary, service and polkit rule. See
|
||||
# dist/arch/build-pkg.sh.
|
||||
- name: Build the Arch package
|
||||
if: matrix.pkg_arch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" \
|
||||
"$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 show up under the run and hands it to `publish`.
|
||||
- name: Upload the archive as a run artifact
|
||||
# what makes the archive (and the .deb, where built) show up under the run
|
||||
# and hands it to `publish`.
|
||||
- name: Upload the build outputs as run artifacts
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: ${{ matrix.asset }}-*.*
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
@@ -211,5 +308,5 @@ jobs:
|
||||
cat up.json >&2 || true
|
||||
rc=1
|
||||
fi
|
||||
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' \) -print0)
|
||||
done < <(find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.deb' -o -name '*.pkg.tar.zst' \) -print0)
|
||||
exit "$rc"
|
||||
@@ -0,0 +1,284 @@
|
||||
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: 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 (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*"]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: "10"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: ${{ matrix.asset }}
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
asset: tsunagi-linux-x86_64
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
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
|
||||
archive: tar.gz
|
||||
zig: true
|
||||
deb: true
|
||||
deb_arch: arm64
|
||||
pkg_arch: aarch64
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
asset: tsunagi-macos-aarch64
|
||||
archive: tar.gz
|
||||
zig: false
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
asset: tsunagi-windows-x86_64
|
||||
archive: zip
|
||||
zig: false
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
# aws-lc-sys (iroh's rustls backend) and the bundled sqlite are C, and the
|
||||
# musl targets have no prebuilt bindings, so bindgen needs libclang and the
|
||||
# C build needs cmake. zig is the cross C compiler cargo-zigbuild drives.
|
||||
- name: Install musl build and packaging tooling
|
||||
if: matrix.zig
|
||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends cmake clang libclang-dev libarchive-tools zstd
|
||||
- name: Install zig
|
||||
if: matrix.zig
|
||||
uses: mlugg/setup-zig@v2
|
||||
with:
|
||||
# Pin a real, archived Zig release. Left unpinned the action resolves
|
||||
# to the newest version it knows of (a yet-unreleased 0.16.0-dev),
|
||||
# whose tarball 404s on ziglang.org while the community mirrors flake.
|
||||
version: 0.14.1
|
||||
- name: Install cargo-zigbuild
|
||||
if: matrix.zig
|
||||
uses: taiki-e/install-action@v2
|
||||
with:
|
||||
tool: cargo-zigbuild
|
||||
|
||||
- name: Build
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ matrix.zig }}" == "true" ]]; then
|
||||
# zig's linker ignores the `-O1` GNU-ld optimization flag Rust passes
|
||||
# and says so on stderr, which rustc now surfaces as a linker_messages
|
||||
# warning. It is cosmetic — the binary is correct — so quiet just that
|
||||
# one lint to keep the musl build log clean. A real link error still
|
||||
# fails the build.
|
||||
export RUSTFLAGS="${RUSTFLAGS:-} -A linker_messages"
|
||||
cargo zigbuild --release --locked --target "${{ matrix.target }}" --bin tsunagi
|
||||
else
|
||||
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 libxdo-dev
|
||||
cargo build --release --locked \
|
||||
--target "${{ matrix.gnu_target }}" --bin tsunagi-tray
|
||||
else
|
||||
echo "no GUI build for ${{ matrix.target }}"
|
||||
fi
|
||||
else
|
||||
cargo build --release --locked --target "${{ matrix.target }}" --bin tsunagi-tray
|
||||
fi
|
||||
|
||||
- name: Package
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
stage="dist/pkg/${{ matrix.asset }}"
|
||||
mkdir -p "$stage"
|
||||
|
||||
bin="tsunagi"
|
||||
[[ "${{ 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/"
|
||||
case "${{ runner.os }}" in
|
||||
Linux)
|
||||
cp dist/linux/tsunagi.service dist/linux/install.sh \
|
||||
dist/linux/50-tsunagi-resolved.rules "$stage/"
|
||||
cp dist/linux/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
macOS)
|
||||
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
|
||||
cp dist/macos/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
Windows)
|
||||
cp dist/windows/README.md "$stage/INSTALL.md"
|
||||
;;
|
||||
esac
|
||||
|
||||
archive="${{ matrix.asset }}-${version}.${{ matrix.archive }}"
|
||||
if [[ "${{ matrix.archive }}" == "zip" ]]; then
|
||||
(cd dist/pkg && 7z a "../../${archive}" "${{ matrix.asset }}")
|
||||
else
|
||||
tar -C dist/pkg -czf "${archive}" "${{ matrix.asset }}"
|
||||
fi
|
||||
|
||||
# A dependency-free .deb for the x86_64 and aarch64 Linux builds: it drops
|
||||
# the static binary, the systemd service and the polkit rule. Built with
|
||||
# dpkg-deb (present on the runner); see dist/deb/build-deb.sh.
|
||||
- name: Build the Debian package
|
||||
if: matrix.deb
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
bash dist/deb/build-deb.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.deb_arch }}" \
|
||||
"$version" \
|
||||
"tsunagi_${version}_${{ matrix.deb_arch }}.deb"
|
||||
|
||||
# A dependency-free Arch package (.pkg.tar.zst), assembled with bsdtar and
|
||||
# zstd (no makepkg); same binary, service and polkit rule. See
|
||||
# dist/arch/build-pkg.sh.
|
||||
- name: Build the Arch package
|
||||
if: matrix.pkg_arch
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="${GITHUB_REF_NAME#v}"
|
||||
pkgver="${version//-/_}"
|
||||
bash dist/arch/build-pkg.sh \
|
||||
"target/${{ matrix.target }}/release/tsunagi" \
|
||||
"${{ matrix.pkg_arch }}" \
|
||||
"$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@v7
|
||||
with:
|
||||
name: ${{ matrix.asset }}
|
||||
path: |
|
||||
${{ matrix.asset }}-*.*
|
||||
*.deb
|
||||
*.pkg.tar.zst
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish release
|
||||
needs: build
|
||||
if: ${{ github.server_url == 'https://github.com' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download every build artifact
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Show what was collected
|
||||
run: find artifacts -type f
|
||||
|
||||
- name: Create the release and upload the archives
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 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[@]}"
|
||||
|
||||
prerelease=()
|
||||
case "$TAG" in *-*) prerelease=(--prerelease) ;; esac
|
||||
|
||||
# Create the release on the first run; on a re-run, add/replace assets
|
||||
# on the release that is already there.
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh release upload "$TAG" "${files[@]}" --repo "$GITHUB_REPOSITORY" --clobber
|
||||
else
|
||||
gh release create "$TAG" "${files[@]}" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "tsunagi $TAG" \
|
||||
--generate-notes \
|
||||
--verify-tag \
|
||||
"${prerelease[@]}"
|
||||
fi
|
||||
Generated
+2740
-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
|
||||
|
||||
+199
-20
@@ -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,10 +793,24 @@ 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();
|
||||
}
|
||||
// When the user did not choose a state directory, prefer a running system
|
||||
// service's socket, so `sudo tsunagi status` reaches the packaged daemon —
|
||||
// which binds there via TSUNAGI_CONTROL_SOCKET — with no flags. Only when it
|
||||
// is actually present, so a per-user agent still uses its own socket.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
if !_is_custom_state_dir {
|
||||
let system = std::path::Path::new("/run/tsunagi/agent.sock");
|
||||
if system.exists() {
|
||||
return system.to_path_buf();
|
||||
}
|
||||
}
|
||||
tsunagi::ipc::control_socket_path(&paths.state_dir)
|
||||
}
|
||||
|
||||
@@ -788,6 +838,33 @@ enum Observed {
|
||||
},
|
||||
}
|
||||
|
||||
/// The directories the running agent uses, unless the user named some.
|
||||
///
|
||||
/// The control socket of a packaged service is found without flags, but the
|
||||
/// state it keeps is not in this user's default directory — reading that
|
||||
/// would report a device that has joined nothing next to an agent that has
|
||||
/// joined a network. The agent knows where its state is, so it is asked.
|
||||
async fn agent_paths(
|
||||
paths: StoragePaths,
|
||||
socket: &std::path::Path,
|
||||
is_custom_state: bool,
|
||||
) -> StoragePaths {
|
||||
if is_custom_state || !tsunagi::ipc::is_serving(socket).await {
|
||||
return paths;
|
||||
}
|
||||
match tsunagi::ipc::request_status(socket).await {
|
||||
Ok(report) if !report.state_dir.is_empty() => {
|
||||
let cache = if report.cache_dir.is_empty() {
|
||||
paths.cache_dir
|
||||
} else {
|
||||
PathBuf::from(report.cache_dir)
|
||||
};
|
||||
StoragePaths::new(report.state_dir, cache)
|
||||
}
|
||||
_ => paths,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the agent, and falls back to the state store.
|
||||
async fn observe(paths: &StoragePaths, socket: &std::path::Path) -> Observed {
|
||||
let socket_present = tsunagi::ipc::is_serving(socket).await;
|
||||
@@ -1324,6 +1401,7 @@ impl tsunagi::ipc::ReportSource for AgentControl {
|
||||
self.plugin.as_deref(),
|
||||
self.tcp_tls.as_deref(),
|
||||
dns,
|
||||
&self.paths,
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -1398,6 +1476,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,
|
||||
@@ -1606,6 +1695,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
let paths = agent_paths(paths, &socket, is_custom_state).await;
|
||||
match args.action {
|
||||
None => show_networks(&paths, &socket).await,
|
||||
Some(NetworkAction::Broadcast { network, choice }) => {
|
||||
@@ -1641,6 +1731,7 @@ async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>>
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
let paths = agent_paths(paths, &socket, is_custom_state).await;
|
||||
let name = NetworkName::new(args.network)?;
|
||||
// A name this device already has means that network; a name nobody
|
||||
// has means a new one, and no secret is needed to make a network with
|
||||
@@ -1800,16 +1891,15 @@ async fn show_networks(
|
||||
use report::{Health, Report, Row, Section};
|
||||
|
||||
let stored = stored_networks(paths);
|
||||
if stored.is_empty() {
|
||||
eprintln!("no network has been joined");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let observed = observe(paths, socket).await;
|
||||
let live = match &observed {
|
||||
Observed::Agent(report) => report.networks.clone(),
|
||||
Observed::Stored { .. } => Vec::new(),
|
||||
};
|
||||
if stored.is_empty() && live.is_empty() {
|
||||
eprintln!("no network has been joined");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut out = Report::new();
|
||||
let mut section = Section::new("networks");
|
||||
@@ -1860,6 +1950,20 @@ async fn show_networks(
|
||||
.with_note(note),
|
||||
);
|
||||
}
|
||||
// What the agent runs but this process could not read from the store,
|
||||
// for instance when the state is not readable by this user.
|
||||
for network in live.iter().filter(|live| {
|
||||
!stored
|
||||
.iter()
|
||||
.any(|n| n.network_id.to_string() == live.network_id)
|
||||
}) {
|
||||
let state = if network.active { "running" } else { "stopped" };
|
||||
section.push(Row::new(
|
||||
Health::Info,
|
||||
network.name.clone(),
|
||||
format!("{} · {state}", network.network_id),
|
||||
));
|
||||
}
|
||||
out.push(section);
|
||||
print_report("tsunagi networks", &out)
|
||||
}
|
||||
@@ -2102,6 +2206,7 @@ async fn dns_command(args: DnsArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
let paths = agent_paths(paths, &socket, is_custom_state).await;
|
||||
|
||||
let enable = match args.action {
|
||||
None => return show_dns(&paths, &socket).await,
|
||||
@@ -2195,6 +2300,7 @@ async fn id(args: IdArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
let paths = agent_paths(paths, &socket, is_custom_state).await;
|
||||
|
||||
match args.action {
|
||||
None => show_identity(&paths, &socket).await,
|
||||
@@ -2387,6 +2493,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let paths = args.paths.resolve()?;
|
||||
let is_custom_state = args.paths.state_dir.is_some();
|
||||
let socket = control_socket(&paths, args.control_socket.as_ref(), is_custom_state);
|
||||
let paths = agent_paths(paths, &socket, is_custom_state).await;
|
||||
let observed = observe(&paths, &socket).await;
|
||||
|
||||
let mut out = Report::new();
|
||||
@@ -2458,7 +2565,7 @@ async fn status(args: StatusArgs) -> Result<(), Box<dyn std::error::Error>> {
|
||||
});
|
||||
}
|
||||
|
||||
out.push(host_section());
|
||||
out.push(host_section(&observed));
|
||||
out.push(addresses_section().await);
|
||||
print_report("tsunagi status", &out)
|
||||
}
|
||||
@@ -2570,10 +2677,8 @@ impl MemberRow<'_> {
|
||||
self.transport.is_some()
|
||||
}
|
||||
|
||||
/// What to call it: the name it announced, or a short form of its id.
|
||||
///
|
||||
/// A member that is away has no hostname, because nothing durable records
|
||||
/// one — only the signed claim survives, and that carries an address.
|
||||
/// What to call it: the name it announced — the last one, when it is
|
||||
/// away — or a short form of its id.
|
||||
fn label(&self) -> String {
|
||||
match self.hostname {
|
||||
Some(hostname) => hostname.to_string(),
|
||||
@@ -2606,10 +2711,11 @@ fn member_rows<'a>(network: &'a tsunagi::ipc::NetworkReport, own_id: &str) -> Ve
|
||||
let row = entry(&mut rows, &member.endpoint_id);
|
||||
row.overlay_address_v4 = member.overlay_address_v4.as_deref();
|
||||
row.failed_dials = member.failed_dials;
|
||||
row.hostname = member.hostname.as_deref();
|
||||
}
|
||||
for peer in &network.peers {
|
||||
let row = entry(&mut rows, &peer.endpoint_id);
|
||||
row.hostname = peer.hostname.as_deref();
|
||||
row.hostname = peer.hostname.as_deref().or(row.hostname);
|
||||
row.transport = Some(&peer.transport);
|
||||
row.rtt_ms = peer.rtt_ms;
|
||||
}
|
||||
@@ -2940,7 +3046,11 @@ fn short(text: &str, len: usize) -> String {
|
||||
}
|
||||
|
||||
/// What this host can and cannot do for the data plane.
|
||||
fn host_section() -> report::Section {
|
||||
///
|
||||
/// The interface is created by the agent, so when one answered, its own
|
||||
/// report of whether it may do that is the answer; what this process holds
|
||||
/// says nothing about it.
|
||||
fn host_section(observed: &Observed) -> report::Section {
|
||||
use report::{Health, Row, Section};
|
||||
|
||||
let mut host = Section::new("host");
|
||||
@@ -2980,8 +3090,20 @@ fn host_section() -> report::Section {
|
||||
});
|
||||
}
|
||||
|
||||
use tsunagi::ipc::PrivilegeReport;
|
||||
use tsunagi::overlay::{Privilege, probe_net_admin};
|
||||
match probe_net_admin() {
|
||||
let (privilege, program) = match observed {
|
||||
Observed::Agent(report) => match &report.privilege {
|
||||
PrivilegeReport::Unknown => (probe_net_admin(), program_path()),
|
||||
PrivilegeReport::Available => (Privilege::Available, report.program.clone()),
|
||||
PrivilegeReport::Missing(reason) => {
|
||||
(Privilege::Missing(reason.clone()), report.program.clone())
|
||||
}
|
||||
PrivilegeReport::Unsupported => (Privilege::Unsupported, report.program.clone()),
|
||||
},
|
||||
Observed::Stored { .. } => (probe_net_admin(), program_path()),
|
||||
};
|
||||
match privilege {
|
||||
Privilege::Available => {
|
||||
let held = if cfg!(target_os = "windows") {
|
||||
"assumes an elevated process; creation reports if not"
|
||||
@@ -3001,10 +3123,7 @@ fn host_section() -> report::Section {
|
||||
// the eye is meant to scan.
|
||||
host.push(
|
||||
Row::new(Health::Degraded, "privileges", "CAP_NET_ADMIN not held").with_note(
|
||||
format!(
|
||||
"sudo setcap cap_net_admin,cap_net_bind_service+p {}",
|
||||
program_path()
|
||||
),
|
||||
format!("sudo setcap cap_net_admin,cap_net_bind_service+p {program}"),
|
||||
),
|
||||
);
|
||||
host.push(Row::new(
|
||||
@@ -3848,7 +3967,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)
|
||||
@@ -3919,6 +4048,7 @@ async fn build_report(
|
||||
wireguard: Option<&WireguardPlugin>,
|
||||
tcp_tls: Option<&TcpTlsPlugin>,
|
||||
dns: Option<DnsState>,
|
||||
paths: &StoragePaths,
|
||||
) -> tsunagi::ipc::StatusReport {
|
||||
use tsunagi::ipc::{
|
||||
MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport,
|
||||
@@ -4018,6 +4148,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,
|
||||
@@ -4036,6 +4168,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(),
|
||||
@@ -4054,6 +4188,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,
|
||||
@@ -4071,6 +4207,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(),
|
||||
@@ -4087,6 +4225,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(),
|
||||
@@ -4169,6 +4309,10 @@ async fn build_report(
|
||||
.iter()
|
||||
.find(|candidate| candidate.endpoint_id == member.endpoint_id)
|
||||
.map_or(0, |candidate| candidate.consecutive_failures),
|
||||
hostname: member
|
||||
.last_hostname
|
||||
.clone()
|
||||
.or_else(|| member.hostname.clone()),
|
||||
})
|
||||
.collect(),
|
||||
range: network.range.map(|range| range.to_string()),
|
||||
@@ -4195,6 +4339,16 @@ async fn build_report(
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns,
|
||||
state_dir: paths.state_dir.display().to_string(),
|
||||
cache_dir: paths.cache_dir.display().to_string(),
|
||||
program: program_path(),
|
||||
privilege: match tsunagi::overlay::probe_net_admin() {
|
||||
tsunagi::overlay::Privilege::Available => tsunagi::ipc::PrivilegeReport::Available,
|
||||
tsunagi::overlay::Privilege::Missing(reason) => {
|
||||
tsunagi::ipc::PrivilegeReport::Missing(reason)
|
||||
}
|
||||
tsunagi::overlay::Privilege::Unsupported => tsunagi::ipc::PrivilegeReport::Unsupported,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4375,7 +4529,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}"
|
||||
@@ -4491,11 +4650,13 @@ mod status_tests {
|
||||
endpoint_id: OWN.into(),
|
||||
overlay_address_v4: Some("10.13.37.69".into()),
|
||||
failed_dials: 0,
|
||||
hostname: None,
|
||||
},
|
||||
MemberReport {
|
||||
endpoint_id: ONLINE.into(),
|
||||
overlay_address_v4: Some("10.13.37.237".into()),
|
||||
failed_dials: 0,
|
||||
hostname: None,
|
||||
},
|
||||
],
|
||||
range: Some("10.13.37.0/24".into()),
|
||||
@@ -4736,6 +4897,7 @@ mod status_tests {
|
||||
endpoint_id: AWAY.into(),
|
||||
overlay_address_v4: Some("10.13.37.99".into()),
|
||||
failed_dials: 9,
|
||||
hostname: None,
|
||||
});
|
||||
|
||||
let rows = member_rows(&network, OWN);
|
||||
@@ -4745,6 +4907,22 @@ mod status_tests {
|
||||
assert_eq!(rows[1].endpoint_id, AWAY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offline_member_keeps_the_last_name_it_announced() {
|
||||
let mut network = network_after_a_peer_returned();
|
||||
network.members.push(MemberReport {
|
||||
endpoint_id: AWAY.into(),
|
||||
overlay_address_v4: Some("10.13.37.99".into()),
|
||||
failed_dials: 0,
|
||||
hostname: Some("laptop".into()),
|
||||
});
|
||||
|
||||
let rows = member_rows(&network, OWN);
|
||||
let away = rows.iter().find(|row| row.endpoint_id == AWAY).unwrap();
|
||||
assert!(!away.online());
|
||||
assert_eq!(away.label(), "laptop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_that_is_away_is_stated_rather_than_flagged() {
|
||||
// In a mesh of laptops a member being away is the ordinary
|
||||
@@ -4756,6 +4934,7 @@ mod status_tests {
|
||||
endpoint_id: AWAY.into(),
|
||||
overlay_address_v4: Some("10.13.37.99".into()),
|
||||
failed_dials: 9,
|
||||
hostname: None,
|
||||
});
|
||||
|
||||
let mut out = report::Report::new();
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
[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"
|
||||
|
||||
# Linux only: tray-icon (already GTK-based there) needs the app to initialise
|
||||
# GTK and run its main loop.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
|
||||
# Windows and macOS: the tray process has no GUI toolkit, only an event loop to
|
||||
# serve the icon (and, on macOS, the Accessory activation policy that keeps it
|
||||
# out of the Dock). Pinned to eframe's winit so the types unify.
|
||||
[target.'cfg(any(windows, target_os = "macos"))'.dependencies]
|
||||
winit = "0.30"
|
||||
|
||||
[lints]
|
||||
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,106 @@
|
||||
//! The eframe application behind the window: the main view, the per-network
|
||||
//! devices windows, and the agent worker.
|
||||
//!
|
||||
//! It runs in its own process, started by the tray when the user chooses Open
|
||||
//! and gone when the window is closed. The tray, which has to outlive the
|
||||
//! window, lives in the other process; see [`crate::tray`].
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use eframe::egui;
|
||||
|
||||
use crate::agent::{AgentClient, resolve_socket};
|
||||
use crate::devices;
|
||||
use crate::stats::Traffic;
|
||||
use crate::ui::{self, UiState};
|
||||
|
||||
/// The window's state.
|
||||
pub(crate) struct App {
|
||||
agent: AgentClient,
|
||||
state: UiState,
|
||||
/// Traffic rates derived from successive snapshots.
|
||||
traffic: Traffic,
|
||||
/// The snapshot generation last folded into `traffic`.
|
||||
sampled: u64,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Builds the app: resolves the socket and starts the worker.
|
||||
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, resolve_socket());
|
||||
Ok(Self {
|
||||
agent,
|
||||
state: UiState::default(),
|
||||
traffic: Traffic::default(),
|
||||
sampled: 0,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for App {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
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 is 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, &report.endpoint_id, traffic);
|
||||
!vctx.input(|i| i.viewport().close_requested())
|
||||
});
|
||||
if !keep {
|
||||
self.state.open_devices.remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
//! 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,
|
||||
/// The last hostname seen for it, kept while it is offline.
|
||||
hostname: Option<&'a str>,
|
||||
/// What clicking the hostname copies: `hostname.network`, so it resolves.
|
||||
copy: Option<String>,
|
||||
/// The full endpoint id, which clicking the short form copies.
|
||||
id: &'a str,
|
||||
proto: &'a str,
|
||||
address: Option<String>,
|
||||
handshake_secs: Option<u64>,
|
||||
overlay: Option<&'a OverlayPeerReport>,
|
||||
}
|
||||
|
||||
impl Device<'_> {
|
||||
fn sort_key(&self) -> String {
|
||||
self.hostname.unwrap_or(self.id).to_lowercase()
|
||||
}
|
||||
}
|
||||
|
||||
/// The first characters of an endpoint id, enough to tell devices apart.
|
||||
fn short_id(id: &str) -> String {
|
||||
id.chars().take(5).collect()
|
||||
}
|
||||
|
||||
/// The device cell: the hostname and, in brackets, the start of the id.
|
||||
/// Clicking either copies its own full value.
|
||||
fn name_cell(ui: &mut egui::Ui, device: &Device<'_>) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
match (device.hostname, &device.copy) {
|
||||
(Some(hostname), Some(copy)) => {
|
||||
format::copy_field(ui, hostname, copy);
|
||||
format::copy_field(ui, &format!("({})", short_id(device.id)), device.id);
|
||||
}
|
||||
_ => format::copy_field(ui, &short_id(device.id), device.id),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Draws the window body into its viewport.
|
||||
pub(crate) fn show(
|
||||
ctx: &egui::Context,
|
||||
_agent: &AgentClient,
|
||||
unit: &mut Unit,
|
||||
network: &NetworkReport,
|
||||
own_id: &str,
|
||||
traffic: &Traffic,
|
||||
) {
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
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, own_id);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
/// Every other device this network knows of, from a live tunnel or the signed
|
||||
/// membership. This device is in the roster too but is not one of the others.
|
||||
fn known_ids<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<&'a str> {
|
||||
let overlay_peers = network
|
||||
.overlay
|
||||
.as_ref()
|
||||
.map(|o| o.peers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
let mut ids: Vec<&str> = Vec::new();
|
||||
let candidates = overlay_peers
|
||||
.iter()
|
||||
.map(|p| p.endpoint_id.as_str())
|
||||
.chain(network.peers.iter().map(|p| p.endpoint_id.as_str()))
|
||||
.chain(network.members.iter().map(|m| m.endpoint_id.as_str()));
|
||||
for id in candidates {
|
||||
if id != own_id && !ids.contains(&id) {
|
||||
ids.push(id);
|
||||
}
|
||||
}
|
||||
ids
|
||||
}
|
||||
|
||||
/// How many other devices this network knows of, connected or not.
|
||||
pub(crate) fn known_count(network: &NetworkReport, own_id: &str) -> usize {
|
||||
known_ids(network, own_id).len()
|
||||
}
|
||||
|
||||
/// Builds the device list: every endpoint known from a live tunnel or the
|
||||
/// signed membership, online ones first.
|
||||
fn devices<'a>(network: &'a NetworkReport, own_id: &str) -> Vec<Device<'a>> {
|
||||
// The signed members carry the last name seen, so a device that is away
|
||||
// is still called by it; a live announcement is fresher and wins.
|
||||
let mut hostnames: HashMap<&str, &str> = network
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|m| m.hostname.as_deref().map(|h| (m.endpoint_id.as_str(), h)))
|
||||
.collect();
|
||||
hostnames.extend(
|
||||
network
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|p| p.hostname.as_deref().map(|h| (p.endpoint_id.as_str(), h))),
|
||||
);
|
||||
let overlay_peers = network
|
||||
.overlay
|
||||
.as_ref()
|
||||
.map(|o| o.peers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
|
||||
let ids = known_ids(network, own_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();
|
||||
Device {
|
||||
online,
|
||||
hostname,
|
||||
copy: hostname.map(|h| format!("{h}.{}", network.name)),
|
||||
id,
|
||||
proto: overlay.map_or("—", |p| p.protocol.as_str()),
|
||||
address: overlay
|
||||
.and_then(|p| p.address.clone())
|
||||
.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.sort_key().cmp(&b.sort_key()))
|
||||
});
|
||||
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 {
|
||||
name_cell(ui, device);
|
||||
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,73 @@
|
||||
//! Tray application for managing a running tsunagi agent.
|
||||
//!
|
||||
//! A tray icon and menu (Open, Quit) that opens a small window (networks, their
|
||||
//! peers and addresses, join/leave, per-network active and broadcast switches).
|
||||
//! There is no window until Open, and closing it leaves the icon. The window
|
||||
//! is a thin client over the agent's local control socket — the same interface
|
||||
//! the CLI uses.
|
||||
|
||||
// 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;
|
||||
|
||||
/// The argument that makes this binary the window rather than the tray.
|
||||
const WINDOW_ARG: &str = "--window";
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn"));
|
||||
let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
|
||||
|
||||
// Two processes from one binary. The tray is what stays; the window is
|
||||
// started by it and ends when it is closed. A window that merely hides is
|
||||
// not something every platform can do — Wayland has no such thing — and a
|
||||
// hidden one that stays is worse than none.
|
||||
if std::env::args().any(|arg| arg == WINDOW_ARG) {
|
||||
window()
|
||||
} else {
|
||||
tray::run()
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the window until it is closed.
|
||||
fn window() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.build()?;
|
||||
let handle = runtime.handle().clone();
|
||||
|
||||
let options = eframe::NativeOptions {
|
||||
// A fixed, small window; the whole layout targets this size.
|
||||
viewport: egui::ViewportBuilder::default()
|
||||
.with_title("tsunagi")
|
||||
.with_app_id("tsunagi")
|
||||
.with_inner_size([360.0, 600.0])
|
||||
.with_resizable(false),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
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; 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,190 @@
|
||||
//! The tray: the icon, its menu, and starting the window.
|
||||
//!
|
||||
//! This is the process that stays. It holds no window and no GUI toolkit of
|
||||
//! its own; choosing Open starts this same binary again as the window, which
|
||||
//! is a perfectly ordinary program that ends when it is closed.
|
||||
|
||||
use std::process::{Child, Command};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use tray_icon::menu::{Menu, MenuEvent, MenuId, MenuItem, PredefinedMenuItem};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
/// The window process, when there is one.
|
||||
struct Window {
|
||||
child: Mutex<Option<Child>>,
|
||||
}
|
||||
|
||||
impl Window {
|
||||
/// Starts the window unless one is already open.
|
||||
///
|
||||
/// Raising an existing one is not something a process can ask for on
|
||||
/// every platform, so choosing Open while it is open does nothing.
|
||||
fn open(&self) {
|
||||
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(child) = slot.as_mut() {
|
||||
match child.try_wait() {
|
||||
Ok(None) => return,
|
||||
_ => *slot = None,
|
||||
}
|
||||
}
|
||||
let started = std::env::current_exe()
|
||||
.map_err(|err| err.to_string())
|
||||
.and_then(|exe| {
|
||||
Command::new(exe)
|
||||
.arg(crate::WINDOW_ARG)
|
||||
.spawn()
|
||||
.map_err(|err| err.to_string())
|
||||
});
|
||||
match started {
|
||||
Ok(child) => *slot = Some(child),
|
||||
Err(err) => eprintln!("cannot open the window: {err}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the window if it is open.
|
||||
fn close(&self) {
|
||||
let mut slot = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(mut child) = slot.take() {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A built tray icon and the ids of its menu items.
|
||||
struct Built {
|
||||
icon: TrayIcon,
|
||||
open: MenuId,
|
||||
quit: MenuId,
|
||||
}
|
||||
|
||||
fn build() -> Result<Built, String> {
|
||||
let open = MenuItem::new("Open tsunagi", true, None);
|
||||
let quit = MenuItem::new("Quit", true, None);
|
||||
let menu = Menu::new();
|
||||
menu.append(&open).map_err(|err| err.to_string())?;
|
||||
menu.append(&PredefinedMenuItem::separator())
|
||||
.map_err(|err| err.to_string())?;
|
||||
menu.append(&quit).map_err(|err| err.to_string())?;
|
||||
|
||||
let icon = TrayIconBuilder::new()
|
||||
.with_tooltip("tsunagi")
|
||||
.with_menu(Box::new(menu))
|
||||
.with_icon(make_icon()?)
|
||||
.build()
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(Built {
|
||||
icon,
|
||||
open: open.id().clone(),
|
||||
quit: quit.id().clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Acts on menu choices as the platform's loop delivers them.
|
||||
fn handle_menu(built: &Built, window: Arc<Window>) {
|
||||
let (open, quit) = (built.open.clone(), built.quit.clone());
|
||||
MenuEvent::set_event_handler(Some(move |event: MenuEvent| {
|
||||
if event.id == open {
|
||||
window.open();
|
||||
} else if event.id == quit {
|
||||
window.close();
|
||||
std::process::exit(0);
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/// Shows the icon and runs until Quit.
|
||||
///
|
||||
/// Linux: the tray needs GTK, which nothing else here initialises, so this
|
||||
/// thread does and runs its main loop.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
gtk::init().map_err(|err| format!("cannot initialise GTK: {err}"))?;
|
||||
let built = build()?;
|
||||
handle_menu(
|
||||
&built,
|
||||
Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
);
|
||||
gtk::main();
|
||||
drop(built.icon);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shows the icon and runs until Quit.
|
||||
///
|
||||
/// Windows and macOS: the icon needs an event loop on the main thread to be
|
||||
/// served, and macOS wants it created once that loop is running. There are no
|
||||
/// windows in it; it exists to be pumped.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
use winit::application::ApplicationHandler;
|
||||
use winit::event::WindowEvent;
|
||||
use winit::event_loop::{ActiveEventLoop, EventLoop};
|
||||
use winit::window::WindowId;
|
||||
|
||||
struct Pump {
|
||||
window: Arc<Window>,
|
||||
icon: Option<TrayIcon>,
|
||||
}
|
||||
|
||||
impl ApplicationHandler for Pump {
|
||||
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
|
||||
if self.icon.is_some() {
|
||||
return;
|
||||
}
|
||||
match build() {
|
||||
Ok(built) => {
|
||||
handle_menu(&built, Arc::clone(&self.window));
|
||||
self.icon = Some(built.icon);
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("cannot create the tray icon: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
|
||||
}
|
||||
|
||||
let mut builder = EventLoop::builder();
|
||||
// macOS: live in the menu bar with no Dock icon.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use winit::platform::macos::{ActivationPolicy, EventLoopBuilderExtMacOS};
|
||||
builder.with_activation_policy(ActivationPolicy::Accessory);
|
||||
}
|
||||
let event_loop = builder.build()?;
|
||||
let mut pump = Pump {
|
||||
window: Arc::new(Window {
|
||||
child: Mutex::new(None),
|
||||
}),
|
||||
icon: None,
|
||||
};
|
||||
event_loop.run_app(&mut pump)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A small round icon, drawn in code so there is no asset to ship yet.
|
||||
fn make_icon() -> Result<Icon, String> {
|
||||
const SIZE: u32 = 32;
|
||||
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,322 @@
|
||||
//! 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::devices;
|
||||
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, &report.endpoint_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 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,
|
||||
own_id: &str,
|
||||
) {
|
||||
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(),
|
||||
devices::known_count(network, own_id)
|
||||
));
|
||||
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};
|
||||
@@ -83,6 +83,7 @@ fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::R
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns: None,
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -130,9 +131,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 +198,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 +326,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 +370,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,
|
||||
|
||||
@@ -331,6 +331,9 @@ struct Runtime {
|
||||
pending_state: Vec<(EndpointId, Vec<SignedRecord>)>,
|
||||
/// The highest version this agent has ever published for this network.
|
||||
own_version: u64,
|
||||
/// The last hostname each peer announced, kept across restarts so a
|
||||
/// member that is away can still be called by name.
|
||||
known_hostnames: HashMap<EndpointId, String>,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
@@ -382,6 +385,7 @@ impl Runtime {
|
||||
state: StateSet::new(),
|
||||
pending_state: Vec::new(),
|
||||
own_version: 0,
|
||||
known_hostnames: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -844,6 +848,17 @@ impl Runtime {
|
||||
.own_record_version(self.network_id, self.local_id)
|
||||
.await
|
||||
.unwrap_or(0);
|
||||
for (id, hostname) in self
|
||||
.params
|
||||
.storage
|
||||
.peer_hostnames(self.network_id)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
{
|
||||
if let Ok(id) = EndpointId::from_bytes(&id) {
|
||||
self.known_hostnames.insert(id, hostname);
|
||||
}
|
||||
}
|
||||
|
||||
self.ensure_own_claim().await;
|
||||
self.publish_allocations().await;
|
||||
@@ -1885,6 +1900,33 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keeps the name a peer announced, on disk too when it changed.
|
||||
///
|
||||
/// Written off the loop: a name that is not stored this time is stored
|
||||
/// the next time the peer announces it.
|
||||
fn remember_hostname(&mut self, peer: EndpointId, hostname: &str) {
|
||||
if hostname.is_empty()
|
||||
|| self
|
||||
.known_hostnames
|
||||
.get(&peer)
|
||||
.is_some_and(|h| h == hostname)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.known_hostnames.insert(peer, hostname.to_string());
|
||||
let storage = self.params.storage.clone();
|
||||
let network_id = self.network_id;
|
||||
let hostname = hostname.to_string();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = storage
|
||||
.remember_peer_hostname(network_id, *peer.as_bytes(), hostname)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(%err, "could not remember a peer's hostname");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
|
||||
match &message {
|
||||
ControlMessage::Announce(announcement) => {
|
||||
@@ -1894,6 +1936,7 @@ impl Runtime {
|
||||
session.broadcast = announcement.broadcast;
|
||||
session.capabilities = capabilities.clone();
|
||||
}
|
||||
self.remember_hostname(peer, &announcement.hostname);
|
||||
self.update_broadcast();
|
||||
self.dispatch_capabilities(peer, &capabilities);
|
||||
self.ensure_links();
|
||||
@@ -1994,7 +2037,10 @@ impl Runtime {
|
||||
broadcast: session.broadcast,
|
||||
endpoint_id: session.peer,
|
||||
role: session.role,
|
||||
hostname: session.hostname.clone(),
|
||||
hostname: session
|
||||
.hostname
|
||||
.clone()
|
||||
.or_else(|| self.known_hostnames.get(&session.peer).cloned()),
|
||||
protocols: session
|
||||
.capabilities
|
||||
.iter()
|
||||
@@ -2052,6 +2098,7 @@ impl Runtime {
|
||||
// both hold it would be untrue on one of them.
|
||||
overlay_address_v4: self.state.address_of(&endpoint_id),
|
||||
hostname: self.state.hostname_of(&endpoint_id).map(str::to_string),
|
||||
last_hostname: self.known_hostnames.get(&endpoint_id).cloned(),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -80,6 +80,11 @@ pub struct MemberStatus {
|
||||
/// Signed, so it is still known while the member is away — which is what
|
||||
/// lets an absent member be named rather than shown as a bare id.
|
||||
pub hostname: Option<String>,
|
||||
/// The last name this agent saw it announce, whether or not it is here.
|
||||
///
|
||||
/// Not signed, so it is a convenience for naming a member that is away
|
||||
/// and never a claim: the zone is served from `hostname`.
|
||||
pub last_hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Status of one authenticated session.
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -295,6 +352,34 @@ pub struct StatusReport {
|
||||
pub networks: Vec<NetworkReport>,
|
||||
/// The local DNS service, when one was asked for.
|
||||
pub dns: Option<DnsReport>,
|
||||
/// The state directory the agent is actually using.
|
||||
///
|
||||
/// A client that was not told one would look in its own default, which is
|
||||
/// not the agent's when the agent runs as a service under another user.
|
||||
pub state_dir: String,
|
||||
/// The cache directory the agent is actually using.
|
||||
pub cache_dir: String,
|
||||
/// The executable the agent runs, which is what needs the capability.
|
||||
pub program: String,
|
||||
/// Whether the agent itself can manage the overlay interface.
|
||||
///
|
||||
/// It is the agent that creates the interface, so the client's own
|
||||
/// capabilities say nothing about it.
|
||||
pub privilege: PrivilegeReport,
|
||||
}
|
||||
|
||||
/// What the agent found when it checked whether it may manage an interface.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PrivilegeReport {
|
||||
/// Not checked.
|
||||
#[default]
|
||||
Unknown,
|
||||
/// It can.
|
||||
Available,
|
||||
/// It cannot, with what was found.
|
||||
Missing(String),
|
||||
/// This platform has no provisioner yet.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
/// The local DNS service.
|
||||
@@ -379,6 +464,8 @@ pub struct MemberReport {
|
||||
pub overlay_address_v4: Option<String>,
|
||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||
pub failed_dials: u32,
|
||||
/// The last hostname this agent saw it announce, kept while it is away.
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// One control plane peer.
|
||||
@@ -457,6 +544,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 +651,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 +692,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 +750,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 +908,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);
|
||||
|
||||
|
||||
@@ -264,7 +264,8 @@ impl Storage {
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)
|
||||
state.forget_signed_records(network_id)?;
|
||||
state.forget_peer_hostnames(network_id)
|
||||
})
|
||||
.await?;
|
||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||
@@ -283,6 +284,26 @@ impl Storage {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member announced, so it can still be named
|
||||
/// once it is away.
|
||||
pub async fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: [u8; 32],
|
||||
hostname: String,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remember_peer_hostname(network_id, &endpoint_id, &hostname)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The hostnames members of a network last announced.
|
||||
pub async fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
self.with_state(move |state| state.peer_hostnames(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records an address hint. Failures are non-fatal.
|
||||
pub async fn record_hint(
|
||||
&self,
|
||||
|
||||
@@ -21,7 +21,7 @@ use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{RecordBody, SignedRecord};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 4;
|
||||
pub const SCHEMA_VERSION: i64 = 5;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
@@ -191,13 +191,34 @@ impl StateStore {
|
||||
PRAGMA user_version = 4;
|
||||
COMMIT;").map_err(|err| self.corrupt(format!("cannot migrate schema to 4: {err}")))?;
|
||||
}
|
||||
if found < 5 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS peer_hostnames (
|
||||
network_id BLOB NOT NULL,
|
||||
endpoint_id BLOB NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
PRIMARY KEY (network_id, endpoint_id)
|
||||
);
|
||||
PRAGMA user_version = 5;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 5: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||
/// database is reported rather than used.
|
||||
fn verify_shape(&self) -> Result<()> {
|
||||
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
||||
for table in [
|
||||
"device_identity",
|
||||
"networks",
|
||||
"settings",
|
||||
"signed_records",
|
||||
"peer_hostnames",
|
||||
] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
@@ -635,6 +656,61 @@ impl StateStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remembers the hostname a member last announced.
|
||||
pub fn remember_peer_hostname(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: &[u8; 32],
|
||||
hostname: &str,
|
||||
) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO peer_hostnames (network_id, endpoint_id, hostname)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(network_id, endpoint_id) DO UPDATE SET hostname = excluded.hostname",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
endpoint_id.as_slice(),
|
||||
hostname
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot remember a hostname: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last hostname every member of a network announced.
|
||||
pub fn peer_hostnames(&self, network_id: NetworkId) -> Result<Vec<([u8; 32], String)>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT endpoint_id, hostname FROM peer_hostnames WHERE network_id = ?1")
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||
Ok((row.get::<_, Vec<u8>>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, hostname) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read hostnames: {err}")))?;
|
||||
if let Ok(id) = <[u8; 32]>::try_from(id.as_slice()) {
|
||||
out.push((id, hostname));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Forgets every remembered hostname of a network.
|
||||
pub fn forget_peer_hostnames(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM peer_hostnames WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot forget hostnames: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an arbitrary setting.
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||
self.conn
|
||||
@@ -698,6 +774,29 @@ mod broadcast_storage_tests {
|
||||
use super::*;
|
||||
use crate::identity::NetworkKeys;
|
||||
|
||||
#[test]
|
||||
fn a_remembered_hostname_survives_a_restart_and_follows_the_latest_name() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("state.sqlite");
|
||||
let name = NetworkName::new("hostnames").unwrap();
|
||||
let secret = NetworkSecret::from_bytes([9; 32]).unwrap();
|
||||
let id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let peer = [3u8; 32];
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop").unwrap();
|
||||
store.remember_peer_hostname(id, &peer, "laptop-2").unwrap();
|
||||
drop(store);
|
||||
|
||||
let store = StateStore::open(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.peer_hostnames(id).unwrap(),
|
||||
vec![(peer, "laptop-2".to_string())]
|
||||
);
|
||||
store.forget_peer_hostnames(id).unwrap();
|
||||
assert!(store.peer_hostnames(id).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_migration_preserves_identity_and_networks_and_defaults_broadcast_on() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
Vendored
+52
@@ -0,0 +1,52 @@
|
||||
# Running tsunagi on Arch Linux
|
||||
|
||||
Install the package built for your architecture:
|
||||
|
||||
```sh
|
||||
sudo pacman -U tsunagi-*-x86_64.pkg.tar.zst
|
||||
```
|
||||
|
||||
It drops a static, dependency-free binary at `/usr/bin/tsunagi`, a systemd
|
||||
service, and a polkit rule, and creates a dedicated system user `tsunagi` the
|
||||
service runs as.
|
||||
|
||||
Start it:
|
||||
|
||||
```sh
|
||||
sudo systemctl enable --now tsunagi
|
||||
```
|
||||
|
||||
Control it with `sudo` (the agent's control socket is private to the service
|
||||
user; `sudo` reaches it with no extra flags, because the CLI finds the running
|
||||
service's socket at `/run/tsunagi/agent.sock`):
|
||||
|
||||
```sh
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
```
|
||||
|
||||
## How it runs
|
||||
|
||||
The service runs as the unprivileged `tsunagi` user with just two capabilities
|
||||
(`CAP_NET_ADMIN`, `CAP_NET_BIND_SERVICE`) granted by systemd — no root. systemd
|
||||
creates and owns its directories:
|
||||
|
||||
| what | path |
|
||||
|---|---|
|
||||
| state | `/var/lib/tsunagi` |
|
||||
| cache | `/var/cache/tsunagi` |
|
||||
| control socket | `/run/tsunagi/agent.sock` |
|
||||
|
||||
The polkit rule lets anyone in the `tsunagi` group configure systemd-resolved
|
||||
for the overlay zones (the service user is in that group). To run the agent as
|
||||
your own user as well, add yourself to the group: `sudo usermod -aG tsunagi
|
||||
"$USER"`.
|
||||
|
||||
## Removal
|
||||
|
||||
```sh
|
||||
sudo pacman -R tsunagi
|
||||
```
|
||||
|
||||
stops and disables the service. The `tsunagi` user and `/var/lib/tsunagi` are
|
||||
left in place; remove them by hand if you want them gone.
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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> [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
|
||||
|
||||
binary="$1"
|
||||
arch="$2"
|
||||
version="$3"
|
||||
output="$4"
|
||||
tray="${5:-}"
|
||||
|
||||
here="$(cd "$(dirname "$0")" && pwd)"
|
||||
root="$(mktemp -d)"
|
||||
trap 'rm -rf "$root"' EXIT
|
||||
|
||||
# pacman pkgver may not contain '-', so a prerelease tag like 0.1.0-rc.9 becomes
|
||||
# 0.1.0_rc.9.
|
||||
pkgver="$(printf '%s' "$version" | tr '-' '_')"
|
||||
pkgrel=1
|
||||
|
||||
# Payload. Arch keeps units under /usr/lib/systemd/system and installs to
|
||||
# /usr/bin, so the service's ExecStart is rewritten to match.
|
||||
install -D -m 0755 "$binary" "$root/usr/bin/tsunagi"
|
||||
|
||||
mkdir -p "$root/usr/lib/systemd/system"
|
||||
sed 's#/usr/local/bin/tsunagi#/usr/bin/tsunagi#' \
|
||||
"$here/../linux/tsunagi.service" > "$root/usr/lib/systemd/system/tsunagi.service"
|
||||
chmod 0644 "$root/usr/lib/systemd/system/tsunagi.service"
|
||||
|
||||
install -D -m 0644 "$here/../linux/50-tsunagi-resolved.rules" \
|
||||
"$root/usr/share/polkit-1/rules.d/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 = ${pkgname}
|
||||
pkgver = ${pkgver}-${pkgrel}
|
||||
pkgdesc = ${pkgdesc}
|
||||
url = https://github.com/Ultradesu/tsunagi
|
||||
builddate = $(date +%s)
|
||||
packager = AB <ab@hexor.cy>
|
||||
size = ${size}
|
||||
arch = ${arch}
|
||||
${relations}license = MIT
|
||||
license = Apache
|
||||
PKGINFO
|
||||
|
||||
# Scriptlet: create the dedicated system user the service runs as, keep systemd
|
||||
# in step, and refresh the desktop/icon caches (a no-op without a GUI payload).
|
||||
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
|
||||
}
|
||||
pre_remove() {
|
||||
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
|
||||
}
|
||||
post_remove() {
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
}
|
||||
INSTALL
|
||||
|
||||
# .MTREE over the metadata and payload (not over itself), gzip-compressed, as
|
||||
# pacman expects.
|
||||
(
|
||||
cd "$root"
|
||||
LANG=C bsdtar -czf .MTREE --format=mtree \
|
||||
--options='!all,use-set,type,uid,gid,mode,time,size,md5,sha256,link' \
|
||||
.PKGINFO .INSTALL usr
|
||||
)
|
||||
|
||||
# Pack with .PKGINFO first, then the rest, and zstd-compress.
|
||||
(
|
||||
cd "$root"
|
||||
bsdtar -cf - .PKGINFO .MTREE .INSTALL usr
|
||||
) | zstd -c -19 -T0 > "$output"
|
||||
|
||||
echo "built $output"
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env bash
|
||||
# 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> [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)"
|
||||
trap 'rm -rf "$root"' EXIT
|
||||
|
||||
# Payload. Debian installs to /usr/bin (not /usr/local), so the service's
|
||||
# ExecStart is rewritten to match; the source unit targets /usr/local/bin for
|
||||
# the tarball installer.
|
||||
install -D -m 0755 "$binary" "$root/usr/bin/tsunagi"
|
||||
|
||||
mkdir -p "$root/lib/systemd/system"
|
||||
sed 's#/usr/local/bin/tsunagi#/usr/bin/tsunagi#' \
|
||||
"$here/../linux/tsunagi.service" > "$root/lib/systemd/system/tsunagi.service"
|
||||
chmod 0644 "$root/lib/systemd/system/tsunagi.service"
|
||||
|
||||
install -D -m 0644 "$here/../linux/50-tsunagi-resolved.rules" \
|
||||
"$root/usr/share/polkit-1/rules.d/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: ${pkgname}
|
||||
Version: ${version}
|
||||
Architecture: ${arch}
|
||||
Maintainer: AB <ab@hexor.cy>
|
||||
Section: net
|
||||
Priority: optional
|
||||
Homepage: https://github.com/Ultradesu/tsunagi
|
||||
${extra_control}Description: Serverless private mesh networking agent
|
||||
tsunagi forms small private mesh IP networks between peers, with NAT
|
||||
traversal and no central server. This package installs a static,
|
||||
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.${desc_gui}
|
||||
CTRL
|
||||
|
||||
# The dedicated system user the service runs as. It is in the "tsunagi" group,
|
||||
# which the polkit rule grants the resolve1 actions. systemd creates the state,
|
||||
# cache and runtime directories for it on first start.
|
||||
cat > "$root/DEBIAN/postinst" <<'POSTINST'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if ! getent group tsunagi >/dev/null 2>&1; then
|
||||
addgroup --system tsunagi >/dev/null 2>&1 || true
|
||||
fi
|
||||
if ! getent passwd tsunagi >/dev/null 2>&1; then
|
||||
adduser --system --ingroup tsunagi --home /var/lib/tsunagi --no-create-home \
|
||||
--gecos "tsunagi agent" --disabled-login tsunagi >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
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
|
||||
if [ "$1" = remove ] || [ "$1" = purge ]; then
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl disable --now tsunagi.service >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
exit 0
|
||||
PRERM
|
||||
|
||||
cat > "$root/DEBIAN/postrm" <<'POSTRM'
|
||||
#!/bin/sh
|
||||
set -e
|
||||
if [ -d /run/systemd/system ]; then
|
||||
systemctl daemon-reload >/dev/null 2>&1 || true
|
||||
fi
|
||||
exit 0
|
||||
POSTRM
|
||||
|
||||
chmod 0755 "$root/DEBIAN/postinst" "$root/DEBIAN/prerm" "$root/DEBIAN/postrm"
|
||||
|
||||
# --root-owner-group writes the payload as root:root without needing fakeroot.
|
||||
dpkg-deb --root-owner-group --build "$root" "$output"
|
||||
echo "built $output"
|
||||
Vendored
+23
@@ -0,0 +1,23 @@
|
||||
// Installed by the tsunagi packages.
|
||||
//
|
||||
// polkit decides who may configure systemd-resolved by user/group id, not by
|
||||
// capability, so the agent needs this even though it holds CAP_NET_ADMIN. It
|
||||
// grants exactly the four resolve1 actions the agent calls, and nothing else,
|
||||
// to anyone in the "tsunagi" group.
|
||||
//
|
||||
// The packaged service runs as the "tsunagi" user (which is in that group), so
|
||||
// its resolver configuration is allowed. To also run the agent as your own
|
||||
// user, add yourself to the group:
|
||||
//
|
||||
// sudo usermod -aG tsunagi "$USER"
|
||||
polkit.addRule(function(action, subject) {
|
||||
var allowed = [
|
||||
"org.freedesktop.resolve1.set-dns-servers",
|
||||
"org.freedesktop.resolve1.set-domains",
|
||||
"org.freedesktop.resolve1.set-default-route",
|
||||
"org.freedesktop.resolve1.revert"
|
||||
];
|
||||
if (allowed.indexOf(action.id) >= 0 && subject.isInGroup("tsunagi")) {
|
||||
return polkit.Result.YES;
|
||||
}
|
||||
});
|
||||
Vendored
+17
-12
@@ -2,40 +2,45 @@
|
||||
|
||||
This archive holds a **static** `tsunagi` binary (musl), so it runs on any
|
||||
distribution — Arch, Alpine, NixOS, old glibc systems — with no shared-library
|
||||
dependencies. Alongside it are a systemd unit and an installer.
|
||||
dependencies. Alongside it are a systemd unit, a polkit rule, and an installer.
|
||||
|
||||
Creating the TUN interface needs `CAP_NET_ADMIN`, and configuring
|
||||
systemd-resolved is decided by polkit (by user id, not capability). There are
|
||||
three ways to satisfy that; pick one.
|
||||
systemd-resolved is decided by polkit. There are three ways to run it; pick one.
|
||||
|
||||
## Quick install — systemd service as root
|
||||
> On Debian/Ubuntu and Arch, prefer the native package (`.deb` / `.pkg.tar.zst`)
|
||||
> from the release — it does all of the below for you.
|
||||
|
||||
## Quick install — systemd service as a dedicated user
|
||||
|
||||
```sh
|
||||
sudo ./install.sh
|
||||
sudo systemctl enable --now tsunagi
|
||||
```
|
||||
|
||||
The service runs the agent as root, so the interface, low ports and the
|
||||
resolver all just work (root is not subject to polkit). Control it **as root**,
|
||||
so the client resolves the same state directory the daemon uses:
|
||||
The service runs as an unprivileged `tsunagi` user with just `CAP_NET_ADMIN`
|
||||
and `CAP_NET_BIND_SERVICE` (granted by systemd), not root. systemd creates its
|
||||
state (`/var/lib/tsunagi`), cache (`/var/cache/tsunagi`) and runtime
|
||||
(`/run/tsunagi`) directories, and the polkit rule lets it configure the
|
||||
resolver. Control it with `sudo` — the CLI finds the running service's socket at
|
||||
`/run/tsunagi/agent.sock` with no flags:
|
||||
|
||||
```sh
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
```
|
||||
|
||||
## As your own user — one capability, no root at runtime
|
||||
## As your own user — one capability, no service
|
||||
|
||||
```sh
|
||||
sudo install -m 0755 tsunagi /usr/local/bin/tsunagi
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p /usr/local/bin/tsunagi
|
||||
sudo usermod -aG tsunagi "$USER" # so the resolver works; log in again after
|
||||
tsunagi up
|
||||
```
|
||||
|
||||
Nothing is left behind and nothing runs as root. The capability is lost on every
|
||||
rebuild or copy of the binary, so re-run `setcap` after replacing it. For the
|
||||
local DNS resolver, run `tsunagi dns` once: it prints the exact polkit rule that
|
||||
lets your user configure systemd-resolved, ready to paste.
|
||||
Nothing runs as root. The capability is lost on every rebuild or copy of the
|
||||
binary, so re-run `setcap` after replacing it. The agent uses your per-user
|
||||
state directory and socket, so plain `tsunagi status` (no sudo) reaches it.
|
||||
|
||||
## Without touching the OS
|
||||
|
||||
|
||||
Vendored
+27
-19
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env sh
|
||||
# Installs the tsunagi binary and, where systemd is present, a root service.
|
||||
# Run it as root (it writes under /usr/local and /etc):
|
||||
# Installs the static tsunagi binary and, where systemd is present, a service
|
||||
# that runs the agent as a dedicated unprivileged "tsunagi" user. Run as root:
|
||||
#
|
||||
# sudo ./install.sh
|
||||
#
|
||||
# PREFIX overrides the install prefix (default /usr/local).
|
||||
# PREFIX overrides the install prefix (default /usr/local); if you change it,
|
||||
# edit ExecStart in tsunagi.service to match.
|
||||
set -eu
|
||||
|
||||
PREFIX="${PREFIX:-/usr/local}"
|
||||
@@ -19,8 +20,23 @@ echo "Installing tsunagi to ${PREFIX}/bin"
|
||||
install -d "${PREFIX}/bin"
|
||||
install -m 0755 "${here}/tsunagi" "${PREFIX}/bin/tsunagi"
|
||||
|
||||
# The dedicated system user the service runs as (works with either shadow's
|
||||
# useradd or Debian's adduser).
|
||||
if ! getent group tsunagi >/dev/null 2>&1; then
|
||||
groupadd -r tsunagi 2>/dev/null || addgroup --system tsunagi 2>/dev/null || true
|
||||
fi
|
||||
if ! getent passwd tsunagi >/dev/null 2>&1; then
|
||||
useradd -r -g tsunagi -d /var/lib/tsunagi -s /usr/sbin/nologin -c "tsunagi agent" tsunagi 2>/dev/null \
|
||||
|| adduser --system --ingroup tsunagi --home /var/lib/tsunagi --no-create-home \
|
||||
--gecos "tsunagi agent" --disabled-login tsunagi 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# polkit rule so the agent (as the tsunagi user) may configure systemd-resolved.
|
||||
install -d /etc/polkit-1/rules.d
|
||||
install -m 0644 "${here}/50-tsunagi-resolved.rules" /etc/polkit-1/rules.d/50-tsunagi-resolved.rules
|
||||
|
||||
if command -v systemctl >/dev/null 2>&1; then
|
||||
echo "Installing the systemd service (runs the agent as root)"
|
||||
echo "Installing the systemd service (runs as the tsunagi user)"
|
||||
install -m 0644 "${here}/tsunagi.service" /etc/systemd/system/tsunagi.service
|
||||
systemctl daemon-reload
|
||||
cat <<EOF
|
||||
@@ -29,7 +45,7 @@ Done. Start the agent with:
|
||||
|
||||
sudo systemctl enable --now tsunagi
|
||||
|
||||
Control it as root, so the client shares the agent's state directory:
|
||||
Control it with sudo (the CLI finds the service's socket automatically):
|
||||
|
||||
sudo tsunagi status
|
||||
sudo tsunagi join <network-name> <tsn1…secret>
|
||||
@@ -39,19 +55,11 @@ else
|
||||
|
||||
Installed the binary; no systemd found, so no service was set up. Either:
|
||||
|
||||
* run it directly as root:
|
||||
|
||||
sudo tsunagi up
|
||||
|
||||
* or grant the capability and run it as your own user (no root at runtime):
|
||||
|
||||
sudo setcap cap_net_admin,cap_net_bind_service+p ${PREFIX}/bin/tsunagi
|
||||
tsunagi up
|
||||
|
||||
For the local DNS resolver as a non-root user, run \`tsunagi dns\` once; it
|
||||
prints the polkit rule that lets your user configure systemd-resolved.
|
||||
|
||||
* or skip the interface entirely with \`tsunagi up --no-tun\`: tunnels still
|
||||
form, they just do not reach the operating system.
|
||||
* run it as root: sudo ${PREFIX}/bin/tsunagi up
|
||||
* or as your own user: sudo setcap cap_net_admin,cap_net_bind_service+p ${PREFIX}/bin/tsunagi
|
||||
tsunagi up
|
||||
(add yourself to the tsunagi group so the resolver works:
|
||||
sudo usermod -aG tsunagi "\$USER")
|
||||
* or without touching the OS: tsunagi up --no-tun
|
||||
EOF
|
||||
fi
|
||||
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
+22
-12
@@ -6,21 +6,31 @@ Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Runs as a dedicated unprivileged user, not root. The two capabilities are all
|
||||
# it needs: CAP_NET_ADMIN to create and configure the TUN interface, and
|
||||
# CAP_NET_BIND_SERVICE for low ports. The packages create the `tsunagi` user.
|
||||
User=tsunagi
|
||||
Group=tsunagi
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
# systemd creates and owns these for the service user, and the agent is pointed
|
||||
# at them: state in /var/lib/tsunagi, cache in /var/cache/tsunagi, and the
|
||||
# control socket in /run/tsunagi, which stays private to the service user.
|
||||
StateDirectory=tsunagi
|
||||
StateDirectoryMode=0700
|
||||
CacheDirectory=tsunagi
|
||||
RuntimeDirectory=tsunagi
|
||||
RuntimeDirectoryMode=0755
|
||||
Environment=TSUNAGI_STATE_DIR=%S/tsunagi
|
||||
Environment=TSUNAGI_CACHE_DIR=%C/tsunagi
|
||||
Environment=TSUNAGI_CONTROL_SOCKET=%t/tsunagi/agent.sock
|
||||
|
||||
# The tarball installer puts the binary here; the deb and the Arch package
|
||||
# rewrite this to /usr/bin where they install it.
|
||||
ExecStart=/usr/local/bin/tsunagi up
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# This unit runs the agent as root, which covers everything it needs: creating
|
||||
# the TUN interface (CAP_NET_ADMIN), binding low ports, and configuring
|
||||
# systemd-resolved — root is not subject to polkit, so no polkit rule is needed.
|
||||
#
|
||||
# To run as a non-root user instead, uncomment the three lines below, set a
|
||||
# User=, and install the polkit rule that `tsunagi dns` prints (resolved decides
|
||||
# by user id, not capability, so the rule is what lets a non-root agent set the
|
||||
# resolver):
|
||||
#User=tsunagi
|
||||
#AmbientCapabilities=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
#CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_BIND_SERVICE
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
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