feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities, SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned protocol-8 pulls. The runtime now owns each network generation and all admitted work through shutdown. Add exact bundled content identities, reproducible manifest publishing, capability-confined downloads, streaming BLAKE3 verification, quarantine and retry, and crash-recoverable download and install transactions. Ship generated fixture catalogs and fail closed when production manifests are absent. The Tauri backend exposes durable sharing policy, redacted identity state, and attempt-keyed transfer snapshots. Frontend consumption follows in the next commit. Repository-wide test certificates and protocol-7 paths are removed. BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts; protocol-7 frames and shared-certificate identities are no longer accepted. Test Plan: - `just test` -- passed on the completed stack (708 workspace tests) - `just clippy` -- passed on the completed stack - `just build` -- passed with fixture catalogs on the completed stack - `just catalog-check-production` -- failed closed because the external production manifest corpus is absent - `git diff --cached --check` -- passed
This commit is contained in:
@@ -5,7 +5,6 @@ edition = "2024"
|
||||
|
||||
[lib]
|
||||
doctest = false
|
||||
test = false
|
||||
|
||||
[dependencies]
|
||||
eyre = { workspace = true }
|
||||
|
||||
@@ -3,18 +3,74 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use eyre::bail;
|
||||
use eyre::{WrapErr as _, bail};
|
||||
pub use mdns_sd::DaemonEvent;
|
||||
use mdns_sd::{Receiver, ResolvedService, ServiceDaemon, ServiceEvent, ServiceInfo};
|
||||
use mdns_sd::{
|
||||
DaemonStatus,
|
||||
Error as MdnsError,
|
||||
Receiver,
|
||||
ResolvedService,
|
||||
ServiceDaemon,
|
||||
ServiceEvent,
|
||||
ServiceInfo,
|
||||
UnregisterStatus,
|
||||
};
|
||||
|
||||
pub const LANSPREAD_SERVICE_TYPE: &str = "_lanspread._udp.local.";
|
||||
pub type MdnsMonitor = Receiver<DaemonEvent>;
|
||||
const DAEMON_COMMAND_RETRY_DELAY: Duration = Duration::from_millis(1);
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum UnregisterOutcome {
|
||||
Removed,
|
||||
AlreadyAbsent,
|
||||
}
|
||||
|
||||
struct DaemonOwner {
|
||||
daemon: Option<ServiceDaemon>,
|
||||
}
|
||||
|
||||
impl DaemonOwner {
|
||||
fn new(daemon: ServiceDaemon) -> Self {
|
||||
Self {
|
||||
daemon: Some(daemon),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon(&self) -> &ServiceDaemon {
|
||||
self.daemon
|
||||
.as_ref()
|
||||
.expect("mDNS daemon is available until its owner is closed")
|
||||
}
|
||||
|
||||
fn is_closed(&self) -> bool {
|
||||
self.daemon.is_none()
|
||||
}
|
||||
|
||||
fn close(&mut self) -> eyre::Result<()> {
|
||||
let Some(daemon) = self.daemon.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
shutdown_and_wait(daemon)?;
|
||||
self.daemon.take();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DaemonOwner {
|
||||
fn drop(&mut self) {
|
||||
if let Err(err) = self.close() {
|
||||
log::error!("Failed to stop mDNS daemon during cleanup: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MdnsAdvertiser {
|
||||
daemon: ServiceDaemon,
|
||||
daemon: DaemonOwner,
|
||||
service_info: ServiceInfo,
|
||||
pub monitor: Receiver<DaemonEvent>,
|
||||
}
|
||||
@@ -26,8 +82,19 @@ impl MdnsAdvertiser {
|
||||
address: SocketAddr,
|
||||
properties: Option<HashMap<String, String>>,
|
||||
) -> eyre::Result<Self> {
|
||||
let host_name = format!("{}.local.", address.ip());
|
||||
let daemon = ServiceDaemon::new()?;
|
||||
Self::new_with_daemon(daemon, service_type, instance_name, address, properties)
|
||||
}
|
||||
|
||||
fn new_with_daemon(
|
||||
daemon: ServiceDaemon,
|
||||
service_type: &str,
|
||||
instance_name: &str,
|
||||
address: SocketAddr,
|
||||
properties: Option<HashMap<String, String>>,
|
||||
) -> eyre::Result<Self> {
|
||||
let daemon = DaemonOwner::new(daemon);
|
||||
let host_name = format!("{}.local.", address.ip());
|
||||
let service_info = ServiceInfo::new(
|
||||
service_type,
|
||||
instance_name,
|
||||
@@ -37,10 +104,10 @@ impl MdnsAdvertiser {
|
||||
properties,
|
||||
)?;
|
||||
|
||||
let monitor = daemon.monitor()?;
|
||||
let monitor = daemon.daemon().monitor()?;
|
||||
|
||||
// Register the service
|
||||
daemon.register(service_info.clone())?;
|
||||
daemon.daemon().register(service_info.clone())?;
|
||||
|
||||
Ok(Self {
|
||||
daemon,
|
||||
@@ -48,17 +115,33 @@ impl MdnsAdvertiser {
|
||||
monitor,
|
||||
})
|
||||
}
|
||||
|
||||
/// Unregisters the service and waits for the daemon's shutdown acknowledgement.
|
||||
pub fn close(mut self) -> eyre::Result<()> {
|
||||
self.close_inner()
|
||||
}
|
||||
|
||||
fn close_inner(&mut self) -> eyre::Result<()> {
|
||||
if self.daemon.is_closed() {
|
||||
return Ok(());
|
||||
}
|
||||
let unregister_result =
|
||||
unregister_and_wait(self.daemon.daemon(), self.service_info.get_fullname()).map(|_| ());
|
||||
let shutdown_result = self.daemon.close();
|
||||
combine_cleanup_results(unregister_result, shutdown_result)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MdnsAdvertiser {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.daemon.unregister(self.service_info.get_fullname());
|
||||
let _ = self.daemon.shutdown();
|
||||
if let Err(err) = self.close_inner() {
|
||||
log::error!("Failed to close mDNS advertiser during cleanup: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MdnsBrowser {
|
||||
daemon: ServiceDaemon,
|
||||
daemon: DaemonOwner,
|
||||
receiver: Receiver<ServiceEvent>,
|
||||
service_type: String,
|
||||
}
|
||||
@@ -81,7 +164,12 @@ pub enum MdnsServicePoll {
|
||||
impl MdnsBrowser {
|
||||
pub fn new(service_type: &str) -> eyre::Result<Self> {
|
||||
let daemon = ServiceDaemon::new()?;
|
||||
let receiver = daemon.browse(service_type)?;
|
||||
Self::new_with_daemon(daemon, service_type)
|
||||
}
|
||||
|
||||
fn new_with_daemon(daemon: ServiceDaemon, service_type: &str) -> eyre::Result<Self> {
|
||||
let daemon = DaemonOwner::new(daemon);
|
||||
let receiver = daemon.daemon().browse(service_type)?;
|
||||
Ok(Self {
|
||||
daemon,
|
||||
receiver,
|
||||
@@ -89,6 +177,11 @@ impl MdnsBrowser {
|
||||
})
|
||||
}
|
||||
|
||||
/// Stops browsing and waits for the daemon's shutdown acknowledgement.
|
||||
pub fn close(mut self) -> eyre::Result<()> {
|
||||
self.close_inner()
|
||||
}
|
||||
|
||||
pub fn next_service(
|
||||
&self,
|
||||
ignore_addr: Option<SocketAddr>,
|
||||
@@ -207,11 +300,99 @@ impl MdnsBrowser {
|
||||
log::error!("No address found in mDNS response: {info:?}");
|
||||
None
|
||||
}
|
||||
|
||||
fn close_inner(&mut self) -> eyre::Result<()> {
|
||||
self.daemon.close()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for MdnsBrowser {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.daemon.shutdown();
|
||||
if let Err(err) = self.close_inner() {
|
||||
log::error!("Failed to close mDNS browser during cleanup: {err:#}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn unregister_and_wait(daemon: &ServiceDaemon, fullname: &str) -> eyre::Result<UnregisterOutcome> {
|
||||
let receiver = loop {
|
||||
match daemon.unregister(fullname) {
|
||||
Ok(receiver) => break receiver,
|
||||
Err(MdnsError::Again) => thread::sleep(DAEMON_COMMAND_RETRY_DELAY),
|
||||
Err(err) => return Err(err).wrap_err("failed to request mDNS service unregister"),
|
||||
}
|
||||
};
|
||||
|
||||
match receiver
|
||||
.recv()
|
||||
.wrap_err("mDNS unregister response channel closed")?
|
||||
{
|
||||
UnregisterStatus::OK => Ok(UnregisterOutcome::Removed),
|
||||
// Cleanup is idempotent: the desired postcondition already holds if
|
||||
// the daemon no longer has this service in its registration table.
|
||||
UnregisterStatus::NotFound => Ok(UnregisterOutcome::AlreadyAbsent),
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown_and_wait(daemon: &ServiceDaemon) -> eyre::Result<()> {
|
||||
let receiver = loop {
|
||||
match daemon.shutdown() {
|
||||
Ok(receiver) => break receiver,
|
||||
Err(MdnsError::Again) => thread::sleep(DAEMON_COMMAND_RETRY_DELAY),
|
||||
Err(MdnsError::DaemonShutdown) => return Ok(()),
|
||||
// `mdns-sd` enqueues the command before waking its daemon socket. A
|
||||
// wake failure can therefore return an error even though shutdown
|
||||
// is already queued. Retry until the daemon confirms it stopped.
|
||||
Err(err) => {
|
||||
log::warn!("Failed to signal mDNS daemon shutdown; retrying: {err}");
|
||||
thread::sleep(DAEMON_COMMAND_RETRY_DELAY);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match receiver.recv() {
|
||||
Ok(DaemonStatus::Shutdown) => Ok(()),
|
||||
Ok(status) => bail!("mDNS daemon returned unexpected shutdown status: {status:?}"),
|
||||
Err(_) => wait_for_reported_shutdown(daemon),
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_reported_shutdown(daemon: &ServiceDaemon) -> eyre::Result<()> {
|
||||
loop {
|
||||
let receiver = match daemon.status() {
|
||||
Ok(receiver) => receiver,
|
||||
Err(MdnsError::Again) => {
|
||||
thread::sleep(DAEMON_COMMAND_RETRY_DELAY);
|
||||
continue;
|
||||
}
|
||||
Err(MdnsError::DaemonShutdown) => return Ok(()),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to query mDNS daemon shutdown status; retrying: {err}");
|
||||
thread::sleep(DAEMON_COMMAND_RETRY_DELAY);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match receiver.recv() {
|
||||
Ok(DaemonStatus::Shutdown) => return Ok(()),
|
||||
Ok(DaemonStatus::Running) | Err(_) => {
|
||||
thread::sleep(DAEMON_COMMAND_RETRY_DELAY);
|
||||
}
|
||||
Ok(status) => bail!("mDNS daemon returned unexpected status: {status:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn combine_cleanup_results(
|
||||
first: eyre::Result<()>,
|
||||
shutdown: eyre::Result<()>,
|
||||
) -> eyre::Result<()> {
|
||||
match (first, shutdown) {
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
(Err(err), Ok(())) | (Ok(()), Err(err)) => Err(err),
|
||||
(Err(first_err), Err(shutdown_err)) => bail!(
|
||||
"mDNS cleanup failed: {first_err:#}; daemon shutdown also failed: {shutdown_err:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,8 +402,135 @@ pub fn discover_service(
|
||||
) -> eyre::Result<SocketAddr> {
|
||||
// Currently unused; kept for potential one-off discovery callers that just need a single address.
|
||||
let browser = MdnsBrowser::new(service_type)?;
|
||||
match browser.next_address(ignore_addr)? {
|
||||
Some(addr) => Ok(addr),
|
||||
None => bail!("No server found."),
|
||||
let result = browser
|
||||
.next_address(ignore_addr)
|
||||
.and_then(|address| address.ok_or_else(|| eyre::eyre!("No server found.")));
|
||||
let shutdown_result = browser.close();
|
||||
match (result, shutdown_result) {
|
||||
(Ok(address), Ok(())) => Ok(address),
|
||||
(Err(err), Ok(())) | (Ok(_), Err(err)) => Err(err),
|
||||
(Err(discovery_err), Err(shutdown_err)) => bail!(
|
||||
"mDNS discovery failed: {discovery_err:#}; daemon shutdown also failed: {shutdown_err:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{net::SocketAddr, time::Duration};
|
||||
|
||||
use mdns_sd::{DaemonStatus, ServiceDaemon};
|
||||
|
||||
use super::{
|
||||
MdnsAdvertiser,
|
||||
MdnsBrowser,
|
||||
UnregisterOutcome,
|
||||
shutdown_and_wait,
|
||||
unregister_and_wait,
|
||||
};
|
||||
|
||||
// mdns-sd rejects service names longer than 15 bytes asynchronously. Keep
|
||||
// the lifecycle fixture valid so it exercises a real registration.
|
||||
const TEST_SERVICE_TYPE: &str = "_ls-lifecycle._udp.local.";
|
||||
|
||||
#[test]
|
||||
fn advertiser_close_waits_for_unregister_and_daemon_shutdown() {
|
||||
let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start");
|
||||
let probe = daemon.clone();
|
||||
let advertiser = MdnsAdvertiser::new_with_daemon(
|
||||
daemon,
|
||||
TEST_SERVICE_TYPE,
|
||||
"advertiser-close",
|
||||
SocketAddr::from(([127, 0, 0, 1], 41_234)),
|
||||
None,
|
||||
)
|
||||
.expect("test advertiser should register");
|
||||
|
||||
advertiser.close().expect("advertiser should close cleanly");
|
||||
|
||||
assert_daemon_stopped(&probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertiser_drop_waits_for_unregister_and_daemon_shutdown() {
|
||||
let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start");
|
||||
let probe = daemon.clone();
|
||||
let advertiser = MdnsAdvertiser::new_with_daemon(
|
||||
daemon,
|
||||
TEST_SERVICE_TYPE,
|
||||
"advertiser-drop",
|
||||
SocketAddr::from(([127, 0, 0, 1], 41_235)),
|
||||
None,
|
||||
)
|
||||
.expect("test advertiser should register");
|
||||
|
||||
drop(advertiser);
|
||||
|
||||
assert_daemon_stopped(&probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_fixture_reaches_the_daemon_registration_table() {
|
||||
let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start");
|
||||
let advertiser = MdnsAdvertiser::new_with_daemon(
|
||||
daemon,
|
||||
TEST_SERVICE_TYPE,
|
||||
"registered-fixture",
|
||||
SocketAddr::from(([127, 0, 0, 1], 41_236)),
|
||||
None,
|
||||
)
|
||||
.expect("test advertiser should register");
|
||||
|
||||
let outcome = unregister_and_wait(
|
||||
advertiser.daemon.daemon(),
|
||||
advertiser.service_info.get_fullname(),
|
||||
)
|
||||
.expect("test registration should be removable");
|
||||
assert_eq!(outcome, UnregisterOutcome::Removed);
|
||||
|
||||
advertiser.close().expect("advertiser should close cleanly");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_is_idempotent_when_service_is_already_absent() {
|
||||
let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start");
|
||||
|
||||
let outcome = unregister_and_wait(&daemon, "absent._ls-lifecycle._udp.local.")
|
||||
.expect("an already-absent service satisfies the cleanup postcondition");
|
||||
assert_eq!(outcome, UnregisterOutcome::AlreadyAbsent);
|
||||
shutdown_and_wait(&daemon).expect("test daemon should stop");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_close_waits_for_daemon_shutdown() {
|
||||
let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start");
|
||||
let probe = daemon.clone();
|
||||
let browser = MdnsBrowser::new_with_daemon(daemon, TEST_SERVICE_TYPE)
|
||||
.expect("test browser should start");
|
||||
|
||||
browser.close().expect("browser should close cleanly");
|
||||
|
||||
assert_daemon_stopped(&probe);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn browser_drop_waits_for_daemon_shutdown() {
|
||||
let daemon = ServiceDaemon::new_with_port(0).expect("test daemon should start");
|
||||
let probe = daemon.clone();
|
||||
let browser = MdnsBrowser::new_with_daemon(daemon, TEST_SERVICE_TYPE)
|
||||
.expect("test browser should start");
|
||||
|
||||
drop(browser);
|
||||
|
||||
assert_daemon_stopped(&probe);
|
||||
}
|
||||
|
||||
fn assert_daemon_stopped(daemon: &ServiceDaemon) {
|
||||
let status = daemon
|
||||
.status()
|
||||
.expect("daemon status should remain observable")
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("daemon status should arrive");
|
||||
assert_eq!(status, DaemonStatus::Shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user