Compare commits

...
Author SHA1 Message Date
Aleksandr BogomiakovandClaude 0cfc1cbafc Tray state icon and window picture; firewall rules without the comment match; macOS app bundle
The tray icon is dark red with no connection (or a lost exit node), blue when
connected and green while an exit node is in use; the windows get the logo and
an app id that matches the desktop entry, and the exit icon moves to the
bottom left of the tile. Exit-node and broadcast firewall rules fall back to
being recognised by their shape on kernels that have no `comment` match, as
small router builds do. The macOS archive carries Tsunagi.app for the Homebrew
cask, and the daemon plist opens the control socket to the admin group.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 03:30:47 +01:00
Aleksandr BogomiakovandClaude c9c653023d Release: build a static ARMv7 (armhf) Linux agent
Adds armv7-unknown-linux-musleabihf to both release pipelines: the archive,
a .deb for armhf and an Arch package for armv7h. Agent and CLI only, like the
other non-x86_64 Linux builds.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 02:25:43 +01:00
16 changed files with 714 additions and 92 deletions

No files matched your search

+14
View File
@@ -45,6 +45,16 @@ jobs:
deb: true
deb_arch: arm64
pkg_arch: aarch64
# 32-bit ARM with hardware float (ARMv7, `v7l`): single-board and edge
# devices. Statically linked like the others.
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
asset: tsunagi-linux-armv7
archive: tar.gz
zig: true
deb: true
deb_arch: armhf
pkg_arch: armv7h
- os: macos-latest
target: aarch64-apple-darwin
asset: tsunagi-macos-aarch64
@@ -163,6 +173,10 @@ jobs:
macOS)
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
cp dist/macos/README.md "$stage/INSTALL.md"
# The tray as an application bundle, for the Homebrew cask.
bash dist/macos/build-app.sh \
"target/${{ matrix.target }}/release/tsunagi-tray" \
"$version" "$stage/Tsunagi.app"
;;
Windows)
cp dist/windows/README.md "$stage/INSTALL.md"
+14
View File
@@ -47,6 +47,16 @@ jobs:
deb: true
deb_arch: arm64
pkg_arch: aarch64
# 32-bit ARM with hardware float (ARMv7, `v7l`): single-board and edge
# devices. Statically linked like the others.
- os: ubuntu-latest
target: armv7-unknown-linux-musleabihf
asset: tsunagi-linux-armv7
archive: tar.gz
zig: true
deb: true
deb_arch: armhf
pkg_arch: armv7h
- os: macos-latest
target: aarch64-apple-darwin
asset: tsunagi-macos-aarch64
@@ -165,6 +175,10 @@ jobs:
macOS)
cp dist/macos/cy.hexor.tsunagi.plist "$stage/"
cp dist/macos/README.md "$stage/INSTALL.md"
# The tray as an application bundle, for the Homebrew cask.
bash dist/macos/build-app.sh \
"target/${{ matrix.target }}/release/tsunagi-tray" \
"$version" "$stage/Tsunagi.app"
;;
Windows)
cp dist/windows/README.md "$stage/INSTALL.md"
@@ -66,24 +66,68 @@ pub(super) enum Ran {
pub(super) type Run<'a> = &'a dyn Fn(&[String]) -> Ran;
/// The match and target of the rule, without the chain or position.
fn rule_spec(plan: &BroadcastRulesPlan) -> Vec<String> {
[
"-i",
plan.interface.as_str(),
"-p",
"udp",
"-s",
&plan.range.to_string(),
"-m",
"comment",
"--comment",
&plan.tag(),
"-j",
"ACCEPT",
]
.into_iter()
.map(str::to_string)
.collect()
///
/// `comment` false leaves the tag off, for a kernel that has no `comment`
/// match; the rule is then recognised by its shape, see [`tagged_deletions`].
fn rule_spec(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
let range = plan.range.to_string();
let tag = plan.tag();
let mut words = vec!["-i", plan.interface.as_str(), "-p", "udp", "-s", &range];
if comment {
words.extend(["-m", "comment", "--comment", &tag]);
}
words.extend(["-j", "ACCEPT"]);
words.into_iter().map(str::to_string).collect()
}
/// Whether the kernel has the `comment` match, which a rule needs to carry
/// its tag. Asked of iptables itself: checking a rule that does not exist
/// fails either way, but only a missing match says so.
pub(super) fn comment_supported(run: Run<'_>) -> bool {
let probe = locked(
[
"-t",
"filter",
"-C",
"FORWARD",
"-m",
"comment",
"--comment",
"tsunagi-probe",
"-j",
"ACCEPT",
]
.map(str::to_string),
);
match run(&probe) {
Ran::Exited {
success: false,
stderr,
..
} => !stderr.contains("No chain/target/match by that name"),
_ => true,
}
}
/// `iptables -S` words as option clauses: an option with its value, a negated
/// one with its `!`. It prints the options in its own order, not the one a
/// rule was written in, so rules are compared by their clauses, sorted.
fn clauses(words: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut at = 0;
while at < words.len() {
let take = if words[at] == "!" { 3 } else { 2 };
let end = (at + take).min(words.len());
out.push(words[at..end].join(" "));
at = end;
}
out.sort();
out
}
/// Whether two rules say the same thing, whatever order they list it in.
pub(super) fn same_rule(a: &[String], b: &[String]) -> bool {
clauses(a) == clauses(b)
}
/// Waits briefly for the xtables lock instead of failing when another tool
@@ -96,19 +140,19 @@ pub(super) fn locked(args: impl IntoIterator<Item = String>) -> Vec<String> {
.collect()
}
fn check_args(plan: &BroadcastRulesPlan) -> Vec<String> {
fn check_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
locked(
["-C".to_string(), CHAIN.to_string()]
.into_iter()
.chain(rule_spec(plan)),
.chain(rule_spec(plan, comment)),
)
}
fn insert_args(plan: &BroadcastRulesPlan) -> Vec<String> {
fn insert_args(plan: &BroadcastRulesPlan, comment: bool) -> Vec<String> {
locked(
["-I".to_string(), CHAIN.to_string(), "1".to_string()]
.into_iter()
.chain(rule_spec(plan)),
.chain(rule_spec(plan, comment)),
)
}
@@ -147,8 +191,28 @@ pub(super) fn split_rule_line(line: &str) -> Vec<String> {
words
}
/// Whether a rule is the one this module writes for some range of the
/// interface the tag belongs to, with no comment on it.
fn is_uncommented_rule(tag: &str, rest: &[String]) -> bool {
let Some(interface) = tag.strip_prefix("tsunagi:") else {
return false;
};
let Some(range) = rest
.windows(2)
.find(|pair| pair[0] == "-s")
.map(|pair| pair[1].as_str())
else {
return false;
};
let ours: Vec<String> = ["-i", interface, "-p", "udp", "-s", range, "-j", "ACCEPT"]
.map(str::to_string)
.to_vec();
same_rule(&ours, rest)
}
/// The delete commands for every rule in an `iptables -S INPUT` listing that
/// carries exactly this tag. Anything else in the chain is left alone.
/// carries exactly this tag, or, with no `comment` match in the kernel, is
/// exactly the rule written for some range. Anything else is left alone.
fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
listing
.lines()
@@ -162,7 +226,8 @@ fn tagged_deletions(listing: &str, tag: &str) -> Vec<Vec<String>> {
}
let tagged = rest
.windows(2)
.any(|pair| pair[0] == "--comment" && pair[1] == tag);
.any(|pair| pair[0] == "--comment" && pair[1] == tag)
|| is_uncommented_rule(tag, rest);
tagged.then(|| {
locked(
["-D".to_string(), CHAIN.to_string()]
@@ -197,7 +262,8 @@ fn missing(plan: &BroadcastRulesPlan) -> String {
/// Makes the firewall hold exactly one rule for this interface, the planned one.
fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome {
let present = match run(&check_args(plan)) {
let comment = comment_supported(run);
let present = match run(&check_args(plan, comment)) {
Ran::Missing => return RuleOutcome::Failed(missing(plan)),
Ran::Exited { success, .. } => success,
};
@@ -227,7 +293,7 @@ fn apply_firewall(run: Run<'_>, plan: &BroadcastRulesPlan) -> RuleOutcome {
return RuleOutcome::Failed(failure(&stderr));
}
}
match run(&insert_args(plan)) {
match run(&insert_args(plan, comment)) {
Ran::Missing => RuleOutcome::Failed(missing(plan)),
Ran::Exited { success: true, .. } => RuleOutcome::Applied,
Ran::Exited { stderr, .. } => RuleOutcome::Failed(failure(&stderr)),
@@ -365,7 +431,7 @@ mod tests {
#[test]
fn the_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
let spec = words(&rule_spec(&plan()));
let spec = words(&rule_spec(&plan(), true));
assert_eq!(
spec,
"-i tsun0 -p udp -s 10.13.37.0/24 -m comment --comment tsunagi:tsun0 -j ACCEPT"
@@ -373,8 +439,8 @@ mod tests {
// Nothing here may widen to other protocols or every source.
assert!(spec.contains("-p udp"));
assert!(!spec.contains("0.0.0.0/0"));
assert!(words(&insert_args(&plan())).starts_with("-w 5 -I INPUT 1 -i tsun0"));
assert!(words(&check_args(&plan())).starts_with("-w 5 -C INPUT -i tsun0"));
assert!(words(&insert_args(&plan(), true)).starts_with("-w 5 -I INPUT 1 -i tsun0"));
assert!(words(&check_args(&plan(), true)).starts_with("-w 5 -C INPUT -i tsun0"));
}
#[test]
@@ -408,6 +474,49 @@ mod tests {
assert!(tagged_deletions("", "tsunagi:tsun0").is_empty());
}
#[test]
fn a_kernel_without_the_comment_match_says_so_in_its_error() {
let answer = |stderr: &'static str| {
move |_: &[String]| Ran::Exited {
success: false,
stdout: String::new(),
stderr: stderr.to_string(),
}
};
assert!(!comment_supported(&answer(
"iptables: No chain/target/match by that name."
)));
assert!(comment_supported(&answer(
"iptables: Bad rule (does a matching rule exist in that chain?)."
)));
assert!(comment_supported(&|_: &[String]| Ran::Missing));
}
#[test]
fn a_rule_written_without_a_comment_is_found_by_its_shape() {
// Listed the way iptables prints it: its own option order.
let listing = "\
-P INPUT ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT
-A INPUT -s 10.99.0.0/16 -i tsun0 -p udp -j ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun1 -p udp -j ACCEPT
-A INPUT -s 10.13.37.0/24 -i tsun0 -p udp -j DROP
-A INPUT -s 10.13.37.0/24 -i tsun0 -p tcp -j ACCEPT
";
let deletions = tagged_deletions(listing, "tsunagi:tsun0");
assert_eq!(deletions.len(), 2, "{deletions:?}");
assert!(words(&deletions[0]).ends_with("-s 10.13.37.0/24 -i tsun0 -p udp -j ACCEPT"));
assert!(words(&deletions[1]).contains("10.99.0.0/16"));
}
#[test]
fn without_a_comment_the_rule_has_none() {
let with = words(&rule_spec(&plan(), true));
let without = words(&rule_spec(&plan(), false));
assert!(with.contains("-m comment --comment tsunagi:tsun0"));
assert_eq!(without, "-i tsun0 -p udp -s 10.13.37.0/24 -j ACCEPT");
}
/// A scripted `iptables`: answers by the leading verb, records every call.
struct Fake {
calls: RefCell<Vec<String>>,
@@ -428,6 +537,14 @@ mod tests {
}
}
fn run(&self, args: &[String]) -> Ran {
// The probe for the `comment` match: not part of what is tested.
if args.iter().any(|word| word == "tsunagi-probe") {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: "Bad rule".into(),
};
}
self.calls.borrow_mut().push(words(args));
if self.missing {
return Ran::Missing;
@@ -47,7 +47,10 @@ use crate::state::Ipv4Range;
use super::RuleOutcome;
use super::exit::{ExitHostPlan, ExitHostReport, ExitHostRules, exit_tag, exit_tag_prefix};
use super::linux::{LinuxHostRules, Ran, Run, failure, locked, run_iptables, split_rule_line};
use super::linux::{
LinuxHostRules, Ran, Run, comment_supported, failure, locked, run_iptables, same_rule,
split_rule_line,
};
/// The chains the offering rules live in: `(table, chain)`.
const CHAINS: [(&str, &str); 2] = [("nat", "POSTROUTING"), ("filter", "FORWARD")];
@@ -65,14 +68,23 @@ fn owned(words: &[&str]) -> Vec<String> {
}
/// The three rules of one offered range.
fn offer_rules(interface: &str, range: &Ipv4Range) -> Vec<Rule> {
///
/// Each carries a comment naming its interface and range, which is how it is
/// found again. A kernel without the `comment` match (small router builds
/// leave it out) cannot take that, so `comment` false leaves it off and the
/// rules are recognised by their shape instead; see [`tagged_rules`].
fn offer_rules(interface: &str, range: &Ipv4Range, comment: bool) -> Vec<Rule> {
let range_text = range.to_string();
let tag = exit_tag(interface, range);
let comment = ["-m", "comment", "--comment", tag.as_str()];
let comment: Vec<String> = if comment {
owned(&["-m", "comment", "--comment", tag.as_str()])
} else {
Vec::new()
};
let spec = |head: &[&str], tail: &[&str]| -> Vec<String> {
owned(head)
.into_iter()
.chain(owned(&comment))
.chain(comment.iter().cloned())
.chain(owned(tail))
.collect()
};
@@ -142,9 +154,16 @@ struct Found {
rest: Vec<String>,
}
/// Every rule in an `iptables -S <chain>` listing carrying a tag that starts
/// with `prefix`. Anything else in the chain is left alone.
/// Every rule in an `iptables -S <chain>` listing that is ours: carrying a
/// tag that starts with `prefix`, or, on a kernel with no `comment` match,
/// being exactly one of the three rules this module writes for some range.
/// Anything else in the chain is left alone.
fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
// `tsunagi-exit:<interface>:`
let interface = prefix
.strip_prefix("tsunagi-exit:")
.and_then(|rest| rest.strip_suffix(':'))
.unwrap_or_default();
listing
.lines()
.filter_map(|line| {
@@ -158,7 +177,8 @@ fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
let tag = rest
.windows(2)
.find(|pair| pair[0] == "--comment" && pair[1].starts_with(prefix))
.map(|pair| pair[1].clone())?;
.map(|pair| pair[1].clone())
.or_else(|| uncommented_tag(interface, chain, rest))?;
Some(Found {
tag,
rest: rest.to_vec(),
@@ -167,6 +187,22 @@ fn tagged_rules(listing: &str, chain: &str, prefix: &str) -> Vec<Found> {
.collect()
}
/// The tag a rule would have carried, when it is one of ours written without
/// a comment: its words are those of the rule for the range it names.
fn uncommented_tag(interface: &str, chain: &str, rest: &[String]) -> Option<String> {
if interface.is_empty() {
return None;
}
let range: Ipv4Range = rest
.windows(2)
.find(|pair| pair[0] == "-s" || pair[0] == "-d")
.and_then(|pair| pair[1].parse().ok())?;
offer_rules(interface, &range, false)
.iter()
.any(|rule| rule.chain == chain && same_rule(&rule.spec, rest))
.then(|| exit_tag(interface, &range))
}
fn delete_args(table: &str, chain: &str, found: &Found) -> Vec<String> {
locked(
owned(&["-t", table, "-D", chain])
@@ -236,6 +272,7 @@ fn apply_offer(
};
let prefix = exit_tag_prefix(interface);
let wanted: Vec<String> = ranges.iter().map(|r| exit_tag(interface, r)).collect();
let comment = comment_supported(run);
// What is there now, per chain.
let mut present: Vec<(&str, &str, Vec<Found>)> = Vec::new();
@@ -263,7 +300,7 @@ fn apply_offer(
ranges
.iter()
.map(|range| {
let rules = offer_rules(interface, range);
let rules = offer_rules(interface, range, comment);
let tag = exit_tag(interface, range);
let in_place = rules
.iter()
@@ -468,7 +505,7 @@ mod tests {
#[test]
fn the_rules_masquerade_the_range_out_of_every_other_interface_and_forward_it() {
let rules = offer_rules("tsun0", &range());
let rules = offer_rules("tsun0", &range(), true);
assert_eq!(rules.len(), 3);
assert_eq!(
words(&rules[0].spec),
@@ -521,6 +558,8 @@ mod tests {
listing: RefCell<Vec<(String, String)>>,
missing: bool,
check_ok: bool,
/// A kernel with no `comment` match, as small router builds have.
no_comment: bool,
}
impl Fake {
@@ -530,6 +569,7 @@ mod tests {
listing: RefCell::new(Vec::new()),
missing: false,
check_ok,
no_comment: false,
}
}
@@ -556,6 +596,13 @@ mod tests {
stdout,
stderr: String::new(),
};
if self.no_comment && line.contains("-m comment") {
return Ran::Exited {
success: false,
stdout: String::new(),
stderr: "iptables: No chain/target/match by that name.".to_string(),
};
}
if line.contains(" -S ") {
let listing: Vec<String> = self
.listing
@@ -586,6 +633,44 @@ mod tests {
}
}
#[test]
fn a_kernel_without_the_comment_match_gets_the_rules_without_comments() {
let mut fake = Fake::new(false);
fake.no_comment = true;
let outcome = apply_offer(&|args| fake.run(args), "tsun0", &[range()]);
assert_eq!(outcome, vec![(range(), RuleOutcome::Applied)]);
let inserted = fake.verbs("-I");
assert_eq!(inserted.len(), 3, "{inserted:?}");
assert!(
inserted.iter().all(|call| !call.contains("comment")),
"{inserted:?}"
);
}
#[test]
fn rules_written_without_a_comment_are_ours_and_are_removed_with_them() {
// The way iptables prints them: its own option order.
let listing = "\
-P FORWARD ACCEPT
-A FORWARD -s 10.13.37.0/24 -i tsun0 ! -o tsun0 -j ACCEPT
-A FORWARD -d 10.13.37.0/24 -o tsun0 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A FORWARD -i tsun0 -j ACCEPT
-A FORWARD -s 10.13.37.0/24 -i tsun1 ! -o tsun1 -j ACCEPT
";
let found = tagged_rules(listing, "FORWARD", &exit_tag_prefix("tsun0"));
assert_eq!(found.len(), 2, "{found:?}");
assert!(
found
.iter()
.all(|rule| rule.tag == exit_tag("tsun0", &range()))
);
let nat = "-A POSTROUTING -s 10.13.37.0/24 ! -o tsun0 -j MASQUERADE\n";
assert_eq!(
tagged_rules(nat, "POSTROUTING", &exit_tag_prefix("tsun0")).len(),
1
);
}
#[test]
fn a_fresh_host_gets_the_three_rules() {
let fake = Fake::new(false);
+4
View File
@@ -81,6 +81,8 @@ impl App {
}
let builder = egui::ViewportBuilder::default()
.with_title("About tsunagi")
.with_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.with_inner_size([560.0, 520.0])
.with_min_inner_size([380.0, 280.0]);
let socket = self.agent.socket();
@@ -112,6 +114,8 @@ impl App {
let viewport_id = egui::ViewportId::from_hash_of(("devices", &id));
let builder = egui::ViewportBuilder::default()
.with_title(devices::title(network))
.with_app_id(crate::icon::APP_ID)
.with_icon(crate::icon::window())
.with_inner_size([940.0, 540.0])
.with_min_inner_size([480.0, 320.0]);
let agent = &self.agent;
+135
View File
@@ -0,0 +1,135 @@
//! The application's picture: the logo drawn in code, in the colour of the
//! state it stands for, so there is no asset to ship or to go missing.
//!
//! It is the same shape as `dist/linux/tsunagi.svg` (a disc, a ring, a dot).
use crate::watch::Health;
/// Normal operation. The colour of the logo itself.
const BLUE: [u8; 3] = [0x2f, 0x80, 0xd8];
/// An exit node is in use.
const GREEN: [u8; 3] = [0x3c, 0xb0, 0x4a];
/// Nothing is connected, or the exit node in use is gone.
const DARK_RED: [u8; 3] = [0x9a, 0x1f, 0x1f];
/// The window application id, which is also the name of the desktop entry
/// (`tsunagi-tray.desktop`). The compositor finds the icon through it.
pub(crate) const APP_ID: &str = "tsunagi-tray";
const SIZE: u32 = 64;
const SAMPLES: u32 = 4;
/// What the logo looks like at a point of its 128 × 128 design, as
/// premultiplied-free RGBA in `0.0..=1.0`.
fn shade(x: f32, y: f32, color: [u8; 3]) -> [f32; 4] {
let distance = ((x - 64.0).powi(2) + (y - 64.0).powi(2)).sqrt();
if distance > 56.0 {
return [0.0; 4];
}
let mut pixel = [
f32::from(color[0]) / 255.0,
f32::from(color[1]) / 255.0,
f32::from(color[2]) / 255.0,
];
let mut white = 0.0;
if (26.5..=33.5).contains(&distance) {
white = 0.9;
}
if distance <= 9.0 {
white = 1.0;
}
for channel in &mut pixel {
*channel = *channel * (1.0 - white) + white;
}
[pixel[0], pixel[1], pixel[2], 1.0]
}
/// The logo as RGBA pixels, antialiased by supersampling.
fn rgba(color: [u8; 3]) -> Vec<u8> {
let scale = 128.0 / SIZE as f32;
let mut out = Vec::with_capacity((SIZE * SIZE * 4) as usize);
for py in 0..SIZE {
for px in 0..SIZE {
let mut sum = [0.0f32; 4];
for sy in 0..SAMPLES {
for sx in 0..SAMPLES {
let x = (px as f32 + (sx as f32 + 0.5) / SAMPLES as f32) * scale;
let y = (py as f32 + (sy as f32 + 0.5) / SAMPLES as f32) * scale;
let [r, g, b, a] = shade(x, y, color);
sum[0] += r * a;
sum[1] += g * a;
sum[2] += b * a;
sum[3] += a;
}
}
let count = (SAMPLES * SAMPLES) as f32;
let alpha = sum[3] / count;
let unmultiply = if sum[3] > 0.0 { 1.0 / sum[3] } else { 0.0 };
for channel in &sum[..3] {
out.push((channel * unmultiply * 255.0).round() as u8);
}
out.push((alpha * 255.0).round() as u8);
}
}
out
}
fn colour_of(health: Health) -> [u8; 3] {
match health {
Health::Disconnected => DARK_RED,
Health::Connected => BLUE,
Health::ExitNode => GREEN,
}
}
/// The tray icon for a state.
pub(crate) fn tray(health: Health) -> Option<tray_icon::Icon> {
tray_icon::Icon::from_rgba(rgba(colour_of(health)), SIZE, SIZE).ok()
}
/// The icon of the application's windows.
pub(crate) fn window() -> eframe::egui::IconData {
eframe::egui::IconData {
rgba: rgba(BLUE),
width: SIZE,
height: SIZE,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pixel(pixels: &[u8], x: u32, y: u32) -> [u8; 4] {
let at = ((y * SIZE + x) * 4) as usize;
[pixels[at], pixels[at + 1], pixels[at + 2], pixels[at + 3]]
}
#[test]
fn the_logo_is_a_coloured_disc_with_a_white_centre_and_clear_corners() {
let pixels = rgba(GREEN);
assert_eq!(pixels.len(), (SIZE * SIZE * 4) as usize);
assert_eq!(pixel(&pixels, 0, 0)[3], 0, "the corner is transparent");
let centre = pixel(&pixels, SIZE / 2, SIZE / 2);
assert_eq!(
centre,
[255, 255, 255, 255],
"the dot in the middle is white"
);
// Halfway between the ring and the edge is the colour itself.
let body = pixel(&pixels, SIZE / 2, SIZE / 2 - (SIZE * 44 / 128));
assert_eq!(body, [GREEN[0], GREEN[1], GREEN[2], 255]);
}
#[test]
fn each_state_has_its_own_colour() {
let colours = [
colour_of(Health::Disconnected),
colour_of(Health::Connected),
colour_of(Health::ExitNode),
];
assert_ne!(colours[0], colours[1]);
assert_ne!(colours[1], colours[2]);
assert_ne!(colours[0], colours[2]);
}
}
+4 -1
View File
@@ -15,9 +15,11 @@ mod app;
mod devices;
mod exit;
mod format;
mod icon;
mod stats;
mod tray;
mod ui;
mod watch;
use eframe::egui;
@@ -53,7 +55,8 @@ fn window() -> Result<(), Box<dyn std::error::Error>> {
// A fixed, small window; the whole layout targets this size.
viewport: egui::ViewportBuilder::default()
.with_title("tsunagi")
.with_app_id("tsunagi")
.with_app_id(icon::APP_ID)
.with_icon(icon::window())
.with_inner_size([360.0, 600.0])
.with_resizable(false),
..Default::default()
+45 -27
View File
@@ -8,7 +8,10 @@ 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};
use tray_icon::{TrayIcon, TrayIconBuilder};
use crate::icon;
use crate::watch::{self, Health};
/// The window process, when there is one.
struct Window {
@@ -69,9 +72,9 @@ fn build() -> Result<Built, String> {
menu.append(&quit).map_err(|err| err.to_string())?;
let icon = TrayIconBuilder::new()
.with_tooltip("tsunagi")
.with_tooltip(Health::Disconnected.label())
.with_menu(Box::new(menu))
.with_icon(make_icon()?)
.with_icon(icon::tray(Health::Disconnected).ok_or("cannot draw the tray icon")?)
.build()
.map_err(|err| err.to_string())?;
Ok(Built {
@@ -101,6 +104,16 @@ fn build_checked() -> Result<Built, String> {
})
}
/// Shows a state on the icon, if it is a different one than is showing.
fn show(icon: &TrayIcon, shown: &mut Option<Health>, health: Health) {
if *shown == Some(health) {
return;
}
*shown = Some(health);
let _ = icon.set_icon(icon::tray(health));
let _ = icon.set_tooltip(Some(health.label()));
}
/// 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());
@@ -128,8 +141,22 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
child: Mutex::new(None),
}),
);
// The icon belongs to this thread, so the answers come to it: the poller
// queues them and the loop here takes them off once a second.
let (tx, rx) = std::sync::mpsc::channel();
watch::spawn(move |health| {
let _ = tx.send(health);
});
let icon = built.icon;
let mut shown = None;
gtk::glib::timeout_add_seconds_local(1, move || {
while let Ok(health) = rx.try_recv() {
show(&icon, &mut shown, health);
}
gtk::glib::ControlFlow::Continue
});
gtk::main();
drop(built.icon);
Ok(())
}
@@ -148,9 +175,16 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
struct Pump {
window: Arc<Window>,
icon: Option<TrayIcon>,
shown: Option<Health>,
}
impl ApplicationHandler for Pump {
impl ApplicationHandler<Health> for Pump {
fn user_event(&mut self, _event_loop: &ActiveEventLoop, health: Health) {
if let Some(icon) = &self.icon {
show(icon, &mut self.shown, health);
}
}
fn resumed(&mut self, _event_loop: &ActiveEventLoop) {
if self.icon.is_some() {
return;
@@ -170,7 +204,7 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
fn window_event(&mut self, _: &ActiveEventLoop, _: WindowId, _: WindowEvent) {}
}
let mut builder = EventLoop::builder();
let mut builder = EventLoop::<Health>::with_user_event();
// macOS: live in the menu bar with no Dock icon.
#[cfg(target_os = "macos")]
{
@@ -178,33 +212,17 @@ pub(crate) fn run() -> Result<(), Box<dyn std::error::Error>> {
builder.with_activation_policy(ActivationPolicy::Accessory);
}
let event_loop = builder.build()?;
let proxy = event_loop.create_proxy();
watch::spawn(move |health| {
let _ = proxy.send_event(health);
});
let mut pump = Pump {
window: Arc::new(Window {
child: Mutex::new(None),
}),
icon: None,
shown: 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())
}
+23 -24
View File
@@ -283,32 +283,31 @@ fn draw_network(
}
});
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
// In the corner while this device sends its traffic through
// somebody: green while that works, red while it does not.
if let Some(via) = &network.exit.via {
let color = if network.exit.via_online {
exit::GOOD
} else {
exit::LOUD
};
let response = ui
.add(
egui::Label::new(
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
)
.sense(egui::Sense::click()),
// In the bottom left corner while this device sends its traffic through
// somebody: green while that works, red while it does not.
if let Some(via) = &network.exit.via {
let color = if network.exit.via_online {
exit::GOOD
} else {
exit::LOUD
};
let response = ui
.add(
egui::Label::new(
egui::RichText::new(icon::SIGN_OUT).size(18.0).color(color),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!(
"exit node: {} — click to stop",
exit::name_of(network, via)
));
if response.clicked() {
state.exit_confirm =
Some(devices::ExitConfirm::stop_from_tile(network, via));
}
.sense(egui::Sense::click()),
)
.on_hover_cursor(egui::CursorIcon::PointingHand)
.on_hover_text(format!(
"exit node: {} — click to stop",
exit::name_of(network, via)
));
if response.clicked() {
state.exit_confirm = Some(devices::ExitConfirm::stop_from_tile(network, via));
}
}
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());
}
+146
View File
@@ -0,0 +1,146 @@
//! What the tray icon shows: whether the agent has anybody to talk to, and
//! whether this device sends its traffic through an exit node.
//!
//! The tray process has no window and no event loop of its own to poll from,
//! so a thread asks the agent on an interval and hands each answer to a
//! callback, which puts it where the platform wants it.
use std::time::Duration;
use tsunagi::ipc::{self, StatusReport};
use crate::agent::resolve_socket;
/// How often the agent is asked.
const INTERVAL: Duration = Duration::from_secs(3);
/// What the icon says.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Health {
/// Nobody is connected, or the agent cannot be reached, or the exit node
/// this device uses is gone, which blocks its traffic.
Disconnected,
/// Connected, nothing out of the ordinary.
Connected,
/// This device's traffic leaves through an exit node, and that works.
ExitNode,
}
impl Health {
/// The state a status report amounts to; `None` is an agent that did not
/// answer.
pub(crate) fn of(report: Option<&StatusReport>) -> Self {
let Some(report) = report else {
return Self::Disconnected;
};
if !report
.networks
.iter()
.any(|network| !network.peers.is_empty())
{
return Self::Disconnected;
}
match report
.networks
.iter()
.find(|network| network.exit.via.is_some())
{
Some(network) if network.exit.via_online => Self::ExitNode,
Some(_) => Self::Disconnected,
None => Self::Connected,
}
}
/// The tooltip of the icon.
pub(crate) fn label(self) -> &'static str {
match self {
Self::Disconnected => "tsunagi: not connected",
Self::Connected => "tsunagi: connected",
Self::ExitNode => "tsunagi: using an exit node",
}
}
}
/// Starts asking the agent, and calls `notify` with every answer.
pub(crate) fn spawn(notify: impl Fn(Health) + Send + 'static) {
let spawned = std::thread::Builder::new()
.name("tray-status".into())
.spawn(move || {
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return;
};
loop {
let report = runtime.block_on(async {
tokio::time::timeout(INTERVAL, ipc::request_status(resolve_socket()))
.await
.ok()
.and_then(Result::ok)
});
notify(Health::of(report.as_ref()));
std::thread::sleep(INTERVAL);
}
});
if let Err(err) = spawned {
eprintln!("cannot watch the agent for the tray icon: {err}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use tsunagi::ipc::{ExitReport, NetworkReport, PeerReport};
fn network(peers: usize, exit: ExitReport) -> NetworkReport {
NetworkReport {
peers: vec![PeerReport::default(); peers],
exit,
..Default::default()
}
}
fn report(networks: Vec<NetworkReport>) -> StatusReport {
StatusReport {
networks,
..Default::default()
}
}
#[test]
fn no_agent_or_nobody_connected_is_the_red_state() {
assert_eq!(Health::of(None), Health::Disconnected);
assert_eq!(Health::of(Some(&report(vec![]))), Health::Disconnected);
assert_eq!(
Health::of(Some(&report(vec![network(0, ExitReport::default())]))),
Health::Disconnected
);
}
#[test]
fn a_connection_in_any_network_is_the_blue_state() {
let status = report(vec![
network(0, ExitReport::default()),
network(2, ExitReport::default()),
]);
assert_eq!(Health::of(Some(&status)), Health::Connected);
}
#[test]
fn an_exit_node_in_use_is_green_while_it_works_and_red_once_it_is_gone() {
let using = |online| ExitReport {
via: Some("abc".into()),
via_online: online,
..Default::default()
};
assert_eq!(
Health::of(Some(&report(vec![network(1, using(true))]))),
Health::ExitNode
);
assert_eq!(
Health::of(Some(&report(vec![network(1, using(false))]))),
Health::Disconnected
);
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
#
# 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
# <arch> pacman architecture: x86_64, aarch64 or armv7h
# <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
+1 -1
View File
@@ -4,7 +4,7 @@
#
# 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
# <deb_arch> Debian architecture: amd64, arm64 or armhf
# <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,
+1
View File
@@ -9,3 +9,4 @@ Terminal=false
Categories=Network;Utility;
Keywords=mesh;vpn;overlay;network;
StartupNotify=false
StartupWMClass=tsunagi-tray
+14
View File
@@ -27,6 +27,20 @@ shell started `sudo`:
| disposable cache | `/var/db/tsunagi/cache` |
| control socket | `/var/run/tsunagi/agent.sock` |
## Homebrew
```sh
brew tap house-of-vanity/tap
brew install --cask tsunagi-gui # the tray app, and the agent it needs
# or just the command line agent:
brew install tsunagi
sudo brew services start tsunagi # the agent, as a root daemon at boot
```
The cask starts the service for you. The agent runs as root, and its control
socket is open to the `admin` group, so the CLI and the tray work without
`sudo` for an administrator.
## Native deployment — root LaunchDaemon
For an always-on agent that starts at boot, install the bundled
Vendored Executable
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Wrap the tray GUI in a macOS application bundle, so it can be installed as an
# app (a Homebrew cask, or dragging it to /Applications) instead of being a bare
# executable.
#
# Usage: build-app.sh <tray_binary> <version> <output.app>
# <tray_binary> the built tsunagi-tray executable
# <version> the release version (the tag without its leading v)
# <output.app> the bundle to create; replaced if it exists
#
# It is a menu-bar app: LSUIElement keeps it out of the Dock and the app
# switcher, and the tray starts the window as a second process of the same
# executable, which inherits that. The bundle is signed ad hoc where `codesign`
# exists (the macOS runner), because arm64 will not run unsigned code; it is not
# notarised, so a downloaded copy needs its quarantine flag cleared, which the
# cask does.
set -euo pipefail
tray="$1"
version="$2"
app="$3"
# CFBundleShortVersionString is dotted numbers only; 0.1.0-rc.22 becomes 0.1.0.
short="${version%%-*}"
rm -rf "$app"
mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
install -m 0755 "$tray" "$app/Contents/MacOS/tsunagi-tray"
cat > "$app/Contents/Info.plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Tsunagi</string>
<key>CFBundleDisplayName</key>
<string>Tsunagi</string>
<key>CFBundleIdentifier</key>
<string>cy.hexor.tsunagi.tray</string>
<key>CFBundleExecutable</key>
<string>tsunagi-tray</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleVersion</key>
<string>${short}</string>
<key>CFBundleShortVersionString</key>
<string>${short}</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>LSUIElement</key>
<true/>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
PLIST
if command -v codesign >/dev/null 2>&1; then
codesign --force --deep --sign - "$app"
fi
echo "built $app"
+9
View File
@@ -36,6 +36,15 @@
<key>ThrottleInterval</key>
<integer>10</integer>
<!-- The control socket belongs to the admin group, so the CLI and the tray
work without sudo for any administrator. A plain account is not in it
and cannot manage the agent. -->
<key>EnvironmentVariables</key>
<dict>
<key>TSUNAGI_CONTROL_GROUP</key>
<string>admin</string>
</dict>
<!-- Logs. launchd creates these as root; rotate them with newsyslog if you
keep the daemon around. -->
<key>StandardOutPath</key>