bos/bos-settings/src/ui/views/firewall.rs
Breadway ad4d71db34 bos-settings 0.6.0: Power, Firewall, Users, AUR, Firmware panels
Continues reducing terminal-reliance for graphical system control. Five
new panels, plus three GUI apps shipped for things better served by an
existing dedicated tool than reimplemented in bos-settings
(gnome-disk-utility, gufw, mission-center).

- Power: battery status/health, brightness, charge-limit thresholds where
  the hardware exposes them, TLP profile shown read-only by design (no
  Balanced/Performance switcher — TLP auto-selects by power source, and
  power-profiles-daemon isn't installed because it conflicts with tlp).
- Firewall: ufw enable/disable, add/remove rules, view active rules.
  ufw's own status check requires root (confirmed against the installed
  script — not just changes, reads too), so unlike every other panel this
  one does NOT query state in build(): every view is constructed eagerly
  at app launch, and an unconditional privileged read here would mean a
  polkit prompt on every single bos-settings open. Starts blank with a
  "Status not loaded" placeholder; state loads only on an explicit Refresh
  click, with a guard so refresh's own set_active() doesn't loop back into
  triggering ufw enable/disable.
- Users: add/remove accounts, change passwords. All through pkexec on a
  background thread. Can't remove the account you're currently running as.
- AUR: search via yay. Installing deliberately opens a terminal instead of
  a silent --noconfirm install — yay's interactive PKGBUILD diff review and
  sudo prompt are the actual safety mechanism against a malicious AUR
  package, not a formality worth automating away.
- Firmware: fwupd device list + updates, same stream-output-then-refresh
  pattern as Packages.

packages.x86_64: gnome-disk-utility, gufw, mission-center for disk/firewall/
task-manager GUIs that don't need reinventing inside bos-settings.
2026-07-04 11:04:16 +08:00

246 lines
8.9 KiB
Rust

//! ufw firewall rules — BOS enables ufw by default (deny incoming, allow
//! outgoing, mDNS allowed for printer discovery; see post-install.sh) but
//! previously offered no graphical way to add/remove a rule for something
//! like a local dev server or a LAN game, only a terminal.
//!
//! `ufw status` itself requires root — confirmed against the installed
//! ufw script, which exits with "ERROR: You need to be root to run this
//! script" for a plain status check, not just for changes. Every other
//! panel's read-only state is free to query eagerly in `build()`, but doing
//! that here would mean a polkit password prompt on every single
//! bos-settings launch (every view is constructed immediately at startup —
//! see window.rs). So this panel starts blank and loads state only when the
//! user clicks Refresh, deferring the one unavoidable prompt to an explicit
//! action instead of forcing it on app open.
use gtk4::prelude::*;
use gtk4::{Box as GBox, Button, Entry, Label, ListBox, ListBoxRow, Orientation, ScrolledWindow, Switch};
use std::cell::Cell;
use std::rc::Rc;
use crate::ui::widgets as w;
#[derive(Clone)]
struct Rule {
number: String,
text: String,
}
struct Status {
active: bool,
rules: Vec<Rule>,
}
/// One `pkexec ufw status numbered` call, parsed for both the active/inactive
/// line and the numbered rules — a single privileged read instead of two.
fn fetch_status() -> Option<Status> {
let output = std::process::Command::new("pkexec")
.args(["ufw", "status", "numbered"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8_lossy(&output.stdout);
let active = text.lines().next().is_some_and(|l| l.trim() == "Status: active");
let rules = text
.lines()
.filter_map(|l| {
let l = l.trim_start();
if !l.starts_with('[') {
return None;
}
let (num, rest) = l.split_once(']')?;
let number = num.trim_start_matches('[').trim().to_string();
Some(Rule { number, text: rest.trim().to_string() })
})
.collect();
Some(Status { active, rules })
}
fn render_rules(list: &ListBox, rules: &[Rule], programmatic: &Rc<Cell<bool>>) {
while let Some(child) = list.first_child() {
list.remove(&child);
}
if rules.is_empty() {
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"No rules",
"Only the default policy (deny incoming, allow outgoing) applies.",
)));
list.append(&row);
return;
}
for rule in rules {
let row = ListBoxRow::new();
row.set_selectable(false);
let hbox = GBox::new(Orientation::Horizontal, 16);
hbox.add_css_class("card");
hbox.set_margin_top(3);
hbox.set_margin_bottom(3);
let text_lbl = Label::new(Some(&rule.text));
text_lbl.set_hexpand(true);
text_lbl.set_xalign(0.0);
text_lbl.set_wrap(true);
let remove_btn = Button::with_label("Remove");
remove_btn.add_css_class("destructive-action");
{
let list = list.clone();
let number = rule.number.clone();
let programmatic = programmatic.clone();
remove_btn.connect_clicked(move |_| {
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "--force", "delete", &number],
log_buf,
move || refresh(&list2, None, &programmatic2),
);
});
}
hbox.append(&text_lbl);
hbox.append(&remove_btn);
row.set_child(Some(&hbox));
list.append(&row);
}
}
/// Re-fetch status on a background thread and update the list (+ switch, if
/// given) on completion. `enabled_sw` is `None` when called from a row
/// action (delete/add) where the enabled state can't have changed.
///
/// `programmatic` guards against `set_active` below re-triggering the
/// switch's own `connect_active_notify` handler (which calls `ufw enable`/
/// `disable`) — without it, the very first Refresh after ufw turns out to
/// already be active would immediately fire an unwanted `ufw enable` the
/// moment `set_active(true)` flips a switch that just became sensitive.
fn refresh(list: &ListBox, enabled_sw: Option<&Switch>, programmatic: &Rc<Cell<bool>>) {
let (tx, rx) = async_channel::bounded::<Option<Status>>(1);
std::thread::spawn(move || {
let _ = tx.send_blocking(fetch_status());
});
let list = list.clone();
let enabled_sw = enabled_sw.cloned();
let programmatic = programmatic.clone();
glib::spawn_future_local(async move {
if let Ok(Some(status)) = rx.recv().await {
render_rules(&list, &status.rules, &programmatic);
if let Some(sw) = &enabled_sw {
programmatic.set(true);
sw.set_sensitive(true);
sw.set_active(status.active);
programmatic.set(false);
}
}
});
}
pub fn build() -> GBox {
let (outer, content) = w::view_scaffold("Firewall");
content.append(&w::hint(
"Reading and changing firewall state needs your password (polkit) \
— ufw requires root even just to check status. Click Refresh to \
load the current state.",
));
let programmatic = Rc::new(Cell::new(false));
let enabled_sw = Switch::new();
enabled_sw.set_sensitive(false);
content.append(&w::row("Firewall enabled", &enabled_sw));
content.append(&w::hint(
"Default policy: deny incoming, allow outgoing. mDNS (5353/udp) is \
allowed by default so printer/network discovery keeps working.",
));
let list = ListBox::new();
list.set_selection_mode(gtk4::SelectionMode::None);
{
let row = ListBoxRow::new();
row.set_selectable(false);
row.set_child(Some(&w::empty_state(
"security-high-symbolic",
"Status not loaded",
"Click Refresh below to check the firewall's current state.",
)));
list.append(&row);
}
let scroll = ScrolledWindow::new();
scroll.set_vexpand(true);
scroll.set_min_content_height(260);
scroll.set_child(Some(&list));
content.append(&scroll);
let refresh_btn = Button::with_label("Refresh status");
{
let list = list.clone();
let enabled_sw = enabled_sw.clone();
let programmatic = programmatic.clone();
refresh_btn.connect_clicked(move |_| refresh(&list, Some(&enabled_sw), &programmatic));
}
content.append(&refresh_btn);
{
let list = list.clone();
let programmatic = programmatic.clone();
enabled_sw.connect_active_notify(move |s| {
// Skip both the pre-refresh insensitive state and any
// programmatic set_active() from refresh() itself — only a
// real user click should call out to ufw enable/disable.
if !s.is_sensitive() || programmatic.get() {
return;
}
let verb = if s.is_active() { "enable" } else { "disable" };
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(&["pkexec", "ufw", "--force", verb], log_buf, move || {
refresh(&list2, None, &programmatic2);
});
});
}
content.append(&w::section("Add rule"));
content.append(&w::hint(
"e.g. \"8080/tcp\", \"22/tcp\", or a service name like \"OpenSSH\".",
));
let add_row = GBox::new(Orientation::Horizontal, 8);
let add_entry = Entry::new();
add_entry.set_hexpand(true);
add_entry.set_placeholder_text(Some("port/proto or service name"));
let add_btn = Button::with_label("Allow");
{
let list = list.clone();
let add_entry = add_entry.clone();
let programmatic = programmatic.clone();
add_btn.connect_clicked(move |_| {
let rule = add_entry.text().to_string();
if rule.trim().is_empty() {
return;
}
let log_buf = gtk4::TextBuffer::new(None);
let list2 = list.clone();
let add_entry2 = add_entry.clone();
let programmatic2 = programmatic.clone();
w::stream_command_then(
&["pkexec", "ufw", "allow", rule.trim()],
log_buf,
move || {
add_entry2.set_text("");
refresh(&list2, None, &programmatic2);
},
);
});
}
add_row.append(&add_entry);
add_row.append(&add_btn);
content.append(&add_row);
outer
}