a5254c8798
The Linux client already had WOL send + MAC storage + a Wake action + auto-wake- on-connect, but the auto-wake just fired a packet and did one dial to the stored address — so a host that woke on a new DHCP lease failed, and there was no "waiting" feedback. Add the polished flow (mirrors Apple/Android HostWaker): - ui_trust::wake_and_connect — send the magic packet, poll mDNS until the host reappears (re-sending every 6 s, 90 s budget) behind a cancelable "Waking…" dialog, then connect; if it woke on a new IP, re-key the saved host first. - trust::rekey_addr — no-churn addr/port update keyed by fingerprint. - the hosts page routes an offline saved-host-with-MAC tap to on_wake_connect (the new flow) instead of fire-and-forget wake + immediate dial. Builds + clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
348 lines
15 KiB
Rust
348 lines
15 KiB
Rust
//! The trust gate and dialogs in front of every connect: TOFU, the SPAKE2 PIN ceremony,
|
||
//! and delegated (request-access) approval.
|
||
|
||
use crate::app::App;
|
||
use crate::launch::{start_session, start_session_with, StartOpts};
|
||
use crate::trust;
|
||
use crate::ui_hosts::ConnectRequest;
|
||
use adw::prelude::*;
|
||
use gtk::glib;
|
||
use std::rc::Rc;
|
||
|
||
/// The trust gate in front of every connect. The host is the policy authority (it
|
||
/// advertises `pair=optional` only when it accepts unpaired clients); the client renders
|
||
/// its trust UI from that:
|
||
/// 1. PINNED RECONNECT — a host already pinned to this exact fingerprint connects silently.
|
||
/// 2. FINGERPRINT CHANGED — a host we know at this address but whose fingerprint no longer
|
||
/// matches is the impostor signal: force re-pairing via the PIN ceremony, regardless of
|
||
/// the advertised policy.
|
||
/// 3. NEW host — TOFU is offered only when the host advertised `pair=optional` (rule 3a);
|
||
/// otherwise (pair=required, unknown/empty policy, or a manual entry) PIN pairing is
|
||
/// mandatory (rule 3b).
|
||
///
|
||
/// A new host is never auto-connected without a stored pin or an explicit trust decision.
|
||
pub fn initiate_connect(app: Rc<App>, req: ConnectRequest) {
|
||
if app.busy.get() {
|
||
return;
|
||
}
|
||
let known = trust::KnownHosts::load();
|
||
match &req.fp_hex {
|
||
Some(fp_hex) => {
|
||
if known.find_by_fp(fp_hex).is_some() {
|
||
// Rule 1: pinned fingerprint matches — silent connect.
|
||
start_session(app, req.clone(), trust::parse_hex32(fp_hex));
|
||
} else if known.find_by_addr(&req.addr, req.port).is_some() {
|
||
// Rule 2: we trust a host at this address but the fingerprint changed —
|
||
// the impostor signal. Re-pair via the PIN ceremony (no TOFU shortcut).
|
||
app.toast("Host fingerprint changed — re-pair with a PIN to continue");
|
||
pin_dialog(app, req);
|
||
} else if req.pair_optional {
|
||
// Rule 3a: the host opted into reduced-security TOFU; offer it alongside PIN.
|
||
tofu_dialog(app, req);
|
||
} else {
|
||
// Rule 3b: pair=required or unknown policy — offer no-PIN delegated approval
|
||
// (request access → approve in the console) or the PIN ceremony.
|
||
approval_dialog(app, req);
|
||
}
|
||
}
|
||
None => {
|
||
// Manual entry (no advertised fingerprint). A known address connects silently
|
||
// on its stored pin (rule 1); an unknown one must pair — request access (approve in
|
||
// the console) or use a PIN; never silent TOFU.
|
||
match known
|
||
.find_by_addr(&req.addr, req.port)
|
||
.and_then(|k| trust::parse_hex32(&k.fp_hex))
|
||
{
|
||
Some(pin) => start_session(app, req, Some(pin)),
|
||
None => approval_dialog(app, req), // rule 3b
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Wake-and-wait: an **offline** saved host with a known MAC is sent a magic packet, then we poll
|
||
/// mDNS until it comes back online — re-sending every few seconds up to a timeout — and dial it via
|
||
/// [`initiate_connect`], **re-keying the saved record if the host woke on a new DHCP IP** (matched by
|
||
/// fingerprint). A "Waking…" dialog lets the user cancel. Mirrors the Apple/Android `HostWaker` (a
|
||
/// 90 s budget, resend every 6 s). The online path stays on the fast [`initiate_connect`]; this runs
|
||
/// only from the hosts page's auto-wake when a saved host isn't advertising.
|
||
pub fn wake_and_connect(app: Rc<App>, req: ConnectRequest) {
|
||
if app.busy.get() {
|
||
return;
|
||
}
|
||
let cancel = 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(&app.window));
|
||
|
||
glib::spawn_future_local(async move {
|
||
use std::time::{Duration, Instant};
|
||
let events = crate::discovery::browse();
|
||
let started = Instant::now();
|
||
let budget = Duration::from_secs(90);
|
||
let resend = Duration::from_secs(6);
|
||
// Fire the first packet now, then re-send on the resend cadence.
|
||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||
let mut last_wake = Instant::now();
|
||
loop {
|
||
if cancel.get() {
|
||
waiting.close();
|
||
return;
|
||
}
|
||
if last_wake.elapsed() >= resend {
|
||
crate::wol::wake(&req.mac, req.addr.parse().ok());
|
||
last_wake = Instant::now();
|
||
}
|
||
// Drain resolved adverts; a match (by fingerprint, else addr:port) means the host is up.
|
||
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 {
|
||
waiting.close();
|
||
let mut req = req.clone();
|
||
// Re-key on a new DHCP lease so this + future connects dial the live address.
|
||
if h.addr != req.addr || h.port != req.port {
|
||
if let Some(fp) = &req.fp_hex {
|
||
trust::rekey_addr(fp, &h.addr, h.port);
|
||
}
|
||
req.addr = h.addr;
|
||
req.port = h.port;
|
||
}
|
||
initiate_connect(app.clone(), req);
|
||
return;
|
||
}
|
||
}
|
||
if started.elapsed() >= budget {
|
||
waiting.close();
|
||
app.toast(&format!(
|
||
"Couldn't reach “{}” — is it powered and on the network?",
|
||
req.name
|
||
));
|
||
return;
|
||
}
|
||
glib::timeout_future(Duration::from_millis(500)).await;
|
||
}
|
||
});
|
||
}
|
||
|
||
/// The certificate fingerprint as grouped monospaced hex — 4-char groups over 2 lines
|
||
/// (the Apple TrustCardView format), 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: show the advertised fingerprint and let the user
|
||
/// trust it (TOFU), run the PIN ceremony instead, or walk away.
|
||
pub fn tofu_dialog(app: Rc<App>, 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 parent = app.window.clone();
|
||
dialog.connect_response(None, move |_, response| match response {
|
||
"trust" => {
|
||
trust::persist_host(&req.name, &req.addr, req.port, &fp, false);
|
||
start_session(app.clone(), req.clone(), trust::parse_hex32(&fp));
|
||
}
|
||
"pair" => pin_dialog(app.clone(), req.clone()),
|
||
_ => {}
|
||
});
|
||
dialog.present(Some(&parent));
|
||
}
|
||
|
||
/// 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.
|
||
pub fn pin_dialog(app: Rc<App>, 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 (its paired-devices list) — prefilled
|
||
// with the machine hostname, editable (the Apple pair sheet does the same).
|
||
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 parent = app.window.clone();
|
||
dialog.connect_response(Some("pair"), move |_, _| {
|
||
let pin = entry.text().to_string();
|
||
let app = app.clone();
|
||
let req = req.clone();
|
||
let identity = app.identity.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 || {
|
||
let result = trust::pair_with_host(&host, port, &identity, &pin, &name)
|
||
.map_err(|e| format!("Pairing failed: {e:?} (wrong PIN, or pairing not armed?)"));
|
||
let _ = tx.send_blocking(result);
|
||
});
|
||
glib::spawn_future_local(async move {
|
||
match rx.recv().await {
|
||
Ok(Ok(fp)) => {
|
||
trust::persist_host(&req.name, &req.addr, req.port, &trust::hex(&fp), true);
|
||
app.toast("Paired — connecting…");
|
||
start_session(app.clone(), req, Some(fp));
|
||
}
|
||
Ok(Err(msg)) => app.toast(&msg),
|
||
Err(_) => {}
|
||
}
|
||
});
|
||
});
|
||
dialog.present(Some(&parent));
|
||
}
|
||
|
||
/// A fresh host that requires pairing: offer the two ways in. "Request access" is the no-PIN
|
||
/// path — connect and wait for the operator to click Approve in the host's console/web UI
|
||
/// (delegated approval); "Use a PIN instead…" runs the SPAKE2 ceremony.
|
||
fn approval_dialog(app: Rc<App>, 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 = app.window.clone();
|
||
dialog.connect_response(None, move |_, response| match response {
|
||
"request" => request_access(app.clone(), req.clone()),
|
||
"pin" => pin_dialog(app.clone(), req.clone()),
|
||
_ => {}
|
||
});
|
||
dialog.present(Some(&parent));
|
||
}
|
||
|
||
/// The no-PIN "request access" flow: open an identified connect that the host PARKS until the
|
||
/// operator approves it in the console, showing a cancelable "waiting" dialog meanwhile. On
|
||
/// approval the same connection is admitted (no reconnect) and the host is saved as paired.
|
||
fn request_access(app: Rc<App>, req: ConnectRequest) {
|
||
// Pin the advertised certificate for a discovered host (defence against a host impostor while
|
||
// we wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
|
||
let pin = req.fp_hex.as_deref().and_then(trust::parse_hex32);
|
||
let cancel = Rc::new(std::cell::Cell::new(false));
|
||
|
||
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 app = app.clone();
|
||
let cancel = cancel.clone();
|
||
waiting.connect_response(Some("cancel"), move |_, _| {
|
||
// Return the UI immediately; the in-flight connect is left to time out and is torn
|
||
// down silently by the event loop (see StartOpts::cancel).
|
||
cancel.set(true);
|
||
app.busy.set(false);
|
||
app.toast("Cancelled — the request may still be pending on the host.");
|
||
});
|
||
}
|
||
waiting.present(Some(&app.window));
|
||
|
||
start_session_with(
|
||
app,
|
||
req,
|
||
pin,
|
||
StartOpts {
|
||
// Must exceed the host's approval window (PENDING_APPROVAL_WAIT) so a slow operator
|
||
// approval still lands on this connection rather than timing the client out first.
|
||
connect_timeout: std::time::Duration::from_secs(185),
|
||
persist_paired: true,
|
||
waiting: Some(waiting),
|
||
cancel: Some(cancel),
|
||
},
|
||
);
|
||
}
|