Legacy sign-in is refused while the switch is off (LP-6) #3
@@ -17,11 +17,16 @@
|
||||
//! back on the next restart. Opening puts the object back first and then
|
||||
//! 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
|
||||
//! (LP-20). The server stops answering; what still routes the port is the
|
||||
//! operator's to reconcile.
|
||||
|
||||
use crate::{Server, config::server::Listeners, network::TcpAcceptor};
|
||||
use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
@@ -209,3 +214,150 @@ impl Server {
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* 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::core::{Session, SessionData, State};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use imap_proto::{
|
||||
@@ -67,6 +69,12 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
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
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
* 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::core::{Command, Session, State, StatusResponse};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
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
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -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::{
|
||||
@@ -10,7 +12,7 @@ use crate::{
|
||||
};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
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<()> {
|
||||
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||
self.server
|
||||
.refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials)
|
||||
.await?;
|
||||
|
||||
// Authenticate
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* 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::core::Session;
|
||||
use common::{auth::AuthRequest, network::SessionStream};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, legacy::LegacyProtocol},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use registry::schema::enums::Permission;
|
||||
@@ -108,6 +113,26 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
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
|
||||
let result = self
|
||||
.server
|
||||
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
// This file is auto-generated. Do not edit directly.
|
||||
|
||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 642;
|
||||
// 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;
|
||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -116,6 +117,8 @@ pub enum AuthEvent {
|
||||
Error = 34,
|
||||
Warning = 595,
|
||||
CredentialExpired = 276,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
LegacyProtocolRefused = 642,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -56,6 +56,8 @@ impl EventType {
|
||||
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
|
||||
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
|
||||
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.warning" => EventType::Auth(AuthEvent::Warning),
|
||||
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
|
||||
@@ -705,6 +707,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
|
||||
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::Warning) => "auth.warning",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
|
||||
@@ -1489,6 +1493,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => 36,
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => 38,
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => 555,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642,
|
||||
EventType::Auth(AuthEvent::Error) => 34,
|
||||
EventType::Auth(AuthEvent::Warning) => 595,
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => 276,
|
||||
@@ -2137,6 +2143,8 @@ impl EventType {
|
||||
36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
|
||||
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
|
||||
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)),
|
||||
34 => Some(EventType::Auth(AuthEvent::Error)),
|
||||
595 => Some(EventType::Auth(AuthEvent::Warning)),
|
||||
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
|
||||
@@ -2848,6 +2856,8 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
|
||||
EventType::Auth(AuthEvent::Success) => 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::ItipMessageSent) => 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::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::Error) => "Authentication error",
|
||||
EventType::Auth(AuthEvent::Warning) => "Authentication warning",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||
@@ -3951,6 +3963,8 @@ 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::Error) => "Authentication error",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
|
||||
@@ -4259,6 +4273,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired),
|
||||
EventType::Auth(AuthEvent::TooManyAttempts),
|
||||
EventType::Auth(AuthEvent::ClientRegistration),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused),
|
||||
EventType::Auth(AuthEvent::Error),
|
||||
EventType::Auth(AuthEvent::Warning),
|
||||
EventType::Auth(AuthEvent::CredentialExpired),
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
|
||||
C32Zc43ANGr52j0cZkTq3IEPrGtbFUX0d2-R91noCho
|
||||
@@ -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
|
||||
(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.
|
||||
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
|
||||
# inside the container, so a bare connect proves nothing: each port has to be
|
||||
# made to speak.
|
||||
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
|
||||
TLS_PORTS = {18993, 18995, 18465}
|
||||
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025, "mistake": 18994}
|
||||
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"
|
||||
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['imap']}:993",
|
||||
"-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:
|
||||
args += ["--env-file", env_file]
|
||||
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||
@@ -122,6 +133,64 @@ def accepts(port, timeout=5):
|
||||
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):
|
||||
"""Wait for a port to reach the wanted state, so the check is not a race."""
|
||||
for _ in range(tries):
|
||||
@@ -176,6 +245,12 @@ def main():
|
||||
check(accepts(PORTS["submissions"]), "submission 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).
|
||||
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||
if got[0] != "inbuxa:ProtocolPolicy/get":
|
||||
@@ -212,12 +287,40 @@ def main():
|
||||
print(" savedListeners:", sorted(saved))
|
||||
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.
|
||||
stop()
|
||||
start()
|
||||
check(settle(PORTS["imap"], False), "IMAP still closed 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).
|
||||
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
policy_set({"legacyProtocols": "enabled"}))
|
||||
@@ -231,6 +334,12 @@ def main():
|
||||
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
|
||||
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()
|
||||
if failures:
|
||||
print(f"{len(failures)} FAILED:")
|
||||
|
||||
Reference in New Issue
Block a user