8 Commits
45 changed files with 5088 additions and 977 deletions
+29
View File
@@ -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
+60 -26
View File
@@ -18,6 +18,8 @@ env:
SCCACHE_GHA_ENABLED: true SCCACHE_GHA_ENABLED: true
RUSTC_WRAPPER: sccache RUSTC_WRAPPER: sccache
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_NET_RETRY: 10
CARGO_NET_GIT_FETCH_WITH_CLI: true
AWS_LC_SYS_PREBUILT_NASM: 1 AWS_LC_SYS_PREBUILT_NASM: 1
concurrency: concurrency:
@@ -43,7 +45,7 @@ jobs:
if: github.event_name == 'push' || inputs.Docker if: github.event_name == 'push' || inputs.Docker
steps: steps:
- name: Install Cosign - name: Install Cosign
uses: sigstore/cosign-installer@v4.1.1 uses: sigstore/cosign-installer@v4.1.2
- name: Log In to GitHub Container Registry - name: Log In to GitHub Container Registry
uses: docker/login-action@v4 uses: docker/login-action@v4
with: with:
@@ -113,50 +115,80 @@ jobs:
matrix: matrix:
include: include:
- target: x86_64-unknown-linux-gnu - target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
platform: linux/amd64 platform: linux/amd64
suffix: "" suffix: ""
- target: x86_64-unknown-linux-musl - target: x86_64-unknown-linux-musl
runner: ubuntu-latest
platform: linux/amd64 platform: linux/amd64
suffix: "-alpine" suffix: "-alpine"
- target: aarch64-unknown-linux-gnu - target: aarch64-unknown-linux-gnu
runner: ubuntu-24.04-arm
platform: linux/arm64 platform: linux/arm64
suffix: "" suffix: ""
- target: aarch64-unknown-linux-musl - target: aarch64-unknown-linux-musl
runner: ubuntu-24.04-arm
platform: linux/arm64 platform: linux/arm64
suffix: "-alpine" suffix: "-alpine"
- target: armv7-unknown-linux-gnueabihf - target: armv7-unknown-linux-gnueabihf
runner: ubuntu-latest
platform: linux/arm/v7 platform: linux/arm/v7
suffix: "" suffix: ""
- target: armv7-unknown-linux-musleabihf - target: armv7-unknown-linux-musleabihf
runner: ubuntu-latest
platform: linux/arm/v7 platform: linux/arm/v7
suffix: "-alpine" suffix: "-alpine"
- target: arm-unknown-linux-gnueabihf - target: arm-unknown-linux-gnueabihf
runner: ubuntu-latest
platform: linux/arm/v6 platform: linux/arm/v6
suffix: "" suffix: ""
- target: arm-unknown-linux-musleabihf - target: arm-unknown-linux-musleabihf
runner: ubuntu-latest
platform: linux/arm/v6 platform: linux/arm/v6
suffix: "-alpine" suffix: "-alpine"
name: Build / ${{matrix.target}} name: Build / ${{matrix.target}}
runs-on: ubuntu-latest runs-on: ${{matrix.runner}}
timeout-minutes: 180
steps: steps:
- name: Checkout - 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) - name: Add swap (heavy ARM targets)
if: contains(matrix.target, 'arm') if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
run: | run: |
sudo fallocate -l 16G /mnt/swapfile swap_dir=""
sudo chmod 600 /mnt/swapfile for dir in /mnt /; do
sudo mkswap /mnt/swapfile if [ -d "$dir" ] && [ "$(df --output=avail -k "$dir" | tail -1)" -ge 18874368 ]; then
sudo swapon /mnt/swapfile swap_dir="$dir"
break
fi
done
if [ -z "$swap_dir" ]; then
echo "Insufficient space for swap on /mnt or /, aborting swap setup"
df -h
exit 1
fi
sudo fallocate -l 16G "${swap_dir%/}/swapfile"
sudo chmod 600 "${swap_dir%/}/swapfile"
sudo mkswap "${swap_dir%/}/swapfile"
sudo swapon "${swap_dir%/}/swapfile"
sudo sysctl vm.swappiness=80 sudo sysctl vm.swappiness=80
free -h free -h
swapon --show swapon --show
- name: Set up QEMU - name: Set up QEMU
if: startsWith(matrix.platform, 'linux/arm/')
uses: docker/setup-qemu-action@v4 uses: docker/setup-qemu-action@v4
with: with:
platforms: "arm64,arm" platforms: "arm"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
@@ -175,6 +207,7 @@ jobs:
password: ${{github.token}} password: ${{github.token}}
- name: Log In to DockerHub - name: Log In to DockerHub
if: github.event_name == 'push' || inputs.Docker
uses: docker/login-action@v4 uses: docker/login-action@v4
with: with:
username: ${{secrets.DOCKERHUB_USERNAME}} username: ${{secrets.DOCKERHUB_USERNAME}}
@@ -186,16 +219,16 @@ jobs:
echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT" echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
- name: Cache apt - name: Cache apt
uses: actions/cache@v5.0.5 uses: actions/cache@v6.1.0
id: apt-cache id: apt-cache
with: with:
path: | path: |
var-cache-apt var-cache-apt
var-lib-apt var-lib-apt
key: apt-cache-${{ hashFiles('Dockerfile.build') }} key: apt-cache-${{ runner.arch }}-${{ hashFiles('Dockerfile.build') }}
- name: Cache Cargo - name: Cache Cargo
uses: actions/cache@v5.0.5 uses: actions/cache@v6.1.0
id: cargo-cache id: cargo-cache
with: with:
path: | path: |
@@ -204,7 +237,7 @@ jobs:
key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }} key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }}
- name: Inject cache into docker - name: Inject cache into docker
uses: reproducible-containers/buildkit-cache-dance@v3.3.2 uses: reproducible-containers/buildkit-cache-dance@v3.4.0
with: with:
cache-map: | cache-map: |
{ {
@@ -229,6 +262,7 @@ jobs:
type=ref,event=branch,prefix=branch- type=ref,event=branch,prefix=branch-
type=edge,branch=main type=edge,branch=main
type=semver,pattern=v{{major}}.{{minor}} type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- name: Build Artifact - name: Build Artifact
id: bake id: bake
@@ -251,7 +285,7 @@ jobs:
targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}} targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}}
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7.0.1
with: with:
name: artifact-${{matrix.target}} name: artifact-${{matrix.target}}
path: | path: |
@@ -268,7 +302,7 @@ jobs:
- name: Upload digest - name: Upload digest
if: github.event_name == 'push' || inputs.Docker if: github.event_name == 'push' || inputs.Docker
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7.0.1
with: with:
name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }} name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }}
path: ${{ runner.temp }}/digests/* path: ${{ runner.temp }}/digests/*
@@ -276,7 +310,7 @@ jobs:
retention-days: 1 retention-days: 1
- name: Upload GNU meta bake definition - 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') if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86')
with: with:
name: bake-meta-gnu name: bake-meta-gnu
@@ -285,7 +319,7 @@ jobs:
retention-days: 1 retention-days: 1
- name: Upload musl meta bake definition - 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') if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86')
with: with:
name: bake-meta-musl name: bake-meta-musl
@@ -303,10 +337,10 @@ jobs:
- target: x86_64-pc-windows-msvc - target: x86_64-pc-windows-msvc
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v7
- name: Run sccache-cache - name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.9 uses: mozilla-actions/sccache-action@v0.0.11
with: with:
disable_annotations: true disable_annotations: true
@@ -318,7 +352,7 @@ jobs:
mv ./target/${{matrix.target}}/release/proxy.exe ./artifacts/proxy.exe mv ./target/${{matrix.target}}/release/proxy.exe ./artifacts/proxy.exe
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7.0.1
with: with:
name: artifact-${{matrix.target}} name: artifact-${{matrix.target}}
path: artifacts path: artifacts
@@ -334,10 +368,10 @@ jobs:
- target: x86_64-apple-darwin - target: x86_64-apple-darwin
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v7
- name: Run sccache-cache - name: Run sccache-cache
uses: mozilla-actions/sccache-action@v0.0.9 uses: mozilla-actions/sccache-action@v0.0.11
with: with:
disable_annotations: true disable_annotations: true
@@ -349,7 +383,7 @@ jobs:
mv ./target/${{matrix.target}}/release/proxy ./artifacts/proxy mv ./target/${{matrix.target}}/release/proxy ./artifacts/proxy
- name: Upload Artifacts - name: Upload Artifacts
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7.0.1
with: with:
name: artifact-${{matrix.target}} name: artifact-${{matrix.target}}
path: artifacts path: artifacts
@@ -367,7 +401,7 @@ jobs:
# Must run before artifacts are downloaded: checkout cleans the workspace. # Must run before artifacts are downloaded: checkout cleans the workspace.
- name: Checkout (for CHANGELOG) - name: Checkout (for CHANGELOG)
if: startsWith(github.ref, 'refs/tags/') if: startsWith(github.ref, 'refs/tags/')
uses: actions/checkout@v6.0.2 uses: actions/checkout@v7
- name: Download Artifacts - name: Download Artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
@@ -410,7 +444,7 @@ jobs:
archive/**/*.zip archive/**/*.zip
- name: Use cosign to sign existing artifacts - 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: with:
inputs: | inputs: |
archive/**/*.tar.gz archive/**/*.tar.gz
@@ -461,7 +495,7 @@ jobs:
cleanup: cleanup:
name: Cleanup failed release name: Cleanup failed release
needs: [linux, windows, macos, multiarch, 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 runs-on: ubuntu-latest
permissions: permissions:
contents: write contents: write
+1
View File
@@ -3,3 +3,4 @@
*.md *.md
!CHANGELOG.md !CHANGELOG.md
!README.md !README.md
__pycache__/
+56 -7
View File
@@ -2,25 +2,74 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.5] - 2026-10-25
### Added
### Changed
- Replaced `base64` with `encodify`.
- An HTTP route's `fallback` is now honoured: unmatched or unmapped requests on that route go to the fallback destination instead of `routing.default_destination`. A `fallback` naming an undeclared destination, or one that does not declare the `http` protocol, is rejected at startup.
- A login held by `POST /connections/delay` now resolves its destination when the hold ends instead of before it.
- `PUT /mappings`, `DELETE /mappings` and `POST /cache/invalidate?identifier=` end the login delay of that identifier; `POST /mappings/reload` and `POST /cache/invalidate` without an identifier end every login delay. Held logins proceed at once to the newly mapped destination.
- Calling `POST /connections/delay` again for an identifier also moves the hold deadline of logins already waiting (still capped at 120 seconds per login).
### Fixed
- The sample configuration written by `install.sh` and `resources/config.sample.toml` named an undeclared HTTP route fallback (`default`) and failed startup validation.
- `resources/config.stalwart-upgrade.toml` now documents the WebUI OAuth client identifiers (`https://<host>/admin/oauth-client.json`, `https://<host>/account/oauth-client.json`) and pins `/admin`, `/account`, `/api/auth/**` to the new deployment.
- The `POST /config/reload` response no longer claims that cache capacity and TTLs require a restart; a reload rebuilds the router and its cache.
- `POST /connections/delay` with a `seconds` value too large to represent now returns `400` instead of panicking.
## [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 ## [1.0.1] - 2026-07-30
### Added ### Added
- `server.host_down_min_clients`: number of distinct client addresses a - `server.host_down_min_clients`: number of distinct client addresses a failure streak must span before client-attributable failures can mark a destination down.
failure streak must span before client-attributable failures can mark a destination
down.
- `GET /destinations` now also reports `failure_sources` and `host_level_failure`. - `GET /destinations` now also reports `failure_sources` and `host_level_failure`.
### Changed ### Changed
### Fixed ### Fixed
- The circuit breaker no longer marks a destination down because a backend rejects - The circuit breaker no longer marks a destination down because a backend rejects the connections of a single banned client (#2).
the connections of a single banned client (#2).
## [1.0.0] - 2026-06-08 ## [1.0.0] - 2026-06-08
### Added ### Added
- Initial release of the migration proxy: pre-auth routing and socket bridging - Initial release of the migration proxy: pre-auth routing and socket bridging for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
### Changed ### Changed
Generated
+459 -397
View File
File diff suppressed because it is too large Load Diff
+4 -6
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "proxy" name = "proxy"
version = "1.0.1" version = "1.0.5"
edition = "2024" edition = "2024"
license = "AGPL-3.0-only OR LicenseRef-SEL" license = "AGPL-3.0-only OR LicenseRef-SEL"
description = "Multi-protocol e-mail proxy" description = "Multi-protocol e-mail proxy"
@@ -22,10 +22,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc
rustls-pemfile = "2.2" rustls-pemfile = "2.2"
rustls-pki-types = "1.15" rustls-pki-types = "1.15"
aws-lc-rs = "1.17" aws-lc-rs = "1.17"
proxy-header = { version = "0.1.2", features = ["tokio"] } proxy-header = { version = "0.1", features = ["tokio"] }
smtp-proto = "0.2.2" smtp-proto = "0.2.2"
mail-parser = "0.11" encodify = "1.0"
base64 = "0.23"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "1" toml = "1"
@@ -49,8 +48,7 @@ rustls-platform-verifier = "0.7"
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
mockito = "1.7" mockito = "1.7"
testcontainers = "0.27" testcontainers = "0.28"
testcontainers-modules = { version = "0.15", features = ["redis", "postgres"] }
pwhash = "1" pwhash = "1"
jmap-client = { version = "0.4", default-features = false, features = ["websockets", "async", "aws_lc_rs"] } jmap-client = { version = "0.4", default-features = false, features = ["websockets", "async", "aws_lc_rs"] }
futures-util = "0.3" futures-util = "0.3"
+38 -13
View File
@@ -9,6 +9,8 @@ FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base
ENV DEBIAN_FRONTEND="noninteractive" \ ENV DEBIAN_FRONTEND="noninteractive" \
BINSTALL_DISABLE_TELEMETRY=true \ BINSTALL_DISABLE_TELEMETRY=true \
CARGO_TERM_COLOR=always \ CARGO_TERM_COLOR=always \
CARGO_NET_RETRY=10 \
CARGO_NET_GIT_FETCH_WITH_CLI=true \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
TZ=UTC \ TZ=UTC \
TERM=xterm-256color \ TERM=xterm-256color \
@@ -21,21 +23,36 @@ RUN \
rm -f /etc/apt/apt.conf.d/docker-clean && \ rm -f /etc/apt/apt.conf.d/docker-clean && \
echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache && \ echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache && \
apt-get update && \ 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
# undefined references to glibc 2.33+ symbols. Zig 0.13 produces binaries with
# a max glibc ref of ~2.28 which stays compatible with Debian 11 / RHEL 8.
RUN \ RUN \
ZIG_VERSION=0.13.0 && \ ZIG_VERSION=0.17.0 && \
curl --retry 5 -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-$(uname -m)-${ZIG_VERSION}.tar.xz" | tar -J -x -C /usr/local && \ case "$(uname -m)" in \
ln -s "/usr/local/zig-linux-$(uname -m)-${ZIG_VERSION}/zig" /usr/local/bin/zig && \ x86_64) ZIG_SHA256=1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 ;; \
aarch64) ZIG_SHA256=9e8d11661d4ae3bd57702a3832781e23ad151dde5798e16a5ccd503f65234ff8 ;; \
*) exit 1 ;; \
esac && \
curl --retry 5 -fsSLo /tmp/zig.tar.xz "https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz" && \
echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - && \
tar -J -x -C /usr/local -f /tmp/zig.tar.xz && \
rm /tmp/zig.tar.xz && \
ln -s "/usr/local/zig-$(uname -m)-linux-${ZIG_VERSION}/zig" /usr/local/bin/zig && \
zig version zig version
# Install cargo-binstall # 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 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 # Install cargo-chef & sccache. Versions are pinned so that an
RUN cargo binstall --no-confirm cargo-chef sccache cargo-zigbuild # 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 && break; \
echo "cargo binstall attempt ${attempt} failed, retrying"; \
sleep $((attempt * 15)); \
done && \
command -v cargo-chef && command -v sccache
RUN cargo install --locked --git https://github.com/rust-cross/cargo-zigbuild --rev d9f7fd32fbac13cc26d23847835f7f23a5ed3a25 cargo-zigbuild
# ***************** # *****************
# Planner # Planner
@@ -54,6 +71,7 @@ WORKDIR /app
COPY --from=planner /app/recipe.json recipe.json COPY --from=planner /app/recipe.json recipe.json
ARG TARGET ARG TARGET
ARG BUILD_ENV ARG BUILD_ENV
ARG GLIBC_VERSION=2.28
SHELL ["/bin/bash", "-o", "pipefail", "-c"] SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install toolchain and specify some env variables # Install toolchain and specify some env variables
RUN \ RUN \
@@ -61,6 +79,13 @@ RUN \
rustup target add ${TARGET} && \ rustup target add ${TARGET} && \
mkdir -p artifact && \ mkdir -p artifact && \
touch /env-cargo && \ touch /env-cargo && \
if [[ "${TARGET}" == *-gnu* ]]; then \
echo "export BUILD_TARGET=${TARGET}.${GLIBC_VERSION}" >> /env-cargo; \
mkdir -p target && \
ln -s "${TARGET}" "target/${TARGET}.${GLIBC_VERSION}"; \
else \
echo "export BUILD_TARGET=${TARGET}" >> /env-cargo; \
fi && \
if [ ! -z "${BUILD_ENV}" ]; then \ if [ ! -z "${BUILD_ENV}" ]; then \
echo "export ${BUILD_ENV}" >> /env-cargo; \ echo "export ${BUILD_ENV}" >> /env-cargo; \
echo "Setting up ${BUILD_ENV}"; \ echo "Setting up ${BUILD_ENV}"; \
@@ -72,7 +97,7 @@ RUN \
--mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
source /env-cargo && \ source /env-cargo && \
cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p proxy cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${BUILD_TARGET} -p proxy
# Copy the source code # Copy the source code
COPY . . COPY . .
ENV RUSTC_WRAPPER="sccache" \ ENV RUSTC_WRAPPER="sccache" \
@@ -84,7 +109,7 @@ RUN \
--mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git \ --mount=type=cache,target=/usr/local/cargo/git \
source /env-cargo && \ source /env-cargo && \
cargo zigbuild --release --target ${TARGET} -p proxy && \ cargo zigbuild --release --target ${BUILD_TARGET} -p proxy && \
mv /app/target/${TARGET}/release/proxy /app/artifact/proxy mv /app/target/${TARGET}/release/proxy /app/artifact/proxy
# ***************** # *****************
+10 -10
View File
@@ -266,8 +266,8 @@ tls = "implicit"
port = 995 port = 995
tls = "implicit" tls = "implicit"
[destination.stalwart.protocol.submission] [destination.stalwart.protocol.submission]
port = 587 port = 465
tls = "starttls" tls = "implicit"
[destination.stalwart.protocol.managesieve] [destination.stalwart.protocol.managesieve]
port = 4190 port = 4190
tls = "starttls" tls = "starttls"
@@ -277,37 +277,37 @@ tls = "implicit"
[listener.imap] [listener.imap]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"] bind = ["[::]:993"]
tls = "implicit" tls = "implicit"
[listener.imap-starttls] [listener.imap-starttls]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"] bind = ["[::]:143"]
tls = "starttls" tls = "starttls"
[listener.pop3] [listener.pop3]
protocol = "pop3" protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"] bind = ["[::]:995"]
tls = "implicit" tls = "implicit"
[listener.submission] [listener.submission]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"] bind = ["[::]:587"]
tls = "starttls" tls = "starttls"
[listener.submissions] [listener.submissions]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"] bind = ["[::]:465"]
tls = "implicit" tls = "implicit"
[listener.managesieve] [listener.managesieve]
protocol = "managesieve" protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"] bind = ["[::]:4190"]
tls = "starttls" tls = "starttls"
[listener.https] [listener.https]
protocol = "http" protocol = "http"
bind = ["0.0.0.0:443", "[::]:443"] bind = ["[::]:443"]
tls = "implicit" tls = "implicit"
forwarded = "off" forwarded = "off"
@@ -318,7 +318,7 @@ destination = "legacy"
[[http.route]] [[http.route]]
match = "/**" match = "/**"
extract = { from = "authorization" } extract = { from = "authorization" }
fallback = "default" fallback = "legacy"
[admin] [admin]
bind = "127.0.0.1:9443" bind = "127.0.0.1:9443"
+17 -10
View File
@@ -112,8 +112,8 @@ tls = "implicit"
port = 995 port = 995
tls = "implicit" tls = "implicit"
[destination.stalwart.protocol.submission] [destination.stalwart.protocol.submission]
port = 587 port = 465
tls = "starttls" tls = "implicit"
[destination.stalwart.protocol.managesieve] [destination.stalwart.protocol.managesieve]
port = 4190 port = 4190
tls = "starttls" tls = "starttls"
@@ -129,38 +129,45 @@ tls = "plain"
# here. Once accounts are on Stalwart, JMAP and the web interface are served by # here. Once accounts are on Stalwart, JMAP and the web interface are served by
# the new server; an HTTP listener routing to "stalwart" can be added at that # the new server; an HTTP listener routing to "stalwart" can be added at that
# point if a single hostname for web access is wanted. # point if a single hostname for web access is wanted.
#
# Each listener binds [::] only: on Linux and macOS that socket also accepts
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
# use "0.0.0.0:<port>" instead.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[listener.imaps] [listener.imaps]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"] bind = ["[::]:993"]
tls = "implicit" tls = "implicit"
[listener.imap] [listener.imap]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"] bind = ["[::]:143"]
tls = "starttls" tls = "starttls"
[listener.pop3s] [listener.pop3s]
protocol = "pop3" protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"] bind = ["[::]:995"]
tls = "implicit" tls = "implicit"
[listener.pop3] [listener.pop3]
protocol = "pop3" protocol = "pop3"
bind = ["0.0.0.0:110", "[::]:110"] bind = ["[::]:110"]
tls = "starttls" tls = "starttls"
[listener.submission] [listener.submission]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"] bind = ["[::]:587"]
tls = "starttls" tls = "starttls"
[listener.submissions] [listener.submissions]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"] bind = ["[::]:465"]
tls = "implicit" tls = "implicit"
[listener.managesieve] [listener.managesieve]
protocol = "managesieve" protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"] bind = ["[::]:4190"]
tls = "starttls" tls = "starttls"
[listener.smtp] [listener.smtp]
protocol = "smtp" protocol = "smtp"
bind = ["0.0.0.0:25", "[::]:25"] bind = ["[::]:25"]
tls = "plain" tls = "plain"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+17 -10
View File
@@ -92,8 +92,8 @@ tls = "implicit"
port = 995 port = 995
tls = "implicit" tls = "implicit"
[destination.stalwart.protocol.submission] [destination.stalwart.protocol.submission]
port = 587 port = 465
tls = "starttls" tls = "implicit"
[destination.stalwart.protocol.managesieve] [destination.stalwart.protocol.managesieve]
port = 4190 port = 4190
tls = "starttls" tls = "starttls"
@@ -104,40 +104,47 @@ tls = "implicit"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Listeners: one entry per protocol + TLS mode + bind set. # Listeners: one entry per protocol + TLS mode + bind set.
# tls = "implicit" | "starttls" | "plain". # tls = "implicit" | "starttls" | "plain".
#
# Each listener binds [::] only: on Linux and macOS that socket also accepts
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
# use "0.0.0.0:<port>" instead.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[listener.imap] [listener.imap]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"] bind = ["[::]:993"]
tls = "implicit" tls = "implicit"
[listener.imap-starttls] [listener.imap-starttls]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"] bind = ["[::]:143"]
tls = "starttls" tls = "starttls"
[listener.pop3] [listener.pop3]
protocol = "pop3" protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"] bind = ["[::]:995"]
tls = "implicit" tls = "implicit"
[listener.submission] [listener.submission]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"] bind = ["[::]:587"]
tls = "starttls" tls = "starttls"
[listener.submissions] [listener.submissions]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"] bind = ["[::]:465"]
tls = "implicit" tls = "implicit"
[listener.managesieve] [listener.managesieve]
protocol = "managesieve" protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"] bind = ["[::]:4190"]
tls = "starttls" tls = "starttls"
[listener.https] [listener.https]
protocol = "http" protocol = "http"
bind = ["0.0.0.0:443", "[::]:443"] bind = ["[::]:443"]
tls = "implicit" tls = "implicit"
forwarded = "off" forwarded = "off"
@@ -152,7 +159,7 @@ destination = "legacy"
[[http.route]] [[http.route]]
match = "/**" match = "/**"
extract = { from = "authorization" } extract = { from = "authorization" }
fallback = "default" fallback = "legacy"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Admin listener: the cache-integrity boundary. Bind to loopback or a dedicated # Admin listener: the cache-integrity boundary. Bind to loopback or a dedicated
+35 -16
View File
@@ -100,8 +100,8 @@ tls = "implicit"
port = 995 port = 995
tls = "implicit" tls = "implicit"
[destination.old.protocol.submission] [destination.old.protocol.submission]
port = 587 port = 465
tls = "starttls" tls = "implicit"
[destination.old.protocol.managesieve] [destination.old.protocol.managesieve]
port = 4190 port = 4190
tls = "starttls" tls = "starttls"
@@ -124,8 +124,8 @@ tls = "implicit"
port = 995 port = 995
tls = "implicit" tls = "implicit"
[destination.new.protocol.submission] [destination.new.protocol.submission]
port = 587 port = 465
tls = "starttls" tls = "implicit"
[destination.new.protocol.managesieve] [destination.new.protocol.managesieve]
port = 4190 port = 4190
tls = "starttls" tls = "starttls"
@@ -165,12 +165,21 @@ destination = "old"
match = "/api/auth" match = "/api/auth"
destination = "new" destination = "new"
[[http.route]] [[http.route]]
match = "/api/auth/**"
destination = "new"
[[http.route]]
match = "/api/discover/**" match = "/api/discover/**"
destination = "new" destination = "new"
[[http.route]] [[http.route]]
match = "/admin"
destination = "new"
[[http.route]]
match = "/admin/**" match = "/admin/**"
destination = "new" destination = "new"
[[http.route]] [[http.route]]
match = "/account"
destination = "new"
[[http.route]]
match = "/account/**" match = "/account/**"
destination = "new" destination = "new"
[[http.route]] [[http.route]]
@@ -181,53 +190,63 @@ match = "/device"
destination = "new" destination = "new"
# OAuth token exchange is shared; route it by the web client that requested it. # OAuth token exchange is shared; route it by the web client that requested it.
# Add matching mapping entries: webadmin old and stalwart-webui new # Add matching mapping entries (replace the hostname with the public one):
# webadmin old
# https://mail.example.org/admin/oauth-client.json new
# https://mail.example.org/account/oauth-client.json new
[[http.route]] [[http.route]]
match = "/auth/token" match = "/auth/token"
extract = { from = "body", regex = "client_id=([^&]+)" } extract = { from = "body", regex = "client_id=([^&]+)" }
fallback = "old" fallback = "old"
# Everything else: route by the authenticated account, fall back to default. # Everything else: route by the authenticated account, fall back to the old deployment.
[[http.route]] [[http.route]]
match = "/**" match = "/**"
extract = { from = "authorization" } extract = { from = "authorization" }
fallback = "default" fallback = "old"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Listeners: the public ports clients connect to. These take over the ports the # Listeners: the public ports clients connect to. These take over the ports the
# old server used to answer on directly. # old server used to answer on directly.
#
# Each listener binds [::] only: on Linux and macOS that socket also accepts
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
# use "0.0.0.0:<port>" instead.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
[listener.imaps] [listener.imaps]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"] bind = ["[::]:993"]
tls = "implicit" tls = "implicit"
[listener.imap] [listener.imap]
protocol = "imap" protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"] bind = ["[::]:143"]
tls = "starttls" tls = "starttls"
[listener.pop3s] [listener.pop3s]
protocol = "pop3" protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"] bind = ["[::]:995"]
tls = "implicit" tls = "implicit"
[listener.pop3] [listener.pop3]
protocol = "pop3" protocol = "pop3"
bind = ["0.0.0.0:110", "[::]:110"] bind = ["[::]:110"]
tls = "starttls" tls = "starttls"
[listener.submission] [listener.submission]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"] bind = ["[::]:587"]
tls = "starttls" tls = "starttls"
[listener.submissions] [listener.submissions]
protocol = "submission" protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"] bind = ["[::]:465"]
tls = "implicit" tls = "implicit"
[listener.managesieve] [listener.managesieve]
protocol = "managesieve" protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"] bind = ["[::]:4190"]
tls = "starttls" tls = "starttls"
[listener.https] [listener.https]
protocol = "http" protocol = "http"
bind = ["0.0.0.0:443", "[::]:443"] bind = ["[::]:443"]
tls = "implicit" tls = "implicit"
# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather # Strip any Forwarded / X-Forwarded-For headers arriving from clients rather
# than trusting them; the proxy sets its own based on the real peer. # than trusting them; the proxy sets its own based on the real peer.
@@ -236,7 +255,7 @@ forwarded = "off"
# a pass-through listener cannot offer STARTTLS. # a pass-through listener cannot offer STARTTLS.
[listener.smtp] [listener.smtp]
protocol = "smtp" protocol = "smtp"
bind = ["0.0.0.0:25", "[::]:25"] bind = ["[::]:25"]
tls = "plain" tls = "plain"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
REQUEST_TIMEOUT = 60
DEFAULT_PAGE_SIZE = 500
MASKED = "****"
USING = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
COPIED_FIELDS = ("description", "quotas", "locale", "timeZone")
class ApiError(RuntimeError):
pass
class StalwartV016Client:
def __init__(self, url, token=None, username=None, password=None, insecure=False):
self.base = url.rstrip("/")
if token:
self.auth = "Bearer " + token
elif username is not None:
raw = f"{username}:{password or ''}".encode()
self.auth = "Basic " + base64.b64encode(raw).decode()
else:
raise ValueError("either a token or a username must be provided")
self.ctx = None
if insecure:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
self.api_url = None
self.page_size = DEFAULT_PAGE_SIZE
def request(self, url, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
req.add_header("Authorization", self.auth)
req.add_header("Accept", "application/json")
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=self.ctx) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise ApiError(f"{exc.code} {exc.reason} for {url}: {detail}") from exc
except urllib.error.URLError as exc:
raise ApiError(f"cannot reach {url}: {exc.reason}") from exc
def connect(self):
session = self.request(self.base + "/.well-known/jmap")
api_url = session.get("apiUrl")
if not api_url:
raise ApiError("the JMAP session does not advertise an apiUrl")
self.api_url = self.base + (urllib.parse.urlsplit(api_url).path or "/jmap")
core = (session.get("capabilities") or {}).get("urn:ietf:params:jmap:core") or {}
max_get = core.get("maxObjectsInGet")
if isinstance(max_get, int) and max_get > 0:
self.page_size = min(DEFAULT_PAGE_SIZE, max_get)
def call(self, method, args):
resp = self.request(self.api_url, {"using": USING, "methodCalls": [[method, args, "c0"]]})
responses = resp.get("methodResponses") or []
if len(responses) != 1:
raise ApiError(f"{method}: unexpected response {resp}")
name, result, _ = responses[0]
if name == "error":
raise ApiError(f"{method}: {result.get('type')}: {result.get('description', '')}")
return result
def query_ids(self, obj):
ids = []
args = {"limit": self.page_size}
while True:
batch = self.call(f"x:{obj}/query", args).get("ids") or []
ids.extend(batch)
if len(batch) < self.page_size:
return ids
args = {"limit": self.page_size, "anchor": batch[-1], "anchorOffset": 1}
def get_all(self, obj):
objects = []
ids = self.query_ids(obj)
for start in range(0, len(ids), self.page_size):
result = self.call(f"x:{obj}/get", {"ids": ids[start : start + self.page_size]})
objects.extend(result.get("list") or [])
return objects
def entries(value):
if isinstance(value, list):
return value
if isinstance(value, dict):
return [value[k] for k in sorted(value, key=lambda k: int(k) if k.isdigit() else k)]
return []
def as_list_map(items):
return {str(i): item for i, item in enumerate(items)}
def domain_ref(domain):
return "domain_" + "".join(c if c.isalnum() else "_" for c in domain)
class PlanBuilder:
def __init__(self, domains, existing, warn):
self.domains = domains
self.existing = existing
self.warn = warn
def domain_target(self, domain_id, address, used):
name = self.domains.get(domain_id)
if name is None:
raise ApiError(f"{address}: references unknown domain id {domain_id}")
used.add(name)
return self.existing.get(name) or ("#" + domain_ref(name))
def credentials(self, account, address):
creds = []
skipped = 0
for cred in entries(account.get("credentials")):
if cred.get("@type") != "Password":
skipped += 1
continue
secret = cred.get("secret")
if not secret or secret == MASKED:
raise ApiError(
f"{address}: the source returned a masked password; restart it with "
"STALWART_INCLUDE_SECRETS=1 in its environment"
)
out = {"@type": "Password", "secret": secret}
otp = cred.get("otpAuth")
if otp and otp != MASKED:
out["otpUrl"] = otp
for key in ("expiresAt", "allowedIps"):
if cred.get(key):
out[key] = cred[key]
creds.append(out)
if skipped:
self.warn(f"{address}: {skipped} app password(s) or API key(s) not migrated")
return creds
def account(self, account, address, used):
kind = account.get("@type")
out = {
"@type": kind,
"name": account["name"],
"domainId": self.domain_target(account["domainId"], address, used),
}
for key in COPIED_FIELDS:
if account.get(key) not in (None, {}, []):
out[key] = account[key]
if kind == "User":
creds = self.credentials(account, address)
if creds:
out["credentials"] = as_list_map(creds)
else:
self.warn(f"{address}: no password credential, the account is created without one")
roles = account.get("roles") or {}
if roles.get("@type") == "Custom":
fallback = "User" if kind == "User" else "Default"
self.warn(f"{address}: custom roles not migrated, assigned the {fallback} roles")
out["roles"] = {"@type": fallback}
elif roles:
out["roles"] = roles
permissions = account.get("permissions") or {}
if permissions.get("@type") in ("Merge", "Replace"):
self.warn(f"{address}: account-level permissions copied, check them on the target")
out["permissions"] = permissions
if kind == "User":
encryption = account.get("encryptionAtRest") or {}
if encryption.get("@type") not in (None, "Disabled"):
self.warn(f"{address}: encryption at rest not migrated, the public key must be added again")
out["encryptionAtRest"] = {"@type": "Disabled"}
if account.get("memberGroupIds"):
self.warn(f"{address}: group memberships not migrated")
if account.get("externalId"):
self.warn(f"{address}: external directory identifier not migrated")
if account.get("memberTenantId"):
self.warn(f"{address}: tenant membership not migrated")
aliases = []
for alias in entries(account.get("aliases")):
entry = {
"name": alias["name"],
"domainId": self.domain_target(alias["domainId"], address, used),
"enable": alias.get("enabled", True),
}
if alias.get("description"):
entry["description"] = alias["description"]
aliases.append(entry)
if aliases:
out["aliases"] = as_list_map(aliases)
return out
def plan_lines(accounts, builder):
used = set()
value = {}
for address, account in accounts:
value["acct_" + str(account["id"])] = builder.account(account, address, used)
ops = []
missing = sorted(d for d in used if d not in builder.existing)
if missing:
ops.append(
{
"@type": "upsert",
"object": "Domain",
"matchOn": ["name"],
"value": {domain_ref(d): {"name": d} for d in missing},
}
)
ops.append({"@type": "create", "object": "Account", "value": value})
return [json.dumps(op) for op in ops]
def write_lines(path, lines):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as out:
for line in lines:
out.write(line + "\n")
def main():
parser = argparse.ArgumentParser(
description="Build Stalwart CLI plans that recreate the accounts of a Stalwart "
"v0.16.27 server, with their password hashes, on a Stalwart v1 server.",
)
parser.add_argument("addresses", nargs="*", help="export only these accounts (default: all)")
parser.add_argument("--url", required=True, help="base URL of the v0.16 server")
parser.add_argument("--token", help="admin bearer token")
parser.add_argument("--user", help="admin username (HTTP basic auth)")
parser.add_argument("--password", help="admin password (default: $SOURCE_ADMIN_PASSWORD)")
parser.add_argument("--insecure", action="store_true", help="skip TLS verification")
parser.add_argument("--include-groups", action="store_true", help="also export groups")
parser.add_argument(
"--existing-domain",
action="append",
default=[],
metavar="NAME=ID",
help="reference a target domain by id instead of matching it by name",
)
target = parser.add_mutually_exclusive_group()
target.add_argument("--output", help="write one plan to this file (default: stdout)")
target.add_argument("--output-dir", help="write one plan per account into this directory")
args = parser.parse_args()
if not args.token and args.user is None:
parser.error("provide --token or --user")
password = args.password if args.password is not None else os.environ.get("SOURCE_ADMIN_PASSWORD")
existing = {}
for entry in args.existing_domain:
name, sep, value = entry.partition("=")
if not sep:
parser.error(f"--existing-domain expects NAME=ID, got {entry!r}")
existing[name.strip().lower()] = value.strip()
client = StalwartV016Client(
args.url, token=args.token, username=args.user, password=password, insecure=args.insecure
)
client.connect()
domains = {d["id"]: d["name"].lower() for d in client.get_all("Domain")}
kinds = {"User", "Group"} if args.include_groups else {"User"}
accounts = {}
for account in client.get_all("Account"):
if account.get("@type") not in kinds:
continue
domain = domains.get(account.get("domainId"))
if domain:
accounts[f"{account['name']}@{domain}".lower()] = account
if args.addresses:
wanted = [a.lower() for a in args.addresses]
unknown = [a for a in wanted if a not in accounts]
if unknown:
sys.stderr.write("unknown account(s): " + ", ".join(unknown) + "\n")
return 1
selected = [(a, accounts[a]) for a in wanted]
else:
selected = sorted(accounts.items())
if not selected:
sys.stderr.write("no accounts to export\n")
return 1
warnings = []
builder = PlanBuilder(domains, existing, warnings.append)
if args.output_dir:
os.makedirs(args.output_dir, mode=0o700, exist_ok=True)
for address, account in selected:
write_lines(
os.path.join(args.output_dir, address + ".ndjson"),
plan_lines([(address, account)], builder),
)
else:
lines = plan_lines(selected, builder)
if args.output:
write_lines(args.output, lines)
else:
for line in lines:
sys.stdout.write(line + "\n")
for warning in warnings:
sys.stderr.write("warning: " + warning + "\n")
sys.stderr.write(f"exported {len(selected)} account(s)\n")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except ApiError as exc:
sys.stderr.write(f"error: {exc}\n")
sys.exit(1)
+22 -10
View File
@@ -191,8 +191,13 @@ async fn handle(
match request.method.as_str() { match request.method.as_str() {
"GET" => write_response(&mut stream, 200, "OK", "ok\n").await?, "GET" => write_response(&mut stream, 200, "OK", "ok\n").await?,
_ => { _ => {
write_response(&mut stream, 405, "Method Not Allowed", "method not allowed\n") write_response(
.await? &mut stream,
405,
"Method Not Allowed",
"method not allowed\n",
)
.await?
} }
} }
return Ok(()); return Ok(());
@@ -298,6 +303,7 @@ async fn handle(
} else { } else {
match ctx.router.upsert(&id, &dest).await { match ctx.router.upsert(&id, &dest).await {
Ok(()) => { Ok(()) => {
ctx.conns.release_delay(&ctx.router.normalize(&id));
tracing::info!(identifier = %crate::observe::redact(&id), %dest, "admin upserted mapping"); tracing::info!(identifier = %crate::observe::redact(&id), %dest, "admin upserted mapping");
write_response(&mut stream, 200, "OK", "ok\n").await?; write_response(&mut stream, 200, "OK", "ok\n").await?;
} }
@@ -338,6 +344,7 @@ async fn handle(
} else { } else {
match ctx.router.remove(&id).await { match ctx.router.remove(&id).await {
Ok(existed) => { Ok(existed) => {
ctx.conns.release_delay(&ctx.router.normalize(&id));
tracing::info!(identifier = %crate::observe::redact(&id), existed, "admin removed mapping"); tracing::info!(identifier = %crate::observe::redact(&id), existed, "admin removed mapping");
let body = if existed { "removed\n" } else { "absent\n" }; let body = if existed { "removed\n" } else { "absent\n" };
write_response(&mut stream, 200, "OK", body).await?; write_response(&mut stream, 200, "OK", body).await?;
@@ -374,7 +381,7 @@ async fn handle(
tracing::info!("admin reloaded configuration"); tracing::info!("admin reloaded configuration");
let body = serde_json::json!({ let body = serde_json::json!({
"reloaded": true, "reloaded": true,
"note": "listener binds/protocols, admin bind/token, and cache capacity/TTLs require a restart to change", "note": "listener binds/protocols and admin bind/token require a restart to change",
}) })
.to_string(); .to_string();
write_json(&mut stream, 200, &body).await?; write_json(&mut stream, 200, &body).await?;
@@ -395,10 +402,12 @@ async fn handle(
match request.query_param("identifier") { match request.query_param("identifier") {
Some(id) => { Some(id) => {
ctx.router.invalidate(&id).await; ctx.router.invalidate(&id).await;
ctx.conns.release_delay(&ctx.router.normalize(&id));
tracing::info!(identifier = %crate::observe::redact(&id), "admin invalidated entry"); tracing::info!(identifier = %crate::observe::redact(&id), "admin invalidated entry");
} }
None => { None => {
ctx.router.invalidate_all(); ctx.router.invalidate_all();
ctx.conns.release_all_delays();
tracing::info!("admin invalidated entire cache"); tracing::info!("admin invalidated entire cache");
} }
} }
@@ -406,6 +415,7 @@ async fn handle(
} }
("POST", "/mappings/reload") => match ctx.router.reload().await { ("POST", "/mappings/reload") => match ctx.router.reload().await {
Ok(()) => { Ok(()) => {
ctx.conns.release_all_delays();
tracing::info!("admin reloaded mappings"); tracing::info!("admin reloaded mappings");
write_response(&mut stream, 200, "OK", "reloaded\n").await?; write_response(&mut stream, 200, "OK", "reloaded\n").await?;
} }
@@ -430,15 +440,17 @@ async fn handle(
request.query_param("identifier"), request.query_param("identifier"),
request.query_param("seconds"), request.query_param("seconds"),
) { ) {
(Some(id), Some(secs)) => match secs.parse::<u64>() { (Some(id), Some(secs)) => match secs.parse::<u64>().ok().and_then(|seconds| {
Ok(seconds) => { std::time::Instant::now()
let until = .checked_add(std::time::Duration::from_secs(seconds))
std::time::Instant::now() + std::time::Duration::from_secs(seconds); .map(|until| (seconds, until))
}) {
Some((seconds, until)) => {
ctx.conns.set_delay(&ctx.router.normalize(&id), until); ctx.conns.set_delay(&ctx.router.normalize(&id), until);
tracing::info!(identifier = %crate::observe::redact(&id), seconds, "admin set login delay"); tracing::info!(identifier = %crate::observe::redact(&id), seconds, "admin set login delay");
write_response(&mut stream, 200, "OK", "ok\n").await?; write_response(&mut stream, 200, "OK", "ok\n").await?;
} }
Err(_) => { None => {
write_response(&mut stream, 400, "Bad Request", "invalid seconds\n") write_response(&mut stream, 400, "Bad Request", "invalid seconds\n")
.await?; .await?;
} }
@@ -653,8 +665,8 @@ mod tests {
fn parse_trims_trailing_slash() { fn parse_trims_trailing_slash() {
let req = parse_request(b"GET /stats/ HTTP/1.1\r\nHost: x\r\n").unwrap(); let req = parse_request(b"GET /stats/ HTTP/1.1\r\nHost: x\r\n").unwrap();
assert_eq!(req.path, "/stats"); assert_eq!(req.path, "/stats");
let req = parse_request(b"GET /cache/invalidate/?identifier=a HTTP/1.1\r\nHost: x\r\n") let req =
.unwrap(); 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.path, "/cache/invalidate");
assert_eq!(req.query_param("identifier").as_deref(), Some("a")); 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(); let req = parse_request(b"GET / HTTP/1.1\r\nHost: x\r\n").unwrap();
+44 -2
View File
@@ -496,7 +496,8 @@ const DEFAULT_IMAP_ADVERTISE: &[&str] = &[
"UTF8=ACCEPT", "UTF8=ACCEPT",
"JMAPACCESS", "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."; const DEFAULT_IMAP_BANNER: &str = "Stalwart IMAP4rev2 at your service.";
fn default_imap_advertise() -> Vec<String> { fn default_imap_advertise() -> Vec<String> {
@@ -512,6 +513,30 @@ fn default_imap_banner() -> String {
DEFAULT_IMAP_BANNER.to_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)] #[derive(Debug, Deserialize)]
pub struct ManageSieveCapabilities { pub struct ManageSieveCapabilities {
#[serde(default = "default_sieve_ext")] #[serde(default = "default_sieve_ext")]
@@ -796,12 +821,21 @@ fn default_lockout_duration() -> Duration {
impl Config { impl Config {
pub fn parse_and_validate(raw: &str) -> Result<Self> { 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}")))?; toml::from_str(raw).map_err(|e| ProxyError::config(format!("TOML parse: {e}")))?;
config.canonicalize_sasl()?;
config.validate()?; config.validate()?;
Ok(config) 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> { fn destination(&self, id: &str) -> Result<&DestinationConfig> {
self.destination self.destination
.get(id) .get(id)
@@ -886,6 +920,14 @@ impl Config {
))); )));
} }
} }
if let Some(fallback) = &route.fallback {
let dest = self.destination(fallback)?;
if !dest.protocol.contains_key(&Protocol::Http) {
return Err(ProxyError::config(format!(
"http route fallback {fallback:?} does not declare the http protocol"
)));
}
}
if let Some(extract) = &route.extract { if let Some(extract) = &route.extract {
if extract.from == ExtractFrom::Body && extract.regex.is_none() { if extract.from == ExtractFrom::Body && extract.regex.is_none() {
return Err(ProxyError::config( return Err(ProxyError::config(
+125 -18
View File
@@ -5,6 +5,7 @@
*/ */
use std::net::{IpAddr, SocketAddr}; use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -13,7 +14,7 @@ use tokio::time::timeout;
use crate::config::{ForwardedMode, ListenerConfig, Protocol, TlsMode}; use crate::config::{ForwardedMode, ListenerConfig, Protocol, TlsMode};
use crate::error::{ProxyError, Result}; use crate::error::{ProxyError, Result};
use crate::net::BoxedStream; 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::head::{BodyFraming, Head, ParseError, ParseOutcome, error_response};
use super::router::{HttpRouter, RouteOutcome}; use super::router::{HttpRouter, RouteOutcome};
@@ -379,12 +380,55 @@ fn response_no_body(req_method: &str, head: &Head) -> bool {
matches!(head.framing, BodyFraming::None) 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 { enum Disposition {
Close, Close,
Hijack { Hijack {
backend: BoxedStream, backend: BoxedStream,
to_backend: Vec<u8>, to_backend: Vec<u8>,
to_client: Vec<u8>, to_client: Vec<u8>,
guard: Option<ConnGuard>,
}, },
} }
@@ -400,7 +444,16 @@ pub async fn handle(
local: SocketAddr, local: SocketAddr,
) -> Result<()> { ) -> Result<()> {
let bridge_idle = ctx.config.server.bridge_idle; 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) => { Ok(Disposition::Close) => {
shutdown_client(&mut stream).await; shutdown_client(&mut stream).await;
Ok(()) Ok(())
@@ -409,9 +462,18 @@ pub async fn handle(
backend, backend,
to_backend, to_backend,
to_client, to_client,
guard,
}) => { }) => {
crate::outbound::bridge(stream, backend, &to_backend, &to_client, bridge_idle, None) let cancel = guard.as_ref().map(|g| g.cancel());
.await?; crate::outbound::bridge(
stream,
backend,
&to_backend,
&to_client,
bridge_idle,
cancel,
)
.await?;
Ok(()) Ok(())
} }
Err(e) => Err(e), Err(e) => Err(e),
@@ -424,6 +486,7 @@ async fn serve(
stream: &mut BoxedStream, stream: &mut BoxedStream,
peer: SocketAddr, peer: SocketAddr,
local: SocketAddr, local: SocketAddr,
cancel: tokio::sync::watch::Sender<bool>,
) -> Result<Disposition> { ) -> Result<Disposition> {
let router = ctx.http_router.as_ref(); let router = ctx.http_router.as_ref();
let max_head = router.max_head_size; let max_head = router.max_head_size;
@@ -459,7 +522,7 @@ async fn serve(
let mut first_head = first_head; let mut first_head = first_head;
sanitize_inbound_forwarded(&mut first_head, listener); sanitize_inbound_forwarded(&mut first_head, listener);
let (buffered_body, dest_id) = match prepare( let prepared = match prepare(
ctx, ctx,
router, router,
stream, stream,
@@ -477,6 +540,13 @@ async fn serve(
} }
}; };
let mut account = Account::new(cancel);
if account.pin(ctx, prepared.identifier.as_deref()) {
crate::proto::common::hold_login(ctx, prepared.identifier.as_deref()).await;
}
let dest_id = prepared.destination(ctx).await;
let buffered_body = prepared.body;
let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) { let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) {
Ok(t) => t, Ok(t) => t,
Err(_) => { Err(_) => {
@@ -552,6 +622,7 @@ async fn serve(
backend, backend,
to_backend, to_backend,
to_client, to_client,
guard: account.into_guard(),
}); });
} }
Ok(ExchangeResult::Close) => return Ok(Disposition::Close), Ok(ExchangeResult::Close) => return Ok(Disposition::Close),
@@ -576,7 +647,7 @@ async fn serve(
let mut next = next; let mut next = next;
sanitize_inbound_forwarded(&mut next, listener); sanitize_inbound_forwarded(&mut next, listener);
let (next_body, next_dest) = match prepare( let prepared = match prepare(
ctx, ctx,
router, router,
stream, stream,
@@ -594,7 +665,14 @@ async fn serve(
} }
}; };
if next_dest != dest_id { let misdirected = !account.accepts(ctx, prepared.identifier.as_deref()) || {
if account.pin(ctx, prepared.identifier.as_deref()) {
crate::proto::common::hold_login(ctx, prepared.identifier.as_deref()).await;
}
prepared.destination(ctx).await != dest_id
};
let next_body = prepared.body;
if misdirected {
if let Some(body) = &next_body { if let Some(body) = &next_body {
drain_buffered(stream, &mut reader, &next, body.len(), idle.body) drain_buffered(stream, &mut reader, &next, body.len(), idle.body)
.await .await
@@ -891,6 +969,34 @@ async fn drain_buffered(
Ok(()) Ok(())
} }
enum Target {
Pinned(String),
Account { fallback: Option<Arc<str>> },
}
struct Prepared {
body: Option<Vec<u8>>,
target: Target,
identifier: Option<String>,
}
impl Prepared {
async fn destination(&self, ctx: &Ctx) -> String {
match &self.target {
Target::Pinned(dest_id) => dest_id.clone(),
Target::Account {
fallback: Some(fallback),
} => ctx
.resolve_or(self.identifier.as_deref(), fallback)
.await
.to_string(),
Target::Account { fallback: None } => {
ctx.resolve(self.identifier.as_deref()).await.to_string()
}
}
}
}
async fn prepare( async fn prepare(
ctx: &Ctx, ctx: &Ctx,
router: &HttpRouter, router: &HttpRouter,
@@ -899,7 +1005,7 @@ async fn prepare(
head: &mut Head, head: &mut Head,
body_cap: usize, body_cap: usize,
body_idle: Duration, body_idle: Duration,
) -> Result<Option<(Option<Vec<u8>>, String)>> { ) -> Result<Option<Prepared>> {
let idx = match router.match_index(head) { let idx = match router.match_index(head) {
Some(i) => i, Some(i) => i,
None => return Ok(None), None => return Ok(None),
@@ -915,21 +1021,22 @@ async fn prepare(
None None
}; };
let dest_id = match router.route_at(idx, head, body.as_deref()) { let (target, identifier) = match router.route_at(idx, head, body.as_deref()) {
RouteOutcome::Pin { dest_id } => dest_id, RouteOutcome::Pin { dest_id } => (Target::Pinned(dest_id), None),
RouteOutcome::Identifier { RouteOutcome::Identifier {
identifier, identifier,
fallback, fallback,
} => { } => (
if identifier.is_none() && fallback.is_none() { Target::Account { fallback },
ctx.resolve(None).await.to_string() ctx.routing_identifier(identifier),
} else { ),
ctx.resolve(identifier.as_deref()).await.to_string()
}
}
}; };
Ok(Some((body, dest_id))) Ok(Some(Prepared {
body,
target,
identifier,
}))
} }
fn reason(status: u16) -> &'static str { fn reason(status: u16) -> &'static str {
+9 -9
View File
@@ -4,9 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex; use regex::Regex;
@@ -21,7 +22,7 @@ pub enum RouteOutcome {
}, },
Identifier { Identifier {
identifier: Option<String>, identifier: Option<String>,
fallback: Option<String>, fallback: Option<Arc<str>>,
}, },
} }
@@ -35,7 +36,7 @@ struct CompiledExtract {
struct CompiledRoute { struct CompiledRoute {
destination: Option<String>, destination: Option<String>,
extract: Option<CompiledExtract>, extract: Option<CompiledExtract>,
fallback: Option<String>, fallback: Option<Arc<str>>,
needs_body: bool, needs_body: bool,
} }
@@ -144,8 +145,7 @@ impl HttpRouter {
let value = head.header("authorization")?; let value = head.header("authorization")?;
let value = value.trim(); let value = value.trim();
if let Some(rest) = strip_scheme(value, "basic") { if let Some(rest) = strip_scheme(value, "basic") {
let decoded = BASE64.decode(rest.trim()).ok()?; let text = BASE64.decode_to_string(rest.trim()).ok()?;
let text = String::from_utf8(decoded).ok()?;
let login = match text.split_once(':') { let login = match text.split_once(':') {
Some((login, _)) => login, Some((login, _)) => login,
None => text.as_str(), None => text.as_str(),
@@ -186,7 +186,7 @@ fn compile_route(route: &HttpRoute) -> Result<CompiledRoute> {
Ok(CompiledRoute { Ok(CompiledRoute {
destination: route.destination.clone(), destination: route.destination.clone(),
extract, extract,
fallback: route.fallback.clone(), fallback: route.fallback.as_deref().map(Arc::from),
needs_body, needs_body,
}) })
} }
@@ -320,7 +320,7 @@ destination = "legacy"
[[route]] [[route]]
match = "/**" match = "/**"
extract = { from = "authorization" } extract = { from = "authorization" }
fallback = "default" fallback = "legacy"
"#, "#,
); );
let head = parse(b"GET /static HTTP/1.1\r\nHost: x\r\n\r\n"); let head = parse(b"GET /static HTTP/1.1\r\nHost: x\r\n\r\n");
@@ -331,7 +331,7 @@ fallback = "default"
let head = parse(b"GET /dyn HTTP/1.1\r\nHost: x\r\n\r\n"); let head = parse(b"GET /dyn HTTP/1.1\r\nHost: x\r\n\r\n");
match route(&router, &head, None) { match route(&router, &head, None) {
RouteOutcome::Identifier { fallback, .. } => { RouteOutcome::Identifier { fallback, .. } => {
assert_eq!(fallback.as_deref(), Some("default")); assert_eq!(fallback.as_deref(), Some("legacy"));
} }
_ => panic!("expected identifier"), _ => panic!("expected identifier"),
} }
@@ -359,7 +359,7 @@ extract = { from = "authorization" }
#[test] #[test]
fn bearer_uses_bearer_identifier() { fn bearer_uses_bearer_identifier() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use encodify::base64::URL_SAFE_NO_PAD;
let router = router_from_toml( let router = router_from_toml(
r#" r#"
[[route]] [[route]]
+2 -2
View File
@@ -190,7 +190,7 @@ pub async fn write_all(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> {
const BRIDGE_BUF: usize = 16 * 1024; 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( pub async fn bridge(
mut client: BoxedStream, 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 { match cancel {
Some(mut rx) => { Some(mut rx) => {
while rx.changed().await.is_ok() { while rx.changed().await.is_ok() {
+398 -45
View File
@@ -23,7 +23,7 @@ use crate::error::{ProxyError, Result};
use crate::imap_receiver::Mechanism; use crate::imap_receiver::Mechanism;
use crate::net::BoxedStream; use crate::net::BoxedStream;
use crate::net::tls; use crate::net::tls;
use crate::sasl::Credential; use crate::sasl::{Credential, SASL_RESPONSE};
pub struct Handoff { pub struct Handoff {
pub stream: BoxedStream, pub stream: BoxedStream,
@@ -33,6 +33,22 @@ pub struct Handoff {
pub residual: Vec<u8>, 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> { pub fn credential_from_sasl(mech: &Mechanism, raw: &[u8]) -> Option<Credential> {
match mech { match mech {
Mechanism::Plain => Credential::decode_sasl_challenge_plain(raw), Mechanism::Plain => Credential::decode_sasl_challenge_plain(raw),
@@ -94,14 +110,41 @@ 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> { pub fn advertised_sasl(sasl: &[String], plain_ok: bool) -> Vec<&str> {
sasl.iter() sasl.iter()
.filter(|m| !m.eq_ignore_ascii_case("LOGIN")) .filter(|m| plain_ok || !is_cleartext_mechanism(m))
.filter(|m| plain_ok || !m.eq_ignore_ascii_case("PLAIN"))
.map(|s| s.as_str()) .map(|s| s.as_str())
.collect() .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 mut raw = Vec::with_capacity(
SASL_RESPONSE.decoded_len_estimate(username_b64.len())
+ SASL_RESPONSE.decoded_len_estimate(password_b64.len())
+ 2,
);
raw.push(0);
SASL_RESPONSE.decode_append(username_b64, &mut raw).ok()?;
raw.push(0);
SASL_RESPONSE.decode_append(password_b64, &mut raw).ok()?;
Some(raw)
}
pub fn advertises_token(haystack: &[u8], token: &str) -> bool { pub fn advertises_token(haystack: &[u8], token: &str) -> bool {
haystack haystack
.split(|b| *b == b'\n' || *b == b'\r') .split(|b| *b == b'\n' || *b == b'\r')
@@ -252,16 +295,22 @@ pub struct HealthSnapshot {
} }
const REGISTRY_SHARDS: usize = 32; const REGISTRY_SHARDS: usize = 32;
const MAX_LOGIN_HOLD: Duration = Duration::from_secs(120);
struct LiveConn { struct LiveConn {
id: u64, id: u64,
cancel: tokio::sync::watch::Sender<bool>, cancel: tokio::sync::watch::Sender<bool>,
} }
struct LoginDelay {
until: Instant,
changed: tokio::sync::watch::Sender<()>,
}
#[derive(Default)] #[derive(Default)]
struct Shard { struct Shard {
live: HashMap<Arc<str>, Vec<LiveConn>>, live: HashMap<Arc<str>, Vec<LiveConn>>,
delays: HashMap<Arc<str>, Instant>, delays: HashMap<Arc<str>, LoginDelay>,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -301,9 +350,27 @@ impl ConnRegistry {
} }
pub fn register(&self, identifier: &str) -> ConnGuard { 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); static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let id = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); 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 key: Arc<str> = Arc::from(identifier);
let shard_idx = self.shard_idx(&key); let shard_idx = self.shard_idx(&key);
self.shards[shard_idx] self.shards[shard_idx]
@@ -337,21 +404,64 @@ impl ConnRegistry {
pub fn set_delay(&self, identifier: &str, until: Instant) { pub fn set_delay(&self, identifier: &str, until: Instant) {
let mut shard = self.shards[self.shard_idx(identifier)].lock(); let mut shard = self.shards[self.shard_idx(identifier)].lock();
let now = Instant::now(); let now = Instant::now();
shard.delays.retain(|_, &mut u| u > now); shard.delays.retain(|_, delay| delay.until > now);
shard.delays.insert(Arc::from(identifier), until); match shard.delays.get_mut(identifier) {
Some(delay) => {
delay.until = until;
delay.changed.send_replace(());
}
None => {
shard.delays.insert(
Arc::from(identifier),
LoginDelay {
until,
changed: tokio::sync::watch::Sender::new(()),
},
);
}
}
} }
pub fn delay_remaining(&self, identifier: &str) -> Option<Duration> { fn delay(&self, identifier: &str) -> Option<(Instant, tokio::sync::watch::Receiver<()>)> {
let mut shard = self.shards[self.shard_idx(identifier)].lock(); let mut shard = self.shards[self.shard_idx(identifier)].lock();
if let Some(&until) = shard.delays.get(identifier) { let delay = shard.delays.get(identifier)?;
let now = Instant::now(); if Instant::now() < delay.until {
if now < until { return Some((delay.until, delay.changed.subscribe()));
return Some(until - now);
}
shard.delays.remove(identifier);
} }
shard.delays.remove(identifier);
None None
} }
pub fn release_delay(&self, identifier: &str) {
self.shards[self.shard_idx(identifier)]
.lock()
.delays
.remove(identifier);
}
pub fn release_all_delays(&self) {
for shard in self.shards.iter() {
shard.lock().delays.clear();
}
}
pub async fn hold_login(&self, identifier: &str) {
let deadline = Instant::now() + MAX_LOGIN_HOLD;
while let Some((until, mut changed)) = self.delay(identifier) {
let wake = until.min(deadline);
if Instant::now() >= wake {
break;
}
tracing::debug!(
hold_ms = wake.saturating_duration_since(Instant::now()).as_millis() as u64,
"holding login (delay_until)"
);
tokio::select! {
_ = tokio::time::sleep_until(wake.into()) => {}
_ = changed.changed() => {}
}
}
}
} }
impl ConnGuard { impl ConnGuard {
@@ -484,15 +594,29 @@ impl Ctx {
self.router.resolve(identifier).await self.router.resolve(identifier).await
} }
pub async fn resolve_or(&self, identifier: Option<&str>, default: &Arc<str>) -> Arc<str> {
self.router.resolve_or(identifier, default).await
}
pub fn register_conn(&self, identifier: &str) -> ConnGuard { pub fn register_conn(&self, identifier: &str) -> ConnGuard {
self.conns.register(&self.router.normalize(identifier)) 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> { pub fn routing_identifier(&self, identifier: Option<String>) -> Option<String> {
identifier.map(|id| { let mut id = identifier?;
crate::route::master_account(&id, &self.config.routing.master_user_separators) let account =
.to_string() 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 { pub fn hide_auth_errors(&self, dest_id: &str) -> bool {
@@ -586,15 +710,57 @@ where
} }
pub async fn hold_login(ctx: &Ctx, identifier: Option<&str>) { pub async fn hold_login(ctx: &Ctx, identifier: Option<&str>) {
if let Some(id) = identifier if let Some(id) = identifier {
&& let Some(remaining) = ctx.conns.delay_remaining(&ctx.router.normalize(id)) ctx.conns.hold_login(&ctx.router.normalize(id)).await;
{ }
let capped = remaining.min(Duration::from_secs(120)); }
tracing::debug!(
hold_ms = capped.as_millis() as u64, pub struct BackendLeg {
"holding login (delay_until)" pub stream: BoxedStream,
); pub residual: Vec<u8>,
tokio::time::sleep(capped).await; 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),
}
} }
} }
@@ -605,6 +771,16 @@ pub enum AuthWire {
ManageSieve, 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 { enum AuthLine {
Continuation, Continuation,
Passthrough, Passthrough,
@@ -690,6 +866,7 @@ pub async fn complete_backend_auth(
backend: &mut BoxedStream, backend: &mut BoxedStream,
residual: &mut Vec<u8>, residual: &mut Vec<u8>,
wire: AuthWire, wire: AuthWire,
mut pending_response: Option<&[u8]>,
hide: bool, hide: bool,
max: usize, max: usize,
) -> Result<bool> { ) -> Result<bool> {
@@ -698,7 +875,8 @@ pub async fn complete_backend_auth(
let line = crate::outbound::read_line(backend, residual, max).await?; let line = crate::outbound::read_line(backend, residual, max).await?;
match classify_auth_line(&line, &wire) { match classify_auth_line(&line, &wire) {
AuthLine::Continuation => { 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 => { AuthLine::Passthrough => {
client_out.extend_from_slice(&line); client_out.extend_from_slice(&line);
@@ -734,18 +912,18 @@ pub async fn bridge_authenticated<F, Fut>(
mut stream: BoxedStream, mut stream: BoxedStream,
peer: SocketAddr, peer: SocketAddr,
identifier: Option<&str>, identifier: Option<&str>,
client_residual: &[u8], client_residual: Vec<u8>,
started: Instant, started: Instant,
label: &'static str, label: &'static str,
wire: AuthWire, wire: AuthWire,
auth_frame: &[u8], auth: &AuthFrame,
unavailable: &[u8], unavailable: &[u8],
max: usize, max: usize,
open: F, open: F,
) -> Result<()> ) -> Result<Option<Resume>>
where where
F: FnMut() -> Fut, F: FnMut() -> Fut,
Fut: Future<Output = Result<(BoxedStream, Vec<u8>)>>, Fut: Future<Output = Result<BackendLeg>>,
{ {
if let Some(id) = identifier if let Some(id) = identifier
&& !crate::token::valid_routing_identifier(id) && !crate::token::valid_routing_identifier(id)
@@ -756,18 +934,24 @@ where
"rejecting login: routing identifier contains invalid characters" "rejecting login: routing identifier contains invalid characters"
); );
let _ = write(&mut stream, unavailable).await; 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, Ok(b) => b,
Err(e) => { Err(e) => {
tracing::warn!(error = %e, protocol = label, destination = %dest_id, "backend unavailable"); tracing::warn!(error = %e, protocol = label, destination = %dest_id, "backend unavailable");
let _ = write(&mut stream, unavailable).await; 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?; crate::outbound::write_all(&mut backend, auth_frame).await?;
let hide = ctx.hide_auth_errors(dest_id); let hide = ctx.hide_auth_errors(dest_id);
@@ -778,6 +962,7 @@ where
&mut backend, &mut backend,
&mut backend_residual, &mut backend_residual,
wire, wire,
pending_response,
hide, hide,
max, max,
), ),
@@ -790,16 +975,26 @@ where
} }
}; };
let guard = if auth_ok { if !auth_ok {
identifier.map(|id| ctx.register_conn(id)) tracing::debug!(
} else { protocol = label,
None 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 cancel = guard.as_ref().map(|g| g.cancel());
let (to_backend, to_client) = crate::outbound::bridge( let (to_backend, to_client) = crate::outbound::bridge(
stream, stream,
backend, backend,
client_residual, &client_residual,
&backend_residual, &backend_residual,
ctx.config.server.bridge_idle, ctx.config.server.bridge_idle,
cancel, cancel,
@@ -813,7 +1008,7 @@ where
secs = started.elapsed().as_secs_f64(), secs = started.elapsed().as_secs_f64(),
"session closed" "session closed"
); );
Ok(()) Ok(None)
} }
pub async fn write(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> { pub async fn write(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> {
@@ -885,6 +1080,37 @@ pub async fn read_crlf_line(
mod tests { mod tests {
use super::*; 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] #[test]
fn advertises_token_handles_smtp_ehlo_continuation_lines() { 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"; let ehlo = b"250-mail.example\r\n250-PIPELINING\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 CHUNKING\r\n";
@@ -904,6 +1130,61 @@ mod tests {
assert!(advertises_token(pop3, "XCLIENT")); 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 encodify::base64::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] #[test]
fn smtp_auth_line_classification() { fn smtp_auth_line_classification() {
assert!(matches!( assert!(matches!(
@@ -1132,14 +1413,86 @@ mod tests {
assert_eq!(reg.kick("user@example.com"), 0); assert_eq!(reg.kick("user@example.com"), 0);
} }
#[tokio::test]
async fn conn_registry_release_ends_hold() {
// releasing a delay wakes a held login immediately
let reg = ConnRegistry::default();
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(60));
let held = reg.clone();
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!hold.is_finished());
reg.release_delay("user@example.com");
tokio::time::timeout(Duration::from_secs(2), hold)
.await
.expect("release did not end the hold")
.unwrap();
assert!(reg.delay("user@example.com").is_none());
}
#[tokio::test]
async fn conn_registry_release_all_ends_holds() {
// releasing every delay wakes all held logins
let reg = ConnRegistry::default();
let until = Instant::now() + Duration::from_secs(60);
reg.set_delay("a@example.com", until);
reg.set_delay("b@example.com", until);
let holds = ["a@example.com", "b@example.com"].map(|id| {
let held = reg.clone();
tokio::spawn(async move { held.hold_login(id).await })
});
tokio::time::sleep(Duration::from_millis(50)).await;
reg.release_all_delays();
for hold in holds {
tokio::time::timeout(Duration::from_secs(2), hold)
.await
.expect("release did not end the hold")
.unwrap();
}
}
#[tokio::test]
async fn conn_registry_shortened_delay_ends_hold() {
// shortening a delay wakes a held login at the new deadline
let reg = ConnRegistry::default();
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(60));
let held = reg.clone();
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
tokio::time::sleep(Duration::from_millis(50)).await;
reg.set_delay("user@example.com", Instant::now());
tokio::time::timeout(Duration::from_secs(2), hold)
.await
.expect("shortened delay did not end the hold")
.unwrap();
}
#[tokio::test]
async fn conn_registry_extended_delay_keeps_hold() {
// extending a delay keeps a held login waiting past the original deadline
let reg = ConnRegistry::default();
reg.set_delay(
"user@example.com",
Instant::now() + Duration::from_millis(100),
);
let held = reg.clone();
let started = Instant::now();
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
reg.set_delay(
"user@example.com",
Instant::now() + Duration::from_millis(400),
);
hold.await.unwrap();
assert!(started.elapsed() >= Duration::from_millis(350));
}
#[test] #[test]
fn conn_registry_delay_window() { fn conn_registry_delay_window() {
let reg = ConnRegistry::default(); let reg = ConnRegistry::default();
assert!(reg.delay_remaining("user@example.com").is_none()); assert!(reg.delay("user@example.com").is_none());
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(30)); reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(30));
assert!(reg.delay_remaining("user@example.com").is_some()); assert!(reg.delay("user@example.com").is_some());
reg.set_delay("user@example.com", Instant::now()); reg.set_delay("user@example.com", Instant::now());
assert!(reg.delay_remaining("user@example.com").is_none()); assert!(reg.delay("user@example.com").is_none());
} }
#[test] #[test]
+216 -101
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::time::timeout; use tokio::time::timeout;
@@ -19,7 +19,7 @@ use crate::proto::common::{
self, Ctx, assert_egress_encrypted, mechanism_name, plain_auth_allowed, read_crlf_line, write, self, Ctx, assert_egress_encrypted, mechanism_name, plain_auth_allowed, read_crlf_line, write,
}; };
use crate::proto::forward; use crate::proto::forward;
use crate::sasl::decode_sasl_ir; use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential; use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024; const MAX_PREAUTH: usize = 64 * 1024;
@@ -39,68 +39,101 @@ enum Decision {
pub async fn handle( pub async fn handle(
ctx: &Ctx, ctx: &Ctx,
listener: &ListenerConfig, listener: &ListenerConfig,
stream: BoxedStream, mut stream: BoxedStream,
peer: SocketAddr, peer: SocketAddr,
local: SocketAddr, local: SocketAddr,
) -> Result<()> { ) -> Result<()> {
let caps = &ctx.config.capabilities.imap; let caps = &ctx.config.capabilities.imap;
let starttls_listener = listener.tls == TlsMode::Starttls;
let decision = match timeout( let is_tls = stream.is_tls();
listener.preauth_timeout, let greeting = format!(
preauth(ctx, listener, caps, stream), "* OK [CAPABILITY {}] {}\r\n",
) ctx.greetings.imap.get(is_tls, starttls_listener && !is_tls),
.await caps.banner
{
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 unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n"); write(&mut stream, greeting.as_bytes()).await?;
common::bridge_authenticated( let mut seed = Vec::new();
ctx, let mut auth_failures = 0u32;
&dest_id,
stream, loop {
peer, let decision = match timeout(
identifier.as_deref(), listener.preauth_timeout,
&residual, preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
started, )
"imap", .await
common::AuthWire::Imap { tag }, {
auth_frame.as_bytes(), Ok(Ok(d)) => d,
unavailable.as_bytes(), Ok(Err(e)) => return Err(e),
MAX_PREAUTH, Err(_) => return Ok(()),
|| open_backend(ctx, &dest_id, &fwd), };
)
.await let Decision::Authenticate {
stream: client,
mech,
tag,
raw_challenge,
identifier,
residual,
} = decision
else {
return Ok(());
};
let identifier = ctx.routing_identifier(identifier);
let started = std::time::Instant::now();
common::hold_login(ctx, identifier.as_deref()).await;
let dest_id = ctx.resolve(identifier.as_deref()).await;
tracing::debug!(destination = %dest_id, "imap routing decision");
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 mut response = String::with_capacity(BASE64.encoded_len(raw_challenge.len()) + 2);
BASE64.encode_append(&raw_challenge, &mut response);
response.push_str("\r\n");
let auth = common::AuthFrame::inline(
format!("{tag} AUTHENTICATE {mech_name} {response}").into_bytes(),
)
.with_deferred(
format!("{tag} AUTHENTICATE {mech_name}\r\n").into_bytes(),
response.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( async fn preauth(
@@ -108,6 +141,8 @@ async fn preauth(
listener: &ListenerConfig, listener: &ListenerConfig,
caps: &ImapCapabilities, caps: &ImapCapabilities,
mut stream: BoxedStream, mut stream: BoxedStream,
seed: Vec<u8>,
auth_failures: &mut u32,
) -> Result<Decision> { ) -> Result<Decision> {
let starttls_listener = listener.tls == TlsMode::Starttls; let starttls_listener = listener.tls == TlsMode::Starttls;
let mut auth = Auth { let mut auth = Auth {
@@ -115,31 +150,29 @@ async fn preauth(
caps, caps,
is_tls: stream.is_tls(), is_tls: stream.is_tls(),
max_attempts: listener.max_auth_attempts, 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(); let mut receiver = Receiver::<Command>::new();
receiver.max_request_size = MAX_PREAUTH; receiver.max_request_size = MAX_PREAUTH;
let mut read = [0u8; 8192]; let mut read = [0u8; 8192];
let mut pending = seed;
loop { loop {
let n = stream let chunk: &[u8] = if pending.is_empty() {
.read(&mut read) let n = stream
.await .read(&mut read)
.map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?; .await
if n == 0 { .map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?;
return Ok(Decision::Closed); if n == 0 {
} return Ok(Decision::Closed);
let mut it = read[..n].iter(); }
&read[..n]
} else {
&pending
};
let mut it = chunk.iter();
let mut carry: Option<Vec<u8>> = None;
loop { loop {
match receiver.parse(&mut it) { match receiver.parse(&mut it) {
@@ -230,6 +263,10 @@ async fn preauth(
residual, residual,
}); });
} }
Step::Resume { residual } => {
carry = Some(residual);
break;
}
Step::Continue => {} Step::Continue => {}
Step::Close => return Ok(Decision::Closed), Step::Close => return Ok(Decision::Closed),
} }
@@ -251,6 +288,10 @@ async fn preauth(
residual, residual,
}); });
} }
Step::Resume { residual } => {
carry = Some(residual);
break;
}
Step::Continue => {} Step::Continue => {}
Step::Close => return Ok(Decision::Closed), Step::Close => return Ok(Decision::Closed),
} }
@@ -276,13 +317,17 @@ async fn preauth(
} }
Err(RecvError::Parse { message }) => { Err(RecvError::Parse { message }) => {
write(&mut stream, format!("* BAD {message}\r\n").as_bytes()).await?; write(&mut stream, format!("* BAD {message}\r\n").as_bytes()).await?;
auth.auth_failures += 1; if common::bump(auth.auth_failures, auth.max_attempts) {
if auth.auth_failures >= auth.max_attempts {
return Ok(Decision::Closed); return Ok(Decision::Closed);
} }
} }
} }
} }
drop(it);
match carry {
Some(rest) => pending = rest,
None => pending.clear(),
}
} }
} }
@@ -291,11 +336,14 @@ struct Auth<'a> {
caps: &'a ImapCapabilities, caps: &'a ImapCapabilities,
is_tls: bool, is_tls: bool,
max_attempts: u32, max_attempts: u32,
auth_failures: u32, auth_failures: &'a mut u32,
} }
enum Step { enum Step {
Continue, Continue,
Resume {
residual: Vec<u8>,
},
Close, Close,
Finalize { Finalize {
mech: Mechanism, mech: Mechanism,
@@ -325,19 +373,15 @@ impl Auth<'_> {
match mech { match mech {
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {} Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {}
Mechanism::Login => { Mechanism::Login if common::sasl_offered(&self.caps.sasl, "LOGIN") => {}
_ => {
return self return self
.reject(stream, tag, "AUTH LOGIN is not supported.") .reject(stream, tag, "Unsupported authentication mechanism.", None)
.await;
}
Mechanism::Other(_) => {
return self
.reject(stream, tag, "Unsupported authentication mechanism.")
.await; .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) && !plain_auth_allowed(self.caps.allow_plain_auth_without_tls, self.is_tls)
{ {
write( write(
@@ -349,23 +393,83 @@ impl Auth<'_> {
return Ok(Step::Continue); 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) { 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 { } else {
write(&mut *stream, b"+ \r\n").await?; 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? read_crlf_line(stream, seed, MAX_PREAUTH).await?
}; };
let Some(raw) = decode_sasl_ir(&raw_b64) else { let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
return self return self
.reject(stream, tag, "Invalid base64 in SASL response.") .reject(
stream,
tag,
"Invalid base64 in SASL response.",
Some(residual),
)
.await; .await;
}; };
self.finish(stream, mech, tag, raw, 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( async fn login(
&mut self, &mut self,
stream: &mut BoxedStream, stream: &mut BoxedStream,
@@ -383,7 +487,7 @@ impl Auth<'_> {
} }
let (Some(user), Some(pass)) = (tokens.first(), tokens.get(1)) else { let (Some(user), Some(pass)) = (tokens.first(), tokens.get(1)) else {
return self return self
.reject(stream, tag, "LOGIN requires a username and password.") .reject(stream, tag, "LOGIN requires a username and password.", None)
.await; .await;
}; };
let (user, pass) = (user.as_bytes(), pass.as_bytes()); let (user, pass) = (user.as_bytes(), pass.as_bytes());
@@ -392,7 +496,7 @@ impl Auth<'_> {
raw.extend_from_slice(user); raw.extend_from_slice(user);
raw.push(0); raw.push(0);
raw.extend_from_slice(pass); 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) self.finish(stream, Mechanism::Plain, tag, raw, residual)
.await .await
@@ -407,7 +511,9 @@ impl Auth<'_> {
residual: Vec<u8>, residual: Vec<u8>,
) -> Result<Step> { ) -> Result<Step> {
let Some(cred) = common::credential_from_sasl(&mech, &raw) else { 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 = let identifier =
identifier_from_credential(&cred, &self.ctx.config.oauth.jwt_username_claim); identifier_from_credential(&cred, &self.ctx.config.oauth.jwt_username_claim);
@@ -420,14 +526,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?; write(&mut *stream, format!("{tag} NO {message}\r\n").as_bytes()).await?;
self.auth_failures += 1; if common::bump(self.auth_failures, self.max_attempts) {
if self.auth_failures >= self.max_attempts { return Ok(Step::Close);
Ok(Step::Close)
} else {
Ok(Step::Continue)
} }
Ok(match residual {
Some(residual) => Step::Resume { residual },
None => Step::Continue,
})
} }
} }
@@ -435,7 +548,7 @@ async fn open_backend(
ctx: &Ctx, ctx: &Ctx,
dest_id: &str, dest_id: &str,
fwd: &forward::ForwardInfo, fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> { ) -> Result<common::BackendLeg> {
let common::DialedEndpoint { let common::DialedEndpoint {
dest, dest,
ep, ep,
@@ -448,6 +561,7 @@ async fn open_backend(
let mut residual = Vec::new(); let mut residual = Vec::new();
let banner = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?; 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 ep.tls == TlsMode::Starttls {
if banner.contains(&b'[') && !common::advertises_token(&banner, "STARTTLS") { if banner.contains(&b'[') && !common::advertises_token(&banner, "STARTTLS") {
@@ -458,6 +572,7 @@ async fn open_backend(
if !tagged_ok(&resp, b"P1") { if !tagged_ok(&resp, b"P1") {
return Err(ProxyError::backend("backend refused STARTTLS")); return Err(ProxyError::backend("backend refused STARTTLS"));
} }
sasl_ir = common::advertises_token(&resp, "SASL-IR");
backend = outbound::tls_connect(backend, &tls_cfg, dest).await?; backend = outbound::tls_connect(backend, &tls_cfg, dest).await?;
} }
@@ -467,7 +582,7 @@ async fn open_backend(
forward::send_imap_id(&mut backend, &mut residual, &banner, fwd, MAX_PREAUTH).await?; 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 { fn tagged_ok(line: &[u8], tag: &[u8]) -> bool {
+251 -95
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::AsyncReadExt; use tokio::io::AsyncReadExt;
use tokio::time::timeout; use tokio::time::timeout;
@@ -16,11 +16,11 @@ use crate::imap_receiver::{Command, Error as RecvError, Mechanism, Receiver, Sta
use crate::net::BoxedStream; use crate::net::BoxedStream;
use crate::outbound; use crate::outbound;
use crate::proto::common::{ 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, plain_auth_allowed, write,
}; };
use crate::proto::forward; use crate::proto::forward;
use crate::sasl::decode_sasl_ir; use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential; use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024; const MAX_PREAUTH: usize = 64 * 1024;
@@ -28,77 +28,14 @@ const MAX_PREAUTH: usize = 64 * 1024;
pub async fn handle( pub async fn handle(
ctx: &Ctx, ctx: &Ctx,
listener: &ListenerConfig, listener: &ListenerConfig,
stream: BoxedStream, mut stream: BoxedStream,
peer: SocketAddr, peer: SocketAddr,
local: SocketAddr, local: SocketAddr,
) -> Result<()> { ) -> Result<()> {
let caps = &ctx.config.capabilities.managesieve; 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 starttls_listener = listener.tls == TlsMode::Starttls;
let is_tls = stream.is_tls();
write( write(
&mut stream, &mut stream,
ctx.greetings ctx.greetings
@@ -108,21 +45,111 @@ async fn preauth(
) )
.await?; .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 started = std::time::Instant::now();
common::hold_login(ctx, identifier.as_deref()).await;
let dest_id = ctx.resolve(identifier.as_deref()).await;
let fwd = forward::ForwardInfo::new(
peer,
local,
client.is_tls(),
None,
ctx.config.server.proxy_ttl,
);
let mut frame = format!("AUTHENTICATE \"{}\" \"", mechanism_name(&mech));
BASE64.encode_append(&raw_challenge, &mut frame);
frame.push_str("\"\r\n");
let auth = common::AuthFrame::inline(frame.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 = let mut receiver =
Receiver::<Command>::new().with_start_state(State::Command { is_uid: false }); Receiver::<Command>::new().with_start_state(State::Command { is_uid: false });
receiver.max_request_size = MAX_PREAUTH; receiver.max_request_size = MAX_PREAUTH;
let mut auth_failures = 0u32;
let mut read = [0u8; 8192]; let mut read = [0u8; 8192];
let mut pending = seed;
loop { loop {
let n = stream let chunk: &[u8] = if pending.is_empty() {
.read(&mut read) let n = stream
.await .read(&mut read)
.map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?; .await
if n == 0 { .map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?;
return Ok(None); if n == 0 {
} return Ok(None);
let mut it = read[..n].iter(); }
&read[..n]
} else {
&pending
};
let mut it = chunk.iter();
let mut carry: Option<Vec<u8>> = None;
loop { loop {
match receiver.parse(&mut it) { match receiver.parse(&mut it) {
@@ -173,9 +200,10 @@ async fn preauth(
ctx, ctx,
&mut stream, &mut stream,
plain_ok, plain_ok,
common::sasl_offered(&caps.sasl, "LOGIN"),
&req.tokens, &req.tokens,
&mut it, &mut it,
&mut auth_failures, &mut *auth_failures,
listener.max_auth_attempts, listener.max_auth_attempts,
) )
.await? .await?
@@ -194,6 +222,10 @@ async fn preauth(
residual, residual,
})); }));
} }
AuthStep::Resume { residual } => {
carry = Some(residual);
break;
}
AuthStep::Continue => {} AuthStep::Continue => {}
AuthStep::Close => return Ok(None), AuthStep::Close => return Ok(None),
} }
@@ -218,13 +250,17 @@ async fn preauth(
format!("NO \"{}\"\r\n", sanitize(&message)).as_bytes(), format!("NO \"{}\"\r\n", sanitize(&message)).as_bytes(),
) )
.await?; .await?;
auth_failures += 1; if bump(auth_failures, listener.max_auth_attempts) {
if auth_failures >= listener.max_auth_attempts {
return Ok(None); return Ok(None);
} }
} }
} }
} }
drop(it);
match carry {
Some(rest) => pending = rest,
None => pending.clear(),
}
} }
} }
@@ -236,13 +272,18 @@ enum AuthStep {
residual: Vec<u8>, residual: Vec<u8>,
}, },
Continue, Continue,
Resume {
residual: Vec<u8>,
},
Close, Close,
} }
#[allow(clippy::too_many_arguments)]
async fn handle_authenticate( async fn handle_authenticate(
ctx: &Ctx, ctx: &Ctx,
stream: &mut BoxedStream, stream: &mut BoxedStream,
plain_ok: bool, plain_ok: bool,
login_offered: bool,
tokens: &[crate::imap_receiver::Token], tokens: &[crate::imap_receiver::Token],
it: &mut std::slice::Iter<'_, u8>, it: &mut std::slice::Iter<'_, u8>,
auth_failures: &mut u32, auth_failures: &mut u32,
@@ -255,9 +296,19 @@ async fn handle_authenticate(
let mech = Mechanism::parse(mech_tok.as_bytes()); let mech = Mechanism::parse(mech_tok.as_bytes());
match mech { match mech {
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {} 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( write(
&mut *stream, &mut *stream,
b"NO \"Cleartext authentication requires TLS\"\r\n", b"NO \"Cleartext authentication requires TLS\"\r\n",
@@ -266,19 +317,37 @@ async fn handle_authenticate(
return Ok(AuthStep::Continue); 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) { 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 { } else {
write(&mut *stream, b"{0}\r\n").await?; 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? read_sasl_response(stream, seed).await?
}; };
let Some(raw) = decode_sasl_ir(&raw_b64) else { let Ok(raw) = SASL_RESPONSE.decode(&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 { 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); let identifier = identifier_from_credential(&cred, &ctx.config.oauth.jwt_username_claim);
@@ -290,19 +359,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( async fn fail(
stream: &mut BoxedStream, stream: &mut BoxedStream,
message: &str, message: &str,
auth_failures: &mut u32, auth_failures: &mut u32,
max_attempts: u32, max_attempts: u32,
residual: Option<Vec<u8>>,
) -> Result<AuthStep> { ) -> Result<AuthStep> {
write(&mut *stream, format!("NO \"{message}\"\r\n").as_bytes()).await?; write(&mut *stream, format!("NO \"{message}\"\r\n").as_bytes()).await?;
*auth_failures += 1; if bump(auth_failures, max_attempts) {
if *auth_failures >= max_attempts { return Ok(AuthStep::Close);
Ok(AuthStep::Close)
} else {
Ok(AuthStep::Continue)
} }
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>)> { async fn read_sasl_response(stream: &mut BoxedStream, seed: Vec<u8>) -> Result<(Vec<u8>, Vec<u8>)> {
@@ -325,7 +473,15 @@ async fn read_sasl_response(stream: &mut BoxedStream, seed: Vec<u8>) -> Result<(
} }
Ok((data, rest)) Ok((data, rest))
} else { } 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 +547,7 @@ async fn open_backend(
ctx: &Ctx, ctx: &Ctx,
dest_id: &str, dest_id: &str,
fwd: &forward::ForwardInfo, fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> { ) -> Result<common::BackendLeg> {
let common::DialedEndpoint { let common::DialedEndpoint {
dest, dest,
ep, ep,
@@ -432,7 +588,7 @@ async fn open_backend(
.await?; .await?;
} }
Ok((backend, residual)) Ok(common::BackendLeg::new(backend, residual))
} }
async fn read_capabilities(stream: &mut BoxedStream, residual: &mut Vec<u8>) -> Result<Vec<u8>> { async fn read_capabilities(stream: &mut BoxedStream, residual: &mut Vec<u8>) -> Result<Vec<u8>> {
+146 -70
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::time::timeout; use tokio::time::timeout;
use crate::config::{Forwarding, ListenerConfig, Pop3Capabilities, Protocol, TlsMode}; use crate::config::{Forwarding, ListenerConfig, Pop3Capabilities, Protocol, TlsMode};
@@ -15,11 +15,11 @@ use crate::imap_receiver::Mechanism;
use crate::net::BoxedStream; use crate::net::BoxedStream;
use crate::outbound; use crate::outbound;
use crate::proto::common::{ 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, plain_auth_allowed, write,
}; };
use crate::proto::forward; use crate::proto::forward;
use crate::sasl::decode_sasl_ir; use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential; use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024; const MAX_PREAUTH: usize = 64 * 1024;
@@ -27,66 +27,85 @@ const MAX_PREAUTH: usize = 64 * 1024;
pub async fn handle( pub async fn handle(
ctx: &Ctx, ctx: &Ctx,
listener: &ListenerConfig, listener: &ListenerConfig,
stream: BoxedStream, mut stream: BoxedStream,
peer: SocketAddr, peer: SocketAddr,
local: SocketAddr, local: SocketAddr,
) -> Result<()> { ) -> Result<()> {
let caps = &ctx.config.capabilities.pop3; let caps = &ctx.config.capabilities.pop3;
let handoff = match timeout( write(&mut stream, format!("+OK {}\r\n", caps.banner).as_bytes()).await?;
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 { let mut seed = Vec::with_capacity(1024);
stream, let mut auth_failures = 0u32;
mech,
raw_challenge,
identifier,
residual,
} = handoff;
let identifier = ctx.routing_identifier(identifier); loop {
let dest_id = ctx.resolve(identifier.as_deref()).await; let handoff = match timeout(
let started = std::time::Instant::now(); listener.preauth_timeout,
common::hold_login(ctx, identifier.as_deref()).await; preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
let fwd = forward::ForwardInfo::new( )
peer, .await
local, {
stream.is_tls(), Ok(Ok(Some(h))) => h,
None, Ok(Ok(None)) => return Ok(()),
ctx.config.server.proxy_ttl, Ok(Err(e)) => return Err(e),
); Err(_) => return Ok(()),
};
let auth_frame = format!( let Handoff {
"AUTH {} {}\r\n", stream: client,
mechanism_name(&mech), mech,
BASE64.encode(&raw_challenge) raw_challenge,
); identifier,
residual,
} = handoff;
common::bridge_authenticated( let identifier = ctx.routing_identifier(identifier);
ctx, let started = std::time::Instant::now();
&dest_id, common::hold_login(ctx, identifier.as_deref()).await;
stream, let dest_id = ctx.resolve(identifier.as_deref()).await;
peer, let fwd = forward::ForwardInfo::new(
identifier.as_deref(), peer,
&residual, local,
started, client.is_tls(),
"pop3", None,
common::AuthWire::Pop3, ctx.config.server.proxy_ttl,
auth_frame.as_bytes(), );
b"-ERR backend unavailable\r\n",
MAX_PREAUTH, let mut frame = format!("AUTH {} ", mechanism_name(&mech));
|| open_backend(ctx, &dest_id, &fwd), BASE64.encode_append(&raw_challenge, &mut frame);
) frame.push_str("\r\n");
.await let auth = common::AuthFrame::inline(frame.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( async fn preauth(
@@ -94,15 +113,14 @@ async fn preauth(
listener: &ListenerConfig, listener: &ListenerConfig,
caps: &Pop3Capabilities, caps: &Pop3Capabilities,
mut stream: BoxedStream, mut stream: BoxedStream,
seed: Vec<u8>,
auth_failures: &mut u32,
) -> Result<Option<Handoff>> { ) -> Result<Option<Handoff>> {
let mut is_tls = stream.is_tls(); let mut is_tls = stream.is_tls();
let starttls_listener = listener.tls == TlsMode::Starttls; let starttls_listener = listener.tls == TlsMode::Starttls;
write(&mut stream, format!("+OK {}\r\n", caps.banner).as_bytes()).await?; let mut buf: Vec<u8> = seed;
let mut buf: Vec<u8> = Vec::with_capacity(1024);
let mut pending_user: Option<Vec<u8>> = None; let mut pending_user: Option<Vec<u8>> = None;
let mut auth_failures = 0u32;
loop { loop {
let Some(nl) = buf.iter().position(|&b| b == b'\n') else { let Some(nl) = buf.iter().position(|&b| b == b'\n') else {
@@ -220,16 +238,17 @@ async fn preauth(
let mech = Mechanism::parse(mech_bytes); let mech = Mechanism::parse(mech_bytes);
match &mech { match &mech {
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {} 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?; write(&mut stream, b"-ERR unsupported mechanism\r\n").await?;
buf.drain(..consumed); buf.drain(..consumed);
if bump(&mut auth_failures, listener.max_auth_attempts) { if bump(auth_failures, listener.max_auth_attempts) {
return Ok(None); return Ok(None);
} }
continue; continue;
} }
} }
if matches!(mech, Mechanism::Plain) && !plain_ok { if matches!(mech, Mechanism::Plain | Mechanism::Login) && !plain_ok {
write( write(
&mut stream, &mut stream,
b"-ERR cleartext authentication requires TLS\r\n", b"-ERR cleartext authentication requires TLS\r\n",
@@ -239,6 +258,68 @@ async fn preauth(
continue; 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 raw_b64: Vec<u8> = if !ir.is_empty() {
let ir = ir.to_vec(); let ir = ir.to_vec();
buf.drain(..consumed); buf.drain(..consumed);
@@ -253,16 +334,16 @@ async fn preauth(
line line
}; };
let Some(raw) = decode_sasl_ir(&raw_b64) else { let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
write(&mut stream, b"-ERR invalid base64\r\n").await?; 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); return Ok(None);
} }
continue; continue;
}; };
let Some(cred) = credential_from_sasl(&mech, &raw) else { let Some(cred) = credential_from_sasl(&mech, &raw) else {
write(&mut stream, b"-ERR malformed credentials\r\n").await?; 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); return Ok(None);
} }
continue; continue;
@@ -286,11 +367,6 @@ async fn preauth(
} }
} }
fn bump(failures: &mut u32, max: u32) -> bool {
*failures += 1;
*failures >= max
}
fn split_verb(line: &[u8]) -> (&[u8], &[u8]) { fn split_verb(line: &[u8]) -> (&[u8], &[u8]) {
match line.iter().position(|&b| b == b' ') { match line.iter().position(|&b| b == b' ') {
Some(pos) => (&line[..pos], trim_leading_space(&line[pos + 1..])), Some(pos) => (&line[..pos], trim_leading_space(&line[pos + 1..])),
@@ -341,7 +417,7 @@ async fn open_backend(
ctx: &Ctx, ctx: &Ctx,
dest_id: &str, dest_id: &str,
fwd: &forward::ForwardInfo, fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> { ) -> Result<common::BackendLeg> {
let common::DialedEndpoint { let common::DialedEndpoint {
dest, dest,
ep, ep,
@@ -384,7 +460,7 @@ async fn open_backend(
.await?; .await?;
} }
Ok((backend, residual)) Ok(common::BackendLeg::new(backend, residual))
} }
async fn backend_advertises_stls( async fn backend_advertises_stls(
+230 -83
View File
@@ -6,8 +6,10 @@
use std::net::SocketAddr; use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::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 tokio::time::timeout;
use crate::config::{Forwarding, ListenerConfig, Protocol, SubmissionCapabilities, TlsMode}; use crate::config::{Forwarding, ListenerConfig, Protocol, SubmissionCapabilities, TlsMode};
@@ -16,11 +18,11 @@ use crate::imap_receiver::Mechanism;
use crate::net::BoxedStream; use crate::net::BoxedStream;
use crate::outbound; use crate::outbound;
use crate::proto::common::{ 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, plain_auth_allowed, write,
}; };
use crate::proto::forward; use crate::proto::forward;
use crate::sasl::decode_sasl_ir; use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential; use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024; const MAX_PREAUTH: usize = 64 * 1024;
@@ -30,70 +32,112 @@ struct SubmissionHandoff {
ehlo_host: String, 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( pub async fn handle_submission(
ctx: &Ctx, ctx: &Ctx,
listener: &ListenerConfig, listener: &ListenerConfig,
stream: BoxedStream, mut stream: BoxedStream,
peer: SocketAddr, peer: SocketAddr,
local: SocketAddr, local: SocketAddr,
) -> Result<()> { ) -> Result<()> {
let caps = &ctx.config.capabilities.submission; let caps = &ctx.config.capabilities.submission;
let sh = match timeout( let hostname = ctx.config.server.hostname_for(local);
listener.preauth_timeout, write(
preauth(ctx, listener, caps, stream, local), &mut stream,
format!("220 {hostname} ESMTP {}\r\n", caps.banner).as_bytes(),
) )
.await .await?;
{
Ok(Ok(Some(h))) => h,
Ok(Ok(None)) => return Ok(()),
Ok(Err(e)) => return Err(e),
Err(_) => return Ok(()),
};
let SubmissionHandoff { handoff, ehlo_host } = sh; let mut state = SubmissionState::default();
let Handoff {
stream,
mech,
raw_challenge,
identifier,
residual,
} = handoff;
let identifier = ctx.routing_identifier(identifier); loop {
let dest_id = ctx.resolve(identifier.as_deref()).await; let sh = match timeout(
let started = std::time::Instant::now(); listener.preauth_timeout,
common::hold_login(ctx, identifier.as_deref()).await; preauth(ctx, listener, caps, stream, &hostname, &mut state),
let fwd = forward::ForwardInfo::new( )
peer, .await
local, {
stream.is_tls(), Ok(Ok(Some(h))) => h,
Some(ehlo_host), Ok(Ok(None)) => return Ok(()),
ctx.config.server.proxy_ttl, Ok(Err(e)) => return Err(e),
); Err(_) => return Ok(()),
};
let auth_frame = format!( let SubmissionHandoff {
"AUTH {} {}\r\n", handoff,
mechanism_name(&mech), ehlo_host: announced,
BASE64.encode(&raw_challenge) } = sh;
); let Handoff {
stream: client,
mech,
raw_challenge,
identifier,
residual,
} = handoff;
common::bridge_authenticated( let identifier = ctx.routing_identifier(identifier);
ctx, let started = std::time::Instant::now();
&dest_id, common::hold_login(ctx, identifier.as_deref()).await;
stream, let dest_id = ctx.resolve(identifier.as_deref()).await;
peer, let fwd = forward::ForwardInfo::new(
identifier.as_deref(), peer,
&residual, local,
started, client.is_tls(),
"submission", Some(announced),
common::AuthWire::Smtp, ctx.config.server.proxy_ttl,
auth_frame.as_bytes(), );
b"421 4.3.0 backend unavailable\r\n",
MAX_PREAUTH, let mut frame = format!("AUTH {} ", mechanism_name(&mech));
|| open_backend(ctx, &dest_id, &fwd), BASE64.encode_append(&raw_challenge, &mut frame);
) frame.push_str("\r\n");
.await let auth = common::AuthFrame::inline(frame.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( async fn preauth(
@@ -101,21 +145,17 @@ async fn preauth(
listener: &ListenerConfig, listener: &ListenerConfig,
caps: &SubmissionCapabilities, caps: &SubmissionCapabilities,
mut stream: BoxedStream, mut stream: BoxedStream,
local: SocketAddr, hostname: &str,
state: &mut SubmissionState,
) -> Result<Option<SubmissionHandoff>> { ) -> Result<Option<SubmissionHandoff>> {
let mut is_tls = stream.is_tls(); let mut is_tls = stream.is_tls();
let starttls_listener = listener.tls == TlsMode::Starttls; let starttls_listener = listener.tls == TlsMode::Starttls;
let hostname = ctx.config.server.hostname_for(local);
write( let SubmissionState {
&mut stream, buf,
format!("220 {hostname} ESMTP {}\r\n", caps.banner).as_bytes(), auth_failures,
) ehlo_host,
.await?; } = state;
let mut buf: Vec<u8> = Vec::with_capacity(1024);
let mut ehlo_host = String::new();
let mut auth_failures = 0u32;
loop { loop {
let mut it = buf.iter(); let mut it = buf.iter();
@@ -128,10 +168,10 @@ async fn preauth(
let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls); let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls);
match req { match req {
Request::Ehlo { host } | Request::Lhlo { host } => { Request::Ehlo { host } | Request::Lhlo { host } => {
ehlo_host = host.to_string(); *ehlo_host = host.to_string();
let resp = ehlo_response( let resp = ehlo_response(
&hostname, hostname,
&ehlo_host, ehlo_host,
caps, caps,
starttls_listener && !is_tls, starttls_listener && !is_tls,
plain_ok, plain_ok,
@@ -140,7 +180,7 @@ async fn preauth(
buf.drain(..consumed); buf.drain(..consumed);
} }
Request::Helo { host } => { Request::Helo { host } => {
ehlo_host = host.to_string(); *ehlo_host = host.to_string();
write(&mut stream, format!("250 {hostname}\r\n").as_bytes()).await?; write(&mut stream, format!("250 {hostname}\r\n").as_bytes()).await?;
buf.drain(..consumed); buf.drain(..consumed);
} }
@@ -181,17 +221,18 @@ async fn preauth(
let params = AuthParams { let params = AuthParams {
ctx, ctx,
plain_ok, plain_ok,
ehlo_host: &ehlo_host, login_offered: common::sasl_offered(&caps.sasl, "LOGIN"),
ehlo_host,
max_attempts: listener.max_auth_attempts, max_attempts: listener.max_auth_attempts,
}; };
match handle_auth( match handle_auth(
&params, &params,
&mut stream, &mut stream,
&mut buf, buf,
consumed, consumed,
mechanism, mechanism,
&ir, &ir,
&mut auth_failures, &mut *auth_failures,
) )
.await? .await?
{ {
@@ -227,7 +268,7 @@ async fn preauth(
if buf.len() > MAX_PREAUTH { if buf.len() > MAX_PREAUTH {
return Err(ProxyError::preauth("smtp line too long")); 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 { if n == 0 {
return Ok(None); return Ok(None);
} }
@@ -236,8 +277,7 @@ async fn preauth(
let drop_to = if consumed == 0 { buf.len() } else { consumed }; let drop_to = if consumed == 0 { buf.len() } else { consumed };
buf.drain(..drop_to); buf.drain(..drop_to);
write(&mut stream, b"500 5.5.2 Syntax error\r\n").await?; write(&mut stream, b"500 5.5.2 Syntax error\r\n").await?;
auth_failures += 1; if bump(auth_failures, listener.max_auth_attempts) {
if auth_failures >= listener.max_auth_attempts {
return Ok(None); return Ok(None);
} }
} }
@@ -260,6 +300,7 @@ enum AuthStep {
struct AuthParams<'a> { struct AuthParams<'a> {
ctx: &'a Ctx, ctx: &'a Ctx,
plain_ok: bool, plain_ok: bool,
login_offered: bool,
ehlo_host: &'a str, ehlo_host: &'a str,
max_attempts: u32, max_attempts: u32,
} }
@@ -275,7 +316,11 @@ async fn handle_auth(
) -> Result<AuthStep> { ) -> Result<AuthStep> {
let max_attempts = params.max_attempts; 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( return fail(
stream, stream,
buf, buf,
@@ -286,7 +331,7 @@ async fn handle_auth(
) )
.await; .await;
}; };
if matches!(mech, Mechanism::Plain) && !params.plain_ok { if matches!(mech, Mechanism::Plain | Mechanism::Login) && !params.plain_ok {
write( write(
&mut *stream, &mut *stream,
b"538 5.7.11 Encryption required for requested authentication mechanism\r\n", b"538 5.7.11 Encryption required for requested authentication mechanism\r\n",
@@ -296,6 +341,18 @@ async fn handle_auth(
return Ok(AuthStep::Continue); 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 raw_b64: Vec<u8> = if !initial_response.is_empty() {
let ir = initial_response.as_bytes().to_vec(); let ir = initial_response.as_bytes().to_vec();
buf.drain(..consumed); buf.drain(..consumed);
@@ -309,7 +366,7 @@ async fn handle_auth(
line line
}; };
let Some(raw) = decode_sasl_ir(&raw_b64) else { let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
return fail( return fail(
stream, stream,
buf, buf,
@@ -343,6 +400,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, &params.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( async fn fail(
stream: &mut BoxedStream, stream: &mut BoxedStream,
buf: &mut Vec<u8>, buf: &mut Vec<u8>,
@@ -355,8 +501,7 @@ async fn fail(
buf.drain(..drain); buf.drain(..drain);
} }
write(&mut *stream, format!("535 {message}\r\n").as_bytes()).await?; write(&mut *stream, format!("535 {message}\r\n").as_bytes()).await?;
*auth_failures += 1; if bump(auth_failures, max_attempts) {
if *auth_failures >= max_attempts {
Ok(AuthStep::Close) Ok(AuthStep::Close)
} else { } else {
Ok(AuthStep::Continue) Ok(AuthStep::Continue)
@@ -370,6 +515,8 @@ fn mech_from_flag(flag: u64) -> Option<Mechanism> {
Some(Mechanism::OAuthBearer) Some(Mechanism::OAuthBearer)
} else if flag & AUTH_XOAUTH2 != 0 { } else if flag & AUTH_XOAUTH2 != 0 {
Some(Mechanism::XOauth2) Some(Mechanism::XOauth2)
} else if flag & AUTH_LOGIN != 0 {
Some(Mechanism::Login)
} else { } else {
None None
} }
@@ -411,7 +558,7 @@ async fn open_backend(
ctx: &Ctx, ctx: &Ctx,
dest_id: &str, dest_id: &str,
fwd: &forward::ForwardInfo, fwd: &forward::ForwardInfo,
) -> Result<(BoxedStream, Vec<u8>)> { ) -> Result<common::BackendLeg> {
let common::DialedEndpoint { let common::DialedEndpoint {
dest, dest,
ep, ep,
@@ -456,7 +603,7 @@ async fn open_backend(
} }
} }
Ok((backend, residual)) Ok(common::BackendLeg::new(backend, residual))
} }
fn advertised_xclient_attrs(ehlo_reply: &[u8]) -> Vec<String> { fn advertised_xclient_attrs(ehlo_reply: &[u8]) -> Vec<String> {
+7 -3
View File
@@ -164,17 +164,21 @@ impl Router {
} }
pub async fn resolve(&self, identifier: Option<&str>) -> Arc<str> { pub async fn resolve(&self, identifier: Option<&str>) -> Arc<str> {
self.resolve_or(identifier, &self.default_destination).await
}
pub async fn resolve_or(&self, identifier: Option<&str>, default: &Arc<str>) -> Arc<str> {
let key: Cow<'_, str> = match identifier { let key: Cow<'_, str> = match identifier {
Some(id) => match self.normalize { Some(id) => match self.normalize {
Normalize::None => Cow::Borrowed(id), Normalize::None => Cow::Borrowed(id),
Normalize::Lowercase => Cow::Owned(id.to_lowercase()), Normalize::Lowercase => Cow::Owned(id.to_lowercase()),
}, },
None => return self.default_destination.clone(), None => return default.clone(),
}; };
if let Some(hit) = self.cache.get(key.as_ref()).await { if let Some(hit) = self.cache.get(key.as_ref()).await {
self.stats.hits.fetch_add(1, Ordering::Relaxed); self.stats.hits.fetch_add(1, Ordering::Relaxed);
return hit.dest(&self.default_destination); return hit.dest(default);
} }
self.stats.misses.fetch_add(1, Ordering::Relaxed); self.stats.misses.fetch_add(1, Ordering::Relaxed);
@@ -215,7 +219,7 @@ impl Router {
.try_get_with(key.into_boxed_str(), init) .try_get_with(key.into_boxed_str(), init)
.await .await
.unwrap_or(Route::Transient); .unwrap_or(Route::Transient);
route.dest(&self.default_destination) route.dest(default)
} }
pub fn normalize(&self, identifier: &str) -> String { pub fn normalize(&self, identifier: &str) -> String {
+7 -7
View File
@@ -4,6 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use encodify::base64::{self, Base64};
pub const SASL_RESPONSE: Base64 = base64::LENIENT;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum Credential { pub enum Credential {
Plain { Plain {
@@ -98,10 +102,6 @@ pub fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option<String>)> {
None None
} }
pub fn decode_sasl_ir(b64: &[u8]) -> Option<Vec<u8>> {
mail_parser::decoders::base64::base64_decode(b64)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -154,7 +154,7 @@ mod tests {
#[test] #[test]
fn lenient_ir_decode() { fn lenient_ir_decode() {
let out = decode_sasl_ir(b"dGVzdAB0ZXN0AHRlc3Q=").unwrap(); let out = SASL_RESPONSE.decode(b"dGVzdAB0ZXN0AHRlc3Q=").unwrap();
assert_eq!(out, b"test\0test\0test"); assert_eq!(out, b"test\0test\0test");
} }
@@ -204,13 +204,13 @@ mod tests {
#[test] #[test]
fn lenient_ir_tolerates_whitespace() { fn lenient_ir_tolerates_whitespace() {
let with_ws = decode_sasl_ir(b"dGVz dAB0\nZXN0AHRlc3Q=").unwrap(); let with_ws = SASL_RESPONSE.decode(b"dGVz dAB0\nZXN0AHRlc3Q=").unwrap();
assert_eq!(with_ws, b"test\0test\0test"); assert_eq!(with_ws, b"test\0test\0test");
} }
#[test] #[test]
fn lenient_ir_decodes_full_quads_without_padding() { fn lenient_ir_decodes_full_quads_without_padding() {
let out = decode_sasl_ir(b"AHRlc3QAc2VjcmV0").unwrap(); let out = SASL_RESPONSE.decode(b"AHRlc3QAc2VjcmV0").unwrap();
assert_eq!(out, b"\0test\0secret"); assert_eq!(out, b"\0test\0secret");
} }
} }
+169
View File
@@ -324,6 +324,14 @@ async fn admin_delay_holds_new_login() {
) )
.await; .await;
assert_eq!(status, 400); assert_eq!(status, 400);
let (status, _) = admin_request(
admin_addr,
"POST",
"/connections/delay?identifier=user@example.com&seconds=18446744073709551615",
Some(TOKEN),
)
.await;
assert_eq!(status, 400);
let listener = ctx.config.listener["pop3"].clone(); let listener = ctx.config.listener["pop3"].clone();
let (mut client, server) = tcp_pair().await; let (mut client, server) = tcp_pair().await;
@@ -645,3 +653,164 @@ async fn admin_config_reload_rejects_invalid() {
"legacy" "legacy"
); );
} }
fn switch_config(old_port: u16, new_port: u16, mapping_path: &str) -> 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 = {old_port}
tls = "plain"
[destination.next]
host = "127.0.0.1"
forwarding = "none"
allow_plaintext_auth = true
[destination.next.protocol.pop3]
port = {new_port}
tls = "plain"
[listener.pop3]
protocol = "pop3"
bind = ["127.0.0.1:0"]
tls = "plain"
[admin]
bind = "127.0.0.1:0"
tls = "implicit"
bearer_token = "{TOKEN}"
min_token_len = 32
lockout_threshold = 100
lockout_duration = "5m"
"#
)
}
async fn pop3_named_backend(name: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
dummy_backend(move |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"AUTH PLAIN").is_none() || find(&buf, b"\r\n").is_none() {
let n = sock.read(&mut scratch).await.unwrap();
if n == 0 {
return;
}
buf.extend_from_slice(&scratch[..n]);
}
sock.write_all(format!("+OK logged in at {name}\r\n").as_bytes())
.await
.unwrap();
let _ = sock.read(&mut scratch).await;
})
.await
}
async fn held_login_follows_mapping_change(method: &str, path: &str, mapping_update: Option<&str>) {
use tokio::io::AsyncReadExt;
let (old_port, old_backend) = pop3_named_backend("legacy").await;
let (new_port, new_backend) = pop3_named_backend("next").await;
let mapping = mapping_file("user@example.com\tlegacy\n");
let toml = switch_config(old_port, new_port, &mapping.path().display().to_string());
let ctx = make_ctx(&toml).await;
let admin_listener = TcpListener::bind(unused_addr()).await.unwrap();
let admin_addr = admin_listener.local_addr().unwrap();
let admin_ctx = ctx.clone();
let admin_config = ctx.config.clone();
tokio::spawn(async move {
let shared = std::sync::Arc::new(arc_swap::ArcSwap::new(admin_ctx));
let _ = crate::admin::serve(shared, admin_config, None, admin_listener).await;
});
let (status, _) = admin_request(
admin_addr,
"POST",
"/connections/delay?identifier=user@example.com&seconds=60",
Some(TOKEN),
)
.await;
assert_eq!(status, 200);
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 secret\r\n").await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Some(contents) = mapping_update {
std::fs::write(mapping.path(), contents).unwrap();
}
let (status, _) = admin_request(admin_addr, method, path, Some(TOKEN)).await;
assert_eq!(status, 200);
let mut seen = Vec::new();
let mut scratch = [0u8; 1024];
while find(&seen, b"+OK logged in at").is_none() {
let n = tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut scratch))
.await
.expect("held login was not released by the mapping change")
.unwrap();
assert!(n > 0, "client closed before login");
seen.extend_from_slice(&scratch[..n]);
}
assert!(
find(&seen, b"+OK logged in at next").is_some(),
"held login must reach the new destination, got {:?}",
String::from_utf8_lossy(&seen)
);
drop(client);
let _ = new_backend.await;
old_backend.abort();
let _ = handler.await;
}
#[tokio::test]
async fn admin_mapping_put_releases_held_login_to_new_destination() {
// a login held by /connections/delay goes to the destination set by PUT /mappings at once
held_login_follows_mapping_change(
"PUT",
"/mappings?identifier=user@example.com&destination=next",
None,
)
.await;
}
#[tokio::test]
async fn admin_mappings_reload_releases_held_login_to_new_destination() {
// a login held by /connections/delay goes to the destination loaded by /mappings/reload at once
held_login_follows_mapping_change("POST", "/mappings/reload", Some("user@example.com\tnext\n"))
.await;
}
+213
View File
@@ -364,3 +364,216 @@ allow_plain_auth_without_tls = true
); );
assert!(Config::parse_and_validate(&toml).is_ok()); 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}"
);
}
}
fn http_fallback_config(mapping_path: &str, fallback: &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.http]
port = 80
tls = "plain"
[destination.imaponly]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.imaponly.protocol.imap]
port = 143
tls = "plain"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "{fallback}"
"#,
mapping_block(mapping_path)
)
}
#[test]
fn http_route_fallback_must_name_known_http_destination() {
let m = mapping_file("");
let path = m.path().display().to_string();
assert!(Config::parse_and_validate(&http_fallback_config(&path, "legacy")).is_ok());
assert!(Config::parse_and_validate(&http_fallback_config(&path, "ghost")).is_err());
assert!(Config::parse_and_validate(&http_fallback_config(&path, "imaponly")).is_err());
}
fn install_script_config() -> &'static str {
let script = include_str!("../../install.sh");
let (_, rest) = script
.split_once("write_config_file() {\n cat > \"$1\" <<'EOF'\n")
.expect("install.sh config heredoc start");
let (config, _) = rest
.split_once("\nEOF\n")
.expect("install.sh config heredoc end");
config
}
#[test]
fn shipped_configs_validate() {
// every configuration shipped with the proxy passes boot validation
for (name, raw) in [
(
"config.sample.toml",
include_str!("../../resources/config.sample.toml"),
),
(
"config.stalwart-upgrade.toml",
include_str!("../../resources/config.stalwart-upgrade.toml"),
),
(
"config.dovecot-postfix.toml",
include_str!("../../resources/config.dovecot-postfix.toml"),
),
("install.sh", install_script_config()),
] {
if let Err(e) = Config::parse_and_validate(raw) {
panic!("{name} fails validation: {e}");
}
}
}
+63 -2
View File
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::Engine; use encodify::base64::STANDARD as B64;
use base64::engine::general_purpose::STANDARD as B64;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
@@ -161,6 +160,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 { 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 mut s = TcpStream::connect((host, port)).await.unwrap();
let _ = read_reply(&mut s).await; let _ = read_reply(&mut s).await;
+4 -5
View File
@@ -6,6 +6,7 @@
use std::sync::Arc; use std::sync::Arc;
use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
@@ -34,11 +35,9 @@ impl Response {
} }
pub fn basic_auth(user: &str, pass: &str) -> String { pub fn basic_auth(user: &str, pass: &str) -> String {
use base64::Engine; let mut header = String::from("Basic ");
format!( B64.encode_append(format!("{user}:{pass}"), &mut header);
"Basic {}", header
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
)
} }
pub async fn upgrade( pub async fn upgrade(
+1 -1
View File
@@ -55,7 +55,7 @@ tls = "implicit"
[[http.route]] [[http.route]]
match = "/**" match = "/**"
extract = {{ from = "authorization" }} extract = {{ from = "authorization" }}
fallback = "default" fallback = "new"
[listener.http] [listener.http]
protocol = "http" protocol = "http"
+1 -2
View File
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::Engine; use encodify::base64::STANDARD as B64;
use base64::engine::general_purpose::STANDARD as B64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
+1 -2
View File
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::Engine; use encodify::base64::STANDARD as B64;
use base64::engine::general_purpose::STANDARD as B64;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
+45
View File
@@ -240,3 +240,48 @@ tls = "plain"
"passthrough RCPT to a hosted Stalwart user should be accepted: {reply:?}" "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:?}"
);
}
+1 -1
View File
@@ -163,7 +163,7 @@ tls = "implicit"
[[http.route]] [[http.route]]
match = "/**" match = "/**"
extract = {{ from = "authorization" }} extract = {{ from = "authorization" }}
fallback = "default" fallback = "old"
[listener.http] [listener.http]
protocol = "http" protocol = "http"
+2 -2
View File
@@ -830,7 +830,7 @@ fn path_of(url: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use base64::Engine; use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn imaps_auth(host: &str, port: u16, login: &str, password: &str) -> String { async fn imaps_auth(host: &str, port: u16, login: &str, password: &str) -> String {
@@ -838,7 +838,7 @@ mod tests {
let mut stream = crate::tests::harness::tls_connect_client(tcp).await; let mut stream = crate::tests::harness::tls_connect_client(tcp).await;
let mut buf = [0u8; 512]; let mut buf = [0u8; 512];
let _ = stream.read(&mut buf).await.unwrap(); let _ = stream.read(&mut buf).await.unwrap();
let ir = base64::engine::general_purpose::STANDARD.encode(format!("\0{login}\0{password}")); let ir = B64.encode(format!("\0{login}\0{password}"));
stream stream
.write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes()) .write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes())
.await .await
+1 -1
View File
@@ -40,7 +40,7 @@ tls = "implicit"
[[http.route]] [[http.route]]
match = "/**" match = "/**"
extract = {{ from = "authorization" }} extract = {{ from = "authorization" }}
fallback = "default" fallback = "new"
[listener.http] [listener.http]
protocol = "http" protocol = "http"
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream; use tokio::net::TcpStream;
+548 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use super::harness::*; use super::harness::*;
@@ -516,6 +516,113 @@ tls = "plain"
handler.abort(); handler.abort();
} }
#[tokio::test]
async fn http_route_fallback_destination_used_without_mapping() {
async fn backend_default(mut sock: tokio::net::TcpStream) {
read_req_head(&mut sock).await;
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\ndefault")
.await
.unwrap();
}
async fn backend_fallback(mut sock: tokio::net::TcpStream) {
read_req_head(&mut sock).await;
sock.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nfallback",
)
.await
.unwrap();
}
let mapping = mapping_file("alice@x\tweb_default\n");
let mut handles = Vec::new();
let cases = [
(None, "fallback"),
(Some("nobody@x"), "fallback"),
(Some("alice@x"), "default"),
];
for (user, expected) in cases {
let (port_d, bd) = dummy_backend(backend_default).await;
let (port_f, bf) = dummy_backend(backend_fallback).await;
handles.push(bd);
handles.push(bf);
let toml = format!(
r#"
[mapping]
source = "file"
normalize = "lowercase"
[mapping.file]
path = "{}"
[routing]
default_destination = "web_default"
[destination.web_default]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.web_default.protocol.http]
port = {port_d}
tls = "plain"
[destination.web_fallback]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.web_fallback.protocol.http]
port = {port_f}
tls = "plain"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "web_fallback"
[listener.http]
protocol = "http"
bind = ["127.0.0.1:0"]
tls = "plain"
"#,
mapping.path().display()
);
let ctx = make_ctx(&toml).await;
let listener = ctx.config.listener["http"].clone();
let (mut client, srv) = tcp_pair().await;
let handler = tokio::spawn(async move {
let _ = crate::http::proxy::handle(
&ctx,
&listener,
Box::new(srv),
"127.0.0.1:40044".parse().unwrap(),
"127.0.0.1:80".parse().unwrap(),
)
.await;
});
let auth = user
.map(|u| {
format!(
"Authorization: Basic {}\r\n",
BASE64.encode(format!("{u}:pw"))
)
})
.unwrap_or_default();
send(
&mut client,
format!("GET / HTTP/1.1\r\nHost: x\r\n{auth}Connection: close\r\n\r\n").as_bytes(),
)
.await;
let resp = read_to_eof(&mut client).await;
assert!(
resp.ends_with(expected.as_bytes()),
"user {user:?} should reach {expected}, got {:?}",
String::from_utf8_lossy(&resp)
);
handles.push(handler);
}
for h in handles {
h.abort();
}
}
async fn read_until_backend(sock: &mut tokio::net::TcpStream, needle: &[u8]) -> Vec<u8> { async fn read_until_backend(sock: &mut tokio::net::TcpStream, needle: &[u8]) -> Vec<u8> {
let mut buf = Vec::new(); let mut buf = Vec::new();
let mut scratch = [0u8; 1024]; let mut scratch = [0u8; 1024];
@@ -985,3 +1092,443 @@ async fn http_chunked_request_and_response_round_trip() {
let _ = backend.await; let _ = backend.await;
handler.abort(); 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();
}
#[tokio::test]
async fn http_held_login_follows_mapping_change() {
// an http request held by a login delay is sent to the destination mapped when the hold ends
async fn backend(name: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
dummy_backend(move |mut sock| async move {
read_req_head(&mut sock).await;
let _ = sock
.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{name}",
name.len()
)
.as_bytes(),
)
.await;
})
.await
}
let (old_port, old_backend) = backend("legacy").await;
let (new_port, new_backend) = backend("next").await;
let mapping = mapping_file("user@example.com\tweb\n");
let toml = format!(
r#"{}
[destination.next]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.next.protocol.http]
port = {new_port}
tls = "plain"
"#,
extract_http_config("127.0.0.1", old_port, &mapping.path().display().to_string())
);
let ctx = make_ctx(&toml).await;
let listener = ctx.config.listener["http"].clone();
let identifier = ctx.router.normalize("user@example.com");
ctx.conns.set_delay(
&identifier,
std::time::Instant::now() + std::time::Duration::from_secs(60),
);
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:40076".parse().unwrap(),
"127.0.0.1:80".parse().unwrap(),
)
.await;
});
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;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
ctx.router.upsert("user@example.com", "next").await.unwrap();
ctx.conns.release_delay(&identifier);
let resp = tokio::time::timeout(std::time::Duration::from_secs(5), read_to_eof(&mut client))
.await
.expect("held request was not released by the mapping change");
assert!(
find(&resp, b"\r\n\r\nnext").is_some(),
"held request must reach the new destination, got {:?}",
String::from_utf8_lossy(&resp)
);
let _ = new_backend.await;
old_backend.abort();
handler.abort();
}
+529 -2
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*; use super::harness::*;
@@ -57,7 +57,7 @@ async fn backend_expect_mech(
mech: &'static str, mech: &'static str,
want_user: &'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 .await
.unwrap(); .unwrap();
@@ -446,3 +446,530 @@ tls = "starttls"
); );
handler.abort(); 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;
}
+131 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*; use super::harness::*;
@@ -306,3 +306,133 @@ tls = "plain"
); );
handler.abort(); 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();
}
+577 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*; use super::harness::*;
@@ -396,3 +396,579 @@ tls = "plain"
); );
handler.abort(); 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;
}
+15 -3
View File
@@ -4,16 +4,28 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner; use testcontainers::runners::AsyncRunner;
use testcontainers_modules::redis::{REDIS_PORT, Redis}; use testcontainers::{ContainerAsync, GenericImage};
use crate::config::RedisMappingConfig; use crate::config::RedisMappingConfig;
use crate::route::store::redis::RedisStore; 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] #[tokio::test]
async fn redis_store_resolves_and_misses() { async fn redis_store_resolves_and_misses() {
let node = Redis::default().start().await.unwrap(); let node = redis_container().await;
let port = node.get_host_port_ipv4(REDIS_PORT).await.unwrap(); let port = node.get_host_port_ipv4(REDIS_PORT.tcp()).await.unwrap();
let url = format!("redis://127.0.0.1:{port}"); let url = format!("redis://127.0.0.1:{port}");
let client = redis::Client::open(url.as_str()).unwrap(); let client = redis::Client::open(url.as_str()).unwrap();
+23
View File
@@ -63,6 +63,29 @@ async fn router_resolves_normalizes_and_caches() {
assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary");
} }
#[tokio::test]
async fn router_resolve_or_uses_given_fallback() {
let mapping = mapping_file("user@example.com\tprimary\n");
let toml = config(&mapping.path().display().to_string());
let router = build_router(&toml).await;
let fallback: std::sync::Arc<str> = "primary".into();
let legacy: std::sync::Arc<str> = "legacy".into();
assert_eq!(&*router.resolve_or(None, &fallback).await, "primary");
assert_eq!(
&*router.resolve_or(Some("nobody@x"), &fallback).await,
"primary"
);
assert_eq!(
&*router.resolve_or(Some("nobody@x"), &legacy).await,
"legacy"
);
assert_eq!(
&*router.resolve_or(Some("user@example.com"), &legacy).await,
"primary"
);
}
#[tokio::test] #[tokio::test]
async fn router_cache_hits_and_misses() { async fn router_cache_hits_and_misses() {
let mapping = mapping_file("user@example.com\tprimary\n"); let mapping = mapping_file("user@example.com\tprimary\n");
+222 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*; use super::harness::*;
@@ -315,3 +315,224 @@ async fn smtp_passthrough_bridges_raw() {
let _ = backend.await; let _ = backend.await;
handler.abort(); 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
View File
@@ -4,16 +4,36 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use testcontainers::core::{IntoContainerPort, WaitFor};
use testcontainers::runners::AsyncRunner; use testcontainers::runners::AsyncRunner;
use testcontainers_modules::postgres::Postgres; use testcontainers::{ContainerAsync, GenericImage, ImageExt};
use crate::config::SqlMappingConfig; use crate::config::SqlMappingConfig;
use crate::route::store::sql::SqlStore; 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] #[tokio::test]
async fn sql_store_resolves_and_misses() { async fn sql_store_resolves_and_misses() {
let node = Postgres::default().start().await.unwrap(); let node = postgres_container().await;
let port = node.get_host_port_ipv4(5432).await.unwrap(); let port = node.get_host_port_ipv4(POSTGRES_PORT.tcp()).await.unwrap();
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
sqlx::any::install_default_drivers(); sqlx::any::install_default_drivers();
@@ -49,8 +69,8 @@ async fn sql_store_resolves_and_misses() {
#[tokio::test] #[tokio::test]
async fn sql_store_upsert_and_remove() { async fn sql_store_upsert_and_remove() {
let node = Postgres::default().start().await.unwrap(); let node = postgres_container().await;
let port = node.get_host_port_ipv4(5432).await.unwrap(); let port = node.get_host_port_ipv4(POSTGRES_PORT.tcp()).await.unwrap();
let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres");
sqlx::any::install_default_drivers(); sqlx::any::install_default_drivers();
+2 -6
View File
@@ -4,17 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/ */
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; use encodify::base64::URL_SAFE_NO_PAD;
use crate::sasl::Credential; use crate::sasl::Credential;
pub fn bearer_identifier(token: &str, jwt_username_claim: &str) -> Option<String> { pub fn bearer_identifier(token: &str, jwt_username_claim: &str) -> Option<String> {
if let Some(rest) = token.strip_prefix("sw1.") { if let Some(rest) = token.strip_prefix("sw1.") {
return match rest.split_once('.') { return match rest.split_once('.') {
Some((_body, footer)) => { Some((_body, footer)) => URL_SAFE_NO_PAD.decode_to_string(footer).ok(),
let decoded = URL_SAFE_NO_PAD.decode(footer).ok()?;
String::from_utf8(decoded).ok()
}
None => None, None => None,
}; };
} }
@@ -61,7 +58,6 @@ pub fn identifier_from_credential(cred: &Credential, jwt_username_claim: &str) -
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
#[test] #[test]
fn sw1_footer() { fn sw1_footer() {