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.
246 lines
8.9 KiB
Rust
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
|
|
}
|