audit / bun-audit (push) Successful in 21s
ci / web (push) Successful in 1m39s
ci / docs-site (push) Successful in 2m7s
audit / cargo-audit (push) Successful in 2m55s
apple / swift (push) Successful in 5m25s
ci / bench (push) Successful in 7m47s
ci / rust-arm64 (push) Successful in 10m28s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
windows-host / package (push) Failing after 11m22s
windows-host / winget-source (push) Skipped
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
deb / build-publish-client-arm64 (push) Failing after 6m0s
deb / build-publish (push) Successful in 13m3s
arch / build-publish (push) Successful in 16m27s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m36s
flatpak / build-publish (push) Successful in 6m50s
android / android (push) Successful in 19m52s
deb / build-publish-host (push) Successful in 16m54s
ci / rust (push) Successful in 22m54s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m48s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 6m1s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 20m33s
release / apple (push) Successful in 30m41s
docker / build-push-arm64cross (push) Successful in 24s
docker / deploy-docs (push) Successful in 26s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 28m24s
apple / screenshots (push) Successful in 22m37s
The last of C0's duplication: GTK's `wake_and_connect` and the WinUI shell's ran their own copies of the same 90 s / 6 s / 1 s loop, and the Windows one's comment said it "mirrors the Apple HostWaker" — now it literally does, because both drive `WakeWait`. What is left in each shell is the part that genuinely differs: its dialog or screen, its advert drain, its re-key, and its route back into the trust gate. Behavioural review, since a sleeping host to test against wasn't available: - GTK polled every 500 ms and resent on a wall-clock timer; it now ticks once a second like every other implementation. Up to half a second slower to notice a host coming back, and exactly the reference cadence in exchange. - GTK took the FIRST matching advert in a drain and returned mid-loop; it now drains fully and takes the last, i.e. the freshest address. Same connect, fresher re-key. - Windows sent one packet before the loop AND one on its first pass; the machine owns the packets now, so that duplicate is gone. Resends still land at 6 s, 12 s, … and the budget still expires at 90 s. - Both keep their own cancel path, their own re-key, and their own error surface. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
351 lines
14 KiB
Rust
351 lines
14 KiB
Rust
//! The trust dialogs in front of a connect: TOFU, the SPAKE2 PIN ceremony, and
|
||
//! delegated (request-access) approval. The trust GATE itself (rules 1–3) 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),
|
||
},
|
||
});
|
||
}
|