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:
@@ -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"])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@
|
|||||||
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||||
//! `legacy-protocols.md`.
|
//! `legacy-protocols.md`.
|
||||||
|
|
||||||
|
pub mod listeners;
|
||||||
pub mod protocol_policy;
|
pub mod protocol_policy;
|
||||||
|
|||||||
Reference in New Issue
Block a user