diff --git a/crates/common/src/network/legacy.rs b/crates/common/src/network/legacy.rs index fbf49ed..8952de1 100644 --- a/crates/common/src/network/legacy.rs +++ b/crates/common/src/network/legacy.rs @@ -107,6 +107,37 @@ impl Server { protocol_policy::set(&self.core.storage.data, &policy).await?; + // LP-8. Raised here rather than by the JMAP method, so whatever turns + // the switch is reported. A /set that changed nothing -- the switch + // already where it was asked to be, nothing to close or reopen -- is + // not a change. + if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() { + let (moved, direction) = if policy.legacy_protocols.is_disabled() { + (&change.closed, "closed") + } else { + (&change.reopened, "reopened") + }; + trc::event!( + Security(trc::SecurityEvent::LegacyProtocolsChanged), + Policy = "server", + Value = if policy.legacy_protocols.is_disabled() { + "disabled" + } else { + "enabled" + }, + AccountId = policy.changed_by.clone(), + Details = direction, + ListenerId = listener_names(moved.iter().map(|l| l.id.clone())), + // Only when a listener could not be put back (LP-5). + Reason = (!change.failed.is_empty()).then(|| listener_names( + change + .failed + .iter() + .map(|(l, why)| format!("{}: {why}", l.id)) + )), + ); + } + Ok(change) } @@ -220,6 +251,12 @@ impl Server { } } +/// Names for an event field: the listeners a change closed, reopened or +/// failed to reopen (LP-8). +fn listener_names>(names: impl Iterator) -> trc::Value { + trc::Value::Array(names.map(Into::into).collect()) +} + /// A protocol a mail app signs in over, which the switch refuses (LP-6). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LegacyProtocol { diff --git a/crates/jmap/src/api/mod.rs b/crates/jmap/src/api/mod.rs index 62377d0..2e61190 100644 --- a/crates/jmap/src/api/mod.rs +++ b/crates/jmap/src/api/mod.rs @@ -2,6 +2,8 @@ * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * + * Modified by Coffey Labs in 2026 for INBUXA. */ use crate::blob::UploadResponse; @@ -186,7 +188,10 @@ impl ToRequestError for trc::Error { trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => { RequestError::forbidden() } - trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => { + // inbuxa: legacy-protocols LP-8 is an event, never an error + trc::SecurityEvent::IpBlockExpired + | trc::SecurityEvent::IpAllowExpired + | trc::SecurityEvent::LegacyProtocolsChanged => { RequestError::internal_server_error() } }, diff --git a/crates/trc/src/event/enums.rs b/crates/trc/src/event/enums.rs index a1ac625..1725ed7 100644 --- a/crates/trc/src/event/enums.rs +++ b/crates/trc/src/event/enums.rs @@ -9,8 +9,9 @@ // This file is auto-generated. Do not edit directly. // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is -// auth.legacy-protocol-refused (legacy-protocols LP-6) -pub const TOTAL_EVENT_COUNT: usize = 643; +// auth.legacy-protocol-refused (legacy-protocols LP-6); 643 is +// security.legacy-protocols-changed (LP-8) +pub const TOTAL_EVENT_COUNT: usize = 644; pub const TOTAL_METRIC_COUNT: usize = 369; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -655,6 +656,8 @@ pub enum SecurityEvent { IpAllowExpired = 594, IpUnauthorized = 279, Unauthorized = 552, + // inbuxa: legacy-protocols LP-8 + LegacyProtocolsChanged = 643, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/crates/trc/src/event/enums_impl.rs b/crates/trc/src/event/enums_impl.rs index 06f9bbe..ddb21c1 100644 --- a/crates/trc/src/event/enums_impl.rs +++ b/crates/trc/src/event/enums_impl.rs @@ -446,6 +446,8 @@ impl EventType { b"security.ip-allow-expired" => EventType::Security(SecurityEvent::IpAllowExpired), b"security.ip-unauthorized" => EventType::Security(SecurityEvent::IpUnauthorized), b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized), + // inbuxa: legacy-protocols LP-8 + b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged), b"server.startup" => EventType::Server(ServerEvent::Startup), b"server.shutdown" => EventType::Server(ServerEvent::Shutdown), b"server.startup-error" => EventType::Server(ServerEvent::StartupError), @@ -1211,6 +1213,10 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => "security.ip-allow-expired", EventType::Security(SecurityEvent::IpUnauthorized) => "security.ip-unauthorized", EventType::Security(SecurityEvent::Unauthorized) => "security.unauthorized", + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { + "security.legacy-protocols-changed" + } EventType::Server(ServerEvent::Startup) => "server.startup", EventType::Server(ServerEvent::Shutdown) => "server.shutdown", EventType::Server(ServerEvent::StartupError) => "server.startup-error", @@ -1883,6 +1889,8 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => 594, EventType::Security(SecurityEvent::IpUnauthorized) => 279, EventType::Security(SecurityEvent::Unauthorized) => 552, + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643, EventType::Server(ServerEvent::Startup) => 393, EventType::Server(ServerEvent::Shutdown) => 392, EventType::Server(ServerEvent::StartupError) => 394, @@ -2571,6 +2579,8 @@ impl EventType { 594 => Some(EventType::Security(SecurityEvent::IpAllowExpired)), 279 => Some(EventType::Security(SecurityEvent::IpUnauthorized)), 552 => Some(EventType::Security(SecurityEvent::Unauthorized)), + // inbuxa: legacy-protocols LP-8 + 643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)), 393 => Some(EventType::Server(ServerEvent::Startup)), 392 => Some(EventType::Server(ServerEvent::Shutdown)), 394 => Some(EventType::Server(ServerEvent::StartupError)), @@ -2990,6 +3000,8 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => Level::Info, EventType::Security(SecurityEvent::IpUnauthorized) => Level::Info, EventType::Security(SecurityEvent::Unauthorized) => Level::Info, + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info, EventType::Server(ServerEvent::Startup) => Level::Info, EventType::Server(ServerEvent::Shutdown) => Level::Info, EventType::Server(ServerEvent::Licensing) => Level::Info, @@ -3198,7 +3210,9 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", // inbuxa: legacy-protocols LP-6 - EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused", + EventType::Auth(AuthEvent::LegacyProtocolRefused) => { + "Legacy mail protocol sign-in refused" + } EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::Warning) => "Authentication warning", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", @@ -3711,6 +3725,10 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => "IP allow expired", EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address", EventType::Security(SecurityEvent::Unauthorized) => "Unauthorized access", + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { + "Legacy mail protocols switch changed" + } EventType::Server(ServerEvent::Startup) => "Starting INBUXA Server", EventType::Server(ServerEvent::Shutdown) => "Shutting down INBUXA Server", EventType::Server(ServerEvent::StartupError) => "Server startup error", @@ -3964,7 +3982,9 @@ impl EventType { EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", // inbuxa: legacy-protocols LP-6 - EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps", + EventType::Auth(AuthEvent::LegacyProtocolRefused) => { + "This server allows only INBUXA webmail and JMAP apps" + } EventType::Auth(AuthEvent::Error) => "Authentication error", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error", @@ -4102,6 +4122,10 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired) => "Insufficient permissions", EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address", EventType::Security(SecurityEvent::Unauthorized) => "Insufficient permissions", + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged) => { + "Legacy mail protocols switch changed" + } EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error", EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error", EventType::Smtp(SmtpEvent::Error) => "SMTP error", @@ -4663,6 +4687,8 @@ impl EventType { EventType::Security(SecurityEvent::IpAllowExpired), EventType::Security(SecurityEvent::IpUnauthorized), EventType::Security(SecurityEvent::Unauthorized), + // inbuxa: legacy-protocols LP-8 + EventType::Security(SecurityEvent::LegacyProtocolsChanged), EventType::Server(ServerEvent::Startup), EventType::Server(ServerEvent::Shutdown), EventType::Server(ServerEvent::StartupError), diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index fa4afe4..54ac1e1 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index f7789de..7d5e6f8 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho \ No newline at end of file +7DAGhNQhe_TWHNme6m7dbMaqwfgHWxF31cwHFvqaMHU \ No newline at end of file diff --git a/tests/e2e/legacy_protocols.py b/tests/e2e/legacy_protocols.py index e310bf0..f74e614 100755 --- a/tests/e2e/legacy_protocols.py +++ b/tests/e2e/legacy_protocols.py @@ -20,6 +20,8 @@ after. And that while it is off, no listener the switch would close can be created, or made by an update (LP-4, test 4), and nothing advertises what is closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission, and the suggested zone marks their SRV names not offered (LP-7, test 5). +Every change of the switch, and every refused sign-in, is an event in the +server's log (LP-8, test 14; LP-6). Passwords are generated into files under target/e2e and never printed. Everything is removed afterwards unless KEEP=1. @@ -225,6 +227,13 @@ def advertised(admin, admin_pw): } +def events(name): + """The server's log lines for one event, from its stdout tracer. The log + is the container's, so it starts afresh at every restart.""" + out = docker("logs", NAME, check_rc=False) + return [l for l in (out.stdout + out.stderr).splitlines() if f"({name})" in l] + + def settle(port, want, tries=30): """Wait for a port to reach the wanted state, so the check is not a race.""" for _ in range(tries): @@ -268,6 +277,15 @@ def main(): stop() start() + # A tracer to stdout, so the events can be read back from the container's + # log. It takes effect from the next start. + res = one(admin, admin_pw, "x:Tracer/set", {"create": {"t": { + "@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}}) + if not (res[1].get("created") or {}).get("t"): + sys.exit("tracer create failed: " + json.dumps(res)) + stop() + start() + sess = session(admin, admin_pw) account = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0] policy_get = {"accountId": account, "ids": None} @@ -380,6 +398,25 @@ def main(): "turning it into an IMAP listener is refused (LP-4)") one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [extra]}) + # The change was reported (LP-8, test 14), with who made it and what closed. + changed = events("security.legacy-protocols-changed") + check(len(changed) == 1 and 'value = "disabled"' in changed[0] + and 'policy = "server"' in changed[0] and 'details = "closed"' in changed[0] + and '"imaps"' in changed[0] and "accountId = " in changed[0], + "turning it off is one event: scope, new value, who, listeners closed (LP-8)") + if len(changed) != 1: + print(" events:", changed) + # Asking for what already holds is not a change. + one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", policy_set({"legacyProtocols": "disabled"})) + check(len(events("security.legacy-protocols-changed")) == 1, + "setting it off again when it is off raises no event (LP-8)") + # Every refused sign-in is an event too, and none is a failed sign-in. + refused = events("auth.legacy-protocol-refused") + check(len(refused) == 7 and all('source = "submission"' in l for l in refused), + "each refused sign-in is an auth.legacy-protocol-refused event (LP-6)") + check(not events("auth.failed") and not events("auth.too-many-attempts"), + "and none is logged as a failed sign-in (LP-11)") + # A restart must not reopen them: the objects are gone, not just the sockets. stop() start() @@ -398,6 +435,10 @@ def main(): policy = got[1]["list"][0] check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") + changed = events("security.legacy-protocols-changed") + check(len(changed) == 1 and 'value = "enabled"' in changed[0] + and 'details = "reopened"' in changed[0] and '"imaps"' in changed[0], + "turning it back on is one event, naming the listeners reopened (LP-8)") after = advertised(admin, admin_pw) check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],