The switch reports every change as an event (LP-8)
Turning legacy mail protocols off or back on raises security.legacy-protocols-changed (id 643, info level, also in the packaged schema), with the scope (policy = server), the new value, who made the change (accountId), whether listeners closed or reopened (details), which ones (listenerId), and -- only when a listener could not be put back -- which and why (reason). It is raised in Server::set_protocol_policy rather than by the JMAP method, so whatever turns the switch is reported. A /set that changes nothing -- the switch already where it was asked to be, nothing to close or reopen -- is not a change and raises nothing. The event is never an error, but jmap's exhaustive map from security events to HTTP errors has to name it; it joins the other two that can't occur there. rustfmt now also wraps LP-6's two over-long lines in enums_impl.rs, which it flagged along with this change's. tests/e2e/legacy_protocols.py now gives the server a stdout tracer and reads events from the container's log: turning the switch off is exactly one event naming the scope, value, author and listeners closed; setting it off again raises none; turning it on is one event naming the listeners reopened. It also proves LP-6's side: seven refused submission sign-ins are seven auth.legacy-protocol-refused events, and there is no auth.failed or auth.too-many-attempts among them. All checks pass.
This commit is contained in:
@@ -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<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> 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 {
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* 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()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho
|
||||
7DAGhNQhe_TWHNme6m7dbMaqwfgHWxF31cwHFvqaMHU
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user