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.
This commit is contained in:
2026-09-20 15:19:08 -07:00
parent c35b24b123
commit f04dbc3417
2 changed files with 296 additions and 0 deletions
+295
View File
@@ -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<u16> {
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<SavedListener> {
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<Vec<SavedListener>> {
let mut removed = Vec::new();
for listener in registry
.list::<NetworkListener>()
.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<SavedListener>, 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<Vec<SavedListener>> {
let mut out = Vec::new();
for listener in registry
.list::<NetworkListener>()
.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::<Vec<_>>(),
),
..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"])
));
}
}
+1
View File
@@ -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;