Merge main, and put the Hardening link on top of LP-6's schema

LP-6 added auth.legacy-protocol-refused to the packaged schema, which this
branch also changes. The file is gzipped, so the two can't be merged line
by line: this takes main's schema and adds the Settings › Security ›
Hardening link to it again, with the hash recomputed.
This commit is contained in:
2026-09-21 10:21:01 -07:00
10 changed files with 337 additions and 10 deletions
+152
View File
@@ -17,11 +17,16 @@
//! back on the next restart. Opening puts the object back first and then //! back on the next restart. Opening puts the object back first and then
//! spawns, for the same reason in reverse. //! spawns, for the same reason in reverse.
//! //!
//! Sign-in is the second lock (LP-6): while the switch is off, a sign-in over
//! a legacy protocol is refused before any password is looked at, so a
//! listener that exists by mistake still lets nobody in.
//!
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy //! Nothing here touches the host's firewall, NAT port-forwards or any proxy
//! (LP-20). The server stops answering; what still routes the port is the //! (LP-20). The server stops answering; what still routes the port is the
//! operator's to reconcile. //! operator's to reconcile.
use crate::{Server, config::server::Listeners, network::TcpAcceptor}; use crate::{Server, config::server::Listeners, network::TcpAcceptor};
use directory::Credentials;
use inbuxa_features::security::{ use inbuxa_features::security::{
listeners, listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener}, protocol_policy::{self, ProtocolPolicy, SavedListener},
@@ -209,3 +214,150 @@ impl Server {
.collect()) .collect())
} }
} }
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LegacyProtocol {
Imap,
Pop3,
ManageSieve,
/// SMTP AUTH, on any SMTP listener: only mail apps authenticate, so
/// inbound delivery is untouched (LP-3).
Submission,
}
impl LegacyProtocol {
pub fn as_str(&self) -> &'static str {
match self {
LegacyProtocol::Imap => "imap",
LegacyProtocol::Pop3 => "pop3",
LegacyProtocol::ManageSieve => "manageSieve",
LegacyProtocol::Submission => "submission",
}
}
/// What the mail app is told, at server scope (LP-12, LP-6). Each
/// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting —
/// is added by its session; POP3 carries `[AUTH]` in the text, since its
/// errors have no separate code, and SMTP is the whole reply line.
pub fn refusal(&self) -> &'static str {
match self {
LegacyProtocol::Imap => {
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
}
LegacyProtocol::Pop3 => {
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
}
LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.",
LegacyProtocol::Submission => {
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
}
}
}
/// The refusal as an error: `auth.legacy-protocol-refused`, not
/// `auth.failed`, so it never counts against the account or feeds the
/// auto-ban (LP-11). It names the protocol and the domain, never the
/// account; the session it is raised in adds the remote IP.
pub fn refused(&self, credentials: &Credentials) -> trc::Error {
trc::AuthEvent::LegacyProtocolRefused
.into_err()
.details(self.refusal())
.ctx(trc::Key::Source, self.as_str())
.ctx(trc::Key::Policy, "server")
.ctx_opt(trc::Key::Domain, domain_of(credentials))
}
}
/// The domain a sign-in is for, from the name it gives, if it gives one.
fn domain_of(credentials: &Credentials) -> Option<String> {
let username = match credentials {
Credentials::Basic { username, .. } => Some(username.as_str()),
Credentials::Bearer { username, .. } => username.as_deref(),
}?;
username
.rsplit_once('@')
.map(|(_, domain)| domain.trim().to_lowercase())
.filter(|domain| !domain.is_empty())
}
impl Server {
/// Refuses a sign-in over a legacy protocol while the server-wide switch
/// is off (LP-6). Called before the credentials are checked, so the
/// answer is the same for a right password, a wrong one and an account
/// that doesn't exist (LP-11).
///
/// Read from the store on each sign-in rather than cached, so every node
/// of a cluster answers the same the moment the switch turns.
pub async fn refuse_legacy_sign_in(
&self,
protocol: LegacyProtocol,
credentials: &Credentials,
) -> trc::Result<()> {
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
Err(protocol.refused(credentials))
} else {
Ok(())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn basic(username: &str) -> Credentials {
Credentials::Basic {
username: username.to_string(),
secret: "wrong or right, it is never read".to_string(),
mfa_token: None,
}
}
#[test]
fn refusals_read_as_the_spec_writes_them() {
// LP-12, with "Your organization" read as "This server" (LP-6).
assert_eq!(
LegacyProtocol::Imap.refusal(),
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
);
assert!(
LegacyProtocol::Pop3
.refusal()
.starts_with("[AUTH] This server allows")
);
assert_eq!(
LegacyProtocol::ManageSieve.refusal(),
"This server allows only INBUXA webmail and JMAP apps."
);
assert_eq!(
LegacyProtocol::Submission.refusal(),
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
);
}
#[test]
fn a_refusal_is_not_a_failed_sign_in() {
let err = LegacyProtocol::Imap.refused(&basic("[email protected]"));
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
// The session stays open: the mail app is told, not thrown off.
assert!(!err.must_disconnect());
assert!(err.should_write_err());
assert_eq!(err.value_as_str(trc::Key::Domain), Some("example.org"));
assert_eq!(err.value_as_str(trc::Key::Source), Some("imap"));
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
}
#[test]
fn the_domain_comes_from_the_name_given() {
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
assert_eq!(domain_of(&basic("no-domain")), None);
assert_eq!(domain_of(&basic("trailing@")), None);
let bearer = Credentials::Bearer {
username: None,
token: "t".to_string(),
};
assert_eq!(domain_of(&bearer), None);
}
}
+9 -1
View File
@@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::core::{Session, SessionData, State}; use crate::core::{Session, SessionData, State};
use common::{ use common::{
auth::AuthRequest, auth::AuthRequest,
network::{SessionStream, limiter::LimiterResult}, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
}; };
use directory::Credentials; use directory::Credentials;
use imap_proto::{ use imap_proto::{
@@ -67,6 +69,12 @@ impl<T: SessionStream> Session<T> {
} }
pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> { pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> {
// inbuxa: legacy-protocols LP-6, before the password is looked at
self.server
.refuse_legacy_sign_in(LegacyProtocol::Imap, &credentials)
.await
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
// Authenticate // Authenticate
let access_token = self let access_token = self
.server .server
+8 -1
View File
@@ -2,12 +2,14 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::core::{Command, Session, State, StatusResponse}; use crate::core::{Command, Session, State, StatusResponse};
use common::{ use common::{
auth::AuthRequest, auth::AuthRequest,
network::{SessionStream, limiter::LimiterResult}, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
}; };
use directory::Credentials; use directory::Credentials;
use imap_proto::{ use imap_proto::{
@@ -65,6 +67,11 @@ impl<T: SessionStream> Session<T> {
} }
}; };
// inbuxa: legacy-protocols LP-6, before the password is looked at
self.server
.refuse_legacy_sign_in(LegacyProtocol::ManageSieve, &credentials)
.await?;
// Authenticate // Authenticate
let access_token = self let access_token = self
.server .server
+8 -1
View File
@@ -2,6 +2,8 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::{ use crate::{
@@ -10,7 +12,7 @@ use crate::{
}; };
use common::{ use common::{
auth::AuthRequest, auth::AuthRequest,
network::{SessionStream, limiter::LimiterResult}, network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
}; };
use directory::Credentials; use directory::Credentials;
use mail_parser::decoders::base64::base64_decode; use mail_parser::decoders::base64::base64_decode;
@@ -61,6 +63,11 @@ impl<T: SessionStream> Session<T> {
} }
pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> { pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> {
// inbuxa: legacy-protocols LP-6, before the password is looked at
self.server
.refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials)
.await?;
// Authenticate // Authenticate
let access_token = self let access_token = self
.server .server
+26 -1
View File
@@ -2,10 +2,15 @@
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]> * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* *
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/ */
use crate::core::Session; use crate::core::Session;
use common::{auth::AuthRequest, network::SessionStream}; use common::{
auth::AuthRequest,
network::{SessionStream, legacy::LegacyProtocol},
};
use directory::Credentials; use directory::Credentials;
use mail_parser::decoders::base64::base64_decode; use mail_parser::decoders::base64::base64_decode;
use registry::schema::enums::Permission; use registry::schema::enums::Permission;
@@ -108,6 +113,26 @@ impl<T: SessionStream> Session<T> {
} }
pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> { pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> {
// inbuxa: legacy-protocols LP-6. Refused before the password is looked
// at, and not counted as an authentication error (LP-11). Only mail
// apps authenticate, so this never touches inbound delivery (LP-3).
if let Err(err) = self
.server
.refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials)
.await
{
let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused));
trc::error!(err.span_id(self.data.session_id));
if refused {
self.write(LegacyProtocol::Submission.refusal().as_bytes())
.await?;
} else {
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
.await?;
}
return Ok(false);
}
// Authenticate // Authenticate
let result = self let result = self
.server .server
+5 -2
View File
@@ -8,8 +8,9 @@
// This file is auto-generated. Do not edit directly. // This file is auto-generated. Do not edit directly.
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54) // inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
pub const TOTAL_EVENT_COUNT: usize = 642; // auth.legacy-protocol-refused (legacy-protocols LP-6)
pub const TOTAL_EVENT_COUNT: usize = 643;
pub const TOTAL_METRIC_COUNT: usize = 369; pub const TOTAL_METRIC_COUNT: usize = 369;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -116,6 +117,8 @@ pub enum AuthEvent {
Error = 34, Error = 34,
Warning = 595, Warning = 595,
CredentialExpired = 276, CredentialExpired = 276,
// inbuxa: legacy-protocols LP-6
LegacyProtocolRefused = 642,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+16
View File
@@ -56,6 +56,8 @@ impl EventType {
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired), b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts), b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration), b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration),
// inbuxa: legacy-protocols LP-6
b"auth.legacy-protocol-refused" => EventType::Auth(AuthEvent::LegacyProtocolRefused),
b"auth.error" => EventType::Auth(AuthEvent::Error), b"auth.error" => EventType::Auth(AuthEvent::Error),
b"auth.warning" => EventType::Auth(AuthEvent::Warning), b"auth.warning" => EventType::Auth(AuthEvent::Warning),
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired), b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
@@ -705,6 +707,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required", EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts", EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration", EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration",
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "auth.legacy-protocol-refused",
EventType::Auth(AuthEvent::Error) => "auth.error", EventType::Auth(AuthEvent::Error) => "auth.error",
EventType::Auth(AuthEvent::Warning) => "auth.warning", EventType::Auth(AuthEvent::Warning) => "auth.warning",
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired", EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
@@ -1489,6 +1493,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired) => 36, EventType::Auth(AuthEvent::MfaRequired) => 36,
EventType::Auth(AuthEvent::TooManyAttempts) => 38, EventType::Auth(AuthEvent::TooManyAttempts) => 38,
EventType::Auth(AuthEvent::ClientRegistration) => 555, EventType::Auth(AuthEvent::ClientRegistration) => 555,
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642,
EventType::Auth(AuthEvent::Error) => 34, EventType::Auth(AuthEvent::Error) => 34,
EventType::Auth(AuthEvent::Warning) => 595, EventType::Auth(AuthEvent::Warning) => 595,
EventType::Auth(AuthEvent::CredentialExpired) => 276, EventType::Auth(AuthEvent::CredentialExpired) => 276,
@@ -2137,6 +2143,8 @@ impl EventType {
36 => Some(EventType::Auth(AuthEvent::MfaRequired)), 36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)), 38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
// inbuxa: legacy-protocols LP-6
642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)),
34 => Some(EventType::Auth(AuthEvent::Error)), 34 => Some(EventType::Auth(AuthEvent::Error)),
595 => Some(EventType::Auth(AuthEvent::Warning)), 595 => Some(EventType::Auth(AuthEvent::Warning)),
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)), 276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
@@ -2848,6 +2856,8 @@ impl EventType {
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info, EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
EventType::Auth(AuthEvent::Success) => Level::Info, EventType::Auth(AuthEvent::Success) => Level::Info,
EventType::Auth(AuthEvent::ClientRegistration) => Level::Info, EventType::Auth(AuthEvent::ClientRegistration) => Level::Info,
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused) => Level::Info,
EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info, EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info,
EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info, EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info,
EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info, EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info,
@@ -3187,6 +3197,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication", EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication",
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration", 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::Error) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::Warning) => "Authentication warning", EventType::Auth(AuthEvent::Warning) => "Authentication warning",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
@@ -3951,6 +3963,8 @@ impl EventType {
} }
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts", EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error", 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::Error) => "Authentication error", EventType::Auth(AuthEvent::Error) => "Authentication error",
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired", EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error", EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
@@ -4259,6 +4273,8 @@ impl EventType {
EventType::Auth(AuthEvent::MfaRequired), EventType::Auth(AuthEvent::MfaRequired),
EventType::Auth(AuthEvent::TooManyAttempts), EventType::Auth(AuthEvent::TooManyAttempts),
EventType::Auth(AuthEvent::ClientRegistration), EventType::Auth(AuthEvent::ClientRegistration),
// inbuxa: legacy-protocols LP-6
EventType::Auth(AuthEvent::LegacyProtocolRefused),
EventType::Auth(AuthEvent::Error), EventType::Auth(AuthEvent::Error),
EventType::Auth(AuthEvent::Warning), EventType::Auth(AuthEvent::Warning),
EventType::Auth(AuthEvent::CredentialExpired), EventType::Auth(AuthEvent::CredentialExpired),
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
nuvjIy1CjdKzUWRaCOjYVxDDThjb3eTz-7viHfo4lfM q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY
+112 -3
View File
@@ -12,6 +12,12 @@ This is the part unit tests cannot reach: whether a socket actually closes on
a running server (LP-2), and whether a listener put back actually binds again a running server (LP-2), and whether a listener put back actually binds again
(LP-5). Acceptance tests 15, 17 and 18. (LP-5). Acceptance tests 15, 17 and 18.
It also checks the second lock (LP-6): while the switch is off, sign-in over
submission -- locked open -- and over an IMAP listener that exists by mistake
is refused with the spec's words, with the right password and with a wrong
one, and refusals never add up to a disconnect (LP-11). And that a normal
IMAP sign-in works with the switch on, before and after.
Passwords are generated into files under target/e2e and never printed. Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1. Everything is removed afterwards unless KEEP=1.
""" """
@@ -25,8 +31,12 @@ HTTP = "http://127.0.0.1:18080"
# makes the host side accept connections whether or not anything is listening # makes the host side accept connections whether or not anything is listening
# inside the container, so a bare connect proves nothing: each port has to be # inside the container, so a bare connect proves nothing: each port has to be
# made to speak. # made to speak.
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025} PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025, "mistake": 18994}
TLS_PORTS = {18993, 18995, 18465} TLS_PORTS = {18993, 18995, 18465, 18994}
IMAP_REFUSAL = ("NO [ALERT] This server allows only INBUXA webmail and JMAP apps. "
"This mail app can't sign in.")
SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. "
"This mail app can't send.")
INBUXA = "urn:inbuxa:jmap" INBUXA = "urn:inbuxa:jmap"
failures = [] failures = []
@@ -60,7 +70,8 @@ def start(env_file=None):
"-p", f"127.0.0.1:{PORTS['submissions']}:465", "-p", f"127.0.0.1:{PORTS['submissions']}:465",
"-p", f"127.0.0.1:{PORTS['imap']}:993", "-p", f"127.0.0.1:{PORTS['imap']}:993",
"-p", f"127.0.0.1:{PORTS['pop3']}:995", "-p", f"127.0.0.1:{PORTS['pop3']}:995",
"-p", f"127.0.0.1:{PORTS['smtp']}:25"] "-p", f"127.0.0.1:{PORTS['smtp']}:25",
"-p", f"127.0.0.1:{PORTS['mistake']}:1993"]
if env_file: if env_file:
args += ["--env-file", env_file] args += ["--env-file", env_file]
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"] args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
@@ -122,6 +133,64 @@ def accepts(port, timeout=5):
return False return False
def tls(port, timeout=10):
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=timeout))
def lines(sock):
"""Yields reply lines, CRLF stripped."""
buf = b""
while True:
while b"\r\n" not in buf:
chunk = sock.recv(4096)
if not chunk:
return
buf += chunk
line, buf = buf.split(b"\r\n", 1)
yield line.decode(errors="replace")
def imap_login(port, user, password):
"""The tagged reply to LOGIN, over implicit TLS."""
with tls(port) as sock:
read = lines(sock)
next(read) # greeting
quote = lambda v: '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
sock.sendall(f"a1 LOGIN {quote(user)} {quote(password)}\r\n".encode())
for line in read:
if line.startswith("a1 "):
return line[3:]
return ""
def smtp_auths(port, user, passwords):
"""The reply to AUTH PLAIN for each password in turn, on one connection.
A reply of "" means the server hung up."""
# Every connection reaches the server from Docker's gateway, one IP, and
# the stock inbound throttle takes five a second from it. The port checks
# just before can use those up, so wait the second out.
time.sleep(1.1)
replies = []
with tls(port) as sock:
read = lines(sock)
next(read) # greeting
sock.sendall(b"EHLO e2e.test\r\n")
for line in read:
if line[3:4] == " ":
break
for password in passwords:
token = base64.b64encode(f"\0{user}\0{password}".encode()).decode()
try:
sock.sendall(f"AUTH PLAIN {token}\r\n".encode())
replies.append(next(read, ""))
except OSError:
replies.append("")
return replies
def settle(port, want, tries=30): def settle(port, want, tries=30):
"""Wait for a port to reach the wanted state, so the check is not a race.""" """Wait for a port to reach the wanted state, so the check is not a race."""
for _ in range(tries): for _ in range(tries):
@@ -176,6 +245,12 @@ def main():
check(accepts(PORTS["submissions"]), "submission accepts before the switch") check(accepts(PORTS["submissions"]), "submission accepts before the switch")
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch") check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
# A normal sign-in works with the switch on -- the control for LP-6.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works with the switch on")
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
"submission sign-in works with the switch on")
# What the screen reads: the locked set and what would close (LP-16, LP-21). # What the screen reads: the locked set and what would close (LP-16, LP-21).
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get) got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
if got[0] != "inbuxa:ProtocolPolicy/get": if got[0] != "inbuxa:ProtocolPolicy/get":
@@ -212,12 +287,40 @@ def main():
print(" savedListeners:", sorted(saved)) print(" savedListeners:", sorted(saved))
check(saved, "the closed listeners were saved (LP-1)") check(saved, "the closed listeners were saved (LP-1)")
# The second lock (LP-6). Submission stays open, being locked, so sign-in
# over it is refused instead -- right password or wrong, the same words,
# and never enough of them to be thrown off (LP-11, test 2, test 18).
replies = smtp_auths(PORTS["submissions"], admin, [admin_pw] + ["wrong"] * 6)
check(replies[0] == SMTP_REFUSAL, "submission refuses the right password (LP-6)")
check(all(r == SMTP_REFUSAL for r in replies[1:]),
"submission refuses wrong passwords the same way, and doesn't hang up (LP-11)")
if not all(r == SMTP_REFUSAL for r in replies):
print(" replies:", replies)
# An IMAP listener that exists by mistake: created while the switch is off
# (LP-4 will refuse this later), and live after the restart below.
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
"name": "imap-mistake", "protocol": "imap", "bind": {"0.0.0.0:1993": True},
"tlsImplicit": True}}})
mistake = (res[1].get("created") or {}).get("m", {}).get("id")
check(mistake is not None, "an IMAP listener can still be created by mistake")
# A restart must not reopen them: the objects are gone, not just the sockets. # A restart must not reopen them: the objects are gone, not just the sockets.
stop() stop()
start() start()
check(settle(PORTS["imap"], False), "IMAP still closed after a restart") check(settle(PORTS["imap"], False), "IMAP still closed after a restart")
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart") check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart")
if mistake:
check(settle(PORTS["mistake"], True), "the mistaken IMAP listener is up")
check(imap_login(PORTS["mistake"], admin, admin_pw) == IMAP_REFUSAL,
"the mistaken listener refuses the right password (LP-6)")
check(imap_login(PORTS["mistake"], admin, "wrong") == IMAP_REFUSAL,
"and a wrong one, the same way (LP-11)")
check(imap_login(PORTS["mistake"], "[email protected]", "x") == IMAP_REFUSAL,
"and an account that doesn't exist (LP-11)")
one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [mistake]})
# Turn it back on: the listeners come back and bind again (LP-5). # Turn it back on: the listeners come back and bind again (LP-5).
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
policy_set({"legacyProtocols": "enabled"})) policy_set({"legacyProtocols": "enabled"}))
@@ -231,6 +334,12 @@ def main():
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled") check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)") check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
# And sign-in works again, with no restart.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
"IMAP sign-in works again once the switch is back on")
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
"submission sign-in works again once the switch is back on")
print() print()
if failures: if failures:
print(f"{len(failures)} FAILED:") print(f"{len(failures)} FAILED:")