From f04dbc3417705a8ca210b580e247714e3ee6900a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 20 Sep 2026 15:19:08 -0700 Subject: [PATCH] Taking the legacy listeners away, and putting them back LP-1 and LP-5, the registry half. close() removes every listener object the policy closes, saving each one whole first; reopen() puts them back. The switch removes the listener objects, not just their sockets. A stopped socket returns on the next restart, which would reopen every port the operator had just closed, and the operator would have no way to know. A removed object stays removed, and a server that boots with the switch on never spawns those listeners at all -- so there is no boot-time special case to write or to forget. LP-3 is decided here, on the object rather than the running socket, and sees every address a listener binds: a submission listener that also binds 25 is inbound and stays. lmtp and http are never candidates. A listener that cannot be put back does not stop the others; it comes back with its reason and stays saved for another try (LP-5). A delete the registry declines is reported as not removed, so the policy never claims a port is closed while it is still accepting. Stopping the running socket is still a separate step in common, which owns the listener registry. Nothing calls any of this yet. --- crates/features/src/security/listeners.rs | 295 ++++++++++++++++++++++ crates/features/src/security/mod.rs | 1 + 2 files changed, 296 insertions(+) create mode 100644 crates/features/src/security/listeners.rs diff --git a/crates/features/src/security/listeners.rs b/crates/features/src/security/listeners.rs new file mode 100644 index 0000000..c7e4187 --- /dev/null +++ b/crates/features/src/security/listeners.rs @@ -0,0 +1,295 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +//! Taking the legacy listeners away and putting them back (legacy-protocols +//! spec, LP-1 and LP-5). +//! +//! The switch removes the listener **objects**, not just their sockets. A +//! stopped socket comes back on the next restart, which would reopen every +//! port the operator just closed; a removed object does not. It also means a +//! server that boots with the switch on never spawns them in the first place, +//! with no boot-time special case. +//! +//! Each removed object is kept whole in the policy so LP-5 can put it back +//! exactly as it was. Closing the running socket is a separate step, and lives +//! in `common`, which owns the listener registry: this crate sits below it. +//! +//! None of this touches the host's firewall or any port-forward (LP-20). + +use crate::security::protocol_policy::{ProtocolPolicy, SavedListener}; +use registry::schema::{ + enums::NetworkListenerProtocol, + prelude::Object, + structs::NetworkListener, +}; +use store::{ + RegistryStore, + registry::write::{RegistryWrite, RegistryWriteResult}, +}; +use trc::AddContext; + +/// How the registry schema spells a listener's protocol. These are the strings +/// [`ProtocolPolicy::closes`] matches on. +pub fn protocol_name(protocol: NetworkListenerProtocol) -> &'static str { + match protocol { + NetworkListenerProtocol::Smtp => "smtp", + NetworkListenerProtocol::Lmtp => "lmtp", + NetworkListenerProtocol::Http => "http", + NetworkListenerProtocol::Imap => "imap", + NetworkListenerProtocol::Pop3 => "pop3", + NetworkListenerProtocol::ManageSieve => "manageSieve", + } +} + +/// Every port a listener binds. A listener may bind several, and one of them +/// being 25 makes the whole listener inbound (LP-3). +pub fn ports(listener: &NetworkListener) -> Vec { + listener.bind.iter().map(|addr| addr.0.port()).collect() +} + +/// Whether the policy closes this listener. +pub fn closes(policy: &ProtocolPolicy, listener: &NetworkListener) -> bool { + policy.closes(protocol_name(listener.protocol), &ports(listener)) +} + +/// Saves a listener whole, ready to be put back (LP-5). +fn save(listener: &NetworkListener) -> trc::Result { + Ok(SavedListener { + id: listener.name.clone(), + protocol: protocol_name(listener.protocol).to_string(), + ports: ports(listener), + object: serde_json::to_value(listener).map_err(|err| { + trc::StoreEvent::UnexpectedError + .caused_by(trc::location!()) + .reason(err) + })?, + }) +} + +/// Removes every listener the policy closes, saving each one whole first +/// (LP-1). Returns what was removed, in the order the registry listed it. +/// +/// The caller then stops the matching running sockets, by the `id` of each +/// returned listener — which is the listener's name, the same key the runtime +/// registry uses. +pub async fn close( + registry: &RegistryStore, + policy: &ProtocolPolicy, +) -> trc::Result> { + let mut removed = Vec::new(); + + for listener in registry + .list::() + .await + .caused_by(trc::location!())? + { + if !closes(policy, &listener.object) { + continue; + } + + let saved = save(&listener.object)?; + match registry + .write(RegistryWrite::delete(listener.id)) + .await + .caused_by(trc::location!())? + { + RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => { + removed.push(saved); + } + // Anything else means the registry declined the delete. Leave the + // listener alone and say nothing was removed, so the policy does + // not claim a port is closed while it is still accepting. + _ => {} + } + } + + Ok(removed) +} + +/// Puts back every saved listener (LP-5). +/// +/// Returns the ones restored and the ones that could not be, each with the +/// reason. A listener that cannot come back — its port taken in the meantime, +/// say — does not stop the others, and stays saved for another try. +pub async fn reopen( + registry: &RegistryStore, + saved: &[SavedListener], +) -> trc::Result<(Vec, Vec<(SavedListener, String)>)> { + let mut restored = Vec::new(); + let mut failed = Vec::new(); + + for listener in saved { + let object: NetworkListener = match serde_json::from_value(listener.object.clone()) { + Ok(object) => object, + Err(err) => { + failed.push((listener.clone(), format!("saved listener unreadable: {err}"))); + continue; + } + }; + + let object: Object = object.into(); + match registry + .write(RegistryWrite::insert(&object)) + .await + .caused_by(trc::location!())? + { + RegistryWriteResult::Success(_) => restored.push(listener.clone()), + other => failed.push((listener.clone(), format!("{other:?}"))), + } + } + + Ok((restored, failed)) +} + +/// The listener objects that exist right now, as `(name, protocol, ports)`. +/// The confirmation (LP-16) lists exactly what will close, before anything +/// happens. +pub async fn would_close( + registry: &RegistryStore, + policy: &ProtocolPolicy, +) -> trc::Result> { + let mut out = Vec::new(); + for listener in registry + .list::() + .await + .caused_by(trc::location!())? + { + if closes(policy, &listener.object) { + out.push(save(&listener.object)?); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::security::protocol_policy::LegacyProtocols; + use registry::{schema::prelude::SocketAddr, types::map::Map}; + use std::str::FromStr; + + fn listener(name: &str, protocol: NetworkListenerProtocol, binds: &[&str]) -> NetworkListener { + NetworkListener { + name: name.to_string(), + protocol, + bind: Map::new( + binds + .iter() + .map(|addr| SocketAddr::from_str(addr).unwrap()) + .collect::>(), + ), + ..Default::default() + } + } + + fn disabled() -> ProtocolPolicy { + ProtocolPolicy { + legacy_protocols: LegacyProtocols::Disabled, + ..Default::default() + } + } + + /// The protocol names match what the policy matches on. + #[test] + fn protocol_names_are_the_schema_spelling() { + assert_eq!(protocol_name(NetworkListenerProtocol::Imap), "imap"); + assert_eq!(protocol_name(NetworkListenerProtocol::Pop3), "pop3"); + assert_eq!( + protocol_name(NetworkListenerProtocol::ManageSieve), + "manageSieve" + ); + assert_eq!(protocol_name(NetworkListenerProtocol::Smtp), "smtp"); + } + + /// Every bound port is seen, so a listener that also binds 25 is caught. + #[test] + fn every_bound_port_is_seen() { + let l = listener( + "mixed", + NetworkListenerProtocol::Smtp, + &["[::]:465", "0.0.0.0:25"], + ); + let mut p = ports(&l); + p.sort(); + assert_eq!(p, vec![25, 465]); + } + + /// The mail-app listeners close; inbound and JMAP do not (LP-1, LP-3). + #[test] + fn the_right_listeners_close() { + let policy = disabled(); + + for (name, protocol, binds) in [ + ("imaps", NetworkListenerProtocol::Imap, &["[::]:993"][..]), + ("pop3s", NetworkListenerProtocol::Pop3, &["[::]:995"][..]), + ( + "sieve", + NetworkListenerProtocol::ManageSieve, + &["[::]:4190"][..], + ), + ( + "submissions", + NetworkListenerProtocol::Smtp, + &["[::]:465"][..], + ), + ] { + assert!( + closes(&policy, &listener(name, protocol, binds)), + "{name} should close" + ); + } + + for (name, protocol, binds) in [ + ("smtp", NetworkListenerProtocol::Smtp, &["[::]:25"][..]), + ("https", NetworkListenerProtocol::Http, &["[::]:443"][..]), + ("lmtp", NetworkListenerProtocol::Lmtp, &["[::]:11200"][..]), + ] { + assert!( + !closes(&policy, &listener(name, protocol, binds)), + "{name} must stay" + ); + } + } + + /// A submission listener that also binds 25 is inbound, and stays (LP-3). + #[test] + fn a_listener_that_also_binds_25_stays() { + let policy = disabled(); + let l = listener( + "mixed", + NetworkListenerProtocol::Smtp, + &["[::]:465", "[::]:25"], + ); + assert!(!closes(&policy, &l)); + } + + /// A saved listener keeps every field, including ones this code never + /// reads, and comes back as the same object (LP-5). + #[test] + fn a_saved_listener_round_trips() { + let mut original = listener("imaps", NetworkListenerProtocol::Imap, &["[::]:993"]); + original.socket_no_delay = true; + original.socket_backlog = Some(2048); + + let saved = save(&original).unwrap(); + assert_eq!(saved.id, "imaps"); + assert_eq!(saved.protocol, "imap"); + assert_eq!(saved.ports, vec![993]); + + let back: NetworkListener = serde_json::from_value(saved.object).unwrap(); + assert_eq!(back, original); + } + + /// While the switch is off, nothing closes. + #[test] + fn enabled_closes_nothing() { + let policy = ProtocolPolicy::default(); + assert!(!closes( + &policy, + &listener("imaps", NetworkListenerProtocol::Imap, &["[::]:993"]) + )); + } +} diff --git a/crates/features/src/security/mod.rs b/crates/features/src/security/mod.rs index d4b16c3..bb06e86 100644 --- a/crates/features/src/security/mod.rs +++ b/crates/features/src/security/mod.rs @@ -10,4 +10,5 @@ //! ships. The legacy-protocols switch is INBUXA's own design, specified in //! `legacy-protocols.md`. +pub mod listeners; pub mod protocol_policy;