Files
punktfunk/crates/pf-client-core/src/ui_trust.rs
T
enricobuehlerandClaude Opus 5 ad99be2fb2 feat(client): the overlay learns absorb + clear, so per-control shells can edit profiles too
Groundwork for the Windows half of P1, and a dedup of the Linux one.

The two shells commit differently: GTK writes once when the dialog closes (so it can hand
over a list of touched rows), WinUI writes on every control change (so it can't). `absorb`
serves the second shape — give it the effective settings before and after one control
fired, and it records the field that moved.

That is deliberately NOT the diff-on-save the design rejects. The comparison is against
the EFFECTIVE settings, i.e. what the control was actually showing, not against the
globals — so setting a value back to whatever the global happens to be still records an
override, which is exactly the pin the design wants. It only ever adds; removing an
override stays an explicit, separate operation.

`clear` is that operation, keyed by the overlay's own field names, with `resolution` as
the one alias for the width/height/match-window tri-state a single control drives on every
client. The GTK reset path now calls it instead of carrying its own `match` — that second
list was already a place where the two could drift, and the next client would have made it
a third.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-29 00:04:26 +02:00

351 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
//! delegated (request-access) approval. The trust GATE itself (rules 13) lives in
//! `AppModel::update` (`AppMsg::Connect`); these are the interaction surfaces it opens,
//! each resolving into typed [`AppMsg`]s.
use crate::app::{AppModel, AppMsg};
use crate::spawn::{CancelHandle, SpawnOpts};
use crate::trust;
use crate::ui_hosts::ConnectRequest;
use adw::prelude::*;
use gtk::glib;
use pf_client_core::orchestrate::{WakeOutcome, WakeWait};
use relm4::prelude::*;
/// Wake-and-wait: the FALLBACK after a failed dial to a non-advertising saved host with a
/// known MAC (`AppMsg::WakeConnect` dials first — mDNS absence ≠ unreachable). The host is
/// sent a magic packet, then we poll mDNS until it comes back online — re-sending every few
/// seconds up to a timeout — and route back into the trust gate, **re-keying the saved
/// record if the host woke on a new DHCP IP** (matched by fingerprint). A "Waking…" dialog
/// lets the user cancel.
///
/// The cadence itself is [`WakeWait`] — the same state machine the WinUI shell drives, ported
/// from Apple's `HostWaker` (design/client-architecture-split.md §3). What is left here is the
/// GTK half: the dialog, the advert drain, the re-key, and the route back into the trust gate.
pub fn wake_and_connect(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
req: ConnectRequest,
) {
let cancel = std::rc::Rc::new(std::cell::Cell::new(false));
let waiting = adw::AlertDialog::new(
Some("Waking Host"),
Some(&format!(
"Sent a wake signal to “{}”. Waiting for it to come online…",
req.name
)),
);
waiting.add_responses(&[("cancel", "Cancel")]);
waiting.set_close_response("cancel");
{
let cancel = cancel.clone();
waiting.connect_response(Some("cancel"), move |_, _| cancel.set(true));
}
waiting.present(Some(window));
let sender = sender.clone();
glib::spawn_future_local(async move {
use std::time::Duration;
let events = crate::discovery::browse();
let mut wait = WakeWait::new();
loop {
if cancel.get() {
waiting.close();
return;
}
// Drain resolved adverts; a match (fingerprint, else addr:port) means it is up,
// and carries the address it came back on.
let mut seen: Option<(String, u16)> = None;
while let Ok(ev) = events.try_recv() {
let crate::discovery::DiscoveryEvent::Resolved(h) = ev else {
continue;
};
let matched = match &req.fp_hex {
Some(fp) => !h.fp_hex.is_empty() && &h.fp_hex == fp,
None => h.addr == req.addr && h.port == req.port,
};
if matched {
seen = Some((h.addr, h.port));
}
}
let tick = wait.tick(seen.is_some());
if tick.send_packet {
crate::wol::wake(&req.mac, req.addr.parse().ok());
}
match tick.outcome {
Some(WakeOutcome::Online) => {
waiting.close();
let mut req = req.clone();
// Re-key on a new DHCP lease so this + future connects dial the
// live address.
if let Some((addr, port)) =
seen.filter(|(a, p)| *a != req.addr || *p != req.port)
{
if let Some(fp) = &req.fp_hex {
trust::rekey_addr(fp, &addr, port);
}
req.addr = addr;
req.port = port;
}
sender.input(AppMsg::Connect(req));
return;
}
Some(WakeOutcome::TimedOut) => {
waiting.close();
sender.input(AppMsg::Toast(format!(
"Couldn't reach “{}” — is it powered and on the network?",
req.name
)));
return;
}
None => {}
}
glib::timeout_future(Duration::from_secs(1)).await;
}
});
}
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines,
/// far easier to compare against the host's log than one 64-char run.
fn grouped_fingerprint(fp: &str) -> String {
let groups: Vec<&str> = fp
.as_bytes()
.chunks(4)
.map(|c| std::str::from_utf8(c).unwrap_or(""))
.collect();
groups
.chunks(8)
.map(|line| line.join(" "))
.collect::<Vec<_>>()
.join("\n")
}
/// First contact with a discovered host that opted into TOFU: show the advertised
/// fingerprint and let the user trust it, run the PIN ceremony instead, or walk away.
/// Trusting starts a session pinned to the advertised fp; it persists once the child
/// proves the host holds that identity (`AppMsg::SessionReady` with `tofu`).
pub fn tofu_dialog(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
req: ConnectRequest,
) {
let fp = req.fp_hex.clone().unwrap_or_default();
let dialog = adw::AlertDialog::new(
Some("New Host"),
Some(&format!(
"{} at {}:{}\n\nPairing with a PIN verifies the certificate fingerprint below; \
trusting accepts it as-is.",
req.name, req.addr, req.port
)),
);
let fp_label = gtk::Label::new(Some(&grouped_fingerprint(&fp)));
fp_label.add_css_class("monospace");
fp_label.set_selectable(true);
fp_label.set_justify(gtk::Justification::Center);
fp_label.set_halign(gtk::Align::Center);
dialog.set_extra_child(Some(&fp_label));
dialog.add_responses(&[
("cancel", "Cancel"),
("pair", "Pair with PIN…"),
("trust", "Trust & Connect"),
]);
dialog.set_response_appearance("trust", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("trust"));
dialog.set_close_response("cancel");
let sender = sender.clone();
dialog.connect_response(None, move |_, response| match response {
"trust" => sender.input(AppMsg::StartSession {
req: req.clone(),
fp_hex: fp.clone(),
tofu: true,
opts: SpawnOpts::default(),
}),
"pair" => sender.input(AppMsg::Pair(req.clone())),
_ => {}
});
dialog.present(Some(window));
}
/// The SPAKE2 ceremony: the host is armed and displays a 4-digit PIN; proving knowledge
/// of it pins the host's certificate (and registers ours) with no offline-guessable
/// transcript. Success persists the host as paired and connects.
pub fn pin_dialog(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
identity: (String, String),
req: ConnectRequest,
) {
let entry = gtk::Entry::builder()
.input_purpose(gtk::InputPurpose::Digits)
.placeholder_text("4-digit PIN shown by the host")
.activates_default(true)
.build();
// The label the HOST stores this client under — prefilled with the hostname.
let name_entry = gtk::Entry::builder()
.text(glib::host_name().as_str())
.activates_default(true)
.build();
let name_caption = gtk::Label::new(Some("This device"));
name_caption.add_css_class("caption");
name_caption.add_css_class("dim-label");
name_caption.set_halign(gtk::Align::Start);
let fields = gtk::Box::new(gtk::Orientation::Vertical, 6);
fields.append(&name_caption);
fields.append(&name_entry);
let pin_caption = gtk::Label::new(Some("PIN"));
pin_caption.add_css_class("caption");
pin_caption.add_css_class("dim-label");
pin_caption.set_halign(gtk::Align::Start);
fields.append(&pin_caption);
fields.append(&entry);
let dialog = adw::AlertDialog::new(
Some("Pair with PIN"),
Some(&format!(
"Arm pairing on {} (console or web UI), then enter the PIN it displays.",
req.name
)),
);
dialog.set_extra_child(Some(&fields));
dialog.add_responses(&[("cancel", "Cancel"), ("pair", "Pair")]);
dialog.set_response_appearance("pair", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("pair"));
dialog.set_close_response("cancel");
let sender = sender.clone();
dialog.connect_response(Some("pair"), move |_, _| {
let pin = entry.text().to_string();
let req = req.clone();
let identity = identity.clone();
let sender = sender.clone();
let (tx, rx) = async_channel::bounded::<Result<[u8; 32], String>>(1);
let device = name_entry.text().trim().to_string();
let name = if device.is_empty() {
glib::host_name().to_string()
} else {
device
};
let (host, port) = (req.addr.clone(), req.port);
std::thread::spawn(move || {
// Cause-specific wording (wrong PIN vs not-armed vs unreachable vs a typed host
// rejection) — never blame the PIN for a dead network path.
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
.map_err(|e| trust::pair_error_message(&e));
let _ = tx.send_blocking(result);
});
glib::spawn_future_local(async move {
match rx.recv().await {
Ok(Ok(fp)) => {
let fp_hex = trust::hex(&fp);
trust::persist_host(&req.name, &req.addr, req.port, &fp_hex, true);
sender.input(AppMsg::Toast("Paired — connecting…".into()));
sender.input(AppMsg::StartSession {
req,
fp_hex,
tofu: false,
opts: SpawnOpts::default(),
});
}
Ok(Err(msg)) => sender.input(AppMsg::Toast(msg)),
Err(_) => {}
}
});
});
dialog.present(Some(window));
}
/// A fresh host that requires pairing: "Request access" (connect and wait for the
/// operator to click Approve in the host's console — delegated approval) or the PIN
/// ceremony.
pub fn approval_dialog(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
req: ConnectRequest,
) {
let dialog = adw::AlertDialog::new(
Some("Pairing Required"),
Some(&format!(
"{} requires pairing.\n\nRequest access and approve this device in the host's console \
(or web UI) — no PIN needed. Or pair with the 4-digit PIN it can display.",
req.name
)),
);
dialog.add_responses(&[
("cancel", "Cancel"),
("pin", "Use a PIN instead…"),
("request", "Request Access"),
]);
dialog.set_response_appearance("request", adw::ResponseAppearance::Suggested);
dialog.set_default_response(Some("request"));
dialog.set_close_response("cancel");
let parent = window.clone();
let window = window.clone();
let sender = sender.clone();
dialog.connect_response(None, move |_, response| match response {
"request" => request_access(&window, &sender, waiting_slot.clone(), req.clone()),
"pin" => sender.input(AppMsg::Pair(req.clone())),
_ => {}
});
dialog.present(Some(&parent));
}
/// The no-PIN "request access" flow: the session child opens an identified connect the
/// host PARKS until the operator approves it in the console; a cancelable "waiting"
/// dialog covers the wait. On approval the same connection is admitted and the host is
/// saved as paired. Cancel kills the child (the only abort a parked connect has).
///
/// The pinned fingerprint is the advertised one for a discovered host (defence against
/// an impostor while we wait). A manually-typed host has no advertised fingerprint —
/// the session binary refuses pinless connects, so this path requires the advert; a
/// manual entry's Request Access rides the same flow only when a fingerprint exists.
fn request_access(
window: &adw::ApplicationWindow,
sender: &ComponentSender<AppModel>,
waiting_slot: std::rc::Rc<std::cell::RefCell<Option<adw::AlertDialog>>>,
req: ConnectRequest,
) {
let Some(fp_hex) = req.fp_hex.clone() else {
// No fingerprint to pin (manual entry): the strict child can't do a
// trust-on-approval connect — route to the PIN ceremony instead.
sender.input(AppMsg::Toast(
"No advertised identity for this host — pair with a PIN instead.".into(),
));
sender.input(AppMsg::Pair(req));
return;
};
let cancel = CancelHandle::default();
let waiting = adw::AlertDialog::new(
Some("Waiting for Approval"),
Some(&format!(
"Approve “{}” in {}s console or web UI.\n\nThis device is waiting to be let in — it \
connects automatically once you approve it.",
glib::host_name(),
req.name
)),
);
waiting.add_responses(&[("cancel", "Cancel")]);
waiting.set_close_response("cancel");
{
let sender = sender.clone();
let cancel = cancel.clone();
waiting.connect_response(Some("cancel"), move |_, _| {
cancel.kill();
sender.input(AppMsg::CancelPending);
});
}
waiting.present(Some(window));
*waiting_slot.borrow_mut() = Some(waiting);
sender.input(AppMsg::StartSession {
req,
fp_hex,
tofu: false,
opts: SpawnOpts {
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow
// operator approval still lands on this connection.
connect_timeout_secs: Some(185),
persist_paired: true,
cancel: Some(cancel),
},
});
}