mirror of
https://github.com/stalwartlabs/proxy.git
synced 2026-10-31 00:12:00 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef9ffe0a2c | ||
|
|
91ffeb1482 | ||
|
|
3a02690854 | ||
|
|
43c5d4c96f |
@@ -0,0 +1,29 @@
|
||||
name: "CI retry"
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["CI"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
rerun:
|
||||
name: Re-run failed jobs
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'failure' &&
|
||||
github.event.workflow_run.run_attempt < 3
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Re-run failed jobs
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CI_RETRY_TOKEN || github.token }}
|
||||
GH_REPO: ${{ github.repository }}
|
||||
RUN_ID: ${{ github.event.workflow_run.id }}
|
||||
RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "Run $RUN_ID failed on attempt $RUN_ATTEMPT, re-running failed jobs"
|
||||
sleep 60
|
||||
gh run rerun "$RUN_ID" --failed
|
||||
+34
-19
@@ -18,6 +18,8 @@ env:
|
||||
SCCACHE_GHA_ENABLED: true
|
||||
RUSTC_WRAPPER: sccache
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_NET_RETRY: 10
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||
AWS_LC_SYS_PREBUILT_NASM: 1
|
||||
|
||||
concurrency:
|
||||
@@ -43,7 +45,7 @@ jobs:
|
||||
if: github.event_name == 'push' || inputs.Docker
|
||||
steps:
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v4.1.1
|
||||
uses: sigstore/cosign-installer@v4.1.2
|
||||
- name: Log In to GitHub Container Registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
@@ -140,11 +142,24 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Free disk space (heavy ARM targets)
|
||||
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
|
||||
run: |
|
||||
df -h /mnt /
|
||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /usr/local/share/powershell /usr/share/swift /opt/hostedtoolcache/CodeQL
|
||||
sudo docker image prune --all --force || true
|
||||
df -h /mnt /
|
||||
|
||||
- name: Add swap (heavy ARM targets)
|
||||
if: contains(matrix.target, 'arm')
|
||||
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
|
||||
run: |
|
||||
mnt_avail=$(df --output=avail -k /mnt | tail -1)
|
||||
if [ "$mnt_avail" -lt 18874368 ]; then
|
||||
echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup"
|
||||
exit 1
|
||||
fi
|
||||
sudo fallocate -l 16G /mnt/swapfile
|
||||
sudo chmod 600 /mnt/swapfile
|
||||
sudo mkswap /mnt/swapfile
|
||||
@@ -186,7 +201,7 @@ jobs:
|
||||
echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache apt
|
||||
uses: actions/cache@v5.0.5
|
||||
uses: actions/cache@v6.1.0
|
||||
id: apt-cache
|
||||
with:
|
||||
path: |
|
||||
@@ -195,7 +210,7 @@ jobs:
|
||||
key: apt-cache-${{ hashFiles('Dockerfile.build') }}
|
||||
|
||||
- name: Cache Cargo
|
||||
uses: actions/cache@v5.0.5
|
||||
uses: actions/cache@v6.1.0
|
||||
id: cargo-cache
|
||||
with:
|
||||
path: |
|
||||
@@ -204,7 +219,7 @@ jobs:
|
||||
key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }}
|
||||
|
||||
- name: Inject cache into docker
|
||||
uses: reproducible-containers/buildkit-cache-dance@v3.3.2
|
||||
uses: reproducible-containers/buildkit-cache-dance@v3.4.0
|
||||
with:
|
||||
cache-map: |
|
||||
{
|
||||
@@ -251,7 +266,7 @@ jobs:
|
||||
targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}}
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: artifact-${{matrix.target}}
|
||||
path: |
|
||||
@@ -268,7 +283,7 @@ jobs:
|
||||
|
||||
- name: Upload digest
|
||||
if: github.event_name == 'push' || inputs.Docker
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }}
|
||||
path: ${{ runner.temp }}/digests/*
|
||||
@@ -276,7 +291,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload GNU meta bake definition
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86')
|
||||
with:
|
||||
name: bake-meta-gnu
|
||||
@@ -285,7 +300,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
- name: Upload musl meta bake definition
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86')
|
||||
with:
|
||||
name: bake-meta-musl
|
||||
@@ -303,10 +318,10 @@ jobs:
|
||||
- target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run sccache-cache
|
||||
uses: mozilla-actions/sccache-action@v0.0.9
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
disable_annotations: true
|
||||
|
||||
@@ -318,7 +333,7 @@ jobs:
|
||||
mv ./target/${{matrix.target}}/release/proxy.exe ./artifacts/proxy.exe
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: artifact-${{matrix.target}}
|
||||
path: artifacts
|
||||
@@ -334,10 +349,10 @@ jobs:
|
||||
- target: x86_64-apple-darwin
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Run sccache-cache
|
||||
uses: mozilla-actions/sccache-action@v0.0.9
|
||||
uses: mozilla-actions/sccache-action@v0.0.11
|
||||
with:
|
||||
disable_annotations: true
|
||||
|
||||
@@ -349,7 +364,7 @@ jobs:
|
||||
mv ./target/${{matrix.target}}/release/proxy ./artifacts/proxy
|
||||
|
||||
- name: Upload Artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v7.0.1
|
||||
with:
|
||||
name: artifact-${{matrix.target}}
|
||||
path: artifacts
|
||||
@@ -367,7 +382,7 @@ jobs:
|
||||
# Must run before artifacts are downloaded: checkout cleans the workspace.
|
||||
- name: Checkout (for CHANGELOG)
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: actions/checkout@v6.0.2
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Download Artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
@@ -410,7 +425,7 @@ jobs:
|
||||
archive/**/*.zip
|
||||
|
||||
- name: Use cosign to sign existing artifacts
|
||||
uses: sigstore/gh-action-sigstore-python@v3.3.0
|
||||
uses: sigstore/gh-action-sigstore-python@v3.5.0
|
||||
with:
|
||||
inputs: |
|
||||
archive/**/*.tar.gz
|
||||
@@ -461,7 +476,7 @@ jobs:
|
||||
cleanup:
|
||||
name: Cleanup failed release
|
||||
needs: [linux, windows, macos, multiarch, release]
|
||||
if: failure() && startsWith(github.ref, 'refs/tags/')
|
||||
if: failure() && startsWith(github.ref, 'refs/tags/') && github.run_attempt >= 3
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
+39
-7
@@ -2,25 +2,57 @@
|
||||
|
||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
## [1.0.4] - 2026-09-10
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
- HTTP and JMAP connections are pinned to the account that authenticated them.
|
||||
- HTTP and JMAP routing identifiers are split on `routing.master_user_separators`.
|
||||
- A backend authentication failure now counts towards `listener.max_auth_attempts`.
|
||||
|
||||
### Fixed
|
||||
- A backend authentication failure no longer leaves the client bridged to the backend unparsed.
|
||||
- `POST /connections/kick` now covers HTTP and JMAP sessions, including upgraded WebSocket bridges.
|
||||
- `POST /connections/delay` now covers HTTP and JMAP logins.
|
||||
- A rejected SASL response is no longer re-parsed as a command, which charged two authentication failures for one client mistake and could drop a command pipelined behind it.
|
||||
- An empty routing identifier is no longer treated as an account, so it is neither registered nor used to pin an HTTP connection.
|
||||
- A backend leg rejected at authentication is now closed with the protocol's logout command instead of having its socket dropped.
|
||||
|
||||
## [1.0.3] - 2026-08-30
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- IMAP authentication is no longer replayed with an inline initial response to backends that do not advertise `SASL-IR` (#4).
|
||||
|
||||
## [1.0.2] - 2026-07-31
|
||||
|
||||
### Added
|
||||
- `AUTH LOGIN` support on IMAP, POP3, ManageSieve and SMTP submission (#3).
|
||||
|
||||
### Changed
|
||||
- `LOGIN` is now part of the default `capabilities.<protocol>.sasl` set.
|
||||
|
||||
### Fixed
|
||||
|
||||
## [1.0.1] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- `server.host_down_min_clients`: number of distinct client addresses a
|
||||
failure streak must span before client-attributable failures can mark a destination
|
||||
down.
|
||||
- `server.host_down_min_clients`: number of distinct client addresses a failure streak must span before client-attributable failures can mark a destination down.
|
||||
- `GET /destinations` now also reports `failure_sources` and `host_level_failure`.
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
- The circuit breaker no longer marks a destination down because a backend rejects
|
||||
the connections of a single banned client (#2).
|
||||
- The circuit breaker no longer marks a destination down because a backend rejects the connections of a single banned client (#2).
|
||||
|
||||
## [1.0.0] - 2026-06-08
|
||||
|
||||
### Added
|
||||
- Initial release of the migration proxy: pre-auth routing and socket bridging
|
||||
for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
|
||||
- Initial release of the migration proxy: pre-auth routing and socket bridging for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
Generated
+352
-283
File diff suppressed because it is too large
Load Diff
+2
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "proxy"
|
||||
version = "1.0.1"
|
||||
version = "1.0.4"
|
||||
edition = "2024"
|
||||
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||
description = "Multi-protocol e-mail proxy"
|
||||
@@ -49,8 +49,7 @@ rustls-platform-verifier = "0.7"
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
mockito = "1.7"
|
||||
testcontainers = "0.27"
|
||||
testcontainers-modules = { version = "0.15", features = ["redis", "postgres"] }
|
||||
testcontainers = "0.28"
|
||||
pwhash = "1"
|
||||
jmap-client = { version = "0.4", default-features = false, features = ["websockets", "async", "aws_lc_rs"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
+16
-3
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base
|
||||
ENV DEBIAN_FRONTEND="noninteractive" \
|
||||
BINSTALL_DISABLE_TELEMETRY=true \
|
||||
CARGO_TERM_COLOR=always \
|
||||
CARGO_NET_RETRY=10 \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true \
|
||||
LANG=C.UTF-8 \
|
||||
TZ=UTC \
|
||||
TERM=xterm-256color \
|
||||
@@ -21,7 +23,7 @@ RUN \
|
||||
rm -f /etc/apt/apt.conf.d/docker-clean && \
|
||||
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache && \
|
||||
apt-get update && \
|
||||
apt-get install -yq --no-install-recommends ca-certificates curl jq xz-utils make libclang-19-dev
|
||||
apt-get install -yq --no-install-recommends ca-certificates curl git jq xz-utils make libclang-19-dev
|
||||
# Zig is pinned to 0.13.0. Zig 0.14+ changed how its default target ABI is
|
||||
# resolved for `x86_64-linux-gnu`-style triples, and on this dependency tree
|
||||
# (aws-lc-sys, libsqlite3-sys, rust-std 1.87+) that surfaces as ld.lld
|
||||
@@ -34,8 +36,19 @@ RUN \
|
||||
zig version
|
||||
# Install cargo-binstall
|
||||
RUN curl --retry 5 -fL --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
||||
# Install cargo-chef & sccache & cargo-zigbuild
|
||||
RUN cargo binstall --no-confirm cargo-chef sccache cargo-zigbuild
|
||||
# Install cargo-chef & sccache & cargo-zigbuild. Versions are pinned so that an
|
||||
# upstream release without prebuilt binaries for this platform cannot silently
|
||||
# turn this into a from-source build, and the `compile` strategy is disabled
|
||||
# because that fallback needs an OpenSSL toolchain this image does not carry:
|
||||
# it burns minutes and then fails. A transient GitHub fetch failure is retried.
|
||||
RUN \
|
||||
for attempt in 1 2 3; do \
|
||||
cargo binstall --no-confirm --disable-strategies compile \
|
||||
cargo-chef@0.1.78 sccache@0.17.0 cargo-zigbuild@0.23.2 && break; \
|
||||
echo "cargo binstall attempt ${attempt} failed, retrying"; \
|
||||
sleep $((attempt * 15)); \
|
||||
done && \
|
||||
command -v cargo-chef && command -v sccache && command -v cargo-zigbuild
|
||||
|
||||
# *****************
|
||||
# Planner
|
||||
|
||||
+9
-4
@@ -191,8 +191,13 @@ async fn handle(
|
||||
match request.method.as_str() {
|
||||
"GET" => write_response(&mut stream, 200, "OK", "ok\n").await?,
|
||||
_ => {
|
||||
write_response(&mut stream, 405, "Method Not Allowed", "method not allowed\n")
|
||||
.await?
|
||||
write_response(
|
||||
&mut stream,
|
||||
405,
|
||||
"Method Not Allowed",
|
||||
"method not allowed\n",
|
||||
)
|
||||
.await?
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
@@ -653,8 +658,8 @@ mod tests {
|
||||
fn parse_trims_trailing_slash() {
|
||||
let req = parse_request(b"GET /stats/ HTTP/1.1\r\nHost: x\r\n").unwrap();
|
||||
assert_eq!(req.path, "/stats");
|
||||
let req = parse_request(b"GET /cache/invalidate/?identifier=a HTTP/1.1\r\nHost: x\r\n")
|
||||
.unwrap();
|
||||
let req =
|
||||
parse_request(b"GET /cache/invalidate/?identifier=a HTTP/1.1\r\nHost: x\r\n").unwrap();
|
||||
assert_eq!(req.path, "/cache/invalidate");
|
||||
assert_eq!(req.query_param("identifier").as_deref(), Some("a"));
|
||||
let req = parse_request(b"GET / HTTP/1.1\r\nHost: x\r\n").unwrap();
|
||||
|
||||
+36
-2
@@ -496,7 +496,8 @@ const DEFAULT_IMAP_ADVERTISE: &[&str] = &[
|
||||
"UTF8=ACCEPT",
|
||||
"JMAPACCESS",
|
||||
];
|
||||
const DEFAULT_SASL: &[&str] = &["PLAIN", "OAUTHBEARER", "XOAUTH2"];
|
||||
const DEFAULT_SASL: &[&str] = &["PLAIN", "LOGIN", "OAUTHBEARER", "XOAUTH2"];
|
||||
pub const SUPPORTED_SASL: &[&str] = &["PLAIN", "LOGIN", "OAUTHBEARER", "XOAUTH2"];
|
||||
const DEFAULT_IMAP_BANNER: &str = "Stalwart IMAP4rev2 at your service.";
|
||||
|
||||
fn default_imap_advertise() -> Vec<String> {
|
||||
@@ -512,6 +513,30 @@ fn default_imap_banner() -> String {
|
||||
DEFAULT_IMAP_BANNER.to_string()
|
||||
}
|
||||
|
||||
fn canonicalize_sasl_list(list: &mut [String], section: &str) -> Result<()> {
|
||||
let mut seen: Vec<&'static str> = Vec::with_capacity(list.len());
|
||||
for entry in list.iter_mut() {
|
||||
let Some(canonical) = SUPPORTED_SASL
|
||||
.iter()
|
||||
.find(|m| m.eq_ignore_ascii_case(entry))
|
||||
.copied()
|
||||
else {
|
||||
return Err(ProxyError::config(format!(
|
||||
"capabilities.{section}.sasl lists unsupported mechanism {entry:?}; supported mechanisms are {}",
|
||||
SUPPORTED_SASL.join(", ")
|
||||
)));
|
||||
};
|
||||
if seen.contains(&canonical) {
|
||||
return Err(ProxyError::config(format!(
|
||||
"capabilities.{section}.sasl lists mechanism {canonical} more than once"
|
||||
)));
|
||||
}
|
||||
seen.push(canonical);
|
||||
*entry = canonical.to_string();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct ManageSieveCapabilities {
|
||||
#[serde(default = "default_sieve_ext")]
|
||||
@@ -796,12 +821,21 @@ fn default_lockout_duration() -> Duration {
|
||||
|
||||
impl Config {
|
||||
pub fn parse_and_validate(raw: &str) -> Result<Self> {
|
||||
let config: Config =
|
||||
let mut config: Config =
|
||||
toml::from_str(raw).map_err(|e| ProxyError::config(format!("TOML parse: {e}")))?;
|
||||
config.canonicalize_sasl()?;
|
||||
config.validate()?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn canonicalize_sasl(&mut self) -> Result<()> {
|
||||
let caps = &mut self.capabilities;
|
||||
canonicalize_sasl_list(&mut caps.imap.sasl, "imap")?;
|
||||
canonicalize_sasl_list(&mut caps.managesieve.sasl, "managesieve")?;
|
||||
canonicalize_sasl_list(&mut caps.submission.sasl, "submission")?;
|
||||
canonicalize_sasl_list(&mut caps.pop3.sasl, "pop3")
|
||||
}
|
||||
|
||||
fn destination(&self, id: &str) -> Result<&DestinationConfig> {
|
||||
self.destination
|
||||
.get(id)
|
||||
|
||||
+104
-13
@@ -13,7 +13,7 @@ use tokio::time::timeout;
|
||||
use crate::config::{ForwardedMode, ListenerConfig, Protocol, TlsMode};
|
||||
use crate::error::{ProxyError, Result};
|
||||
use crate::net::BoxedStream;
|
||||
use crate::proto::common::Ctx;
|
||||
use crate::proto::common::{ConnGuard, Ctx};
|
||||
|
||||
use super::head::{BodyFraming, Head, ParseError, ParseOutcome, error_response};
|
||||
use super::router::{HttpRouter, RouteOutcome};
|
||||
@@ -379,12 +379,55 @@ fn response_no_body(req_method: &str, head: &Head) -> bool {
|
||||
matches!(head.framing, BodyFraming::None)
|
||||
}
|
||||
|
||||
struct Account {
|
||||
cancel: Option<tokio::sync::watch::Sender<bool>>,
|
||||
guard: Option<ConnGuard>,
|
||||
pinned: Option<String>,
|
||||
}
|
||||
|
||||
impl Account {
|
||||
fn new(cancel: tokio::sync::watch::Sender<bool>) -> Self {
|
||||
Account {
|
||||
cancel: Some(cancel),
|
||||
guard: None,
|
||||
pinned: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn accepts(&self, ctx: &Ctx, identifier: Option<&str>) -> bool {
|
||||
match (&self.pinned, identifier) {
|
||||
(Some(pinned), Some(id)) => *pinned == ctx.router.normalize(id),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn pin(&mut self, ctx: &Ctx, identifier: Option<&str>) -> bool {
|
||||
if self.pinned.is_some() {
|
||||
return false;
|
||||
}
|
||||
let Some(id) = identifier else {
|
||||
return false;
|
||||
};
|
||||
let Some(cancel) = self.cancel.take() else {
|
||||
return false;
|
||||
};
|
||||
self.pinned = Some(ctx.router.normalize(id));
|
||||
self.guard = Some(ctx.register_conn_cancel(id, cancel));
|
||||
true
|
||||
}
|
||||
|
||||
fn into_guard(self) -> Option<ConnGuard> {
|
||||
self.guard
|
||||
}
|
||||
}
|
||||
|
||||
enum Disposition {
|
||||
Close,
|
||||
Hijack {
|
||||
backend: BoxedStream,
|
||||
to_backend: Vec<u8>,
|
||||
to_client: Vec<u8>,
|
||||
guard: Option<ConnGuard>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -400,7 +443,16 @@ pub async fn handle(
|
||||
local: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let bridge_idle = ctx.config.server.bridge_idle;
|
||||
match serve(ctx, listener, &mut stream, peer, local).await {
|
||||
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||
|
||||
let served = tokio::select! {
|
||||
result = serve(ctx, listener, &mut stream, peer, local, cancel_tx) => result,
|
||||
_ = crate::outbound::wait_kick(Some(cancel_rx)) => {
|
||||
return Err(ProxyError::backend("disconnected by admin"));
|
||||
}
|
||||
};
|
||||
|
||||
match served {
|
||||
Ok(Disposition::Close) => {
|
||||
shutdown_client(&mut stream).await;
|
||||
Ok(())
|
||||
@@ -409,9 +461,18 @@ pub async fn handle(
|
||||
backend,
|
||||
to_backend,
|
||||
to_client,
|
||||
guard,
|
||||
}) => {
|
||||
crate::outbound::bridge(stream, backend, &to_backend, &to_client, bridge_idle, None)
|
||||
.await?;
|
||||
let cancel = guard.as_ref().map(|g| g.cancel());
|
||||
crate::outbound::bridge(
|
||||
stream,
|
||||
backend,
|
||||
&to_backend,
|
||||
&to_client,
|
||||
bridge_idle,
|
||||
cancel,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
@@ -424,6 +485,7 @@ async fn serve(
|
||||
stream: &mut BoxedStream,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
cancel: tokio::sync::watch::Sender<bool>,
|
||||
) -> Result<Disposition> {
|
||||
let router = ctx.http_router.as_ref();
|
||||
let max_head = router.max_head_size;
|
||||
@@ -459,7 +521,11 @@ async fn serve(
|
||||
let mut first_head = first_head;
|
||||
sanitize_inbound_forwarded(&mut first_head, listener);
|
||||
|
||||
let (buffered_body, dest_id) = match prepare(
|
||||
let Prepared {
|
||||
body: buffered_body,
|
||||
dest_id,
|
||||
identifier,
|
||||
} = match prepare(
|
||||
ctx,
|
||||
router,
|
||||
stream,
|
||||
@@ -477,6 +543,11 @@ async fn serve(
|
||||
}
|
||||
};
|
||||
|
||||
let mut account = Account::new(cancel);
|
||||
if account.pin(ctx, identifier.as_deref()) {
|
||||
crate::proto::common::hold_login(ctx, identifier.as_deref()).await;
|
||||
}
|
||||
|
||||
let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
@@ -552,6 +623,7 @@ async fn serve(
|
||||
backend,
|
||||
to_backend,
|
||||
to_client,
|
||||
guard: account.into_guard(),
|
||||
});
|
||||
}
|
||||
Ok(ExchangeResult::Close) => return Ok(Disposition::Close),
|
||||
@@ -576,7 +648,11 @@ async fn serve(
|
||||
let mut next = next;
|
||||
sanitize_inbound_forwarded(&mut next, listener);
|
||||
|
||||
let (next_body, next_dest) = match prepare(
|
||||
let Prepared {
|
||||
body: next_body,
|
||||
dest_id: next_dest,
|
||||
identifier: next_identifier,
|
||||
} = match prepare(
|
||||
ctx,
|
||||
router,
|
||||
stream,
|
||||
@@ -594,7 +670,7 @@ async fn serve(
|
||||
}
|
||||
};
|
||||
|
||||
if next_dest != dest_id {
|
||||
if next_dest != dest_id || !account.accepts(ctx, next_identifier.as_deref()) {
|
||||
if let Some(body) = &next_body {
|
||||
drain_buffered(stream, &mut reader, &next, body.len(), idle.body)
|
||||
.await
|
||||
@@ -603,6 +679,9 @@ async fn serve(
|
||||
let _ = write_client(stream, &error_response(421, "Misdirected Request")).await;
|
||||
return Ok(Disposition::Close);
|
||||
}
|
||||
if account.pin(ctx, next_identifier.as_deref()) {
|
||||
crate::proto::common::hold_login(ctx, next_identifier.as_deref()).await;
|
||||
}
|
||||
|
||||
current_head = next;
|
||||
current_body = next_body;
|
||||
@@ -891,6 +970,12 @@ async fn drain_buffered(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Prepared {
|
||||
body: Option<Vec<u8>>,
|
||||
dest_id: String,
|
||||
identifier: Option<String>,
|
||||
}
|
||||
|
||||
async fn prepare(
|
||||
ctx: &Ctx,
|
||||
router: &HttpRouter,
|
||||
@@ -899,7 +984,7 @@ async fn prepare(
|
||||
head: &mut Head,
|
||||
body_cap: usize,
|
||||
body_idle: Duration,
|
||||
) -> Result<Option<(Option<Vec<u8>>, String)>> {
|
||||
) -> Result<Option<Prepared>> {
|
||||
let idx = match router.match_index(head) {
|
||||
Some(i) => i,
|
||||
None => return Ok(None),
|
||||
@@ -915,21 +1000,27 @@ async fn prepare(
|
||||
None
|
||||
};
|
||||
|
||||
let dest_id = match router.route_at(idx, head, body.as_deref()) {
|
||||
RouteOutcome::Pin { dest_id } => dest_id,
|
||||
let (dest_id, identifier) = match router.route_at(idx, head, body.as_deref()) {
|
||||
RouteOutcome::Pin { dest_id } => (dest_id, None),
|
||||
RouteOutcome::Identifier {
|
||||
identifier,
|
||||
fallback,
|
||||
} => {
|
||||
if identifier.is_none() && fallback.is_none() {
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = if identifier.is_none() && fallback.is_none() {
|
||||
ctx.resolve(None).await.to_string()
|
||||
} else {
|
||||
ctx.resolve(identifier.as_deref()).await.to_string()
|
||||
}
|
||||
};
|
||||
(dest_id, identifier)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some((body, dest_id)))
|
||||
Ok(Some(Prepared {
|
||||
body,
|
||||
dest_id,
|
||||
identifier,
|
||||
}))
|
||||
}
|
||||
|
||||
fn reason(status: u16) -> &'static str {
|
||||
|
||||
+2
-2
@@ -190,7 +190,7 @@ pub async fn write_all(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> {
|
||||
|
||||
const BRIDGE_BUF: usize = 16 * 1024;
|
||||
|
||||
const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
|
||||
pub(crate) const SHUTDOWN_GRACE: Duration = Duration::from_secs(3);
|
||||
|
||||
pub async fn bridge(
|
||||
mut client: BoxedStream,
|
||||
@@ -245,7 +245,7 @@ pub async fn bridge(
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_kick(cancel: Option<tokio::sync::watch::Receiver<bool>>) {
|
||||
pub(crate) async fn wait_kick(cancel: Option<tokio::sync::watch::Receiver<bool>>) {
|
||||
match cancel {
|
||||
Some(mut rx) => {
|
||||
while rx.changed().await.is_ok() {
|
||||
|
||||
+255
-22
@@ -33,6 +33,22 @@ pub struct Handoff {
|
||||
pub residual: Vec<u8>,
|
||||
}
|
||||
|
||||
pub struct Resume {
|
||||
pub stream: BoxedStream,
|
||||
pub buf: Vec<u8>,
|
||||
}
|
||||
|
||||
pub fn take_rest(it: &mut std::slice::Iter<'_, u8>) -> Vec<u8> {
|
||||
let rest = it.as_slice().to_vec();
|
||||
*it = Default::default();
|
||||
rest
|
||||
}
|
||||
|
||||
pub fn bump(failures: &mut u32, max: u32) -> bool {
|
||||
*failures += 1;
|
||||
*failures >= max
|
||||
}
|
||||
|
||||
pub fn credential_from_sasl(mech: &Mechanism, raw: &[u8]) -> Option<Credential> {
|
||||
match mech {
|
||||
Mechanism::Plain => Credential::decode_sasl_challenge_plain(raw),
|
||||
@@ -94,14 +110,39 @@ pub fn build_greetings(config: &Config) -> Greetings {
|
||||
}
|
||||
}
|
||||
|
||||
pub const LOGIN_USERNAME_CHALLENGE: &str = "VXNlcm5hbWU6";
|
||||
pub const LOGIN_PASSWORD_CHALLENGE: &str = "UGFzc3dvcmQ6";
|
||||
|
||||
pub fn is_cleartext_mechanism(mech: &str) -> bool {
|
||||
mech.eq_ignore_ascii_case("PLAIN") || mech.eq_ignore_ascii_case("LOGIN")
|
||||
}
|
||||
|
||||
pub fn advertised_sasl(sasl: &[String], plain_ok: bool) -> Vec<&str> {
|
||||
sasl.iter()
|
||||
.filter(|m| !m.eq_ignore_ascii_case("LOGIN"))
|
||||
.filter(|m| plain_ok || !m.eq_ignore_ascii_case("PLAIN"))
|
||||
.filter(|m| plain_ok || !is_cleartext_mechanism(m))
|
||||
.map(|s| s.as_str())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn sasl_offered(sasl: &[String], mech: &str) -> bool {
|
||||
sasl.iter().any(|m| m.eq_ignore_ascii_case(mech))
|
||||
}
|
||||
|
||||
pub fn sasl_cancelled(response: &[u8]) -> bool {
|
||||
response.trim_ascii() == b"*"
|
||||
}
|
||||
|
||||
pub fn login_challenge(username_b64: &[u8], password_b64: &[u8]) -> Option<Vec<u8>> {
|
||||
let username = crate::sasl::decode_sasl_ir(username_b64)?;
|
||||
let password = crate::sasl::decode_sasl_ir(password_b64)?;
|
||||
let mut raw = Vec::with_capacity(username.len() + password.len() + 2);
|
||||
raw.push(0);
|
||||
raw.extend_from_slice(&username);
|
||||
raw.push(0);
|
||||
raw.extend_from_slice(&password);
|
||||
Some(raw)
|
||||
}
|
||||
|
||||
pub fn advertises_token(haystack: &[u8], token: &str) -> bool {
|
||||
haystack
|
||||
.split(|b| *b == b'\n' || *b == b'\r')
|
||||
@@ -301,9 +342,27 @@ impl ConnRegistry {
|
||||
}
|
||||
|
||||
pub fn register(&self, identifier: &str) -> ConnGuard {
|
||||
let (cancel, rx) = tokio::sync::watch::channel(false);
|
||||
self.insert(identifier, cancel, rx)
|
||||
}
|
||||
|
||||
pub fn register_cancel(
|
||||
&self,
|
||||
identifier: &str,
|
||||
cancel: tokio::sync::watch::Sender<bool>,
|
||||
) -> ConnGuard {
|
||||
let rx = cancel.subscribe();
|
||||
self.insert(identifier, cancel, rx)
|
||||
}
|
||||
|
||||
fn insert(
|
||||
&self,
|
||||
identifier: &str,
|
||||
cancel: tokio::sync::watch::Sender<bool>,
|
||||
rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> ConnGuard {
|
||||
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let id = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let (cancel, rx) = tokio::sync::watch::channel(false);
|
||||
let key: Arc<str> = Arc::from(identifier);
|
||||
let shard_idx = self.shard_idx(&key);
|
||||
self.shards[shard_idx]
|
||||
@@ -488,11 +547,21 @@ impl Ctx {
|
||||
self.conns.register(&self.router.normalize(identifier))
|
||||
}
|
||||
|
||||
pub fn register_conn_cancel(
|
||||
&self,
|
||||
identifier: &str,
|
||||
cancel: tokio::sync::watch::Sender<bool>,
|
||||
) -> ConnGuard {
|
||||
self.conns
|
||||
.register_cancel(&self.router.normalize(identifier), cancel)
|
||||
}
|
||||
|
||||
pub fn routing_identifier(&self, identifier: Option<String>) -> Option<String> {
|
||||
identifier.map(|id| {
|
||||
crate::route::master_account(&id, &self.config.routing.master_user_separators)
|
||||
.to_string()
|
||||
})
|
||||
let mut id = identifier?;
|
||||
let account =
|
||||
crate::route::master_account(&id, &self.config.routing.master_user_separators);
|
||||
id.truncate(account.len());
|
||||
(!id.is_empty()).then_some(id)
|
||||
}
|
||||
|
||||
pub fn hide_auth_errors(&self, dest_id: &str) -> bool {
|
||||
@@ -598,6 +667,55 @@ pub async fn hold_login(ctx: &Ctx, identifier: Option<&str>) {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct BackendLeg {
|
||||
pub stream: BoxedStream,
|
||||
pub residual: Vec<u8>,
|
||||
pub accepts_initial_response: bool,
|
||||
}
|
||||
|
||||
impl BackendLeg {
|
||||
pub fn new(stream: BoxedStream, residual: Vec<u8>) -> Self {
|
||||
Self {
|
||||
stream,
|
||||
residual,
|
||||
accepts_initial_response: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_initial_response(mut self, accepted: bool) -> Self {
|
||||
self.accepts_initial_response = accepted;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AuthFrame {
|
||||
inline: Vec<u8>,
|
||||
deferred: Option<(Vec<u8>, Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl AuthFrame {
|
||||
pub fn inline(frame: Vec<u8>) -> Self {
|
||||
Self {
|
||||
inline: frame,
|
||||
deferred: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_deferred(mut self, command: Vec<u8>, response: Vec<u8>) -> Self {
|
||||
self.deferred = Some((command, response));
|
||||
self
|
||||
}
|
||||
|
||||
fn wire(&self, accepts_initial_response: bool) -> (&[u8], Option<&[u8]>) {
|
||||
match &self.deferred {
|
||||
Some((command, response)) if !accepts_initial_response => {
|
||||
(command.as_slice(), Some(response.as_slice()))
|
||||
}
|
||||
_ => (self.inline.as_slice(), None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AuthWire {
|
||||
Imap { tag: String },
|
||||
Pop3,
|
||||
@@ -605,6 +723,16 @@ pub enum AuthWire {
|
||||
ManageSieve,
|
||||
}
|
||||
|
||||
impl AuthWire {
|
||||
fn logout(&self) -> &'static [u8] {
|
||||
match self {
|
||||
AuthWire::Imap { .. } => b"ZZ LOGOUT\r\n",
|
||||
AuthWire::Pop3 | AuthWire::Smtp => b"QUIT\r\n",
|
||||
AuthWire::ManageSieve => b"LOGOUT\r\n",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AuthLine {
|
||||
Continuation,
|
||||
Passthrough,
|
||||
@@ -690,6 +818,7 @@ pub async fn complete_backend_auth(
|
||||
backend: &mut BoxedStream,
|
||||
residual: &mut Vec<u8>,
|
||||
wire: AuthWire,
|
||||
mut pending_response: Option<&[u8]>,
|
||||
hide: bool,
|
||||
max: usize,
|
||||
) -> Result<bool> {
|
||||
@@ -698,7 +827,8 @@ pub async fn complete_backend_auth(
|
||||
let line = crate::outbound::read_line(backend, residual, max).await?;
|
||||
match classify_auth_line(&line, &wire) {
|
||||
AuthLine::Continuation => {
|
||||
crate::outbound::write_all(backend, SASL_EMPTY_RESPONSE).await?;
|
||||
let response = pending_response.take().unwrap_or(SASL_EMPTY_RESPONSE);
|
||||
crate::outbound::write_all(backend, response).await?;
|
||||
}
|
||||
AuthLine::Passthrough => {
|
||||
client_out.extend_from_slice(&line);
|
||||
@@ -734,18 +864,18 @@ pub async fn bridge_authenticated<F, Fut>(
|
||||
mut stream: BoxedStream,
|
||||
peer: SocketAddr,
|
||||
identifier: Option<&str>,
|
||||
client_residual: &[u8],
|
||||
client_residual: Vec<u8>,
|
||||
started: Instant,
|
||||
label: &'static str,
|
||||
wire: AuthWire,
|
||||
auth_frame: &[u8],
|
||||
auth: &AuthFrame,
|
||||
unavailable: &[u8],
|
||||
max: usize,
|
||||
open: F,
|
||||
) -> Result<()>
|
||||
) -> Result<Option<Resume>>
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Result<(BoxedStream, Vec<u8>)>>,
|
||||
Fut: Future<Output = Result<BackendLeg>>,
|
||||
{
|
||||
if let Some(id) = identifier
|
||||
&& !crate::token::valid_routing_identifier(id)
|
||||
@@ -756,18 +886,24 @@ where
|
||||
"rejecting login: routing identifier contains invalid characters"
|
||||
);
|
||||
let _ = write(&mut stream, unavailable).await;
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let (mut backend, mut backend_residual) = match establish(ctx, dest_id, peer, open).await {
|
||||
let BackendLeg {
|
||||
stream: mut backend,
|
||||
residual: mut backend_residual,
|
||||
accepts_initial_response,
|
||||
} = match establish(ctx, dest_id, peer, open).await {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, protocol = label, destination = %dest_id, "backend unavailable");
|
||||
let _ = write(&mut stream, unavailable).await;
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
let logout = wire.logout();
|
||||
let (auth_frame, pending_response) = auth.wire(accepts_initial_response);
|
||||
crate::outbound::write_all(&mut backend, auth_frame).await?;
|
||||
|
||||
let hide = ctx.hide_auth_errors(dest_id);
|
||||
@@ -778,6 +914,7 @@ where
|
||||
&mut backend,
|
||||
&mut backend_residual,
|
||||
wire,
|
||||
pending_response,
|
||||
hide,
|
||||
max,
|
||||
),
|
||||
@@ -790,16 +927,26 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let guard = if auth_ok {
|
||||
identifier.map(|id| ctx.register_conn(id))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !auth_ok {
|
||||
tracing::debug!(
|
||||
protocol = label,
|
||||
destination = %dest_id,
|
||||
"backend rejected authentication; resuming pre-authentication dialogue"
|
||||
);
|
||||
let _ = crate::outbound::write_all(&mut backend, logout).await;
|
||||
let _ = tokio::time::timeout(crate::outbound::SHUTDOWN_GRACE, backend.shutdown()).await;
|
||||
return Ok(Some(Resume {
|
||||
stream,
|
||||
buf: client_residual,
|
||||
}));
|
||||
}
|
||||
|
||||
let guard = identifier.map(|id| ctx.register_conn(id));
|
||||
let cancel = guard.as_ref().map(|g| g.cancel());
|
||||
let (to_backend, to_client) = crate::outbound::bridge(
|
||||
stream,
|
||||
backend,
|
||||
client_residual,
|
||||
&client_residual,
|
||||
&backend_residual,
|
||||
ctx.config.server.bridge_idle,
|
||||
cancel,
|
||||
@@ -813,7 +960,7 @@ where
|
||||
secs = started.elapsed().as_secs_f64(),
|
||||
"session closed"
|
||||
);
|
||||
Ok(())
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn write(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> {
|
||||
@@ -885,6 +1032,37 @@ pub async fn read_crlf_line(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn auth_frame_defers_initial_response_when_not_accepted() {
|
||||
let frame = AuthFrame::inline(b"a1 AUTHENTICATE PLAIN AGFAYgBj\r\n".to_vec())
|
||||
.with_deferred(
|
||||
b"a1 AUTHENTICATE PLAIN\r\n".to_vec(),
|
||||
b"AGFAYgBj\r\n".to_vec(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
frame.wire(true),
|
||||
(b"a1 AUTHENTICATE PLAIN AGFAYgBj\r\n".as_slice(), None)
|
||||
);
|
||||
assert_eq!(
|
||||
frame.wire(false),
|
||||
(
|
||||
b"a1 AUTHENTICATE PLAIN\r\n".as_slice(),
|
||||
Some(b"AGFAYgBj\r\n".as_slice())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_frame_without_deferred_form_stays_inline() {
|
||||
let frame = AuthFrame::inline(b"AUTH PLAIN AGFAYgBj\r\n".to_vec());
|
||||
|
||||
assert_eq!(
|
||||
frame.wire(false),
|
||||
(b"AUTH PLAIN AGFAYgBj\r\n".as_slice(), None)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertises_token_handles_smtp_ehlo_continuation_lines() {
|
||||
let ehlo = b"250-mail.example\r\n250-PIPELINING\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 CHUNKING\r\n";
|
||||
@@ -904,6 +1082,61 @@ mod tests {
|
||||
assert!(advertises_token(pop3, "XCLIENT"));
|
||||
}
|
||||
|
||||
fn sasl_list(mechs: &[&str]) -> Vec<String> {
|
||||
mechs.iter().map(|s| s.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_sasl_keeps_login_when_cleartext_is_allowed() {
|
||||
let sasl = sasl_list(&["PLAIN", "LOGIN", "OAUTHBEARER", "XOAUTH2"]);
|
||||
assert_eq!(
|
||||
advertised_sasl(&sasl, true),
|
||||
vec!["PLAIN", "LOGIN", "OAUTHBEARER", "XOAUTH2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_sasl_drops_login_with_plain_when_cleartext_is_refused() {
|
||||
let sasl = sasl_list(&["PLAIN", "LOGIN", "OAUTHBEARER", "XOAUTH2"]);
|
||||
assert_eq!(
|
||||
advertised_sasl(&sasl, false),
|
||||
vec!["OAUTHBEARER", "XOAUTH2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_sasl_omits_login_when_not_configured() {
|
||||
let sasl = sasl_list(&["PLAIN", "OAUTHBEARER"]);
|
||||
assert_eq!(advertised_sasl(&sasl, true), vec!["PLAIN", "OAUTHBEARER"]);
|
||||
assert!(!sasl_offered(&sasl, "LOGIN"));
|
||||
assert!(sasl_offered(&sasl, "plain"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_challenge_builds_a_plain_authentication_frame() {
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
let user = BASE64.encode("user@example.com");
|
||||
let pass = BASE64.encode("secret");
|
||||
assert_eq!(
|
||||
login_challenge(user.as_bytes(), pass.as_bytes()).unwrap(),
|
||||
b"\0user@example.com\0secret".to_vec()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_challenge_rejects_invalid_base64() {
|
||||
assert!(login_challenge(b"!!!!", b"c2VjcmV0").is_none());
|
||||
assert!(login_challenge(b"dXNlcg==", b"!!!!").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sasl_cancellation_is_a_bare_asterisk() {
|
||||
assert!(sasl_cancelled(b"*"));
|
||||
assert!(sasl_cancelled(b"*\r"));
|
||||
assert!(!sasl_cancelled(b"KiA="));
|
||||
assert!(!sasl_cancelled(b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smtp_auth_line_classification() {
|
||||
assert!(matches!(
|
||||
|
||||
+212
-98
@@ -39,68 +39,100 @@ enum Decision {
|
||||
pub async fn handle(
|
||||
ctx: &Ctx,
|
||||
listener: &ListenerConfig,
|
||||
stream: BoxedStream,
|
||||
mut stream: BoxedStream,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let caps = &ctx.config.capabilities.imap;
|
||||
let starttls_listener = listener.tls == TlsMode::Starttls;
|
||||
|
||||
let decision = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(d)) => d,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let Decision::Authenticate {
|
||||
stream,
|
||||
mech,
|
||||
tag,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = decision
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
tracing::debug!(destination = %dest_id, "imap routing decision");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
|
||||
let client_tls = stream.is_tls();
|
||||
let fwd = forward::ForwardInfo::new(peer, local, client_tls, None, ctx.config.server.proxy_ttl);
|
||||
|
||||
let auth_frame = format!(
|
||||
"{tag} AUTHENTICATE {} {}\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
let is_tls = stream.is_tls();
|
||||
let greeting = format!(
|
||||
"* OK [CAPABILITY {}] {}\r\n",
|
||||
ctx.greetings.imap.get(is_tls, starttls_listener && !is_tls),
|
||||
caps.banner
|
||||
);
|
||||
let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n");
|
||||
write(&mut stream, greeting.as_bytes()).await?;
|
||||
|
||||
common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
stream,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
&residual,
|
||||
started,
|
||||
"imap",
|
||||
common::AuthWire::Imap { tag },
|
||||
auth_frame.as_bytes(),
|
||||
unavailable.as_bytes(),
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await
|
||||
let mut seed = Vec::new();
|
||||
let mut auth_failures = 0u32;
|
||||
|
||||
loop {
|
||||
let decision = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(d)) => d,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let Decision::Authenticate {
|
||||
stream: client,
|
||||
mech,
|
||||
tag,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = decision
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
tracing::debug!(destination = %dest_id, "imap routing decision");
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
|
||||
let client_tls = client.is_tls();
|
||||
let fwd =
|
||||
forward::ForwardInfo::new(peer, local, client_tls, None, ctx.config.server.proxy_ttl);
|
||||
|
||||
let mech_name = mechanism_name(&mech);
|
||||
let response = BASE64.encode(&raw_challenge);
|
||||
let auth = common::AuthFrame::inline(
|
||||
format!("{tag} AUTHENTICATE {mech_name} {response}\r\n").into_bytes(),
|
||||
)
|
||||
.with_deferred(
|
||||
format!("{tag} AUTHENTICATE {mech_name}\r\n").into_bytes(),
|
||||
format!("{response}\r\n").into_bytes(),
|
||||
);
|
||||
let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n");
|
||||
|
||||
let resume = common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
client,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
residual,
|
||||
started,
|
||||
"imap",
|
||||
common::AuthWire::Imap { tag },
|
||||
&auth,
|
||||
unavailable.as_bytes(),
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(common::Resume {
|
||||
stream: client,
|
||||
buf,
|
||||
}) = resume
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if common::bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(());
|
||||
}
|
||||
stream = client;
|
||||
seed = buf;
|
||||
}
|
||||
}
|
||||
|
||||
async fn preauth(
|
||||
@@ -108,6 +140,8 @@ async fn preauth(
|
||||
listener: &ListenerConfig,
|
||||
caps: &ImapCapabilities,
|
||||
mut stream: BoxedStream,
|
||||
seed: Vec<u8>,
|
||||
auth_failures: &mut u32,
|
||||
) -> Result<Decision> {
|
||||
let starttls_listener = listener.tls == TlsMode::Starttls;
|
||||
let mut auth = Auth {
|
||||
@@ -115,31 +149,29 @@ async fn preauth(
|
||||
caps,
|
||||
is_tls: stream.is_tls(),
|
||||
max_attempts: listener.max_auth_attempts,
|
||||
auth_failures: 0,
|
||||
auth_failures,
|
||||
};
|
||||
|
||||
let greeting = format!(
|
||||
"* OK [CAPABILITY {}] {}\r\n",
|
||||
ctx.greetings
|
||||
.imap
|
||||
.get(auth.is_tls, starttls_listener && !auth.is_tls),
|
||||
caps.banner
|
||||
);
|
||||
write(&mut stream, greeting.as_bytes()).await?;
|
||||
|
||||
let mut receiver = Receiver::<Command>::new();
|
||||
receiver.max_request_size = MAX_PREAUTH;
|
||||
let mut read = [0u8; 8192];
|
||||
let mut pending = seed;
|
||||
|
||||
loop {
|
||||
let n = stream
|
||||
.read(&mut read)
|
||||
.await
|
||||
.map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?;
|
||||
if n == 0 {
|
||||
return Ok(Decision::Closed);
|
||||
}
|
||||
let mut it = read[..n].iter();
|
||||
let chunk: &[u8] = if pending.is_empty() {
|
||||
let n = stream
|
||||
.read(&mut read)
|
||||
.await
|
||||
.map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?;
|
||||
if n == 0 {
|
||||
return Ok(Decision::Closed);
|
||||
}
|
||||
&read[..n]
|
||||
} else {
|
||||
&pending
|
||||
};
|
||||
let mut it = chunk.iter();
|
||||
let mut carry: Option<Vec<u8>> = None;
|
||||
|
||||
loop {
|
||||
match receiver.parse(&mut it) {
|
||||
@@ -230,6 +262,10 @@ async fn preauth(
|
||||
residual,
|
||||
});
|
||||
}
|
||||
Step::Resume { residual } => {
|
||||
carry = Some(residual);
|
||||
break;
|
||||
}
|
||||
Step::Continue => {}
|
||||
Step::Close => return Ok(Decision::Closed),
|
||||
}
|
||||
@@ -251,6 +287,10 @@ async fn preauth(
|
||||
residual,
|
||||
});
|
||||
}
|
||||
Step::Resume { residual } => {
|
||||
carry = Some(residual);
|
||||
break;
|
||||
}
|
||||
Step::Continue => {}
|
||||
Step::Close => return Ok(Decision::Closed),
|
||||
}
|
||||
@@ -276,13 +316,17 @@ async fn preauth(
|
||||
}
|
||||
Err(RecvError::Parse { message }) => {
|
||||
write(&mut stream, format!("* BAD {message}\r\n").as_bytes()).await?;
|
||||
auth.auth_failures += 1;
|
||||
if auth.auth_failures >= auth.max_attempts {
|
||||
if common::bump(auth.auth_failures, auth.max_attempts) {
|
||||
return Ok(Decision::Closed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(it);
|
||||
match carry {
|
||||
Some(rest) => pending = rest,
|
||||
None => pending.clear(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,11 +335,14 @@ struct Auth<'a> {
|
||||
caps: &'a ImapCapabilities,
|
||||
is_tls: bool,
|
||||
max_attempts: u32,
|
||||
auth_failures: u32,
|
||||
auth_failures: &'a mut u32,
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Continue,
|
||||
Resume {
|
||||
residual: Vec<u8>,
|
||||
},
|
||||
Close,
|
||||
Finalize {
|
||||
mech: Mechanism,
|
||||
@@ -325,19 +372,15 @@ impl Auth<'_> {
|
||||
|
||||
match mech {
|
||||
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {}
|
||||
Mechanism::Login => {
|
||||
Mechanism::Login if common::sasl_offered(&self.caps.sasl, "LOGIN") => {}
|
||||
_ => {
|
||||
return self
|
||||
.reject(stream, tag, "AUTH LOGIN is not supported.")
|
||||
.await;
|
||||
}
|
||||
Mechanism::Other(_) => {
|
||||
return self
|
||||
.reject(stream, tag, "Unsupported authentication mechanism.")
|
||||
.reject(stream, tag, "Unsupported authentication mechanism.", None)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(mech, Mechanism::Plain)
|
||||
if matches!(mech, Mechanism::Plain | Mechanism::Login)
|
||||
&& !plain_auth_allowed(self.caps.allow_plain_auth_without_tls, self.is_tls)
|
||||
{
|
||||
write(
|
||||
@@ -349,23 +392,83 @@ impl Auth<'_> {
|
||||
return Ok(Step::Continue);
|
||||
}
|
||||
|
||||
if matches!(mech, Mechanism::Login) {
|
||||
return self.authenticate_login(stream, tokens, tag, it).await;
|
||||
}
|
||||
|
||||
let (raw_b64, residual): (Vec<u8>, Vec<u8>) = if let Some(ir) = tokens.get(1) {
|
||||
(ir.as_bytes().to_vec(), it.as_slice().to_vec())
|
||||
(ir.as_bytes().to_vec(), common::take_rest(it))
|
||||
} else {
|
||||
write(&mut *stream, b"+ \r\n").await?;
|
||||
let seed = it.as_slice().to_vec();
|
||||
let seed = common::take_rest(it);
|
||||
read_crlf_line(stream, seed, MAX_PREAUTH).await?
|
||||
};
|
||||
|
||||
let Some(raw) = decode_sasl_ir(&raw_b64) else {
|
||||
return self
|
||||
.reject(stream, tag, "Invalid base64 in SASL response.")
|
||||
.reject(
|
||||
stream,
|
||||
tag,
|
||||
"Invalid base64 in SASL response.",
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
self.finish(stream, mech, tag, raw, residual).await
|
||||
}
|
||||
|
||||
async fn authenticate_login(
|
||||
&mut self,
|
||||
stream: &mut BoxedStream,
|
||||
tokens: &[Token],
|
||||
tag: &str,
|
||||
it: &mut std::slice::Iter<'_, u8>,
|
||||
) -> Result<Step> {
|
||||
let (user_b64, residual): (Vec<u8>, Vec<u8>) = if let Some(ir) = tokens.get(1) {
|
||||
(ir.as_bytes().to_vec(), common::take_rest(it))
|
||||
} else {
|
||||
write(
|
||||
&mut *stream,
|
||||
format!("+ {}\r\n", common::LOGIN_USERNAME_CHALLENGE).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let seed = common::take_rest(it);
|
||||
read_crlf_line(stream, seed, MAX_PREAUTH).await?
|
||||
};
|
||||
if common::sasl_cancelled(&user_b64) {
|
||||
return self
|
||||
.reject(stream, tag, "Authentication cancelled.", Some(residual))
|
||||
.await;
|
||||
}
|
||||
|
||||
write(
|
||||
&mut *stream,
|
||||
format!("+ {}\r\n", common::LOGIN_PASSWORD_CHALLENGE).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let (pass_b64, residual) = read_crlf_line(stream, residual, MAX_PREAUTH).await?;
|
||||
if common::sasl_cancelled(&pass_b64) {
|
||||
return self
|
||||
.reject(stream, tag, "Authentication cancelled.", Some(residual))
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
||||
return self
|
||||
.reject(
|
||||
stream,
|
||||
tag,
|
||||
"Invalid base64 in SASL response.",
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
|
||||
self.finish(stream, Mechanism::Plain, tag, raw, residual)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn login(
|
||||
&mut self,
|
||||
stream: &mut BoxedStream,
|
||||
@@ -383,7 +486,7 @@ impl Auth<'_> {
|
||||
}
|
||||
let (Some(user), Some(pass)) = (tokens.first(), tokens.get(1)) else {
|
||||
return self
|
||||
.reject(stream, tag, "LOGIN requires a username and password.")
|
||||
.reject(stream, tag, "LOGIN requires a username and password.", None)
|
||||
.await;
|
||||
};
|
||||
let (user, pass) = (user.as_bytes(), pass.as_bytes());
|
||||
@@ -392,7 +495,7 @@ impl Auth<'_> {
|
||||
raw.extend_from_slice(user);
|
||||
raw.push(0);
|
||||
raw.extend_from_slice(pass);
|
||||
let residual = it.as_slice().to_vec();
|
||||
let residual = common::take_rest(it);
|
||||
|
||||
self.finish(stream, Mechanism::Plain, tag, raw, residual)
|
||||
.await
|
||||
@@ -407,7 +510,9 @@ impl Auth<'_> {
|
||||
residual: Vec<u8>,
|
||||
) -> Result<Step> {
|
||||
let Some(cred) = common::credential_from_sasl(&mech, &raw) else {
|
||||
return self.reject(stream, tag, "Malformed credentials.").await;
|
||||
return self
|
||||
.reject(stream, tag, "Malformed credentials.", Some(residual))
|
||||
.await;
|
||||
};
|
||||
let identifier =
|
||||
identifier_from_credential(&cred, &self.ctx.config.oauth.jwt_username_claim);
|
||||
@@ -420,14 +525,21 @@ impl Auth<'_> {
|
||||
})
|
||||
}
|
||||
|
||||
async fn reject(&mut self, stream: &mut BoxedStream, tag: &str, message: &str) -> Result<Step> {
|
||||
async fn reject(
|
||||
&mut self,
|
||||
stream: &mut BoxedStream,
|
||||
tag: &str,
|
||||
message: &str,
|
||||
residual: Option<Vec<u8>>,
|
||||
) -> Result<Step> {
|
||||
write(&mut *stream, format!("{tag} NO {message}\r\n").as_bytes()).await?;
|
||||
self.auth_failures += 1;
|
||||
if self.auth_failures >= self.max_attempts {
|
||||
Ok(Step::Close)
|
||||
} else {
|
||||
Ok(Step::Continue)
|
||||
if common::bump(self.auth_failures, self.max_attempts) {
|
||||
return Ok(Step::Close);
|
||||
}
|
||||
Ok(match residual {
|
||||
Some(residual) => Step::Resume { residual },
|
||||
None => Step::Continue,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,7 +547,7 @@ async fn open_backend(
|
||||
ctx: &Ctx,
|
||||
dest_id: &str,
|
||||
fwd: &forward::ForwardInfo,
|
||||
) -> Result<(BoxedStream, Vec<u8>)> {
|
||||
) -> Result<common::BackendLeg> {
|
||||
let common::DialedEndpoint {
|
||||
dest,
|
||||
ep,
|
||||
@@ -448,6 +560,7 @@ async fn open_backend(
|
||||
let mut residual = Vec::new();
|
||||
|
||||
let banner = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?;
|
||||
let mut sasl_ir = common::advertises_token(&banner, "SASL-IR");
|
||||
|
||||
if ep.tls == TlsMode::Starttls {
|
||||
if banner.contains(&b'[') && !common::advertises_token(&banner, "STARTTLS") {
|
||||
@@ -458,6 +571,7 @@ async fn open_backend(
|
||||
if !tagged_ok(&resp, b"P1") {
|
||||
return Err(ProxyError::backend("backend refused STARTTLS"));
|
||||
}
|
||||
sasl_ir = common::advertises_token(&resp, "SASL-IR");
|
||||
backend = outbound::tls_connect(backend, &tls_cfg, dest).await?;
|
||||
}
|
||||
|
||||
@@ -467,7 +581,7 @@ async fn open_backend(
|
||||
forward::send_imap_id(&mut backend, &mut residual, &banner, fwd, MAX_PREAUTH).await?;
|
||||
}
|
||||
|
||||
Ok((backend, residual))
|
||||
Ok(common::BackendLeg::new(backend, residual).with_initial_response(sasl_ir))
|
||||
}
|
||||
|
||||
fn tagged_ok(line: &[u8], tag: &[u8]) -> bool {
|
||||
|
||||
+252
-92
@@ -16,7 +16,7 @@ use crate::imap_receiver::{Command, Error as RecvError, Mechanism, Receiver, Sta
|
||||
use crate::net::BoxedStream;
|
||||
use crate::outbound;
|
||||
use crate::proto::common::{
|
||||
self, Ctx, Handoff, assert_egress_encrypted, credential_from_sasl, mechanism_name,
|
||||
self, Ctx, Handoff, assert_egress_encrypted, bump, credential_from_sasl, mechanism_name,
|
||||
plain_auth_allowed, write,
|
||||
};
|
||||
use crate::proto::forward;
|
||||
@@ -28,77 +28,14 @@ const MAX_PREAUTH: usize = 64 * 1024;
|
||||
pub async fn handle(
|
||||
ctx: &Ctx,
|
||||
listener: &ListenerConfig,
|
||||
stream: BoxedStream,
|
||||
mut stream: BoxedStream,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let caps = &ctx.config.capabilities.managesieve;
|
||||
|
||||
let handoff = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(h))) => h,
|
||||
Ok(Ok(None)) => return Ok(()),
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let Handoff {
|
||||
stream,
|
||||
mech,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = handoff;
|
||||
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
let fwd = forward::ForwardInfo::new(
|
||||
peer,
|
||||
local,
|
||||
stream.is_tls(),
|
||||
None,
|
||||
ctx.config.server.proxy_ttl,
|
||||
);
|
||||
|
||||
let auth_frame = format!(
|
||||
"AUTHENTICATE \"{}\" \"{}\"\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
);
|
||||
|
||||
common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
stream,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
&residual,
|
||||
started,
|
||||
"managesieve",
|
||||
common::AuthWire::ManageSieve,
|
||||
auth_frame.as_bytes(),
|
||||
b"NO \"backend unavailable\"\r\n",
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn preauth(
|
||||
ctx: &Ctx,
|
||||
listener: &ListenerConfig,
|
||||
caps: &ManageSieveCapabilities,
|
||||
mut stream: BoxedStream,
|
||||
) -> Result<Option<Handoff>> {
|
||||
let mut is_tls = stream.is_tls();
|
||||
let starttls_listener = listener.tls == TlsMode::Starttls;
|
||||
|
||||
let is_tls = stream.is_tls();
|
||||
write(
|
||||
&mut stream,
|
||||
ctx.greetings
|
||||
@@ -108,21 +45,115 @@ async fn preauth(
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut seed = Vec::new();
|
||||
let mut auth_failures = 0u32;
|
||||
|
||||
loop {
|
||||
let handoff = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(h))) => h,
|
||||
Ok(Ok(None)) => return Ok(()),
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let Handoff {
|
||||
stream: client,
|
||||
mech,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = handoff;
|
||||
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
let fwd = forward::ForwardInfo::new(
|
||||
peer,
|
||||
local,
|
||||
client.is_tls(),
|
||||
None,
|
||||
ctx.config.server.proxy_ttl,
|
||||
);
|
||||
|
||||
let auth = common::AuthFrame::inline(
|
||||
format!(
|
||||
"AUTHENTICATE \"{}\" \"{}\"\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
let resume = common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
client,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
residual,
|
||||
started,
|
||||
"managesieve",
|
||||
common::AuthWire::ManageSieve,
|
||||
&auth,
|
||||
b"NO \"backend unavailable\"\r\n",
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(common::Resume {
|
||||
stream: client,
|
||||
buf,
|
||||
}) = resume
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(());
|
||||
}
|
||||
stream = client;
|
||||
seed = buf;
|
||||
}
|
||||
}
|
||||
|
||||
async fn preauth(
|
||||
ctx: &Ctx,
|
||||
listener: &ListenerConfig,
|
||||
caps: &ManageSieveCapabilities,
|
||||
mut stream: BoxedStream,
|
||||
seed: Vec<u8>,
|
||||
auth_failures: &mut u32,
|
||||
) -> Result<Option<Handoff>> {
|
||||
let mut is_tls = stream.is_tls();
|
||||
let starttls_listener = listener.tls == TlsMode::Starttls;
|
||||
|
||||
let mut receiver =
|
||||
Receiver::<Command>::new().with_start_state(State::Command { is_uid: false });
|
||||
receiver.max_request_size = MAX_PREAUTH;
|
||||
let mut auth_failures = 0u32;
|
||||
let mut read = [0u8; 8192];
|
||||
let mut pending = seed;
|
||||
|
||||
loop {
|
||||
let n = stream
|
||||
.read(&mut read)
|
||||
.await
|
||||
.map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut it = read[..n].iter();
|
||||
let chunk: &[u8] = if pending.is_empty() {
|
||||
let n = stream
|
||||
.read(&mut read)
|
||||
.await
|
||||
.map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
&read[..n]
|
||||
} else {
|
||||
&pending
|
||||
};
|
||||
let mut it = chunk.iter();
|
||||
let mut carry: Option<Vec<u8>> = None;
|
||||
|
||||
loop {
|
||||
match receiver.parse(&mut it) {
|
||||
@@ -173,9 +204,10 @@ async fn preauth(
|
||||
ctx,
|
||||
&mut stream,
|
||||
plain_ok,
|
||||
common::sasl_offered(&caps.sasl, "LOGIN"),
|
||||
&req.tokens,
|
||||
&mut it,
|
||||
&mut auth_failures,
|
||||
&mut *auth_failures,
|
||||
listener.max_auth_attempts,
|
||||
)
|
||||
.await?
|
||||
@@ -194,6 +226,10 @@ async fn preauth(
|
||||
residual,
|
||||
}));
|
||||
}
|
||||
AuthStep::Resume { residual } => {
|
||||
carry = Some(residual);
|
||||
break;
|
||||
}
|
||||
AuthStep::Continue => {}
|
||||
AuthStep::Close => return Ok(None),
|
||||
}
|
||||
@@ -218,13 +254,17 @@ async fn preauth(
|
||||
format!("NO \"{}\"\r\n", sanitize(&message)).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
auth_failures += 1;
|
||||
if auth_failures >= listener.max_auth_attempts {
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(it);
|
||||
match carry {
|
||||
Some(rest) => pending = rest,
|
||||
None => pending.clear(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,13 +276,18 @@ enum AuthStep {
|
||||
residual: Vec<u8>,
|
||||
},
|
||||
Continue,
|
||||
Resume {
|
||||
residual: Vec<u8>,
|
||||
},
|
||||
Close,
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn handle_authenticate(
|
||||
ctx: &Ctx,
|
||||
stream: &mut BoxedStream,
|
||||
plain_ok: bool,
|
||||
login_offered: bool,
|
||||
tokens: &[crate::imap_receiver::Token],
|
||||
it: &mut std::slice::Iter<'_, u8>,
|
||||
auth_failures: &mut u32,
|
||||
@@ -255,9 +300,19 @@ async fn handle_authenticate(
|
||||
let mech = Mechanism::parse(mech_tok.as_bytes());
|
||||
match mech {
|
||||
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {}
|
||||
_ => return fail(stream, "Unsupported mechanism", auth_failures, max_attempts).await,
|
||||
Mechanism::Login if login_offered => {}
|
||||
_ => {
|
||||
return fail(
|
||||
stream,
|
||||
"Unsupported mechanism",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if matches!(mech, Mechanism::Plain) && !plain_ok {
|
||||
if matches!(mech, Mechanism::Plain | Mechanism::Login) && !plain_ok {
|
||||
write(
|
||||
&mut *stream,
|
||||
b"NO \"Cleartext authentication requires TLS\"\r\n",
|
||||
@@ -266,19 +321,37 @@ async fn handle_authenticate(
|
||||
return Ok(AuthStep::Continue);
|
||||
}
|
||||
|
||||
if matches!(mech, Mechanism::Login) {
|
||||
return authenticate_login(ctx, stream, tokens, it, auth_failures, max_attempts).await;
|
||||
}
|
||||
|
||||
let (raw_b64, residual): (Vec<u8>, Vec<u8>) = if let Some(ir) = tokens.get(1) {
|
||||
(ir.as_bytes().to_vec(), it.as_slice().to_vec())
|
||||
(ir.as_bytes().to_vec(), common::take_rest(it))
|
||||
} else {
|
||||
write(&mut *stream, b"{0}\r\n").await?;
|
||||
let seed = it.as_slice().to_vec();
|
||||
let seed = common::take_rest(it);
|
||||
read_sasl_response(stream, seed).await?
|
||||
};
|
||||
|
||||
let Some(raw) = decode_sasl_ir(&raw_b64) else {
|
||||
return fail(stream, "Invalid base64", auth_failures, max_attempts).await;
|
||||
return fail(
|
||||
stream,
|
||||
"Invalid base64",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let Some(cred) = credential_from_sasl(&mech, &raw) else {
|
||||
return fail(stream, "Malformed credentials", auth_failures, max_attempts).await;
|
||||
return fail(
|
||||
stream,
|
||||
"Malformed credentials",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let identifier = identifier_from_credential(&cred, &ctx.config.oauth.jwt_username_claim);
|
||||
|
||||
@@ -290,19 +363,98 @@ async fn handle_authenticate(
|
||||
})
|
||||
}
|
||||
|
||||
async fn authenticate_login(
|
||||
ctx: &Ctx,
|
||||
stream: &mut BoxedStream,
|
||||
tokens: &[crate::imap_receiver::Token],
|
||||
it: &mut std::slice::Iter<'_, u8>,
|
||||
auth_failures: &mut u32,
|
||||
max_attempts: u32,
|
||||
) -> Result<AuthStep> {
|
||||
let (user_b64, residual): (Vec<u8>, Vec<u8>) = if let Some(ir) = tokens.get(1) {
|
||||
(ir.as_bytes().to_vec(), common::take_rest(it))
|
||||
} else {
|
||||
write_challenge(stream, common::LOGIN_USERNAME_CHALLENGE).await?;
|
||||
let seed = common::take_rest(it);
|
||||
read_sasl_response(stream, seed).await?
|
||||
};
|
||||
if common::sasl_cancelled(&user_b64) {
|
||||
return fail(
|
||||
stream,
|
||||
"Authentication cancelled",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
write_challenge(stream, common::LOGIN_PASSWORD_CHALLENGE).await?;
|
||||
let (pass_b64, residual) = read_sasl_response(stream, residual).await?;
|
||||
if common::sasl_cancelled(&pass_b64) {
|
||||
return fail(
|
||||
stream,
|
||||
"Authentication cancelled",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let Some(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
||||
return fail(
|
||||
stream,
|
||||
"Invalid base64",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let Some(cred) = credential_from_sasl(&Mechanism::Plain, &raw) else {
|
||||
return fail(
|
||||
stream,
|
||||
"Malformed credentials",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
Some(residual),
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let identifier = identifier_from_credential(&cred, &ctx.config.oauth.jwt_username_claim);
|
||||
|
||||
Ok(AuthStep::Done {
|
||||
mech: Mechanism::Plain,
|
||||
raw_challenge: raw,
|
||||
identifier,
|
||||
residual,
|
||||
})
|
||||
}
|
||||
|
||||
async fn write_challenge(stream: &mut BoxedStream, challenge: &str) -> Result<()> {
|
||||
write(
|
||||
&mut *stream,
|
||||
format!("{{{}}}\r\n{challenge}\r\n", challenge.len()).as_bytes(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fail(
|
||||
stream: &mut BoxedStream,
|
||||
message: &str,
|
||||
auth_failures: &mut u32,
|
||||
max_attempts: u32,
|
||||
residual: Option<Vec<u8>>,
|
||||
) -> Result<AuthStep> {
|
||||
write(&mut *stream, format!("NO \"{message}\"\r\n").as_bytes()).await?;
|
||||
*auth_failures += 1;
|
||||
if *auth_failures >= max_attempts {
|
||||
Ok(AuthStep::Close)
|
||||
} else {
|
||||
Ok(AuthStep::Continue)
|
||||
if bump(auth_failures, max_attempts) {
|
||||
return Ok(AuthStep::Close);
|
||||
}
|
||||
Ok(match residual {
|
||||
Some(residual) => AuthStep::Resume { residual },
|
||||
None => AuthStep::Continue,
|
||||
})
|
||||
}
|
||||
|
||||
async fn read_sasl_response(stream: &mut BoxedStream, seed: Vec<u8>) -> Result<(Vec<u8>, Vec<u8>)> {
|
||||
@@ -325,7 +477,15 @@ async fn read_sasl_response(stream: &mut BoxedStream, seed: Vec<u8>) -> Result<(
|
||||
}
|
||||
Ok((data, rest))
|
||||
} else {
|
||||
Ok((line, residual))
|
||||
Ok((unquote(line), residual))
|
||||
}
|
||||
}
|
||||
|
||||
fn unquote(line: Vec<u8>) -> Vec<u8> {
|
||||
if line.len() >= 2 && line.first() == Some(&b'"') && line.last() == Some(&b'"') {
|
||||
line[1..line.len() - 1].to_vec()
|
||||
} else {
|
||||
line
|
||||
}
|
||||
}
|
||||
|
||||
@@ -391,7 +551,7 @@ async fn open_backend(
|
||||
ctx: &Ctx,
|
||||
dest_id: &str,
|
||||
fwd: &forward::ForwardInfo,
|
||||
) -> Result<(BoxedStream, Vec<u8>)> {
|
||||
) -> Result<common::BackendLeg> {
|
||||
let common::DialedEndpoint {
|
||||
dest,
|
||||
ep,
|
||||
@@ -432,7 +592,7 @@ async fn open_backend(
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok((backend, residual))
|
||||
Ok(common::BackendLeg::new(backend, residual))
|
||||
}
|
||||
|
||||
async fn read_capabilities(stream: &mut BoxedStream, residual: &mut Vec<u8>) -> Result<Vec<u8>> {
|
||||
|
||||
+147
-67
@@ -15,7 +15,7 @@ use crate::imap_receiver::Mechanism;
|
||||
use crate::net::BoxedStream;
|
||||
use crate::outbound;
|
||||
use crate::proto::common::{
|
||||
self, Ctx, Handoff, assert_egress_encrypted, credential_from_sasl, mechanism_name,
|
||||
self, Ctx, Handoff, assert_egress_encrypted, bump, credential_from_sasl, mechanism_name,
|
||||
plain_auth_allowed, write,
|
||||
};
|
||||
use crate::proto::forward;
|
||||
@@ -27,66 +27,89 @@ const MAX_PREAUTH: usize = 64 * 1024;
|
||||
pub async fn handle(
|
||||
ctx: &Ctx,
|
||||
listener: &ListenerConfig,
|
||||
stream: BoxedStream,
|
||||
mut stream: BoxedStream,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let caps = &ctx.config.capabilities.pop3;
|
||||
|
||||
let handoff = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(h))) => h,
|
||||
Ok(Ok(None)) => return Ok(()),
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
write(&mut stream, format!("+OK {}\r\n", caps.banner).as_bytes()).await?;
|
||||
|
||||
let Handoff {
|
||||
stream,
|
||||
mech,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = handoff;
|
||||
let mut seed = Vec::with_capacity(1024);
|
||||
let mut auth_failures = 0u32;
|
||||
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
let fwd = forward::ForwardInfo::new(
|
||||
peer,
|
||||
local,
|
||||
stream.is_tls(),
|
||||
None,
|
||||
ctx.config.server.proxy_ttl,
|
||||
);
|
||||
loop {
|
||||
let handoff = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(h))) => h,
|
||||
Ok(Ok(None)) => return Ok(()),
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let auth_frame = format!(
|
||||
"AUTH {} {}\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
);
|
||||
let Handoff {
|
||||
stream: client,
|
||||
mech,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = handoff;
|
||||
|
||||
common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
stream,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
&residual,
|
||||
started,
|
||||
"pop3",
|
||||
common::AuthWire::Pop3,
|
||||
auth_frame.as_bytes(),
|
||||
b"-ERR backend unavailable\r\n",
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
let fwd = forward::ForwardInfo::new(
|
||||
peer,
|
||||
local,
|
||||
client.is_tls(),
|
||||
None,
|
||||
ctx.config.server.proxy_ttl,
|
||||
);
|
||||
|
||||
let auth = common::AuthFrame::inline(
|
||||
format!(
|
||||
"AUTH {} {}\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
let resume = common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
client,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
residual,
|
||||
started,
|
||||
"pop3",
|
||||
common::AuthWire::Pop3,
|
||||
&auth,
|
||||
b"-ERR backend unavailable\r\n",
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(common::Resume {
|
||||
stream: client,
|
||||
buf,
|
||||
}) = resume
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(());
|
||||
}
|
||||
stream = client;
|
||||
seed = buf;
|
||||
}
|
||||
}
|
||||
|
||||
async fn preauth(
|
||||
@@ -94,15 +117,14 @@ async fn preauth(
|
||||
listener: &ListenerConfig,
|
||||
caps: &Pop3Capabilities,
|
||||
mut stream: BoxedStream,
|
||||
seed: Vec<u8>,
|
||||
auth_failures: &mut u32,
|
||||
) -> Result<Option<Handoff>> {
|
||||
let mut is_tls = stream.is_tls();
|
||||
let starttls_listener = listener.tls == TlsMode::Starttls;
|
||||
|
||||
write(&mut stream, format!("+OK {}\r\n", caps.banner).as_bytes()).await?;
|
||||
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||
let mut buf: Vec<u8> = seed;
|
||||
let mut pending_user: Option<Vec<u8>> = None;
|
||||
let mut auth_failures = 0u32;
|
||||
|
||||
loop {
|
||||
let Some(nl) = buf.iter().position(|&b| b == b'\n') else {
|
||||
@@ -220,16 +242,17 @@ async fn preauth(
|
||||
let mech = Mechanism::parse(mech_bytes);
|
||||
match &mech {
|
||||
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {}
|
||||
Mechanism::Login if common::sasl_offered(&caps.sasl, "LOGIN") => {}
|
||||
_ => {
|
||||
write(&mut stream, b"-ERR unsupported mechanism\r\n").await?;
|
||||
buf.drain(..consumed);
|
||||
if bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if matches!(mech, Mechanism::Plain) && !plain_ok {
|
||||
if matches!(mech, Mechanism::Plain | Mechanism::Login) && !plain_ok {
|
||||
write(
|
||||
&mut stream,
|
||||
b"-ERR cleartext authentication requires TLS\r\n",
|
||||
@@ -239,6 +262,68 @@ async fn preauth(
|
||||
continue;
|
||||
}
|
||||
|
||||
if matches!(mech, Mechanism::Login) {
|
||||
buf.drain(..consumed);
|
||||
let seed = std::mem::take(&mut buf);
|
||||
let (user_b64, rest) = if ir.is_empty() {
|
||||
write(
|
||||
&mut stream,
|
||||
format!("+ {}\r\n", common::LOGIN_USERNAME_CHALLENGE).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
common::read_crlf_line(&mut stream, seed, MAX_PREAUTH).await?
|
||||
} else {
|
||||
(ir.to_vec(), seed)
|
||||
};
|
||||
if common::sasl_cancelled(&user_b64) {
|
||||
buf = rest;
|
||||
write(&mut stream, b"-ERR authentication cancelled\r\n").await?;
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
write(
|
||||
&mut stream,
|
||||
format!("+ {}\r\n", common::LOGIN_PASSWORD_CHALLENGE).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let (pass_b64, rest) =
|
||||
common::read_crlf_line(&mut stream, rest, MAX_PREAUTH).await?;
|
||||
buf = rest;
|
||||
if common::sasl_cancelled(&pass_b64) {
|
||||
write(&mut stream, b"-ERR authentication cancelled\r\n").await?;
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let Some(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
||||
write(&mut stream, b"-ERR invalid base64\r\n").await?;
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(cred) = credential_from_sasl(&Mechanism::Plain, &raw) else {
|
||||
write(&mut stream, b"-ERR malformed credentials\r\n").await?;
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let identifier =
|
||||
identifier_from_credential(&cred, &ctx.config.oauth.jwt_username_claim);
|
||||
let residual = std::mem::take(&mut buf);
|
||||
return Ok(Some(Handoff {
|
||||
stream,
|
||||
mech: Mechanism::Plain,
|
||||
raw_challenge: raw,
|
||||
identifier,
|
||||
residual,
|
||||
}));
|
||||
}
|
||||
|
||||
let raw_b64: Vec<u8> = if !ir.is_empty() {
|
||||
let ir = ir.to_vec();
|
||||
buf.drain(..consumed);
|
||||
@@ -255,14 +340,14 @@ async fn preauth(
|
||||
|
||||
let Some(raw) = decode_sasl_ir(&raw_b64) else {
|
||||
write(&mut stream, b"-ERR invalid base64\r\n").await?;
|
||||
if bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
let Some(cred) = credential_from_sasl(&mech, &raw) else {
|
||||
write(&mut stream, b"-ERR malformed credentials\r\n").await?;
|
||||
if bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
continue;
|
||||
@@ -286,11 +371,6 @@ async fn preauth(
|
||||
}
|
||||
}
|
||||
|
||||
fn bump(failures: &mut u32, max: u32) -> bool {
|
||||
*failures += 1;
|
||||
*failures >= max
|
||||
}
|
||||
|
||||
fn split_verb(line: &[u8]) -> (&[u8], &[u8]) {
|
||||
match line.iter().position(|&b| b == b' ') {
|
||||
Some(pos) => (&line[..pos], trim_leading_space(&line[pos + 1..])),
|
||||
@@ -341,7 +421,7 @@ async fn open_backend(
|
||||
ctx: &Ctx,
|
||||
dest_id: &str,
|
||||
fwd: &forward::ForwardInfo,
|
||||
) -> Result<(BoxedStream, Vec<u8>)> {
|
||||
) -> Result<common::BackendLeg> {
|
||||
let common::DialedEndpoint {
|
||||
dest,
|
||||
ep,
|
||||
@@ -384,7 +464,7 @@ async fn open_backend(
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok((backend, residual))
|
||||
Ok(common::BackendLeg::new(backend, residual))
|
||||
}
|
||||
|
||||
async fn backend_advertises_stls(
|
||||
|
||||
+231
-80
@@ -7,7 +7,9 @@
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
||||
use smtp_proto::{AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, Error as SmtpError, Request};
|
||||
use smtp_proto::{
|
||||
AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, Error as SmtpError, Request,
|
||||
};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use crate::config::{Forwarding, ListenerConfig, Protocol, SubmissionCapabilities, TlsMode};
|
||||
@@ -16,7 +18,7 @@ use crate::imap_receiver::Mechanism;
|
||||
use crate::net::BoxedStream;
|
||||
use crate::outbound;
|
||||
use crate::proto::common::{
|
||||
self, Ctx, Handoff, assert_egress_encrypted, credential_from_sasl, mechanism_name,
|
||||
self, Ctx, Handoff, assert_egress_encrypted, bump, credential_from_sasl, mechanism_name,
|
||||
plain_auth_allowed, write,
|
||||
};
|
||||
use crate::proto::forward;
|
||||
@@ -30,70 +32,116 @@ struct SubmissionHandoff {
|
||||
ehlo_host: String,
|
||||
}
|
||||
|
||||
struct SubmissionState {
|
||||
buf: Vec<u8>,
|
||||
auth_failures: u32,
|
||||
ehlo_host: String,
|
||||
}
|
||||
|
||||
impl Default for SubmissionState {
|
||||
fn default() -> Self {
|
||||
SubmissionState {
|
||||
buf: Vec::with_capacity(1024),
|
||||
auth_failures: 0,
|
||||
ehlo_host: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn handle_submission(
|
||||
ctx: &Ctx,
|
||||
listener: &ListenerConfig,
|
||||
stream: BoxedStream,
|
||||
mut stream: BoxedStream,
|
||||
peer: SocketAddr,
|
||||
local: SocketAddr,
|
||||
) -> Result<()> {
|
||||
let caps = &ctx.config.capabilities.submission;
|
||||
|
||||
let sh = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream, local),
|
||||
let hostname = ctx.config.server.hostname_for(local);
|
||||
write(
|
||||
&mut stream,
|
||||
format!("220 {hostname} ESMTP {}\r\n", caps.banner).as_bytes(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(h))) => h,
|
||||
Ok(Ok(None)) => return Ok(()),
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
.await?;
|
||||
|
||||
let SubmissionHandoff { handoff, ehlo_host } = sh;
|
||||
let Handoff {
|
||||
stream,
|
||||
mech,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = handoff;
|
||||
let mut state = SubmissionState::default();
|
||||
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
let fwd = forward::ForwardInfo::new(
|
||||
peer,
|
||||
local,
|
||||
stream.is_tls(),
|
||||
Some(ehlo_host),
|
||||
ctx.config.server.proxy_ttl,
|
||||
);
|
||||
loop {
|
||||
let sh = match timeout(
|
||||
listener.preauth_timeout,
|
||||
preauth(ctx, listener, caps, stream, &hostname, &mut state),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Ok(Some(h))) => h,
|
||||
Ok(Ok(None)) => return Ok(()),
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_) => return Ok(()),
|
||||
};
|
||||
|
||||
let auth_frame = format!(
|
||||
"AUTH {} {}\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
);
|
||||
let SubmissionHandoff {
|
||||
handoff,
|
||||
ehlo_host: announced,
|
||||
} = sh;
|
||||
let Handoff {
|
||||
stream: client,
|
||||
mech,
|
||||
raw_challenge,
|
||||
identifier,
|
||||
residual,
|
||||
} = handoff;
|
||||
|
||||
common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
stream,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
&residual,
|
||||
started,
|
||||
"submission",
|
||||
common::AuthWire::Smtp,
|
||||
auth_frame.as_bytes(),
|
||||
b"421 4.3.0 backend unavailable\r\n",
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await
|
||||
let identifier = ctx.routing_identifier(identifier);
|
||||
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||
let started = std::time::Instant::now();
|
||||
common::hold_login(ctx, identifier.as_deref()).await;
|
||||
let fwd = forward::ForwardInfo::new(
|
||||
peer,
|
||||
local,
|
||||
client.is_tls(),
|
||||
Some(announced),
|
||||
ctx.config.server.proxy_ttl,
|
||||
);
|
||||
|
||||
let auth = common::AuthFrame::inline(
|
||||
format!(
|
||||
"AUTH {} {}\r\n",
|
||||
mechanism_name(&mech),
|
||||
BASE64.encode(&raw_challenge)
|
||||
)
|
||||
.into_bytes(),
|
||||
);
|
||||
|
||||
let resume = common::bridge_authenticated(
|
||||
ctx,
|
||||
&dest_id,
|
||||
client,
|
||||
peer,
|
||||
identifier.as_deref(),
|
||||
residual,
|
||||
started,
|
||||
"submission",
|
||||
common::AuthWire::Smtp,
|
||||
&auth,
|
||||
b"421 4.3.0 backend unavailable\r\n",
|
||||
MAX_PREAUTH,
|
||||
|| open_backend(ctx, &dest_id, &fwd),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let Some(common::Resume {
|
||||
stream: client,
|
||||
buf,
|
||||
}) = resume
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
if bump(&mut state.auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(());
|
||||
}
|
||||
stream = client;
|
||||
state.buf = buf;
|
||||
}
|
||||
}
|
||||
|
||||
async fn preauth(
|
||||
@@ -101,21 +149,17 @@ async fn preauth(
|
||||
listener: &ListenerConfig,
|
||||
caps: &SubmissionCapabilities,
|
||||
mut stream: BoxedStream,
|
||||
local: SocketAddr,
|
||||
hostname: &str,
|
||||
state: &mut SubmissionState,
|
||||
) -> Result<Option<SubmissionHandoff>> {
|
||||
let mut is_tls = stream.is_tls();
|
||||
let starttls_listener = listener.tls == TlsMode::Starttls;
|
||||
let hostname = ctx.config.server.hostname_for(local);
|
||||
|
||||
write(
|
||||
&mut stream,
|
||||
format!("220 {hostname} ESMTP {}\r\n", caps.banner).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut buf: Vec<u8> = Vec::with_capacity(1024);
|
||||
let mut ehlo_host = String::new();
|
||||
let mut auth_failures = 0u32;
|
||||
let SubmissionState {
|
||||
buf,
|
||||
auth_failures,
|
||||
ehlo_host,
|
||||
} = state;
|
||||
|
||||
loop {
|
||||
let mut it = buf.iter();
|
||||
@@ -128,10 +172,10 @@ async fn preauth(
|
||||
let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls);
|
||||
match req {
|
||||
Request::Ehlo { host } | Request::Lhlo { host } => {
|
||||
ehlo_host = host.to_string();
|
||||
*ehlo_host = host.to_string();
|
||||
let resp = ehlo_response(
|
||||
&hostname,
|
||||
&ehlo_host,
|
||||
hostname,
|
||||
ehlo_host,
|
||||
caps,
|
||||
starttls_listener && !is_tls,
|
||||
plain_ok,
|
||||
@@ -140,7 +184,7 @@ async fn preauth(
|
||||
buf.drain(..consumed);
|
||||
}
|
||||
Request::Helo { host } => {
|
||||
ehlo_host = host.to_string();
|
||||
*ehlo_host = host.to_string();
|
||||
write(&mut stream, format!("250 {hostname}\r\n").as_bytes()).await?;
|
||||
buf.drain(..consumed);
|
||||
}
|
||||
@@ -181,17 +225,18 @@ async fn preauth(
|
||||
let params = AuthParams {
|
||||
ctx,
|
||||
plain_ok,
|
||||
ehlo_host: &ehlo_host,
|
||||
login_offered: common::sasl_offered(&caps.sasl, "LOGIN"),
|
||||
ehlo_host,
|
||||
max_attempts: listener.max_auth_attempts,
|
||||
};
|
||||
match handle_auth(
|
||||
¶ms,
|
||||
&mut stream,
|
||||
&mut buf,
|
||||
buf,
|
||||
consumed,
|
||||
mechanism,
|
||||
&ir,
|
||||
&mut auth_failures,
|
||||
&mut *auth_failures,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
@@ -227,7 +272,7 @@ async fn preauth(
|
||||
if buf.len() > MAX_PREAUTH {
|
||||
return Err(ProxyError::preauth("smtp line too long"));
|
||||
}
|
||||
let n = common::fill(&mut stream, &mut buf, MAX_PREAUTH + 8192).await?;
|
||||
let n = common::fill(&mut stream, buf, MAX_PREAUTH + 8192).await?;
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -236,8 +281,7 @@ async fn preauth(
|
||||
let drop_to = if consumed == 0 { buf.len() } else { consumed };
|
||||
buf.drain(..drop_to);
|
||||
write(&mut stream, b"500 5.5.2 Syntax error\r\n").await?;
|
||||
auth_failures += 1;
|
||||
if auth_failures >= listener.max_auth_attempts {
|
||||
if bump(auth_failures, listener.max_auth_attempts) {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
@@ -260,6 +304,7 @@ enum AuthStep {
|
||||
struct AuthParams<'a> {
|
||||
ctx: &'a Ctx,
|
||||
plain_ok: bool,
|
||||
login_offered: bool,
|
||||
ehlo_host: &'a str,
|
||||
max_attempts: u32,
|
||||
}
|
||||
@@ -275,7 +320,11 @@ async fn handle_auth(
|
||||
) -> Result<AuthStep> {
|
||||
let max_attempts = params.max_attempts;
|
||||
|
||||
let Some(mech) = mech_from_flag(mechanism) else {
|
||||
let mech = match mech_from_flag(mechanism) {
|
||||
Some(Mechanism::Login) if !params.login_offered => None,
|
||||
other => other,
|
||||
};
|
||||
let Some(mech) = mech else {
|
||||
return fail(
|
||||
stream,
|
||||
buf,
|
||||
@@ -286,7 +335,7 @@ async fn handle_auth(
|
||||
)
|
||||
.await;
|
||||
};
|
||||
if matches!(mech, Mechanism::Plain) && !params.plain_ok {
|
||||
if matches!(mech, Mechanism::Plain | Mechanism::Login) && !params.plain_ok {
|
||||
write(
|
||||
&mut *stream,
|
||||
b"538 5.7.11 Encryption required for requested authentication mechanism\r\n",
|
||||
@@ -296,6 +345,18 @@ async fn handle_auth(
|
||||
return Ok(AuthStep::Continue);
|
||||
}
|
||||
|
||||
if matches!(mech, Mechanism::Login) {
|
||||
return handle_auth_login(
|
||||
params,
|
||||
stream,
|
||||
buf,
|
||||
consumed,
|
||||
initial_response,
|
||||
auth_failures,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
let raw_b64: Vec<u8> = if !initial_response.is_empty() {
|
||||
let ir = initial_response.as_bytes().to_vec();
|
||||
buf.drain(..consumed);
|
||||
@@ -343,6 +404,95 @@ async fn handle_auth(
|
||||
})
|
||||
}
|
||||
|
||||
async fn handle_auth_login(
|
||||
params: &AuthParams<'_>,
|
||||
stream: &mut BoxedStream,
|
||||
buf: &mut Vec<u8>,
|
||||
consumed: usize,
|
||||
initial_response: &str,
|
||||
auth_failures: &mut u32,
|
||||
) -> Result<AuthStep> {
|
||||
let max_attempts = params.max_attempts;
|
||||
|
||||
let user_b64: Vec<u8> = if !initial_response.is_empty() {
|
||||
let ir = initial_response.as_bytes().to_vec();
|
||||
buf.drain(..consumed);
|
||||
ir
|
||||
} else {
|
||||
write(
|
||||
&mut *stream,
|
||||
format!("334 {}\r\n", common::LOGIN_USERNAME_CHALLENGE).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
buf.drain(..consumed);
|
||||
let seed = std::mem::take(buf);
|
||||
let (line, rest) = common::read_crlf_line(stream, seed, MAX_PREAUTH).await?;
|
||||
*buf = rest;
|
||||
line
|
||||
};
|
||||
if common::sasl_cancelled(&user_b64) {
|
||||
return cancelled(stream, auth_failures, max_attempts).await;
|
||||
}
|
||||
|
||||
write(
|
||||
&mut *stream,
|
||||
format!("334 {}\r\n", common::LOGIN_PASSWORD_CHALLENGE).as_bytes(),
|
||||
)
|
||||
.await?;
|
||||
let seed = std::mem::take(buf);
|
||||
let (pass_b64, rest) = common::read_crlf_line(stream, seed, MAX_PREAUTH).await?;
|
||||
*buf = rest;
|
||||
if common::sasl_cancelled(&pass_b64) {
|
||||
return cancelled(stream, auth_failures, max_attempts).await;
|
||||
}
|
||||
|
||||
let Some(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
||||
return fail(
|
||||
stream,
|
||||
buf,
|
||||
0,
|
||||
"5.7.8 Invalid base64",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let Some(cred) = credential_from_sasl(&Mechanism::Plain, &raw) else {
|
||||
return fail(
|
||||
stream,
|
||||
buf,
|
||||
0,
|
||||
"5.7.8 Malformed credentials",
|
||||
auth_failures,
|
||||
max_attempts,
|
||||
)
|
||||
.await;
|
||||
};
|
||||
let identifier = identifier_from_credential(&cred, ¶ms.ctx.config.oauth.jwt_username_claim);
|
||||
let residual = std::mem::take(buf);
|
||||
|
||||
Ok(AuthStep::Done {
|
||||
mech: Mechanism::Plain,
|
||||
raw_challenge: raw,
|
||||
identifier,
|
||||
residual,
|
||||
ehlo_host: params.ehlo_host.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn cancelled(
|
||||
stream: &mut BoxedStream,
|
||||
auth_failures: &mut u32,
|
||||
max_attempts: u32,
|
||||
) -> Result<AuthStep> {
|
||||
write(&mut *stream, b"501 5.7.0 Authentication cancelled\r\n").await?;
|
||||
if bump(auth_failures, max_attempts) {
|
||||
Ok(AuthStep::Close)
|
||||
} else {
|
||||
Ok(AuthStep::Continue)
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail(
|
||||
stream: &mut BoxedStream,
|
||||
buf: &mut Vec<u8>,
|
||||
@@ -355,8 +505,7 @@ async fn fail(
|
||||
buf.drain(..drain);
|
||||
}
|
||||
write(&mut *stream, format!("535 {message}\r\n").as_bytes()).await?;
|
||||
*auth_failures += 1;
|
||||
if *auth_failures >= max_attempts {
|
||||
if bump(auth_failures, max_attempts) {
|
||||
Ok(AuthStep::Close)
|
||||
} else {
|
||||
Ok(AuthStep::Continue)
|
||||
@@ -370,6 +519,8 @@ fn mech_from_flag(flag: u64) -> Option<Mechanism> {
|
||||
Some(Mechanism::OAuthBearer)
|
||||
} else if flag & AUTH_XOAUTH2 != 0 {
|
||||
Some(Mechanism::XOauth2)
|
||||
} else if flag & AUTH_LOGIN != 0 {
|
||||
Some(Mechanism::Login)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -411,7 +562,7 @@ async fn open_backend(
|
||||
ctx: &Ctx,
|
||||
dest_id: &str,
|
||||
fwd: &forward::ForwardInfo,
|
||||
) -> Result<(BoxedStream, Vec<u8>)> {
|
||||
) -> Result<common::BackendLeg> {
|
||||
let common::DialedEndpoint {
|
||||
dest,
|
||||
ep,
|
||||
@@ -456,7 +607,7 @@ async fn open_backend(
|
||||
}
|
||||
}
|
||||
|
||||
Ok((backend, residual))
|
||||
Ok(common::BackendLeg::new(backend, residual))
|
||||
}
|
||||
|
||||
fn advertised_xclient_attrs(ehlo_reply: &[u8]) -> Vec<String> {
|
||||
|
||||
@@ -364,3 +364,140 @@ allow_plain_auth_without_tls = true
|
||||
);
|
||||
assert!(Config::parse_and_validate(&toml).is_ok());
|
||||
}
|
||||
|
||||
fn submission_config_with_sasl(mapping: &str, sasl: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
{}
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.submission]
|
||||
port = 587
|
||||
tls = "plain"
|
||||
|
||||
[listener.submission]
|
||||
protocol = "submission"
|
||||
bind = ["127.0.0.1:587"]
|
||||
tls = "plain"
|
||||
|
||||
[capabilities.submission]
|
||||
allow_plain_auth_without_tls = true
|
||||
sasl = {sasl}
|
||||
"#,
|
||||
mapping_block(mapping)
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_sasl_set_includes_login() {
|
||||
let m = mapping_file("");
|
||||
let toml = format!(
|
||||
r#"
|
||||
{}
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.submission]
|
||||
port = 587
|
||||
tls = "plain"
|
||||
|
||||
[listener.submission]
|
||||
protocol = "submission"
|
||||
bind = ["127.0.0.1:587"]
|
||||
tls = "plain"
|
||||
"#,
|
||||
mapping_block(&m.path().display().to_string())
|
||||
);
|
||||
let config = Config::parse_and_validate(&toml).unwrap();
|
||||
for sasl in [
|
||||
&config.capabilities.submission.sasl,
|
||||
&config.capabilities.imap.sasl,
|
||||
&config.capabilities.pop3.sasl,
|
||||
&config.capabilities.managesieve.sasl,
|
||||
] {
|
||||
assert!(
|
||||
sasl.iter().any(|m| m == "LOGIN"),
|
||||
"LOGIN missing from default sasl set {sasl:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_login_in_sasl_and_canonicalizes_case() {
|
||||
let m = mapping_file("");
|
||||
let toml = submission_config_with_sasl(
|
||||
&m.path().display().to_string(),
|
||||
r#"["plain", "Login", "oauthbearer"]"#,
|
||||
);
|
||||
let config = Config::parse_and_validate(&toml).unwrap();
|
||||
assert_eq!(
|
||||
config.capabilities.submission.sasl,
|
||||
vec!["PLAIN", "LOGIN", "OAUTHBEARER"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sasl_mechanism() {
|
||||
let m = mapping_file("");
|
||||
let toml =
|
||||
submission_config_with_sasl(&m.path().display().to_string(), r#"["PLAIN", "CRAM-MD5"]"#);
|
||||
let err = Config::parse_and_validate(&toml).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("CRAM-MD5") && err.contains("capabilities.submission.sasl"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicate_sasl_mechanism() {
|
||||
let m = mapping_file("");
|
||||
let toml =
|
||||
submission_config_with_sasl(&m.path().display().to_string(), r#"["PLAIN", "plain"]"#);
|
||||
let err = Config::parse_and_validate(&toml).unwrap_err().to_string();
|
||||
assert!(err.contains("more than once"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_unsupported_sasl_mechanism_in_every_section() {
|
||||
let m = mapping_file("");
|
||||
for section in ["imap", "pop3", "managesieve"] {
|
||||
let toml = format!(
|
||||
r#"
|
||||
{}
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.imap]
|
||||
port = 143
|
||||
tls = "plain"
|
||||
|
||||
[listener.imap]
|
||||
protocol = "imap"
|
||||
bind = ["127.0.0.1:143"]
|
||||
tls = "plain"
|
||||
|
||||
[capabilities.{section}]
|
||||
sasl = ["NTLM"]
|
||||
"#,
|
||||
mapping_block(&m.path().display().to_string())
|
||||
);
|
||||
let err = Config::parse_and_validate(&toml).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains(&format!("capabilities.{section}.sasl")),
|
||||
"unexpected error for {section}: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,6 +161,68 @@ pub async fn submission_send(
|
||||
}
|
||||
}
|
||||
|
||||
async fn submission_auth_login<S: AsyncRead + AsyncWrite + Unpin>(
|
||||
stream: &mut S,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> String {
|
||||
stream.write_all(b"EHLO proxy.test\r\n").await.unwrap();
|
||||
let ehlo = read_until(stream, "250 ").await;
|
||||
assert!(
|
||||
ehlo.to_uppercase().contains("LOGIN"),
|
||||
"submission must advertise AUTH LOGIN: {ehlo:?}"
|
||||
);
|
||||
|
||||
stream.write_all(b"AUTH LOGIN\r\n").await.unwrap();
|
||||
let user_prompt = read_until(stream, "334 ").await;
|
||||
assert!(
|
||||
user_prompt.contains("VXNlcm5hbWU6"),
|
||||
"expected a username challenge: {user_prompt:?}"
|
||||
);
|
||||
stream
|
||||
.write_all(format!("{}\r\n", B64.encode(login)).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let pass_prompt = read_until(stream, "334 ").await;
|
||||
assert!(
|
||||
pass_prompt.contains("UGFzc3dvcmQ6"),
|
||||
"expected a password challenge: {pass_prompt:?}"
|
||||
);
|
||||
stream
|
||||
.write_all(format!("{}\r\n", B64.encode(password)).as_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
read_reply(stream).await
|
||||
}
|
||||
|
||||
pub async fn submission_send_auth_login(
|
||||
host: &str,
|
||||
port: u16,
|
||||
tls: Tls,
|
||||
login: &str,
|
||||
password: &str,
|
||||
) -> String {
|
||||
match tls {
|
||||
Tls::Implicit => {
|
||||
let tcp = TcpStream::connect((host, port)).await.unwrap();
|
||||
let mut s = tls_connect_client(tcp).await;
|
||||
let _ = read_reply(&mut s).await;
|
||||
submission_auth_login(&mut s, login, password).await
|
||||
}
|
||||
Tls::Starttls => {
|
||||
let mut tcp = TcpStream::connect((host, port)).await.unwrap();
|
||||
let _ = read_reply(&mut tcp).await;
|
||||
tcp.write_all(b"EHLO proxy.test\r\n").await.unwrap();
|
||||
let _ = read_until(&mut tcp, "250 ").await;
|
||||
tcp.write_all(b"STARTTLS\r\n").await.unwrap();
|
||||
let _ = read_reply(&mut tcp).await;
|
||||
let mut s = tls_connect_client(tcp).await;
|
||||
submission_auth_login(&mut s, login, password).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn smtp_passthrough_send(host: &str, port: u16, from: &str, to: &str) -> String {
|
||||
let mut s = TcpStream::connect((host, port)).await.unwrap();
|
||||
let _ = read_reply(&mut s).await;
|
||||
|
||||
@@ -240,3 +240,48 @@ tls = "plain"
|
||||
"passthrough RCPT to a hosted Stalwart user should be accepted: {reply:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Docker"]
|
||||
async fn scenario1_submission_auth_login_reaches_both_backends() {
|
||||
let legacy = Legacy::start().await;
|
||||
let stalwart =
|
||||
Stalwart::start_proxied(Edition::New, "new.test", &[("carol", "CarolPass#2026")]).await;
|
||||
|
||||
let legacy_user = ("alice@legacy.test", "AlicePass#2026");
|
||||
let stalwart_user = ("carol@new.test", "CarolPass#2026");
|
||||
let map = mapping_file(&format!(
|
||||
"{}\tlegacy\n{}\tstalwartnew\n",
|
||||
legacy_user.0, stalwart_user.0
|
||||
));
|
||||
|
||||
let mode = Tls::Implicit;
|
||||
let listen = pick_free_port();
|
||||
let toml = build_config(
|
||||
"submission",
|
||||
mode,
|
||||
listen,
|
||||
legacy_endpoint(&legacy, "submission", mode),
|
||||
stalwart_endpoint(&stalwart, "submission", mode),
|
||||
map.path().to_str().unwrap(),
|
||||
);
|
||||
let proxy = Proxy::launch(&toml).await;
|
||||
let port = proxy.port("l");
|
||||
|
||||
for (who, (login, password)) in [("legacy", legacy_user), ("stalwart", stalwart_user)] {
|
||||
let reply =
|
||||
clients::submission_send_auth_login("127.0.0.1", port, mode, login, password).await;
|
||||
assert!(
|
||||
reply.contains("235"),
|
||||
"AUTH LOGIN through the proxy to the {who} backend failed: {reply:?}"
|
||||
);
|
||||
}
|
||||
|
||||
let wrong =
|
||||
clients::submission_send_auth_login("127.0.0.1", port, mode, legacy_user.0, "WrongPass#0")
|
||||
.await;
|
||||
assert!(
|
||||
wrong.contains("535") || wrong.contains("454"),
|
||||
"a bad password over AUTH LOGIN must still be rejected by the backend: {wrong:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -985,3 +985,364 @@ async fn http_chunked_request_and_response_round_trip() {
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
fn extract_http_config(host: &str, port: u16, mapping_path: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{mapping_path}"
|
||||
|
||||
[routing]
|
||||
default_destination = "web"
|
||||
|
||||
[destination.web]
|
||||
host = "{host}"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.web.protocol.http]
|
||||
port = {port}
|
||||
tls = "plain"
|
||||
|
||||
[[http.route]]
|
||||
match = "/**"
|
||||
extract = {{ from = "authorization" }}
|
||||
|
||||
[listener.http]
|
||||
protocol = "http"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_authenticated_keepalive_connection_is_kickable() {
|
||||
async fn backend(mut sock: tokio::net::TcpStream) {
|
||||
read_req_head(&mut sock).await;
|
||||
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (port, backend) = dummy_backend(backend).await;
|
||||
let mapping = mapping_file("user@example.com\tweb\n");
|
||||
let toml = extract_http_config("127.0.0.1", port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["http"].clone();
|
||||
|
||||
let (mut client, srv) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::http::proxy::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(srv),
|
||||
"127.0.0.1:40070".parse().unwrap(),
|
||||
"127.0.0.1:80".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let creds = BASE64.encode("user@example.com:pw");
|
||||
let req = format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\n\r\n");
|
||||
send(&mut client, req.as_bytes()).await;
|
||||
let resp = read_until(&mut client, b"ok").await;
|
||||
assert!(find(&resp, b"200").is_some(), "expected 200");
|
||||
|
||||
assert_eq!(
|
||||
ctx.conns.kick(&ctx.router.normalize("user@example.com")),
|
||||
1,
|
||||
"an authenticated http connection must be registered for its account"
|
||||
);
|
||||
|
||||
let n = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.read(&mut [0u8; 64]),
|
||||
)
|
||||
.await
|
||||
.expect("kick did not disconnect the client in time")
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "expected EOF after kick, got {n} bytes");
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_websocket_bridge_is_kickable() {
|
||||
async fn backend(mut sock: tokio::net::TcpStream) {
|
||||
read_req_head(&mut sock).await;
|
||||
sock.write_all(
|
||||
b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=\r\n\r\n",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (port, backend) = dummy_backend(backend).await;
|
||||
let mapping = mapping_file("user@example.com\tweb\n");
|
||||
let toml = extract_http_config("127.0.0.1", port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["http"].clone();
|
||||
|
||||
let (mut client, srv) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::http::proxy::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(srv),
|
||||
"127.0.0.1:40071".parse().unwrap(),
|
||||
"127.0.0.1:80".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let creds = BASE64.encode("user@example.com:pw");
|
||||
let req = format!(
|
||||
"GET /ws HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n"
|
||||
);
|
||||
send(&mut client, req.as_bytes()).await;
|
||||
|
||||
let upgrade = read_until(&mut client, b"\r\n\r\n").await;
|
||||
assert!(find(&upgrade, b"101").is_some(), "expected the 101 upgrade");
|
||||
|
||||
assert_eq!(
|
||||
ctx.conns.kick(&ctx.router.normalize("user@example.com")),
|
||||
1,
|
||||
"an upgraded websocket must stay registered for its account"
|
||||
);
|
||||
|
||||
let n = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.read(&mut [0u8; 64]),
|
||||
)
|
||||
.await
|
||||
.expect("kick did not disconnect the websocket in time")
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "expected EOF after kick, got {n} bytes");
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_keepalive_rejects_a_second_account_with_421() {
|
||||
async fn backend(mut sock: tokio::net::TcpStream) {
|
||||
for _ in 0..2 {
|
||||
read_req_head(&mut sock).await;
|
||||
if sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (port, backend) = dummy_backend(backend).await;
|
||||
let mapping = mapping_file("user@example.com\tweb\nother@example.com\tweb\n");
|
||||
let toml = extract_http_config("127.0.0.1", port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["http"].clone();
|
||||
|
||||
let (mut client, srv) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::http::proxy::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(srv),
|
||||
"127.0.0.1:40072".parse().unwrap(),
|
||||
"127.0.0.1:80".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let first = BASE64.encode("user@example.com:pw");
|
||||
send(
|
||||
&mut client,
|
||||
format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {first}\r\n\r\n").as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let resp = read_until(&mut client, b"ok").await;
|
||||
assert!(
|
||||
find(&resp, b"200").is_some(),
|
||||
"expected 200 for the pinned account"
|
||||
);
|
||||
|
||||
let second = BASE64.encode("other@example.com:pw");
|
||||
send(
|
||||
&mut client,
|
||||
format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {second}\r\n\r\n")
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let tail = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&tail, b"421").is_some(),
|
||||
"a second account on a pinned connection must get 421, got {:?}",
|
||||
String::from_utf8_lossy(&tail)
|
||||
);
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_empty_identifier_does_not_pin_or_register_the_connection() {
|
||||
async fn backend(mut sock: tokio::net::TcpStream) {
|
||||
for _ in 0..2 {
|
||||
read_req_head(&mut sock).await;
|
||||
if sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (port, backend) = dummy_backend(backend).await;
|
||||
let mapping = mapping_file("user@example.com\tweb\n");
|
||||
let toml = extract_http_config("127.0.0.1", port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["http"].clone();
|
||||
|
||||
let (mut client, srv) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::http::proxy::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(srv),
|
||||
"127.0.0.1:40073".parse().unwrap(),
|
||||
"127.0.0.1:80".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let empty = BASE64.encode(":pw");
|
||||
send(
|
||||
&mut client,
|
||||
format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {empty}\r\n\r\n").as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let first = read_until(&mut client, b"ok").await;
|
||||
assert!(
|
||||
find(&first, b"200").is_some(),
|
||||
"expected 200 for the empty login"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ctx.conns.kick(""),
|
||||
0,
|
||||
"an empty identifier must not be registered as an account"
|
||||
);
|
||||
|
||||
let creds = BASE64.encode("user@example.com:pw");
|
||||
send(
|
||||
&mut client,
|
||||
format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\n\r\n").as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let second = read_until(&mut client, b"ok").await;
|
||||
assert!(
|
||||
find(&second, b"421").is_none(),
|
||||
"an empty first login must not pin the connection, got {:?}",
|
||||
String::from_utf8_lossy(&second)
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.conns.kick(&ctx.router.normalize("user@example.com")),
|
||||
1,
|
||||
"the real account must be the one registered"
|
||||
);
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_login_delay_holds_an_authenticated_request() {
|
||||
async fn backend(mut sock: tokio::net::TcpStream) {
|
||||
read_req_head(&mut sock).await;
|
||||
let _ = sock
|
||||
.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
|
||||
.await;
|
||||
}
|
||||
|
||||
let (port, backend) = dummy_backend(backend).await;
|
||||
let mapping = mapping_file("user@example.com\tweb\n");
|
||||
let toml = extract_http_config("127.0.0.1", port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["http"].clone();
|
||||
|
||||
ctx.conns.set_delay(
|
||||
&ctx.router.normalize("user@example.com"),
|
||||
std::time::Instant::now() + std::time::Duration::from_millis(1500),
|
||||
);
|
||||
|
||||
let (mut client, srv) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::http::proxy::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(srv),
|
||||
"127.0.0.1:40075".parse().unwrap(),
|
||||
"127.0.0.1:80".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
let creds = BASE64.encode("user@example.com:pw");
|
||||
send(
|
||||
&mut client,
|
||||
format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\nConnection: close\r\n\r\n").as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(find(&resp, b"200").is_some(), "expected 200");
|
||||
assert!(
|
||||
started.elapsed() >= std::time::Duration::from_millis(1400),
|
||||
"the login delay must hold an http request too, elapsed {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
+528
-1
@@ -57,7 +57,7 @@ async fn backend_expect_mech(
|
||||
mech: &'static str,
|
||||
want_user: &'static str,
|
||||
) {
|
||||
sock.write_all(b"* OK [CAPABILITY IMAP4rev2] backend ready\r\n")
|
||||
sock.write_all(b"* OK [CAPABILITY IMAP4rev2 SASL-IR] backend ready\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -446,3 +446,530 @@ tls = "starttls"
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_authenticate_login_replays_as_plain() {
|
||||
let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40060".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let greeting = read_line(&mut client).await;
|
||||
assert!(
|
||||
greeting.contains("AUTH=LOGIN"),
|
||||
"capability must advertise AUTH=LOGIN, got {greeting}"
|
||||
);
|
||||
|
||||
send(&mut client, b"a1 AUTHENTICATE LOGIN\r\n").await;
|
||||
let user_prompt = read_line(&mut client).await;
|
||||
assert_eq!(user_prompt.trim_end(), "+ VXNlcm5hbWU6", "{user_prompt}");
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("user@example.com")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let pass_prompt = read_line(&mut client).await;
|
||||
assert_eq!(pass_prompt.trim_end(), "+ UGFzc3dvcmQ6", "{pass_prompt}");
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
send(&mut client, b"a2 NOOP\r\n").await;
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
let resp = String::from_utf8_lossy(&resp).into_owned();
|
||||
assert!(resp.contains("a1 OK"), "expected a1 OK, got {resp}");
|
||||
assert!(resp.contains("a2 OK"), "expected bridged a2 OK, got {resp}");
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_authenticate_login_accepts_username_as_initial_response() {
|
||||
let (port, backend) =
|
||||
dummy_backend(|sock| backend_expect_auth(sock, "alice@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40061".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(
|
||||
&mut client,
|
||||
format!(
|
||||
"a1 AUTHENTICATE LOGIN {}\r\n",
|
||||
BASE64.encode("alice@example.com")
|
||||
)
|
||||
.as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let pass_prompt = read_line(&mut client).await;
|
||||
assert_eq!(pass_prompt.trim_end(), "+ UGFzc3dvcmQ6", "{pass_prompt}");
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
send(&mut client, b"a2 NOOP\r\n").await;
|
||||
|
||||
let resp = read_until(&mut client, b"a1 OK").await;
|
||||
assert!(find(&resp, b"a1 OK").is_some());
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_authenticate_login_cancelled_by_client() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(1, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40062".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"a1 AUTHENTICATE LOGIN\r\n").await;
|
||||
let _user_prompt = read_line(&mut client).await;
|
||||
send(&mut client, b"*\r\n").await;
|
||||
|
||||
let resp = read_until(&mut client, b"a1 NO").await;
|
||||
assert!(
|
||||
find(&resp, b"a1 NO").is_some(),
|
||||
"expected tagged NO, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
|
||||
send(&mut client, b"a2 CAPABILITY\r\n").await;
|
||||
let resp = read_until(&mut client, b"a2 OK").await;
|
||||
assert!(
|
||||
find(&resp, b"a2 OK").is_some(),
|
||||
"session must survive a cancelled AUTHENTICATE, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_authenticate_login_refused_without_tls_when_plain_disabled() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{}"
|
||||
|
||||
[capabilities.imap]
|
||||
allow_plain_auth_without_tls = false
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
tls_server_name = "backend.example.com"
|
||||
[destination.legacy.protocol.imap]
|
||||
port = 1
|
||||
tls = "implicit"
|
||||
|
||||
[listener.imap]
|
||||
protocol = "imap"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "starttls"
|
||||
"#,
|
||||
mapping.path().display()
|
||||
);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40063".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let greeting = read_line(&mut client).await;
|
||||
assert!(
|
||||
!greeting.contains("AUTH=LOGIN"),
|
||||
"AUTH=LOGIN must not be advertised without TLS, got {greeting}"
|
||||
);
|
||||
|
||||
send(&mut client, b"a1 AUTHENTICATE LOGIN\r\n").await;
|
||||
let resp = read_until(&mut client, b"a1 NO").await;
|
||||
assert!(
|
||||
find(&resp, b"PRIVACYREQUIRED").is_some(),
|
||||
"expected PRIVACYREQUIRED, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
const EXCHANGE_GREETING: &[u8] =
|
||||
b"* OK [CAPABILITY IMAP4 IMAP4rev1 AUTH=PLAIN AUTH=NTLM AUTH=GSSAPI UIDPLUS MOVE ID \
|
||||
CHILDREN IDLE NAMESPACE LITERAL+] backend ready\r\n";
|
||||
|
||||
async fn backend_without_sasl_ir(mut sock: tokio::net::TcpStream, want_user: &'static str) {
|
||||
sock.write_all(EXCHANGE_GREETING).await.unwrap();
|
||||
|
||||
let command = read_line(&mut sock).await;
|
||||
assert_eq!(
|
||||
command, "a1 AUTHENTICATE PLAIN",
|
||||
"backend without SASL-IR must not receive an inline initial response"
|
||||
);
|
||||
sock.write_all(b"+ \r\n").await.unwrap();
|
||||
|
||||
let response = read_line(&mut sock).await;
|
||||
let raw = BASE64.decode(response.trim()).unwrap();
|
||||
assert_eq!(
|
||||
raw,
|
||||
format!("\0{want_user}\0secret").into_bytes(),
|
||||
"deferred initial response should carry the credentials"
|
||||
);
|
||||
sock.write_all(b"a1 OK [CAPABILITY IMAP4rev1] authenticated\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = read_until(&mut sock, b"NOOP").await;
|
||||
sock.write_all(b"a2 OK NOOP done\r\n").await.unwrap();
|
||||
}
|
||||
|
||||
async fn run_against_backend_without_sasl_ir(
|
||||
ctx: std::sync::Arc<crate::proto::common::Ctx>,
|
||||
first: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40010".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, first).await;
|
||||
let resp = read_until(&mut client, b"a1 OK").await;
|
||||
send(&mut client, b"a2 NOOP\r\n").await;
|
||||
let mut resp = resp;
|
||||
resp.extend_from_slice(&read_until(&mut client, b"a2 OK").await);
|
||||
handler.abort();
|
||||
resp
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_defers_initial_response_when_backend_lacks_sasl_ir() {
|
||||
let (port, backend) =
|
||||
dummy_backend(|sock| backend_without_sasl_ir(sock, "alice@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let ir = BASE64.encode(b"\0alice@example.com\0secret");
|
||||
let resp = run_against_backend_without_sasl_ir(
|
||||
ctx,
|
||||
format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(
|
||||
find(&resp, b"a1 OK").is_some(),
|
||||
"expected a1 OK, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
assert!(
|
||||
find(&resp, b"a2 OK").is_some(),
|
||||
"expected bridged a2 OK, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
let _ = backend.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_login_defers_initial_response_when_backend_lacks_sasl_ir() {
|
||||
let (port, backend) =
|
||||
dummy_backend(|sock| backend_without_sasl_ir(sock, "bob@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let resp =
|
||||
run_against_backend_without_sasl_ir(ctx, b"a1 LOGIN bob@example.com secret\r\n").await;
|
||||
|
||||
assert!(
|
||||
find(&resp, b"a1 OK").is_some(),
|
||||
"expected a1 OK, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
let _ = backend.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_keeps_inline_initial_response_when_backend_advertises_sasl_ir() {
|
||||
let (port, backend) = dummy_backend(|mut sock: tokio::net::TcpStream| async move {
|
||||
sock.write_all(b"* OK [CAPABILITY IMAP4rev2 SASL-IR] backend ready\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let command = read_line(&mut sock).await;
|
||||
let ir = BASE64.encode(b"\0dave@example.com\0secret");
|
||||
assert_eq!(command, format!("a1 AUTHENTICATE PLAIN {ir}"));
|
||||
sock.write_all(b"a1 OK authenticated\r\n").await.unwrap();
|
||||
let _ = read_until(&mut sock, b"NOOP").await;
|
||||
sock.write_all(b"a2 OK NOOP done\r\n").await.unwrap();
|
||||
})
|
||||
.await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40011".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
let ir = BASE64.encode(b"\0dave@example.com\0secret");
|
||||
send(
|
||||
&mut client,
|
||||
format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let resp = read_until(&mut client, b"a1 OK").await;
|
||||
assert!(find(&resp, b"a1 OK").is_some());
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_pipelined_command_after_failed_auth_is_parsed_not_bridged() {
|
||||
let (port, backend) = dummy_backend(|mut sock| async move {
|
||||
sock.write_all(b"* OK [CAPABILITY IMAP4rev2 SASL-IR] backend ready\r\n")
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let mut scratch = [0u8; 1024];
|
||||
while find(&buf, b"AUTHENTICATE").is_none() || find(&buf, b"\r\n").is_none() {
|
||||
match sock.read(&mut scratch).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&scratch[..n]),
|
||||
}
|
||||
}
|
||||
let _ = sock
|
||||
.write_all(b"a1 NO [AUTHENTICATIONFAILED] nope\r\n")
|
||||
.await;
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
assert!(
|
||||
find(&sink[..n], b"CAPABILITY").is_none(),
|
||||
"pipelined client bytes must not reach the backend after a failed auth"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let mapping = mapping_file("user@example.com\tlegacy\n");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40010".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _ = read_line(&mut client).await;
|
||||
send(
|
||||
&mut client,
|
||||
b"a1 LOGIN user@example.com wrong\r\na2 CAPABILITY\r\n",
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = read_until(&mut client, b"a2 OK").await;
|
||||
assert!(
|
||||
find(&seen, b"a1 NO").is_some(),
|
||||
"expected the backend rejection, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
assert!(
|
||||
find(&seen, b"a2 OK CAPABILITY completed").is_some(),
|
||||
"the pipelined command must be answered by the proxy, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
|
||||
drop(client);
|
||||
let _ = backend.await;
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
async fn preauth_only_ctx() -> (
|
||||
std::sync::Arc<crate::proto::common::Ctx>,
|
||||
tokio::task::JoinHandle<()>,
|
||||
) {
|
||||
let (port, backend) = dummy_backend(|_sock| async move {}).await;
|
||||
let mapping = mapping_file("user@example.com\tlegacy\n");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
(make_ctx(&toml).await, backend)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_rejected_sasl_response_is_not_reparsed_as_a_command() {
|
||||
let (ctx, backend) = preauth_only_ctx().await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40011".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _ = read_line(&mut client).await;
|
||||
send(
|
||||
&mut client,
|
||||
b"a1 AUTHENTICATE PLAIN\r\n@@@\r\na2 CAPABILITY\r\n",
|
||||
)
|
||||
.await;
|
||||
|
||||
let seen = read_until(&mut client, b"a2 OK").await;
|
||||
assert!(
|
||||
find(&seen, b"a1 NO").is_some(),
|
||||
"expected the SASL response to be rejected, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
assert!(
|
||||
find(&seen, b"* BAD").is_none(),
|
||||
"the rejected SASL response must not be re-parsed as a command, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
assert!(
|
||||
find(&seen, b"a2 OK CAPABILITY completed").is_some(),
|
||||
"the pipelined command must still be answered, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
|
||||
drop(client);
|
||||
backend.abort();
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn imap_command_pipelined_behind_a_rejected_sasl_response_is_not_lost() {
|
||||
let (ctx, backend) = preauth_only_ctx().await;
|
||||
let listener = ctx.config.listener["imap"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::imap::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40012".parse().unwrap(),
|
||||
"127.0.0.1:143".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _ = read_line(&mut client).await;
|
||||
send(&mut client, b"a1 AUTHENTICATE PLAIN\r\n").await;
|
||||
let challenge = read_until(&mut client, b"+ ").await;
|
||||
assert!(find(&challenge, b"+ ").is_some(), "expected a continuation");
|
||||
|
||||
send(&mut client, b"@@@\r\na2 CAPABILITY\r\n").await;
|
||||
let seen = read_until(&mut client, b"a2 OK").await;
|
||||
assert!(
|
||||
find(&seen, b"a1 NO").is_some(),
|
||||
"expected the SASL response to be rejected, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
assert!(
|
||||
find(&seen, b"a2 OK CAPABILITY completed").is_some(),
|
||||
"the command behind the rejected response must not be dropped, got {:?}",
|
||||
String::from_utf8_lossy(&seen)
|
||||
);
|
||||
|
||||
drop(client);
|
||||
backend.abort();
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
@@ -306,3 +306,133 @@ tls = "plain"
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managesieve_authenticate_login_replays_as_plain() {
|
||||
let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["managesieve"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::managesieve::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40014".parse().unwrap(),
|
||||
"127.0.0.1:4190".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let greeting = read_until(&mut client, b"OK ").await;
|
||||
let greeting = String::from_utf8_lossy(&greeting).into_owned();
|
||||
assert!(
|
||||
greeting.contains("LOGIN"),
|
||||
"greeting must advertise SASL LOGIN, got {greeting}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTHENTICATE \"LOGIN\"\r\n").await;
|
||||
let user_prompt = read_until(&mut client, b"VXNlcm5hbWU6\r\n").await;
|
||||
assert!(
|
||||
find(&user_prompt, b"{12}\r\nVXNlcm5hbWU6").is_some(),
|
||||
"expected a username challenge literal, got {:?}",
|
||||
String::from_utf8_lossy(&user_prompt)
|
||||
);
|
||||
|
||||
send(
|
||||
&mut client,
|
||||
format!("\"{}\"\r\n", BASE64.encode("user@example.com")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let pass_prompt = read_until(&mut client, b"UGFzc3dvcmQ6\r\n").await;
|
||||
assert!(
|
||||
find(&pass_prompt, b"{12}\r\nUGFzc3dvcmQ6").is_some(),
|
||||
"expected a password challenge literal, got {:?}",
|
||||
String::from_utf8_lossy(&pass_prompt)
|
||||
);
|
||||
|
||||
send(
|
||||
&mut client,
|
||||
format!("\"{}\"\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&resp, b"OK \"authenticated\"").is_some(),
|
||||
"expected OK authenticated, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn managesieve_authenticate_login_rejected_when_not_configured() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{}"
|
||||
|
||||
[capabilities.managesieve]
|
||||
allow_plain_auth_without_tls = true
|
||||
sasl = ["PLAIN", "OAUTHBEARER"]
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.managesieve]
|
||||
port = 1
|
||||
tls = "plain"
|
||||
|
||||
[listener.managesieve]
|
||||
protocol = "managesieve"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
"#,
|
||||
mapping.path().display()
|
||||
);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["managesieve"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::managesieve::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"127.0.0.1:40015".parse().unwrap(),
|
||||
"127.0.0.1:4190".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let greeting = read_until(&mut client, b"OK ").await;
|
||||
let greeting = String::from_utf8_lossy(&greeting).into_owned();
|
||||
assert!(
|
||||
!greeting.to_uppercase().contains("LOGIN"),
|
||||
"LOGIN must not be advertised when unconfigured, got {greeting}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTHENTICATE \"LOGIN\"\r\n").await;
|
||||
let resp = read_until(&mut client, b"\r\n").await;
|
||||
assert!(
|
||||
find(&resp, b"NO \"Unsupported mechanism\"").is_some(),
|
||||
"expected an unsupported-mechanism NO, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
@@ -396,3 +396,579 @@ tls = "plain"
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_auth_login_replays_as_plain() {
|
||||
let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let mut client = spawn_pop3(&ctx).await;
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
|
||||
send(&mut client, b"CAPA\r\n").await;
|
||||
let capa = read_until(&mut client, b".\r\n").await;
|
||||
let capa = String::from_utf8_lossy(&capa).into_owned();
|
||||
assert!(
|
||||
capa.contains("LOGIN"),
|
||||
"CAPA must advertise SASL LOGIN, got {capa}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let user_prompt = read_until(&mut client, b"\r\n").await;
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&user_prompt).trim_end(),
|
||||
"+ VXNlcm5hbWU6"
|
||||
);
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("user@example.com")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let pass_prompt = read_until(&mut client, b"\r\n").await;
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&pass_prompt).trim_end(),
|
||||
"+ UGFzc3dvcmQ6"
|
||||
);
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\nSTAT\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&resp, b"+OK logged in").is_some(),
|
||||
"expected +OK logged in, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
assert!(
|
||||
find(&resp, b"+OK 0 0").is_some(),
|
||||
"expected bridged STAT reply, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
let _ = backend.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_auth_login_accepts_username_as_initial_response() {
|
||||
let (port, backend) =
|
||||
dummy_backend(|sock| backend_expect_auth(sock, "alice@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(port, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let mut client = spawn_pop3(&ctx).await;
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(
|
||||
&mut client,
|
||||
format!("AUTH LOGIN {}\r\n", BASE64.encode("alice@example.com")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let pass_prompt = read_until(&mut client, b"\r\n").await;
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&pass_prompt).trim_end(),
|
||||
"+ UGFzc3dvcmQ6"
|
||||
);
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\nSTAT\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&resp, b"+OK logged in").is_some(),
|
||||
"expected +OK logged in, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
let _ = backend.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_auth_login_cancelled_by_client() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = config(1, &mapping.path().display().to_string());
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let mut client = spawn_pop3(&ctx).await;
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let _user_prompt = read_until(&mut client, b"\r\n").await;
|
||||
send(&mut client, b"*\r\nQUIT\r\n").await;
|
||||
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&resp, b"-ERR authentication cancelled").is_some(),
|
||||
"expected a cancel error, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
assert!(
|
||||
find(&resp, b"+OK Bye").is_some(),
|
||||
"session must survive a cancelled AUTH, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_auth_login_refused_without_tls_when_plain_disabled() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{}"
|
||||
|
||||
[capabilities.pop3]
|
||||
allow_plain_auth_without_tls = false
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
tls_server_name = "backend.example.com"
|
||||
[destination.legacy.protocol.pop3]
|
||||
port = 1
|
||||
tls = "implicit"
|
||||
|
||||
[listener.pop3]
|
||||
protocol = "pop3"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
"#,
|
||||
mapping.path().display()
|
||||
);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let mut client = spawn_pop3(&ctx).await;
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"CAPA\r\n").await;
|
||||
let capa = read_until(&mut client, b".\r\n").await;
|
||||
let capa = String::from_utf8_lossy(&capa).into_owned();
|
||||
assert!(
|
||||
!capa.to_uppercase().contains("LOGIN"),
|
||||
"CAPA must omit LOGIN without TLS, got {capa}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let resp = read_until(&mut client, b"-ERR").await;
|
||||
assert!(
|
||||
find(&resp, b"cleartext authentication requires TLS").is_some(),
|
||||
"expected a cleartext refusal, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_auth_login_rejected_when_not_configured() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{}"
|
||||
|
||||
[capabilities.pop3]
|
||||
allow_plain_auth_without_tls = true
|
||||
sasl = ["PLAIN", "OAUTHBEARER"]
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.pop3]
|
||||
port = 1
|
||||
tls = "plain"
|
||||
|
||||
[listener.pop3]
|
||||
protocol = "pop3"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
"#,
|
||||
mapping.path().display()
|
||||
);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let mut client = spawn_pop3(&ctx).await;
|
||||
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let resp = read_until(&mut client, b"-ERR").await;
|
||||
assert!(
|
||||
find(&resp, b"unsupported mechanism").is_some(),
|
||||
"expected an unsupported-mechanism error, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
fn retry_config(backend_port: u16, mapping_path: &str, max_auth_attempts: u32) -> String {
|
||||
format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{mapping_path}"
|
||||
|
||||
[capabilities.pop3]
|
||||
allow_plain_auth_without_tls = true
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
forwarding = "none"
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.pop3]
|
||||
port = {backend_port}
|
||||
tls = "plain"
|
||||
|
||||
[listener.pop3]
|
||||
protocol = "pop3"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
max_auth_attempts = {max_auth_attempts}
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
async fn rejecting_backend(
|
||||
attempts: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||
) -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind(unused_addr()).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
let _ = sock.set_nodelay(true);
|
||||
tokio::spawn(async move {
|
||||
if sock.write_all(b"+OK ready\r\n").await.is_err() {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
let mut scratch = [0u8; 1024];
|
||||
while find(&buf, b"\r\n").is_none() {
|
||||
match sock.read(&mut scratch).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&scratch[..n]),
|
||||
}
|
||||
}
|
||||
let _ = sock.write_all(b"-ERR bad password\r\n").await;
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_failed_backend_auth_does_not_hand_the_client_a_raw_bridge() {
|
||||
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (backend_port, backend) = rejecting_backend(attempts.clone()).await;
|
||||
|
||||
let mapping = mapping_file("user@example.com\tlegacy\n");
|
||||
let toml = retry_config(backend_port, &mapping.path().display().to_string(), 10);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["pop3"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::pop3::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40000".parse().unwrap(),
|
||||
"127.0.0.1:110".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let greeting = read_line(&mut client).await;
|
||||
assert!(greeting.starts_with("+OK"), "greeting: {greeting}");
|
||||
|
||||
send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await;
|
||||
let failure = read_until(&mut client, b"-ERR").await;
|
||||
assert!(find(&failure, b"-ERR").is_some(), "expected a rejection");
|
||||
|
||||
send(&mut client, b"CAPA\r\n").await;
|
||||
let capa = read_until(&mut client, b"\r\n.\r\n").await;
|
||||
assert!(
|
||||
find(&capa, b"USER").is_some(),
|
||||
"the proxy must still be parsing after a failed auth, got {:?}",
|
||||
String::from_utf8_lossy(&capa)
|
||||
);
|
||||
assert_eq!(
|
||||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"CAPA must be answered by the proxy, not forwarded to a backend"
|
||||
);
|
||||
|
||||
drop(client);
|
||||
backend.abort();
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_rejected_auth_closes_the_backend_leg_with_quit() {
|
||||
let (backend_port, backend) = dummy_backend(|mut sock| async move {
|
||||
sock.write_all(b"+OK ready\r\n").await.unwrap();
|
||||
let mut buf = Vec::new();
|
||||
let mut scratch = [0u8; 1024];
|
||||
while find(&buf, b"\r\n").is_none() {
|
||||
match sock.read(&mut scratch).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&scratch[..n]),
|
||||
}
|
||||
}
|
||||
let _ = sock.write_all(b"-ERR bad password\r\n").await;
|
||||
buf.clear();
|
||||
loop {
|
||||
match sock.read(&mut scratch).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => buf.extend_from_slice(&scratch[..n]),
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
find(&buf, b"QUIT").is_some(),
|
||||
"backend leg must be closed with QUIT, saw {:?}",
|
||||
String::from_utf8_lossy(&buf)
|
||||
);
|
||||
})
|
||||
.await;
|
||||
|
||||
let mapping = mapping_file("user@example.com\tlegacy\n");
|
||||
let toml = retry_config(backend_port, &mapping.path().display().to_string(), 10);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["pop3"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::pop3::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40003".parse().unwrap(),
|
||||
"127.0.0.1:110".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _ = read_line(&mut client).await;
|
||||
send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await;
|
||||
let _ = read_until(&mut client, b"-ERR").await;
|
||||
|
||||
drop(client);
|
||||
backend.await.expect("backend assertion failed");
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_backend_auth_failures_count_toward_max_auth_attempts() {
|
||||
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (backend_port, backend) = rejecting_backend(attempts.clone()).await;
|
||||
|
||||
let mapping = mapping_file("user@example.com\tlegacy\n");
|
||||
let toml = retry_config(backend_port, &mapping.path().display().to_string(), 2);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["pop3"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::pop3::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40001".parse().unwrap(),
|
||||
"127.0.0.1:110".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _ = read_line(&mut client).await;
|
||||
send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await;
|
||||
let _ = read_until(&mut client, b"-ERR").await;
|
||||
send(&mut client, b"USER user@example.com\r\nPASS wrong\r\n").await;
|
||||
|
||||
let tail = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&tail, b"-ERR").is_some(),
|
||||
"expected the second rejection, got {:?}",
|
||||
String::from_utf8_lossy(&tail)
|
||||
);
|
||||
assert_eq!(
|
||||
attempts.load(std::sync::atomic::Ordering::SeqCst),
|
||||
2,
|
||||
"max_auth_attempts must bound the backend connections a client can force"
|
||||
);
|
||||
|
||||
backend.abort();
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pop3_retry_after_failed_auth_is_routed_and_registered_for_its_own_account() {
|
||||
async fn accepting_backend(
|
||||
seen: std::sync::Arc<tokio::sync::Mutex<Vec<u8>>>,
|
||||
) -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind(unused_addr()).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Ok((mut sock, _)) = listener.accept().await {
|
||||
let seen = seen.clone();
|
||||
let _ = sock.set_nodelay(true);
|
||||
tokio::spawn(async move {
|
||||
if sock.write_all(b"+OK ready\r\n").await.is_err() {
|
||||
return;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
let mut scratch = [0u8; 1024];
|
||||
while find(&buf, b"\r\n").is_none() {
|
||||
match sock.read(&mut scratch).await {
|
||||
Ok(0) | Err(_) => return,
|
||||
Ok(n) => buf.extend_from_slice(&scratch[..n]),
|
||||
}
|
||||
}
|
||||
seen.lock().await.extend_from_slice(&buf);
|
||||
let _ = sock.write_all(b"+OK logged in\r\n").await;
|
||||
let mut sink = [0u8; 1024];
|
||||
loop {
|
||||
match sock.read(&mut sink).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
let rejects = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let (legacy_port, legacy) = rejecting_backend(rejects.clone()).await;
|
||||
let seen = std::sync::Arc::new(tokio::sync::Mutex::new(Vec::new()));
|
||||
let (modern_port, modern) = accepting_backend(seen.clone()).await;
|
||||
|
||||
let mapping = mapping_file("alice@example.com\tlegacy\nbob@example.com\tmodern\n");
|
||||
let toml = format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{}"
|
||||
|
||||
[capabilities.pop3]
|
||||
allow_plain_auth_without_tls = true
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
forwarding = "none"
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.pop3]
|
||||
port = {legacy_port}
|
||||
tls = "plain"
|
||||
|
||||
[destination.modern]
|
||||
host = "127.0.0.1"
|
||||
forwarding = "none"
|
||||
allow_plaintext_auth = true
|
||||
[destination.modern.protocol.pop3]
|
||||
port = {modern_port}
|
||||
tls = "plain"
|
||||
|
||||
[listener.pop3]
|
||||
protocol = "pop3"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
max_auth_attempts = 10
|
||||
"#,
|
||||
mapping.path().display()
|
||||
);
|
||||
|
||||
let ctx = make_ctx(&toml).await;
|
||||
let listener = ctx.config.listener["pop3"].clone();
|
||||
|
||||
let (mut client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::pop3::handle(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
"203.0.113.9:40002".parse().unwrap(),
|
||||
"127.0.0.1:110".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
|
||||
let _ = read_line(&mut client).await;
|
||||
send(&mut client, b"USER alice@example.com\r\nPASS wrong\r\n").await;
|
||||
let _ = read_until(&mut client, b"-ERR").await;
|
||||
|
||||
send(&mut client, b"USER bob@example.com\r\nPASS right\r\n").await;
|
||||
let ok = read_until(&mut client, b"+OK logged in").await;
|
||||
assert!(
|
||||
find(&ok, b"+OK logged in").is_some(),
|
||||
"second account should authenticate, got {:?}",
|
||||
String::from_utf8_lossy(&ok)
|
||||
);
|
||||
|
||||
let replayed = seen.lock().await.clone();
|
||||
let expected = BASE64.encode("\0bob@example.com\0right");
|
||||
assert!(
|
||||
find(&replayed, expected.as_bytes()).is_some(),
|
||||
"the retry must be re-routed to bob's destination, backend saw {:?}",
|
||||
String::from_utf8_lossy(&replayed)
|
||||
);
|
||||
assert_eq!(
|
||||
rejects.load(std::sync::atomic::Ordering::SeqCst),
|
||||
1,
|
||||
"alice's destination must see only the first attempt"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
ctx.conns.kick(&ctx.router.normalize("alice@example.com")),
|
||||
0,
|
||||
"the failed account must not hold a registration"
|
||||
);
|
||||
assert_eq!(
|
||||
ctx.conns.kick(&ctx.router.normalize("bob@example.com")),
|
||||
1,
|
||||
"the account that actually authenticated must be kickable"
|
||||
);
|
||||
|
||||
let n = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
client.read(&mut [0u8; 64]),
|
||||
)
|
||||
.await
|
||||
.expect("kick did not disconnect the client in time")
|
||||
.unwrap();
|
||||
assert_eq!(n, 0, "expected EOF after kick, got {n} bytes");
|
||||
|
||||
legacy.abort();
|
||||
modern.abort();
|
||||
let _ = handler.await;
|
||||
}
|
||||
|
||||
@@ -4,16 +4,28 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::AsyncRunner;
|
||||
use testcontainers_modules::redis::{REDIS_PORT, Redis};
|
||||
use testcontainers::{ContainerAsync, GenericImage};
|
||||
|
||||
use crate::config::RedisMappingConfig;
|
||||
use crate::route::store::redis::RedisStore;
|
||||
|
||||
const REDIS_PORT: u16 = 6379;
|
||||
|
||||
async fn redis_container() -> ContainerAsync<GenericImage> {
|
||||
GenericImage::new("redis", "7.4-alpine")
|
||||
.with_exposed_port(REDIS_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
|
||||
.start()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn redis_store_resolves_and_misses() {
|
||||
let node = Redis::default().start().await.unwrap();
|
||||
let port = node.get_host_port_ipv4(REDIS_PORT).await.unwrap();
|
||||
let node = redis_container().await;
|
||||
let port = node.get_host_port_ipv4(REDIS_PORT.tcp()).await.unwrap();
|
||||
let url = format!("redis://127.0.0.1:{port}");
|
||||
|
||||
let client = redis::Client::open(url.as_str()).unwrap();
|
||||
|
||||
@@ -315,3 +315,224 @@ async fn smtp_passthrough_bridges_raw() {
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
fn submission_config_with_sasl(port: u16, mapping_path: &str, plain: bool, sasl: &str) -> String {
|
||||
format!(
|
||||
r#"
|
||||
[mapping]
|
||||
source = "file"
|
||||
normalize = "lowercase"
|
||||
[mapping.file]
|
||||
path = "{mapping_path}"
|
||||
|
||||
[capabilities.submission]
|
||||
allow_plain_auth_without_tls = {plain}
|
||||
sasl = {sasl}
|
||||
|
||||
[routing]
|
||||
default_destination = "legacy"
|
||||
|
||||
[destination.legacy]
|
||||
host = "127.0.0.1"
|
||||
proxy_protocol = false
|
||||
allow_plaintext_auth = true
|
||||
[destination.legacy.protocol.submission]
|
||||
port = {port}
|
||||
tls = "plain"
|
||||
|
||||
[listener.submission]
|
||||
protocol = "submission"
|
||||
bind = ["127.0.0.1:0"]
|
||||
tls = "plain"
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
async fn spawn_submission(
|
||||
ctx: &std::sync::Arc<crate::proto::common::Ctx>,
|
||||
peer: &str,
|
||||
) -> (tokio::net::TcpStream, tokio::task::JoinHandle<()>) {
|
||||
let listener = ctx.config.listener["submission"].clone();
|
||||
let (client, server) = tcp_pair().await;
|
||||
let ctx2 = ctx.clone();
|
||||
let peer = peer.to_string();
|
||||
let handler = tokio::spawn(async move {
|
||||
let _ = crate::proto::smtp::handle_submission(
|
||||
&ctx2,
|
||||
&listener,
|
||||
Box::new(server),
|
||||
peer.parse().unwrap(),
|
||||
"127.0.0.1:587".parse().unwrap(),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
(client, handler)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_advertises_and_completes_auth_login() {
|
||||
let (port, backend) = dummy_backend(|sock| submission_backend(sock, "user@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = submission_config(port, &mapping.path().display().to_string(), true);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let (mut client, handler) = spawn_submission(&ctx, "127.0.0.1:40030").await;
|
||||
|
||||
let greeting = read_line(&mut client).await;
|
||||
assert!(greeting.starts_with("220"), "greeting: {greeting}");
|
||||
|
||||
send(&mut client, b"EHLO test.local\r\n").await;
|
||||
let ehlo = read_until(&mut client, b"250 ").await;
|
||||
let ehlo = String::from_utf8_lossy(&ehlo).into_owned();
|
||||
assert!(
|
||||
ehlo.contains("AUTH ") && ehlo.to_uppercase().contains("LOGIN"),
|
||||
"EHLO must advertise AUTH LOGIN, got {ehlo}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let user_prompt = read_line(&mut client).await;
|
||||
assert_eq!(user_prompt.trim_end(), "334 VXNlcm5hbWU6", "{user_prompt}");
|
||||
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("user@example.com")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let pass_prompt = read_line(&mut client).await;
|
||||
assert_eq!(pass_prompt.trim_end(), "334 UGFzc3dvcmQ6", "{pass_prompt}");
|
||||
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&resp, b"235").is_some(),
|
||||
"expected 235, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_auth_login_accepts_username_as_initial_response() {
|
||||
let (port, backend) = dummy_backend(|sock| submission_backend(sock, "user@example.com")).await;
|
||||
let mapping = mapping_file("");
|
||||
let toml = submission_config(port, &mapping.path().display().to_string(), true);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let (mut client, handler) = spawn_submission(&ctx, "127.0.0.1:40031").await;
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"EHLO test.local\r\n").await;
|
||||
let _ehlo = read_until(&mut client, b"250 ").await;
|
||||
|
||||
send(
|
||||
&mut client,
|
||||
format!("AUTH LOGIN {}\r\n", BASE64.encode("user@example.com")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
let pass_prompt = read_line(&mut client).await;
|
||||
assert_eq!(pass_prompt.trim_end(), "334 UGFzc3dvcmQ6", "{pass_prompt}");
|
||||
|
||||
send(
|
||||
&mut client,
|
||||
format!("{}\r\n", BASE64.encode("secret")).as_bytes(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let resp = read_to_eof(&mut client).await;
|
||||
assert!(
|
||||
find(&resp, b"235").is_some(),
|
||||
"expected 235, got {:?}",
|
||||
String::from_utf8_lossy(&resp)
|
||||
);
|
||||
|
||||
let _ = backend.await;
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_auth_login_cancelled_by_client() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = submission_config(1, &mapping.path().display().to_string(), true);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let (mut client, handler) = spawn_submission(&ctx, "127.0.0.1:40032").await;
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"EHLO test.local\r\n").await;
|
||||
let _ehlo = read_until(&mut client, b"250 ").await;
|
||||
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let _user_prompt = read_line(&mut client).await;
|
||||
send(&mut client, b"*\r\n").await;
|
||||
|
||||
let resp = read_until(&mut client, b"\r\n").await;
|
||||
let resp = String::from_utf8_lossy(&resp).into_owned();
|
||||
assert!(resp.starts_with("501"), "expected 501 cancel, got {resp}");
|
||||
|
||||
send(&mut client, b"NOOP\r\n").await;
|
||||
let noop = read_line(&mut client).await;
|
||||
assert!(
|
||||
noop.starts_with("250"),
|
||||
"session must survive a cancelled AUTH, got {noop}"
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_auth_login_refused_when_cleartext_is_not_allowed() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = submission_config(1, &mapping.path().display().to_string(), false);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let (mut client, handler) = spawn_submission(&ctx, "127.0.0.1:40033").await;
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"EHLO test.local\r\n").await;
|
||||
let ehlo = read_until(&mut client, b"250 ").await;
|
||||
let ehlo = String::from_utf8_lossy(&ehlo).into_owned();
|
||||
assert!(
|
||||
!ehlo.to_uppercase().contains("LOGIN"),
|
||||
"LOGIN must not be advertised without TLS, got {ehlo}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let resp = read_until(&mut client, b"\r\n").await;
|
||||
let resp = String::from_utf8_lossy(&resp).into_owned();
|
||||
assert!(resp.starts_with("538"), "expected 538, got {resp}");
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn submission_auth_login_rejected_when_not_configured() {
|
||||
let mapping = mapping_file("");
|
||||
let toml = submission_config_with_sasl(
|
||||
1,
|
||||
&mapping.path().display().to_string(),
|
||||
true,
|
||||
r#"["PLAIN", "OAUTHBEARER", "XOAUTH2"]"#,
|
||||
);
|
||||
let ctx = make_ctx(&toml).await;
|
||||
|
||||
let (mut client, handler) = spawn_submission(&ctx, "127.0.0.1:40034").await;
|
||||
let _greeting = read_line(&mut client).await;
|
||||
send(&mut client, b"EHLO test.local\r\n").await;
|
||||
let ehlo = read_until(&mut client, b"250 ").await;
|
||||
let ehlo = String::from_utf8_lossy(&ehlo).into_owned();
|
||||
assert!(
|
||||
!ehlo.to_uppercase().contains("LOGIN"),
|
||||
"LOGIN must not be advertised when unconfigured, got {ehlo}"
|
||||
);
|
||||
|
||||
send(&mut client, b"AUTH LOGIN\r\n").await;
|
||||
let resp = read_until(&mut client, b"\r\n").await;
|
||||
let resp = String::from_utf8_lossy(&resp).into_owned();
|
||||
assert!(
|
||||
resp.starts_with("535") && resp.contains("Unsupported"),
|
||||
"expected 535 unsupported, got {resp}"
|
||||
);
|
||||
handler.abort();
|
||||
}
|
||||
|
||||
+25
-5
@@ -4,16 +4,36 @@
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||
use testcontainers::runners::AsyncRunner;
|
||||
use testcontainers_modules::postgres::Postgres;
|
||||
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
||||
|
||||
use crate::config::SqlMappingConfig;
|
||||
use crate::route::store::sql::SqlStore;
|
||||
|
||||
const POSTGRES_PORT: u16 = 5432;
|
||||
|
||||
async fn postgres_container() -> ContainerAsync<GenericImage> {
|
||||
GenericImage::new("postgres", "17-alpine")
|
||||
.with_exposed_port(POSTGRES_PORT.tcp())
|
||||
.with_wait_for(WaitFor::message_on_stderr(
|
||||
"database system is ready to accept connections",
|
||||
))
|
||||
.with_wait_for(WaitFor::message_on_stderr(
|
||||
"database system is ready to accept connections",
|
||||
))
|
||||
.with_env_var("POSTGRES_USER", "postgres")
|
||||
.with_env_var("POSTGRES_PASSWORD", "postgres")
|
||||
.with_env_var("POSTGRES_DB", "postgres")
|
||||
.start()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_store_resolves_and_misses() {
|
||||
let node = Postgres::default().start().await.unwrap();
|
||||
let port = node.get_host_port_ipv4(5432).await.unwrap();
|
||||
let node = postgres_container().await;
|
||||
let port = node.get_host_port_ipv4(POSTGRES_PORT.tcp()).await.unwrap();
|
||||
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
|
||||
|
||||
sqlx::any::install_default_drivers();
|
||||
@@ -49,8 +69,8 @@ async fn sql_store_resolves_and_misses() {
|
||||
|
||||
#[tokio::test]
|
||||
async fn sql_store_upsert_and_remove() {
|
||||
let node = Postgres::default().start().await.unwrap();
|
||||
let port = node.get_host_port_ipv4(5432).await.unwrap();
|
||||
let node = postgres_container().await;
|
||||
let port = node.get_host_port_ipv4(POSTGRES_PORT.tcp()).await.unwrap();
|
||||
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
|
||||
|
||||
sqlx::any::install_default_drivers();
|
||||
|
||||
Reference in New Issue
Block a user