mirror of
https://github.com/stalwartlabs/proxy.git
synced 2026-10-31 08:13:11 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc612a6d42 | ||
|
|
b923651c89 | ||
|
|
24c53fc1e7 | ||
|
|
65e71079ec | ||
|
|
ef9ffe0a2c | ||
|
|
91ffeb1482 | ||
|
|
3a02690854 | ||
|
|
43c5d4c96f | ||
|
|
70ed7417ed | ||
|
|
1ddc5dce99 | ||
|
|
69d28e4c25 |
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
*.md
|
*.md
|
||||||
!CHANGELOG.md
|
!CHANGELOG.md
|
||||||
!README.md
|
!README.md
|
||||||
|
__pycache__/
|
||||||
|
|||||||
+66
-3
@@ -2,11 +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.0] - 2026-06-08
|
## [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
|
### Added
|
||||||
- Initial release of the migration proxy: pre-auth routing and socket bridging
|
|
||||||
for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- IMAP authentication is no longer replayed with an inline initial response to backends that do not advertise `SASL-IR` (#4).
|
||||||
|
|
||||||
|
## [1.0.2] - 2026-07-31
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `AUTH LOGIN` support on IMAP, POP3, ManageSieve and SMTP submission (#3).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `LOGIN` is now part of the default `capabilities.<protocol>.sasl` set.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
## [1.0.1] - 2026-07-30
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `server.host_down_min_clients`: number of distinct client addresses a failure streak must span before client-attributable failures can mark a destination down.
|
||||||
|
- `GET /destinations` now also reports `failure_sources` and `host_level_failure`.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The circuit breaker no longer marks a destination down because a backend rejects the connections of a single banned client (#2).
|
||||||
|
|
||||||
|
## [1.0.0] - 2026-06-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of the migration proxy: pre-auth routing and socket bridging for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
Generated
+623
-733
File diff suppressed because it is too large
Load Diff
+9
-12
@@ -1,8 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "proxy"
|
name = "proxy"
|
||||||
version = "1.0.0"
|
version = "1.0.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
|
||||||
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"
|
||||||
authors = ["Stalwart Labs LLC <hello@stalw.art>"]
|
authors = ["Stalwart Labs LLC <hello@stalw.art>"]
|
||||||
@@ -17,22 +16,21 @@ name = "proxy"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tokio = { version = "1.52", features = ["net", "io-util", "rt-multi-thread", "macros", "signal", "sync", "time"] }
|
tokio = { version = "1.53", features = ["net", "io-util", "rt-multi-thread", "macros", "signal", "sync", "time"] }
|
||||||
rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "tls12"] }
|
rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "tls12"] }
|
||||||
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] }
|
||||||
rustls-pemfile = "2.2"
|
rustls-pemfile = "2.2"
|
||||||
rustls-pki-types = "1.14"
|
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.1"
|
smtp-proto = "0.2.2"
|
||||||
mail-parser = "0.11"
|
encodify = "1.0"
|
||||||
base64 = "0.22"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
globset = "0.4"
|
globset = "0.4"
|
||||||
regex = "1.12"
|
regex = "1.13"
|
||||||
redis = { version = "1.2", features = ["tokio-comp", "aio", "connection-manager"] }
|
redis = { version = "1.5", features = ["tokio-comp", "aio", "connection-manager"] }
|
||||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "any", "postgres", "mysql", "sqlite"] }
|
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "any", "postgres", "mysql", "sqlite"] }
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
@@ -50,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
@@ -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
|
||||||
|
|
||||||
# *****************
|
# *****************
|
||||||
|
|||||||
@@ -76,10 +76,10 @@ Additionally, you may purchase an [Enterprise License](https://stalw.art/enterpr
|
|||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v1 (SELv1)**:
|
This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v2 (SELv2)**:
|
||||||
|
|
||||||
- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
|
- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
|
||||||
- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
|
- The [Stalwart Enterprise License v2 (SELv2)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
|
||||||
|
|
||||||
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
# ===========================================================================
|
||||||
|
# Migration proxy: Dovecot + Postfix to Stalwart sample configuration.
|
||||||
|
#
|
||||||
|
# This configuration fronts a legacy mail stack (Dovecot serving IMAP, POP3 and
|
||||||
|
# ManageSieve, Postfix handling SMTP submission) and a new Stalwart server at the
|
||||||
|
# same time, migrating accounts from the legacy stack to Stalwart one at a time.
|
||||||
|
# Clients keep talking to a single hostname on the usual ports; the proxy decides
|
||||||
|
# per account which backend currently holds the mailbox.
|
||||||
|
#
|
||||||
|
# The legacy backend speaks the Dovecot and Postfix conventions: it expects the
|
||||||
|
# real client address through the XCLIENT command (and the IMAP ID command), not
|
||||||
|
# through a PROXY protocol header. The legacy destination therefore uses
|
||||||
|
# forwarding = "xclient", while the Stalwart destination uses forwarding =
|
||||||
|
# "proxy". For XCLIENT to be honoured, Dovecot and Postfix must be told to trust
|
||||||
|
# the proxy; see the companion file resources/dovecot-postfix-trust.conf.
|
||||||
|
#
|
||||||
|
# Inbound mail on port 25 is handed to Stalwart, which performs split delivery:
|
||||||
|
# mail for a migrated account is delivered locally, and mail for an account still
|
||||||
|
# on the legacy stack is relayed back to Postfix.
|
||||||
|
#
|
||||||
|
# Replace every value marked CHANGE ME and review the rest before starting.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
[server]
|
||||||
|
hostname = "mail.example.com" # CHANGE ME (public hostname clients use)
|
||||||
|
shutdown_grace = "30s"
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# TLS certificate the proxy presents to clients (the public hostname's cert).
|
||||||
|
[tls.certificate.default]
|
||||||
|
cert = "/etc/proxy/tls/fullchain.pem" # CHANGE ME
|
||||||
|
key = "/etc/proxy/tls/privkey.pem" # CHANGE ME
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[tls.protocols]
|
||||||
|
min_version = "1.2"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routing.
|
||||||
|
#
|
||||||
|
# default_destination is "legacy" so that accounts not yet migrated resolve to
|
||||||
|
# the Dovecot/Postfix stack. As each account is migrated, an entry pointing it at
|
||||||
|
# "stalwart" is added to the mapping file.
|
||||||
|
#
|
||||||
|
# smtp_passthrough_destination is "stalwart": port 25 carries no login to route
|
||||||
|
# on, so it is handed whole to the new server, which performs split delivery and
|
||||||
|
# relays not-yet-migrated recipients back to Postfix.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[routing]
|
||||||
|
default_destination = "legacy"
|
||||||
|
smtp_passthrough_destination = "stalwart"
|
||||||
|
|
||||||
|
[mapping]
|
||||||
|
source = "file"
|
||||||
|
normalize = "lowercase"
|
||||||
|
positive_ttl = "10m"
|
||||||
|
negative_ttl = "30s"
|
||||||
|
|
||||||
|
[mapping.file]
|
||||||
|
# Whitespace-separated "identifier destination" lines. A migrated account gets a
|
||||||
|
# line pointing it at "stalwart"; everything else falls through to "legacy". For
|
||||||
|
# example:
|
||||||
|
# alice@example.com stalwart
|
||||||
|
path = "/etc/proxy/mappings.tsv" # CHANGE ME
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Legacy Dovecot + Postfix backend. Announced to the backend over XCLIENT and the
|
||||||
|
# IMAP ID command (forwarding = "xclient"), which is what Dovecot and Postfix
|
||||||
|
# understand. The backend leg uses STARTTLS on the classic plaintext ports.
|
||||||
|
#
|
||||||
|
# The legacy server presents a self-signed certificate. Pin it by fingerprint
|
||||||
|
# with tls_pinned_cert_sha256 (recommended), or, on a trusted internal network,
|
||||||
|
# set tls_allow_invalid_certs = true.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[destination.legacy]
|
||||||
|
host = "legacy.internal.example.com" # CHANGE ME (internal address of the legacy stack)
|
||||||
|
tls_server_name = "legacy.internal.example.com"
|
||||||
|
forwarding = "xclient"
|
||||||
|
# tls_pinned_cert_sha256 = "aa:bb:...:99" # CHANGE ME (legacy cert SHA-256), or:
|
||||||
|
tls_allow_invalid_certs = true
|
||||||
|
|
||||||
|
[destination.legacy.protocol.imap]
|
||||||
|
port = 143
|
||||||
|
tls = "starttls"
|
||||||
|
[destination.legacy.protocol.pop3]
|
||||||
|
port = 110
|
||||||
|
tls = "starttls"
|
||||||
|
[destination.legacy.protocol.submission]
|
||||||
|
port = 587
|
||||||
|
tls = "starttls"
|
||||||
|
[destination.legacy.protocol.managesieve]
|
||||||
|
port = 4190
|
||||||
|
tls = "starttls"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# New Stalwart backend. Announced over the PROXY protocol (forwarding = "proxy",
|
||||||
|
# proxy_protocol = true); Stalwart is configured to trust the proxy's address.
|
||||||
|
# Declares an smtp endpoint because it is the pass-through destination for port 25.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[destination.stalwart]
|
||||||
|
host = "new.internal.example.com" # CHANGE ME (internal address of the new server)
|
||||||
|
tls_server_name = "new.internal.example.com"
|
||||||
|
forwarding = "proxy"
|
||||||
|
proxy_protocol = true
|
||||||
|
forwarded = true
|
||||||
|
# tls_allow_invalid_certs = true # if the new server uses a self-signed cert
|
||||||
|
|
||||||
|
[destination.stalwart.protocol.imap]
|
||||||
|
port = 993
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.stalwart.protocol.pop3]
|
||||||
|
port = 995
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.stalwart.protocol.submission]
|
||||||
|
port = 465
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.stalwart.protocol.managesieve]
|
||||||
|
port = 4190
|
||||||
|
tls = "starttls"
|
||||||
|
[destination.stalwart.protocol.smtp]
|
||||||
|
port = 25
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Client-facing listeners on the familiar ports. Port 25 is a raw pass-through to
|
||||||
|
# Stalwart and therefore plain (a pass-through listener cannot offer STARTTLS).
|
||||||
|
#
|
||||||
|
# Note: Dovecot and Postfix serve no HTTP or JMAP, so no HTTP listener is defined
|
||||||
|
# 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
|
||||||
|
# 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]
|
||||||
|
protocol = "imap"
|
||||||
|
bind = ["[::]:993"]
|
||||||
|
tls = "implicit"
|
||||||
|
[listener.imap]
|
||||||
|
protocol = "imap"
|
||||||
|
bind = ["[::]:143"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.pop3s]
|
||||||
|
protocol = "pop3"
|
||||||
|
bind = ["[::]:995"]
|
||||||
|
tls = "implicit"
|
||||||
|
[listener.pop3]
|
||||||
|
protocol = "pop3"
|
||||||
|
bind = ["[::]:110"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.submission]
|
||||||
|
protocol = "submission"
|
||||||
|
bind = ["[::]:587"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.submissions]
|
||||||
|
protocol = "submission"
|
||||||
|
bind = ["[::]:465"]
|
||||||
|
tls = "implicit"
|
||||||
|
[listener.managesieve]
|
||||||
|
protocol = "managesieve"
|
||||||
|
bind = ["[::]:4190"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.smtp]
|
||||||
|
protocol = "smtp"
|
||||||
|
bind = ["[::]:25"]
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Management API: mapping changes, cache invalidation and connection control.
|
||||||
|
# TLS is mandatory. Bind to loopback or a trusted management network only.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[admin]
|
||||||
|
bind = "127.0.0.1:9443"
|
||||||
|
tls = "implicit"
|
||||||
|
bearer_token_file = "/etc/proxy/admin.token" # CHANGE ME
|
||||||
|
min_token_len = 32
|
||||||
|
lockout_threshold = 5
|
||||||
|
lockout_duration = "5m"
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
# ===========================================================================
|
||||||
|
# Migration proxy: Stalwart upgrade (old -> new) sample configuration.
|
||||||
|
#
|
||||||
|
# This configuration fronts two Stalwart deployments at the same time: an older
|
||||||
|
# one ("old", typically v0.15 or below) that still holds most accounts, and a
|
||||||
|
# new one ("new", the latest release) that accounts are migrated onto one by
|
||||||
|
# one. Clients keep talking to a single hostname on the usual ports; the proxy
|
||||||
|
# decides per account which deployment currently holds the mailbox.
|
||||||
|
#
|
||||||
|
# Before each account is migrated, it resolves to "old". Inbound mail on port 25
|
||||||
|
# is always handed to "new", which performs split delivery: mail for an account
|
||||||
|
# that already lives on "new" is delivered locally, while mail for an account
|
||||||
|
# still on "old" is relayed there. As each account is moved its mapping is
|
||||||
|
# flipped to "new".
|
||||||
|
#
|
||||||
|
# Replace every value marked CHANGE ME and review the rest before starting.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
[server]
|
||||||
|
# Public hostname clients connect to. Advertised in protocol greetings.
|
||||||
|
hostname = "mail.example.org" # CHANGE ME
|
||||||
|
shutdown_grace = "30s"
|
||||||
|
log_level = "info"
|
||||||
|
|
||||||
|
# TLS certificate the proxy presents to clients. This is the certificate for
|
||||||
|
# the public hostname above; it terminates implicit TLS and STARTTLS.
|
||||||
|
[tls.certificate.default]
|
||||||
|
cert = "/etc/proxy/tls/fullchain.pem" # CHANGE ME
|
||||||
|
key = "/etc/proxy/tls/privkey.pem" # CHANGE ME
|
||||||
|
default = true
|
||||||
|
|
||||||
|
[tls.protocols]
|
||||||
|
min_version = "1.2"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Routing.
|
||||||
|
#
|
||||||
|
# default_destination is where accounts with no explicit mapping resolve. While
|
||||||
|
# the migration is in progress this must be "old", so that accounts that have
|
||||||
|
# not been moved yet keep reaching their current mailbox.
|
||||||
|
#
|
||||||
|
# smtp_passthrough_destination is the fixed target for raw SMTP on port 25 and
|
||||||
|
# LMTP on port 24. It must be "new", because the new deployment is the one that
|
||||||
|
# performs split delivery and relays not-yet-migrated recipients back to old.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[routing]
|
||||||
|
default_destination = "old"
|
||||||
|
smtp_passthrough_destination = "new"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mapping store: identifier -> destination. A migrated account gets a line
|
||||||
|
# pointing it at "new"; everything else falls through to default_destination.
|
||||||
|
# The identifier is the login the client presents (the e-mail address, or for
|
||||||
|
# older servers the bare account name), normalized to lowercase.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[mapping]
|
||||||
|
source = "file"
|
||||||
|
normalize = "lowercase"
|
||||||
|
positive_ttl = "10m"
|
||||||
|
negative_ttl = "30s"
|
||||||
|
|
||||||
|
[mapping.file]
|
||||||
|
# A whitespace-separated file of "identifier destination" lines, for example:
|
||||||
|
# alice@example.org new
|
||||||
|
# alice new
|
||||||
|
# List both the e-mail address and the bare account name when the old server
|
||||||
|
# allowed logging in with either form. New entries take effect after a mapping
|
||||||
|
# reload (see the management API) or a cache expiry.
|
||||||
|
path = "/etc/proxy/mappings.tsv" # CHANGE ME
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Destinations.
|
||||||
|
#
|
||||||
|
# Both backends are Stalwart and both are configured to accept the PROXY
|
||||||
|
# protocol on their listeners, so the proxy announces the real client over a
|
||||||
|
# PROXY v2 header (forwarding = "proxy", proxy_protocol = true) on every mail
|
||||||
|
# leg, and appends Forwarded / X-Forwarded-For on the HTTP leg (forwarded =
|
||||||
|
# true). The old server is the single exception: its port 25 must NOT expect a
|
||||||
|
# PROXY header, because the new server relays to it as an ordinary SMTP client.
|
||||||
|
# That exception is configured on the old server, not here.
|
||||||
|
#
|
||||||
|
# host is the internal address each deployment now listens on. tls_server_name
|
||||||
|
# is the name presented for SNI and certificate matching on the backend leg.
|
||||||
|
# If a backend uses a self-signed or internal certificate, either pin it with
|
||||||
|
# tls_pinned_cert_sha256 or, for a trusted internal network only, uncomment
|
||||||
|
# tls_allow_invalid_certs.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[destination.old]
|
||||||
|
host = "old.internal.example.org" # CHANGE ME
|
||||||
|
tls_server_name = "old.internal.example.org"
|
||||||
|
forwarding = "proxy"
|
||||||
|
proxy_protocol = true
|
||||||
|
forwarded = true
|
||||||
|
# tls_allow_invalid_certs = true
|
||||||
|
|
||||||
|
[destination.old.protocol.imap]
|
||||||
|
port = 993
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.old.protocol.pop3]
|
||||||
|
port = 995
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.old.protocol.submission]
|
||||||
|
port = 465
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.old.protocol.managesieve]
|
||||||
|
port = 4190
|
||||||
|
tls = "starttls"
|
||||||
|
[destination.old.protocol.http]
|
||||||
|
port = 443
|
||||||
|
tls = "implicit"
|
||||||
|
|
||||||
|
[destination.new]
|
||||||
|
host = "new.internal.example.org" # CHANGE ME
|
||||||
|
tls_server_name = "new.internal.example.org"
|
||||||
|
forwarding = "proxy"
|
||||||
|
proxy_protocol = true
|
||||||
|
forwarded = true
|
||||||
|
# tls_allow_invalid_certs = true
|
||||||
|
|
||||||
|
[destination.new.protocol.imap]
|
||||||
|
port = 993
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.new.protocol.pop3]
|
||||||
|
port = 995
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.new.protocol.submission]
|
||||||
|
port = 465
|
||||||
|
tls = "implicit"
|
||||||
|
[destination.new.protocol.managesieve]
|
||||||
|
port = 4190
|
||||||
|
tls = "starttls"
|
||||||
|
[destination.new.protocol.http]
|
||||||
|
port = 443
|
||||||
|
tls = "implicit"
|
||||||
|
# Required because new is the smtp_passthrough_destination (port 25 / LMTP).
|
||||||
|
[destination.new.protocol.smtp]
|
||||||
|
port = 25
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP routing. Rules are evaluated top to bottom and the first match wins.
|
||||||
|
#
|
||||||
|
# The web interfaces and login endpoints differ between the two versions, so a
|
||||||
|
# handful of paths that are unique to each version are pinned to the backend
|
||||||
|
# that serves them. The OAuth token exchange is shared, so it is split by the
|
||||||
|
# client_id present in the request body. Everything else, including JMAP,
|
||||||
|
# WebDAV and the JMAP session resource, carries credentials and is routed by
|
||||||
|
# the account those credentials identify, falling back to the default
|
||||||
|
# destination when no account can be determined.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Endpoints served only by the old deployment.
|
||||||
|
[[http.route]]
|
||||||
|
match = "/api/oauth"
|
||||||
|
destination = "old"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/authorize"
|
||||||
|
destination = "old"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/authorize/**"
|
||||||
|
destination = "old"
|
||||||
|
|
||||||
|
# Endpoints served only by the new deployment.
|
||||||
|
[[http.route]]
|
||||||
|
match = "/api/auth"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/api/auth/**"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/api/discover/**"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/admin"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/admin/**"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/account"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/account/**"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/login"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
|
match = "/device"
|
||||||
|
destination = "new"
|
||||||
|
|
||||||
|
# OAuth token exchange is shared; route it by the web client that requested it.
|
||||||
|
# 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]]
|
||||||
|
match = "/auth/token"
|
||||||
|
extract = { from = "body", regex = "client_id=([^&]+)" }
|
||||||
|
fallback = "old"
|
||||||
|
|
||||||
|
# Everything else: route by the authenticated account, fall back to the old deployment.
|
||||||
|
[[http.route]]
|
||||||
|
match = "/**"
|
||||||
|
extract = { from = "authorization" }
|
||||||
|
fallback = "old"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Listeners: the public ports clients connect to. These take over the ports the
|
||||||
|
# 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]
|
||||||
|
protocol = "imap"
|
||||||
|
bind = ["[::]:993"]
|
||||||
|
tls = "implicit"
|
||||||
|
[listener.imap]
|
||||||
|
protocol = "imap"
|
||||||
|
bind = ["[::]:143"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.pop3s]
|
||||||
|
protocol = "pop3"
|
||||||
|
bind = ["[::]:995"]
|
||||||
|
tls = "implicit"
|
||||||
|
[listener.pop3]
|
||||||
|
protocol = "pop3"
|
||||||
|
bind = ["[::]:110"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.submission]
|
||||||
|
protocol = "submission"
|
||||||
|
bind = ["[::]:587"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.submissions]
|
||||||
|
protocol = "submission"
|
||||||
|
bind = ["[::]:465"]
|
||||||
|
tls = "implicit"
|
||||||
|
[listener.managesieve]
|
||||||
|
protocol = "managesieve"
|
||||||
|
bind = ["[::]:4190"]
|
||||||
|
tls = "starttls"
|
||||||
|
[listener.https]
|
||||||
|
protocol = "http"
|
||||||
|
bind = ["[::]:443"]
|
||||||
|
tls = "implicit"
|
||||||
|
# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather
|
||||||
|
# than trusting them; the proxy sets its own based on the real peer.
|
||||||
|
forwarded = "off"
|
||||||
|
# Raw SMTP. Pass-through to new (smtp_passthrough_destination). Must be plain:
|
||||||
|
# a pass-through listener cannot offer STARTTLS.
|
||||||
|
[listener.smtp]
|
||||||
|
protocol = "smtp"
|
||||||
|
bind = ["[::]:25"]
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Management API: mapping changes, cache invalidation and connection control.
|
||||||
|
# TLS is mandatory. Bind it to loopback or a trusted management network only.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
[admin]
|
||||||
|
bind = "127.0.0.1:9443"
|
||||||
|
tls = "implicit"
|
||||||
|
bearer_token_file = "/etc/proxy/admin.token" # CHANGE ME
|
||||||
|
min_token_len = 32
|
||||||
|
lockout_threshold = 5
|
||||||
|
lockout_duration = "5m"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# ===========================================================================
|
||||||
|
# Legacy backend trust: Dovecot + Postfix settings for the migration proxy.
|
||||||
|
#
|
||||||
|
# These are the changes the legacy Dovecot and Postfix stack needs so that the
|
||||||
|
# migration proxy can front it. They do two things:
|
||||||
|
#
|
||||||
|
# 1. Trust the proxy to announce the real client address. The proxy forwards it
|
||||||
|
# over the XCLIENT command (Postfix submission and Dovecot ManageSieve/POP3)
|
||||||
|
# and the IMAP ID command, instead of a PROXY protocol header. Without this
|
||||||
|
# trust the backend records the proxy's address as the client; logins still
|
||||||
|
# work, but the real client address is lost.
|
||||||
|
#
|
||||||
|
# 2. Provide a master user, so the migration tool (Vandelay) can read any
|
||||||
|
# mailbox over IMAP with a single administrative credential rather than each
|
||||||
|
# user's password.
|
||||||
|
#
|
||||||
|
# Replace the address 203.0.113.10 with the proxy's actual address. Restrict the
|
||||||
|
# trust to the proxy, not to the whole network, so that arbitrary hosts cannot
|
||||||
|
# spoof a client address.
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dovecot: /etc/dovecot/dovecot.conf (or a file under /etc/dovecot/conf.d)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Trust the proxy to forward the real client address via XCLIENT and the IMAP ID
|
||||||
|
# command. List the proxy's address only.
|
||||||
|
login_trusted_networks = 203.0.113.10/32
|
||||||
|
|
||||||
|
# Master user support, used by Vandelay to read any mailbox during migration. The
|
||||||
|
# login presented is "<user>%<master-user>" (here the separator is "%"), with the
|
||||||
|
# master user's password. For example, reading alice's mailbox uses the login
|
||||||
|
# "alice@example.com%vmadmin" and vmadmin's password.
|
||||||
|
auth_master_user_separator = %
|
||||||
|
|
||||||
|
passdb {
|
||||||
|
driver = passwd-file
|
||||||
|
master = yes
|
||||||
|
args = scheme=PLAIN username_format=%u /etc/dovecot/master-users
|
||||||
|
}
|
||||||
|
|
||||||
|
# /etc/dovecot/master-users (one line; use a real password hash in production,
|
||||||
|
# for example one produced by "doveadm pw -s SHA512-CRYPT"):
|
||||||
|
# vmadmin:{PLAIN}ChangeThisMasterPassword
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Postfix: apply with postconf, or add to /etc/postfix/main.cf
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Trust the proxy to announce the real client over XCLIENT on the SMTP and
|
||||||
|
# submission services. List the proxy's address only.
|
||||||
|
#
|
||||||
|
# postconf -e "smtpd_authorized_xclient_hosts=203.0.113.10/32"
|
||||||
|
#
|
||||||
|
# main.cf equivalent:
|
||||||
|
# smtpd_authorized_xclient_hosts = 203.0.113.10/32
|
||||||
|
|
||||||
|
# After changing either server, reload it:
|
||||||
|
# doveadm reload # Dovecot
|
||||||
|
# postfix reload # Postfix
|
||||||
|
#
|
||||||
|
# To confirm XCLIENT is being honoured, sign in through the proxy and check the
|
||||||
|
# Dovecot login line: the "rip=" field should show the real client address rather
|
||||||
|
# than the proxy's. If it shows the proxy's address, login_trusted_networks does
|
||||||
|
# not include the proxy.
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Build a Stalwart CLI migration plan from a Stalwart v0.15 server.
|
||||||
|
|
||||||
|
This reads principal (account) definitions from a running Stalwart v0.15
|
||||||
|
server through its management API and emits an NDJSON plan that the Stalwart
|
||||||
|
CLI can apply against a new (latest) Stalwart server to recreate those
|
||||||
|
accounts:
|
||||||
|
|
||||||
|
stalwart-cli apply --file migration-plan.ndjson
|
||||||
|
|
||||||
|
Each line of the plan is a create operation in the form consumed by
|
||||||
|
`stalwart-cli apply`:
|
||||||
|
|
||||||
|
{"@type":"create","object":"Domain","value":{"<ref>":{...}}}
|
||||||
|
{"@type":"create","object":"Account","value":{"<ref>":{"@type":"User",...}}}
|
||||||
|
|
||||||
|
Accounts reference their domain through a plan-local "#<ref>" placeholder that
|
||||||
|
the CLI resolves while applying. When a domain already exists on the target
|
||||||
|
server, pass --existing-domain NAME=ID so accounts reference the real id
|
||||||
|
instead of trying to recreate the domain.
|
||||||
|
|
||||||
|
Password hashes are migrated verbatim, so users keep their existing passwords.
|
||||||
|
App passwords and one-time-password (TOTP) secrets are not migrated.
|
||||||
|
|
||||||
|
If a username is given, only that account is exported; otherwise every
|
||||||
|
individual (optionally every group with --include-groups) is exported.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import ssl
|
||||||
|
import sys
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
PAGE_SIZE = 200
|
||||||
|
REQUEST_TIMEOUT = 60
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class StalwartV015Client:
|
||||||
|
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
|
||||||
|
|
||||||
|
def get(self, path, params=None):
|
||||||
|
url = self.base + path
|
||||||
|
if params:
|
||||||
|
url += "?" + urllib.parse.urlencode(params)
|
||||||
|
req = urllib.request.Request(url, method="GET")
|
||||||
|
req.add_header("Authorization", self.auth)
|
||||||
|
req.add_header("Accept", "application/json")
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=self.ctx) as resp:
|
||||||
|
payload = json.loads(resp.read().decode())
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
body = exc.read().decode(errors="replace")
|
||||||
|
raise ApiError(f"{exc.code} {exc.reason} for {path}: {body}") from exc
|
||||||
|
except urllib.error.URLError as exc:
|
||||||
|
raise ApiError(f"cannot reach {url}: {exc.reason}") from exc
|
||||||
|
if isinstance(payload, dict) and "data" in payload:
|
||||||
|
return payload["data"]
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def list_principal_names(self, types):
|
||||||
|
names = []
|
||||||
|
for kind in types:
|
||||||
|
page = 1
|
||||||
|
while True:
|
||||||
|
data = self.get(
|
||||||
|
"/api/principal",
|
||||||
|
{"page": page, "limit": PAGE_SIZE, "types": kind},
|
||||||
|
)
|
||||||
|
items = data.get("items") or []
|
||||||
|
for item in items:
|
||||||
|
name = principal_name(item)
|
||||||
|
if name:
|
||||||
|
names.append(name)
|
||||||
|
total = data.get("total") or 0
|
||||||
|
if page * PAGE_SIZE >= total or not items:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
return names
|
||||||
|
|
||||||
|
def fetch_principal(self, name):
|
||||||
|
return self.get("/api/principal/" + urllib.parse.quote(name, safe=""))
|
||||||
|
|
||||||
|
|
||||||
|
def principal_name(record):
|
||||||
|
name = record.get("name")
|
||||||
|
if isinstance(name, dict):
|
||||||
|
return name.get("string")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def scalar_list(value):
|
||||||
|
if value is None:
|
||||||
|
return []
|
||||||
|
if isinstance(value, list):
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
if "stringList" in value:
|
||||||
|
return value["stringList"]
|
||||||
|
if "string" in value:
|
||||||
|
return [value["string"]]
|
||||||
|
return list(value.values())
|
||||||
|
return [value]
|
||||||
|
|
||||||
|
|
||||||
|
def scalar(value):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in ("string", "integer"):
|
||||||
|
if key in value:
|
||||||
|
return value[key]
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def domain_ref(domain):
|
||||||
|
safe = "".join(c if c.isalnum() else "_" for c in domain)
|
||||||
|
return "domain_" + safe
|
||||||
|
|
||||||
|
|
||||||
|
def split_address(record):
|
||||||
|
name = principal_name(record) or ""
|
||||||
|
emails = [e for e in scalar_list(record.get("emails")) if e]
|
||||||
|
primary = emails[0] if emails else name
|
||||||
|
if "@" in primary:
|
||||||
|
local, _, domain = primary.partition("@")
|
||||||
|
elif "@" in name:
|
||||||
|
local, _, domain = name.partition("@")
|
||||||
|
primary = name
|
||||||
|
else:
|
||||||
|
local, domain = name, None
|
||||||
|
return local, domain, primary, emails
|
||||||
|
|
||||||
|
|
||||||
|
def build_credentials(record):
|
||||||
|
creds = {}
|
||||||
|
index = 0
|
||||||
|
for secret in scalar_list(record.get("secrets")):
|
||||||
|
if not isinstance(secret, str) or not secret:
|
||||||
|
continue
|
||||||
|
if secret.startswith("$app$"):
|
||||||
|
continue
|
||||||
|
if secret.startswith("otpauth://"):
|
||||||
|
continue
|
||||||
|
creds[str(index)] = {"@type": "Password", "secret": secret}
|
||||||
|
index += 1
|
||||||
|
return creds
|
||||||
|
|
||||||
|
|
||||||
|
def build_aliases(primary, emails, domain_refs, existing):
|
||||||
|
aliases = {}
|
||||||
|
index = 0
|
||||||
|
for email in emails:
|
||||||
|
if email == primary or "@" not in email:
|
||||||
|
continue
|
||||||
|
local, _, domain = email.partition("@")
|
||||||
|
ref = existing.get(domain) or ("#" + domain_ref(domain))
|
||||||
|
aliases[str(index)] = {"name": local, "domainId": ref}
|
||||||
|
domain_refs.add(domain)
|
||||||
|
index += 1
|
||||||
|
return aliases
|
||||||
|
|
||||||
|
|
||||||
|
def build_account(record, domain_value, existing):
|
||||||
|
kind = scalar(record.get("type"))
|
||||||
|
local, domain, primary, emails = split_address(record)
|
||||||
|
if not domain:
|
||||||
|
raise ApiError(
|
||||||
|
f"principal {principal_name(record)!r} has no email domain; cannot map it"
|
||||||
|
)
|
||||||
|
seen_domains = {domain}
|
||||||
|
domain_id = existing.get(domain) or ("#" + domain_ref(domain))
|
||||||
|
|
||||||
|
account = {
|
||||||
|
"@type": "Group" if kind == "group" else "User",
|
||||||
|
"name": local,
|
||||||
|
"domainId": domain_id,
|
||||||
|
}
|
||||||
|
description = scalar(record.get("description"))
|
||||||
|
if description:
|
||||||
|
account["description"] = description
|
||||||
|
creds = build_credentials(record)
|
||||||
|
if creds and kind != "group":
|
||||||
|
account["credentials"] = creds
|
||||||
|
if kind != "group":
|
||||||
|
account["roles"] = {"@type": "User"}
|
||||||
|
account["encryptionAtRest"] = {"@type": "Disabled"}
|
||||||
|
aliases = build_aliases(primary, emails, seen_domains, existing)
|
||||||
|
if aliases:
|
||||||
|
account["aliases"] = aliases
|
||||||
|
|
||||||
|
for dom in seen_domains:
|
||||||
|
if dom not in existing:
|
||||||
|
domain_value.setdefault(domain_ref(dom), {"name": dom})
|
||||||
|
return account
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Build a Stalwart CLI migration plan from a Stalwart v0.15 server.",
|
||||||
|
)
|
||||||
|
parser.add_argument("username", nargs="?", help="export only this account (default: all)")
|
||||||
|
parser.add_argument("--url", required=True, help="base URL of the v0.15 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 (HTTP basic auth)")
|
||||||
|
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 an existing target domain by id instead of creating it",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
help="write the plan to this file (default: stdout)",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.token and args.user is None:
|
||||||
|
parser.error("provide --token or --user/--password")
|
||||||
|
|
||||||
|
existing = {}
|
||||||
|
for entry in args.existing_domain:
|
||||||
|
if "=" not in entry:
|
||||||
|
parser.error(f"--existing-domain expects NAME=ID, got {entry!r}")
|
||||||
|
name, _, value = entry.partition("=")
|
||||||
|
existing[name.strip()] = value.strip()
|
||||||
|
|
||||||
|
client = StalwartV015Client(
|
||||||
|
args.url,
|
||||||
|
token=args.token,
|
||||||
|
username=args.user,
|
||||||
|
password=args.password,
|
||||||
|
insecure=args.insecure,
|
||||||
|
)
|
||||||
|
|
||||||
|
if args.username:
|
||||||
|
names = [args.username]
|
||||||
|
else:
|
||||||
|
types = ["individual"]
|
||||||
|
if args.include_groups:
|
||||||
|
types.append("group")
|
||||||
|
names = client.list_principal_names(types)
|
||||||
|
|
||||||
|
if not names:
|
||||||
|
sys.stderr.write("no accounts to export\n")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
domain_value = {}
|
||||||
|
account_value = {}
|
||||||
|
for name in names:
|
||||||
|
record = client.fetch_principal(name)
|
||||||
|
account = build_account(record, domain_value, existing)
|
||||||
|
account_value["acct_" + str(scalar(record.get("id")) or name)] = account
|
||||||
|
|
||||||
|
ops = []
|
||||||
|
if domain_value:
|
||||||
|
ops.append({"@type": "create", "object": "Domain", "value": domain_value})
|
||||||
|
ops.append({"@type": "create", "object": "Account", "value": account_value})
|
||||||
|
|
||||||
|
out = open(args.output, "w") if args.output else sys.stdout
|
||||||
|
try:
|
||||||
|
for op in ops:
|
||||||
|
out.write(json.dumps(op) + "\n")
|
||||||
|
finally:
|
||||||
|
if args.output:
|
||||||
|
out.close()
|
||||||
|
|
||||||
|
sys.stderr.write(
|
||||||
|
f"exported {len(account_value)} account(s), {len(domain_value)} domain(s)\n"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -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)
|
||||||
+24
-10
@@ -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(());
|
||||||
@@ -237,6 +242,8 @@ async fn handle(
|
|||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"destination": id,
|
"destination": id,
|
||||||
"consecutive_failures": s.consecutive_failures,
|
"consecutive_failures": s.consecutive_failures,
|
||||||
|
"failure_sources": s.failure_sources,
|
||||||
|
"host_level_failure": s.host_level_failure,
|
||||||
"down": s.down,
|
"down": s.down,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -296,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?;
|
||||||
}
|
}
|
||||||
@@ -336,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?;
|
||||||
@@ -372,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?;
|
||||||
@@ -393,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -404,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?;
|
||||||
}
|
}
|
||||||
@@ -428,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?;
|
||||||
}
|
}
|
||||||
@@ -651,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();
|
||||||
|
|||||||
+51
-2
@@ -140,6 +140,8 @@ pub struct ServerConfig {
|
|||||||
pub backend_connect_retries: u32,
|
pub backend_connect_retries: u32,
|
||||||
#[serde(default = "default_host_down_threshold")]
|
#[serde(default = "default_host_down_threshold")]
|
||||||
pub host_down_threshold: u32,
|
pub host_down_threshold: u32,
|
||||||
|
#[serde(default = "default_host_down_min_clients")]
|
||||||
|
pub host_down_min_clients: u32,
|
||||||
#[serde(default = "default_host_down_cooldown", with = "duration")]
|
#[serde(default = "default_host_down_cooldown", with = "duration")]
|
||||||
pub host_down_cooldown: Duration,
|
pub host_down_cooldown: Duration,
|
||||||
#[serde(default = "default_proxy_ttl")]
|
#[serde(default = "default_proxy_ttl")]
|
||||||
@@ -159,6 +161,7 @@ impl Default for ServerConfig {
|
|||||||
backend_timeout: DEFAULT_BACKEND_TIMEOUT,
|
backend_timeout: DEFAULT_BACKEND_TIMEOUT,
|
||||||
backend_connect_retries: DEFAULT_BACKEND_CONNECT_RETRIES,
|
backend_connect_retries: DEFAULT_BACKEND_CONNECT_RETRIES,
|
||||||
host_down_threshold: DEFAULT_HOST_DOWN_THRESHOLD,
|
host_down_threshold: DEFAULT_HOST_DOWN_THRESHOLD,
|
||||||
|
host_down_min_clients: DEFAULT_HOST_DOWN_MIN_CLIENTS,
|
||||||
host_down_cooldown: DEFAULT_HOST_DOWN_COOLDOWN,
|
host_down_cooldown: DEFAULT_HOST_DOWN_COOLDOWN,
|
||||||
proxy_ttl: DEFAULT_PROXY_TTL,
|
proxy_ttl: DEFAULT_PROXY_TTL,
|
||||||
log_level: DEFAULT_LOG_LEVEL.to_string(),
|
log_level: DEFAULT_LOG_LEVEL.to_string(),
|
||||||
@@ -181,6 +184,7 @@ const DEFAULT_BRIDGE_IDLE: Duration = Duration::from_secs(1800);
|
|||||||
const DEFAULT_BACKEND_TIMEOUT: Duration = Duration::from_secs(10);
|
const DEFAULT_BACKEND_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const DEFAULT_BACKEND_CONNECT_RETRIES: u32 = 1;
|
const DEFAULT_BACKEND_CONNECT_RETRIES: u32 = 1;
|
||||||
const DEFAULT_HOST_DOWN_THRESHOLD: u32 = 5;
|
const DEFAULT_HOST_DOWN_THRESHOLD: u32 = 5;
|
||||||
|
const DEFAULT_HOST_DOWN_MIN_CLIENTS: u32 = 2;
|
||||||
const DEFAULT_HOST_DOWN_COOLDOWN: Duration = Duration::from_secs(30);
|
const DEFAULT_HOST_DOWN_COOLDOWN: Duration = Duration::from_secs(30);
|
||||||
const DEFAULT_PROXY_TTL: u32 = 7;
|
const DEFAULT_PROXY_TTL: u32 = 7;
|
||||||
const DEFAULT_LOG_LEVEL: &str = "info";
|
const DEFAULT_LOG_LEVEL: &str = "info";
|
||||||
@@ -200,6 +204,9 @@ fn default_backend_connect_retries() -> u32 {
|
|||||||
fn default_host_down_threshold() -> u32 {
|
fn default_host_down_threshold() -> u32 {
|
||||||
DEFAULT_HOST_DOWN_THRESHOLD
|
DEFAULT_HOST_DOWN_THRESHOLD
|
||||||
}
|
}
|
||||||
|
fn default_host_down_min_clients() -> u32 {
|
||||||
|
DEFAULT_HOST_DOWN_MIN_CLIENTS
|
||||||
|
}
|
||||||
fn default_host_down_cooldown() -> Duration {
|
fn default_host_down_cooldown() -> Duration {
|
||||||
DEFAULT_HOST_DOWN_COOLDOWN
|
DEFAULT_HOST_DOWN_COOLDOWN
|
||||||
}
|
}
|
||||||
@@ -489,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> {
|
||||||
@@ -505,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")]
|
||||||
@@ -789,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)
|
||||||
@@ -879,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(
|
||||||
|
|||||||
+11
-1
@@ -25,6 +25,9 @@ pub enum ProxyError {
|
|||||||
#[error("backend connection failed: {0}")]
|
#[error("backend connection failed: {0}")]
|
||||||
BackendConnect(Cow<'static, str>),
|
BackendConnect(Cow<'static, str>),
|
||||||
|
|
||||||
|
#[error("backend session failed: {0}")]
|
||||||
|
BackendSession(Cow<'static, str>),
|
||||||
|
|
||||||
#[error("protocol error: {0}")]
|
#[error("protocol error: {0}")]
|
||||||
Protocol(Cow<'static, str>),
|
Protocol(Cow<'static, str>),
|
||||||
|
|
||||||
@@ -52,8 +55,15 @@ impl ProxyError {
|
|||||||
ProxyError::BackendConnect(msg.into())
|
ProxyError::BackendConnect(msg.into())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn backend_session(msg: impl Into<Cow<'static, str>>) -> Self {
|
||||||
|
ProxyError::BackendSession(msg.into())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn is_retryable(&self) -> bool {
|
pub fn is_retryable(&self) -> bool {
|
||||||
matches!(self, ProxyError::BackendConnect(_) | ProxyError::Io(_))
|
matches!(
|
||||||
|
self,
|
||||||
|
ProxyError::BackendConnect(_) | ProxyError::BackendSession(_) | ProxyError::Io(_)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn protocol(msg: impl Into<Cow<'static, str>>) -> Self {
|
pub fn protocol(msg: impl Into<Cow<'static, str>>) -> Self {
|
||||||
|
|||||||
+126
-19
@@ -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(_) => {
|
||||||
@@ -502,7 +572,7 @@ async fn serve(
|
|||||||
let _ = write_client(stream, &error_response(502, "Bad Gateway")).await;
|
let _ = write_client(stream, &error_response(502, "Bad Gateway")).await;
|
||||||
return Ok(Disposition::Close);
|
return Ok(Disposition::Close);
|
||||||
}
|
}
|
||||||
let mut backend = match crate::proto::common::establish(ctx, &dest_id, || {
|
let mut backend = match crate::proto::common::establish(ctx, &dest_id, peer, || {
|
||||||
crate::outbound::dial(dest, ep, fwd, &tls_cfg, peer, local)
|
crate::outbound::dial(dest, ep, fwd, &tls_cfg, peer, local)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
@@ -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
@@ -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]]
|
||||||
|
|||||||
+5
-5
@@ -134,7 +134,7 @@ pub async fn tls_connect(
|
|||||||
let tls = connector
|
let tls = connector
|
||||||
.connect(name, stream)
|
.connect(name, stream)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::backend_connect(format!("backend TLS handshake failed: {e}")))?;
|
.map_err(|e| ProxyError::backend_session(format!("backend TLS handshake failed: {e}")))?;
|
||||||
Ok(Box::new(tls))
|
Ok(Box::new(tls))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,9 +160,9 @@ pub async fn read_line(
|
|||||||
let n = stream
|
let n = stream
|
||||||
.read(&mut buf)
|
.read(&mut buf)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ProxyError::backend_connect(format!("reading backend: {e}")))?;
|
.map_err(|e| ProxyError::backend_session(format!("reading backend: {e}")))?;
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
return Err(ProxyError::backend_connect(
|
return Err(ProxyError::backend_session(
|
||||||
"backend closed before sending a complete line",
|
"backend closed before sending a complete line",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
+651
-64
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::net::SocketAddr;
|
use std::net::{IpAddr, SocketAddr};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -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')
|
||||||
@@ -141,9 +184,35 @@ pub struct DestHealth {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct HealthState {
|
struct HealthState {
|
||||||
consecutive_failures: u32,
|
consecutive_failures: u32,
|
||||||
|
failure_sources: AHashSet<IpAddr>,
|
||||||
|
host_level_failure: bool,
|
||||||
down_until: Option<Instant>,
|
down_until: Option<Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl HealthState {
|
||||||
|
fn clear_evidence(&mut self) {
|
||||||
|
self.failure_sources.clear();
|
||||||
|
self.host_level_failure = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_TRACKED_FAILURE_SOURCES: usize = 64;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||||
|
pub enum Attribution {
|
||||||
|
Host,
|
||||||
|
Client(IpAddr),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Attribution {
|
||||||
|
fn of(err: &ProxyError, peer: IpAddr) -> Self {
|
||||||
|
match err {
|
||||||
|
ProxyError::BackendConnect(_) => Attribution::Host,
|
||||||
|
_ => Attribution::Client(peer),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl DestHealth {
|
impl DestHealth {
|
||||||
fn gate(&self) -> Result<()> {
|
fn gate(&self) -> Result<()> {
|
||||||
let mut st = self.inner.lock();
|
let mut st = self.inner.lock();
|
||||||
@@ -154,26 +223,39 @@ impl DestHealth {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
st.down_until = None;
|
st.down_until = None;
|
||||||
|
st.clear_evidence();
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn record(&self, ok: bool, threshold: u32, cooldown: Duration) {
|
fn record_success(&self) {
|
||||||
let mut st = self.inner.lock();
|
let mut st = self.inner.lock();
|
||||||
if ok {
|
st.consecutive_failures = 0;
|
||||||
st.consecutive_failures = 0;
|
st.clear_evidence();
|
||||||
st.down_until = None;
|
st.down_until = None;
|
||||||
} else {
|
}
|
||||||
st.consecutive_failures = st.consecutive_failures.saturating_add(1);
|
|
||||||
if threshold > 0 && st.consecutive_failures >= threshold {
|
fn record_failure(&self, at: Attribution, limits: &HealthLimits) {
|
||||||
st.down_until = Some(Instant::now() + cooldown);
|
let mut st = self.inner.lock();
|
||||||
|
st.consecutive_failures = st.consecutive_failures.saturating_add(1);
|
||||||
|
match at {
|
||||||
|
Attribution::Host => st.host_level_failure = true,
|
||||||
|
Attribution::Client(ip) => {
|
||||||
|
if st.failure_sources.len() < MAX_TRACKED_FAILURE_SOURCES {
|
||||||
|
st.failure_sources.insert(ip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let broad = st.host_level_failure || st.failure_sources.len() as u32 >= limits.min_clients;
|
||||||
|
if limits.threshold > 0 && st.consecutive_failures >= limits.threshold && broad {
|
||||||
|
st.down_until = Some(Instant::now() + limits.cooldown);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(&self) {
|
pub fn reset(&self) {
|
||||||
let mut st = self.inner.lock();
|
let mut st = self.inner.lock();
|
||||||
st.consecutive_failures = 0;
|
st.consecutive_failures = 0;
|
||||||
|
st.clear_evidence();
|
||||||
st.down_until = None;
|
st.down_until = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,27 +264,53 @@ impl DestHealth {
|
|||||||
let down = matches!(st.down_until, Some(until) if Instant::now() < until);
|
let down = matches!(st.down_until, Some(until) if Instant::now() < until);
|
||||||
HealthSnapshot {
|
HealthSnapshot {
|
||||||
consecutive_failures: st.consecutive_failures,
|
consecutive_failures: st.consecutive_failures,
|
||||||
|
failure_sources: st.failure_sources.len() as u32,
|
||||||
|
host_level_failure: st.host_level_failure,
|
||||||
down,
|
down,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct HealthLimits {
|
||||||
|
threshold: u32,
|
||||||
|
min_clients: u32,
|
||||||
|
cooldown: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HealthLimits {
|
||||||
|
fn for_server(server: &crate::config::ServerConfig) -> Self {
|
||||||
|
HealthLimits {
|
||||||
|
threshold: server.host_down_threshold,
|
||||||
|
min_clients: server.host_down_min_clients,
|
||||||
|
cooldown: server.host_down_cooldown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct HealthSnapshot {
|
pub struct HealthSnapshot {
|
||||||
pub consecutive_failures: u32,
|
pub consecutive_failures: u32,
|
||||||
|
pub failure_sources: u32,
|
||||||
|
pub host_level_failure: bool,
|
||||||
pub down: bool,
|
pub down: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
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)]
|
||||||
@@ -242,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]
|
||||||
@@ -278,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 {
|
||||||
@@ -425,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 {
|
||||||
@@ -465,7 +648,12 @@ pub fn assert_egress_encrypted(
|
|||||||
|
|
||||||
const BACKEND_RETRY_DELAY: Duration = Duration::from_millis(200);
|
const BACKEND_RETRY_DELAY: Duration = Duration::from_millis(200);
|
||||||
|
|
||||||
pub async fn establish<F, Fut, T>(ctx: &Ctx, dest_id: &str, mut open: F) -> Result<T>
|
pub async fn establish<F, Fut, T>(
|
||||||
|
ctx: &Ctx,
|
||||||
|
dest_id: &str,
|
||||||
|
peer: SocketAddr,
|
||||||
|
mut open: F,
|
||||||
|
) -> Result<T>
|
||||||
where
|
where
|
||||||
F: FnMut() -> Fut,
|
F: FnMut() -> Fut,
|
||||||
Fut: Future<Output = Result<T>>,
|
Fut: Future<Output = Result<T>>,
|
||||||
@@ -475,6 +663,7 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let server = &ctx.config.server;
|
let server = &ctx.config.server;
|
||||||
|
let limits = HealthLimits::for_server(server);
|
||||||
let mut attempt: u32 = 0;
|
let mut attempt: u32 = 0;
|
||||||
loop {
|
loop {
|
||||||
let outcome = match tokio::time::timeout(server.backend_timeout, open()).await {
|
let outcome = match tokio::time::timeout(server.backend_timeout, open()).await {
|
||||||
@@ -487,7 +676,7 @@ where
|
|||||||
match outcome {
|
match outcome {
|
||||||
Ok(value) => {
|
Ok(value) => {
|
||||||
if let Some(health) = ctx.dest_health.get(dest_id) {
|
if let Some(health) = ctx.dest_health.get(dest_id) {
|
||||||
health.record(true, server.host_down_threshold, server.host_down_cooldown);
|
health.record_success();
|
||||||
}
|
}
|
||||||
return Ok(value);
|
return Ok(value);
|
||||||
}
|
}
|
||||||
@@ -504,7 +693,15 @@ where
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Some(health) = ctx.dest_health.get(dest_id) {
|
if let Some(health) = ctx.dest_health.get(dest_id) {
|
||||||
health.record(false, server.host_down_threshold, server.host_down_cooldown);
|
let at = Attribution::of(&err, peer.ip());
|
||||||
|
if matches!(at, Attribution::Client(_)) {
|
||||||
|
tracing::debug!(
|
||||||
|
destination = %dest_id,
|
||||||
|
error = %err,
|
||||||
|
"backend failure attributed to the client, not counted as host evidence on its own"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
health.record_failure(at, &limits);
|
||||||
}
|
}
|
||||||
return Err(err);
|
return Err(err);
|
||||||
}
|
}
|
||||||
@@ -513,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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,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,
|
||||||
@@ -617,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> {
|
||||||
@@ -625,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);
|
||||||
@@ -659,19 +910,20 @@ pub async fn bridge_authenticated<F, Fut>(
|
|||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
dest_id: &str,
|
dest_id: &str,
|
||||||
mut stream: BoxedStream,
|
mut stream: BoxedStream,
|
||||||
|
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)
|
||||||
@@ -682,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, 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);
|
||||||
@@ -704,6 +962,7 @@ where
|
|||||||
&mut backend,
|
&mut backend,
|
||||||
&mut backend_residual,
|
&mut backend_residual,
|
||||||
wire,
|
wire,
|
||||||
|
pending_response,
|
||||||
hide,
|
hide,
|
||||||
max,
|
max,
|
||||||
),
|
),
|
||||||
@@ -716,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,
|
||||||
@@ -739,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<()> {
|
||||||
@@ -811,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";
|
||||||
@@ -830,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!(
|
||||||
@@ -854,29 +1209,189 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn limits(threshold: u32, min_clients: u32, cooldown: Duration) -> HealthLimits {
|
||||||
|
HealthLimits {
|
||||||
|
threshold,
|
||||||
|
min_clients,
|
||||||
|
cooldown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn client(addr: &str) -> Attribution {
|
||||||
|
Attribution::Client(addr.parse().unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dest_health_opens_after_threshold_and_recovers() {
|
fn dest_health_opens_after_threshold_and_recovers() {
|
||||||
let h = DestHealth::default();
|
let h = DestHealth::default();
|
||||||
let cooldown = Duration::from_secs(60);
|
let lim = limits(3, 2, Duration::from_secs(60));
|
||||||
|
|
||||||
h.record(false, 3, cooldown);
|
h.record_failure(Attribution::Host, &lim);
|
||||||
h.record(false, 3, cooldown);
|
h.record_failure(Attribution::Host, &lim);
|
||||||
assert!(h.gate().is_ok());
|
assert!(h.gate().is_ok());
|
||||||
|
|
||||||
h.record(false, 3, cooldown);
|
h.record_failure(Attribution::Host, &lim);
|
||||||
assert!(h.gate().is_err());
|
assert!(h.gate().is_err());
|
||||||
|
|
||||||
h.record(true, 3, cooldown);
|
h.record_success();
|
||||||
assert!(h.gate().is_ok());
|
assert!(h.gate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn dest_health_cooldown_elapses_into_a_probe() {
|
fn dest_health_cooldown_elapses_into_a_probe() {
|
||||||
let h = DestHealth::default();
|
let h = DestHealth::default();
|
||||||
h.record(false, 1, Duration::from_secs(0));
|
h.record_failure(Attribution::Host, &limits(1, 2, Duration::from_secs(0)));
|
||||||
assert!(h.gate().is_ok());
|
assert!(h.gate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attribution_follows_the_failure_stage() {
|
||||||
|
let ip: IpAddr = "203.0.113.9".parse().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
Attribution::of(&ProxyError::backend_connect("refused"), ip),
|
||||||
|
Attribution::Host
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Attribution::of(&ProxyError::backend("closed after PROXY header"), ip),
|
||||||
|
Attribution::Client(ip)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Attribution::of(
|
||||||
|
&ProxyError::backend_session("backend closed before sending a complete line"),
|
||||||
|
ip
|
||||||
|
),
|
||||||
|
Attribution::Client(ip)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Attribution::of(
|
||||||
|
&ProxyError::backend_session("backend TLS handshake failed"),
|
||||||
|
ip
|
||||||
|
),
|
||||||
|
Attribution::Client(ip)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
Attribution::of(
|
||||||
|
&ProxyError::Io(std::io::Error::from(std::io::ErrorKind::ConnectionReset)),
|
||||||
|
ip
|
||||||
|
),
|
||||||
|
Attribution::Client(ip)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn banned_client_alone_never_opens_the_breaker() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 2, Duration::from_secs(60));
|
||||||
|
for _ in 0..50 {
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
}
|
||||||
|
assert!(h.gate().is_ok());
|
||||||
|
let s = h.snapshot();
|
||||||
|
assert_eq!(s.consecutive_failures, 50);
|
||||||
|
assert_eq!(s.failure_sources, 1);
|
||||||
|
assert!(!s.down);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn failures_spanning_distinct_clients_open_the_breaker() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 2, Duration::from_secs(60));
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
assert!(h.gate().is_ok());
|
||||||
|
|
||||||
|
h.record_failure(client("203.0.113.4"), &lim);
|
||||||
|
assert!(h.gate().is_err());
|
||||||
|
assert_eq!(h.snapshot().failure_sources, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_level_failure_opens_the_breaker_without_breadth() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 2, Duration::from_secs(60));
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(Attribution::Host, &lim);
|
||||||
|
assert!(h.gate().is_err());
|
||||||
|
let s = h.snapshot();
|
||||||
|
assert!(s.host_level_failure);
|
||||||
|
assert!(s.down);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cooldown_expiry_clears_stale_evidence() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 2, Duration::from_secs(0));
|
||||||
|
for _ in 0..3 {
|
||||||
|
h.record_failure(Attribution::Host, &lim);
|
||||||
|
}
|
||||||
|
assert!(h.gate().is_ok());
|
||||||
|
let s = h.snapshot();
|
||||||
|
assert!(!s.host_level_failure);
|
||||||
|
assert_eq!(s.failure_sources, 0);
|
||||||
|
|
||||||
|
h.record_failure(
|
||||||
|
client("198.51.100.7"),
|
||||||
|
&limits(3, 2, Duration::from_secs(60)),
|
||||||
|
);
|
||||||
|
assert!(h.gate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cooldown_expiry_still_re_opens_on_a_failed_host_probe() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 2, Duration::from_secs(0));
|
||||||
|
for _ in 0..3 {
|
||||||
|
h.record_failure(Attribution::Host, &lim);
|
||||||
|
}
|
||||||
|
assert!(h.gate().is_ok());
|
||||||
|
|
||||||
|
h.record_failure(Attribution::Host, &limits(3, 2, Duration::from_secs(60)));
|
||||||
|
assert!(h.gate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn success_clears_the_failure_streak() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 2, Duration::from_secs(60));
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(client("203.0.113.4"), &lim);
|
||||||
|
h.record_success();
|
||||||
|
let s = h.snapshot();
|
||||||
|
assert_eq!(s.consecutive_failures, 0);
|
||||||
|
assert_eq!(s.failure_sources, 0);
|
||||||
|
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
assert!(h.gate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn min_clients_zero_counts_failures_alone() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(3, 0, Duration::from_secs(60));
|
||||||
|
for _ in 0..3 {
|
||||||
|
h.record_failure(client("198.51.100.7"), &lim);
|
||||||
|
}
|
||||||
|
assert!(h.gate().is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tracked_failure_sources_are_bounded() {
|
||||||
|
let h = DestHealth::default();
|
||||||
|
let lim = limits(0, 2, Duration::from_secs(60));
|
||||||
|
for i in 0..(MAX_TRACKED_FAILURE_SOURCES + 100) {
|
||||||
|
let ip = IpAddr::from([10, (i / 256) as u8, (i % 256) as u8, 1]);
|
||||||
|
h.record_failure(Attribution::Client(ip), &lim);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
h.snapshot().failure_sources as usize,
|
||||||
|
MAX_TRACKED_FAILURE_SOURCES
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn conn_registry_kick_signals_live_sessions() {
|
async fn conn_registry_kick_signals_live_sessions() {
|
||||||
let reg = ConnRegistry::default();
|
let reg = ConnRegistry::default();
|
||||||
@@ -898,21 +1413,93 @@ 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]
|
||||||
fn dest_health_threshold_zero_never_trips() {
|
fn dest_health_threshold_zero_never_trips() {
|
||||||
let h = DestHealth::default();
|
let h = DestHealth::default();
|
||||||
for _ in 0..100 {
|
for _ in 0..100 {
|
||||||
h.record(false, 0, Duration::from_secs(60));
|
h.record_failure(Attribution::Host, &limits(0, 2, Duration::from_secs(60)));
|
||||||
}
|
}
|
||||||
assert!(h.gate().is_ok());
|
assert!(h.gate().is_ok());
|
||||||
}
|
}
|
||||||
|
|||||||
+216
-100
@@ -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,67 +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 {
|
||||||
identifier.as_deref(),
|
let decision = match timeout(
|
||||||
&residual,
|
listener.preauth_timeout,
|
||||||
started,
|
preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
|
||||||
"imap",
|
)
|
||||||
common::AuthWire::Imap { tag },
|
.await
|
||||||
auth_frame.as_bytes(),
|
{
|
||||||
unavailable.as_bytes(),
|
Ok(Ok(d)) => d,
|
||||||
MAX_PREAUTH,
|
Ok(Err(e)) => return Err(e),
|
||||||
|| open_backend(ctx, &dest_id, &fwd),
|
Err(_) => return Ok(()),
|
||||||
)
|
};
|
||||||
.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(
|
||||||
@@ -107,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 {
|
||||||
@@ -114,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) {
|
||||||
@@ -229,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),
|
||||||
}
|
}
|
||||||
@@ -250,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),
|
||||||
}
|
}
|
||||||
@@ -275,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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,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,
|
||||||
@@ -324,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(
|
||||||
@@ -348,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,
|
||||||
@@ -382,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());
|
||||||
@@ -391,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
|
||||||
@@ -406,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);
|
||||||
@@ -419,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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,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,
|
||||||
@@ -447,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") {
|
||||||
@@ -457,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?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -466,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
-94
@@ -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,76 +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,
|
|
||||||
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
|
||||||
@@ -107,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) {
|
||||||
@@ -172,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?
|
||||||
@@ -193,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),
|
||||||
}
|
}
|
||||||
@@ -217,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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,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,
|
||||||
@@ -254,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",
|
||||||
@@ -265,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);
|
||||||
|
|
||||||
@@ -289,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>)> {
|
||||||
@@ -324,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,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,
|
||||||
@@ -431,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
-69
@@ -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,65 +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;
|
||||||
identifier.as_deref(),
|
let fwd = forward::ForwardInfo::new(
|
||||||
&residual,
|
peer,
|
||||||
started,
|
local,
|
||||||
"pop3",
|
client.is_tls(),
|
||||||
common::AuthWire::Pop3,
|
None,
|
||||||
auth_frame.as_bytes(),
|
ctx.config.server.proxy_ttl,
|
||||||
b"-ERR backend unavailable\r\n",
|
);
|
||||||
MAX_PREAUTH,
|
|
||||||
|| open_backend(ctx, &dest_id, &fwd),
|
let mut frame = format!("AUTH {} ", mechanism_name(&mech));
|
||||||
)
|
BASE64.encode_append(&raw_challenge, &mut frame);
|
||||||
.await
|
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,
|
||||||
|
"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(
|
||||||
@@ -93,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 {
|
||||||
@@ -219,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",
|
||||||
@@ -238,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);
|
||||||
@@ -252,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;
|
||||||
@@ -285,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..])),
|
||||||
@@ -340,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,
|
||||||
@@ -383,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(
|
||||||
|
|||||||
+231
-83
@@ -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,69 +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;
|
||||||
identifier.as_deref(),
|
let fwd = forward::ForwardInfo::new(
|
||||||
&residual,
|
peer,
|
||||||
started,
|
local,
|
||||||
"submission",
|
client.is_tls(),
|
||||||
common::AuthWire::Smtp,
|
Some(announced),
|
||||||
auth_frame.as_bytes(),
|
ctx.config.server.proxy_ttl,
|
||||||
b"421 4.3.0 backend unavailable\r\n",
|
);
|
||||||
MAX_PREAUTH,
|
|
||||||
|| open_backend(ctx, &dest_id, &fwd),
|
let mut frame = format!("AUTH {} ", mechanism_name(&mech));
|
||||||
)
|
BASE64.encode_append(&raw_challenge, &mut frame);
|
||||||
.await
|
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,
|
||||||
|
"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(
|
||||||
@@ -100,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();
|
||||||
@@ -127,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,
|
||||||
@@ -139,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);
|
||||||
}
|
}
|
||||||
@@ -180,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(
|
||||||
¶ms,
|
¶ms,
|
||||||
&mut stream,
|
&mut stream,
|
||||||
&mut buf,
|
buf,
|
||||||
consumed,
|
consumed,
|
||||||
mechanism,
|
mechanism,
|
||||||
&ir,
|
&ir,
|
||||||
&mut auth_failures,
|
&mut *auth_failures,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
{
|
{
|
||||||
@@ -226,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);
|
||||||
}
|
}
|
||||||
@@ -235,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -259,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,
|
||||||
}
|
}
|
||||||
@@ -274,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,
|
||||||
@@ -285,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",
|
||||||
@@ -295,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);
|
||||||
@@ -308,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,
|
||||||
@@ -342,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, ¶ms.ctx.config.oauth.jwt_username_claim);
|
||||||
|
let residual = std::mem::take(buf);
|
||||||
|
|
||||||
|
Ok(AuthStep::Done {
|
||||||
|
mech: Mechanism::Plain,
|
||||||
|
raw_challenge: raw,
|
||||||
|
identifier,
|
||||||
|
residual,
|
||||||
|
ehlo_host: params.ehlo_host.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn cancelled(
|
||||||
|
stream: &mut BoxedStream,
|
||||||
|
auth_failures: &mut u32,
|
||||||
|
max_attempts: u32,
|
||||||
|
) -> Result<AuthStep> {
|
||||||
|
write(&mut *stream, b"501 5.7.0 Authentication cancelled\r\n").await?;
|
||||||
|
if bump(auth_failures, max_attempts) {
|
||||||
|
Ok(AuthStep::Close)
|
||||||
|
} else {
|
||||||
|
Ok(AuthStep::Continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn fail(
|
async fn fail(
|
||||||
stream: &mut BoxedStream,
|
stream: &mut BoxedStream,
|
||||||
buf: &mut Vec<u8>,
|
buf: &mut Vec<u8>,
|
||||||
@@ -354,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)
|
||||||
@@ -369,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
|
||||||
}
|
}
|
||||||
@@ -410,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,
|
||||||
@@ -455,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> {
|
||||||
@@ -572,7 +720,7 @@ pub async fn handle_passthrough(
|
|||||||
let (dest, ep, tls_cfg) = ctx.endpoint(&dest_id, proto)?;
|
let (dest, ep, tls_cfg) = ctx.endpoint(&dest_id, proto)?;
|
||||||
let fwd = dest.forwarding_for(proto);
|
let fwd = dest.forwarding_for(proto);
|
||||||
forward::guard_loop(&ctx.self_binds, dest, ep, fwd, ctx.config.server.proxy_ttl)?;
|
forward::guard_loop(&ctx.self_binds, dest, ep, fwd, ctx.config.server.proxy_ttl)?;
|
||||||
let backend = common::establish(ctx, &dest_id, || {
|
let backend = common::establish(ctx, &dest_id, peer, || {
|
||||||
outbound::dial(dest, ep, fwd, &tls_cfg, peer, local)
|
outbound::dial(dest, ep, fwd, &tls_cfg, peer, local)
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+7
-3
@@ -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
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -473,6 +481,10 @@ async fn admin_mapping_crud_and_reads() {
|
|||||||
body.contains("legacy") && body.contains("secondary"),
|
body.contains("legacy") && body.contains("secondary"),
|
||||||
"body: {body}"
|
"body: {body}"
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
body.contains("failure_sources") && body.contains("host_level_failure"),
|
||||||
|
"body: {body}"
|
||||||
|
);
|
||||||
|
|
||||||
let (status, _) = admin_request(
|
let (status, _) = admin_request(
|
||||||
addr,
|
addr,
|
||||||
@@ -641,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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use testcontainers::core::{IntoContainerPort, WaitFor};
|
use testcontainers::core::{IntoContainerPort, WaitFor};
|
||||||
use testcontainers::runners::{AsyncBuilder, AsyncRunner};
|
use testcontainers::runners::AsyncRunner;
|
||||||
use testcontainers::{ContainerAsync, GenericBuildableImage, GenericImage, ImageExt};
|
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use super::{ensure_image, owner_labels};
|
||||||
|
|
||||||
pub const MASTER_USER: &str = "admin";
|
pub const MASTER_USER: &str = "admin";
|
||||||
pub const MASTER_PASSWORD: &str = "MasterPass#2026";
|
pub const MASTER_PASSWORD: &str = "MasterPass#2026";
|
||||||
|
|
||||||
@@ -218,15 +220,17 @@ pub struct Legacy {
|
|||||||
|
|
||||||
impl Legacy {
|
impl Legacy {
|
||||||
pub async fn start() -> Legacy {
|
pub async fn start() -> Legacy {
|
||||||
let image: GenericImage = GenericBuildableImage::new("proxy-legacymail", "test")
|
let image = ensure_image(
|
||||||
.with_dockerfile_string(DOCKERFILE.to_string())
|
"proxy-legacymail",
|
||||||
.with_data(DOVECOT_CONF.as_bytes().to_vec(), "dovecot.conf")
|
DOCKERFILE,
|
||||||
.with_data(USERS.as_bytes().to_vec(), "users")
|
&[
|
||||||
.with_data(MASTER_USERS.as_bytes().to_vec(), "master-users")
|
("dovecot.conf", DOVECOT_CONF.as_bytes()),
|
||||||
.with_data(ENTRYPOINT.as_bytes().to_vec(), "entrypoint.sh")
|
("users", USERS.as_bytes()),
|
||||||
.build_image()
|
("master-users", MASTER_USERS.as_bytes()),
|
||||||
.await
|
("entrypoint.sh", ENTRYPOINT.as_bytes()),
|
||||||
.expect("build legacy image");
|
],
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
let container = image
|
let container = image
|
||||||
.with_exposed_port(143u16.tcp())
|
.with_exposed_port(143u16.tcp())
|
||||||
@@ -239,6 +243,7 @@ impl Legacy {
|
|||||||
.with_exposed_port(25u16.tcp())
|
.with_exposed_port(25u16.tcp())
|
||||||
.with_wait_for(WaitFor::message_on_stdout("LEGACY-MAIL READY"))
|
.with_wait_for(WaitFor::message_on_stdout("LEGACY-MAIL READY"))
|
||||||
.with_startup_timeout(Duration::from_secs(240))
|
.with_startup_timeout(Duration::from_secs(240))
|
||||||
|
.with_labels(owner_labels())
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
.expect("start legacy container");
|
.expect("start legacy container");
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::hash::{BuildHasher, Hash, Hasher};
|
||||||
use std::net::{SocketAddr, TcpListener as StdTcpListener};
|
use std::net::{SocketAddr, TcpListener as StdTcpListener};
|
||||||
use std::sync::Arc;
|
use std::process::{Command, Stdio};
|
||||||
use std::time::Duration;
|
use std::sync::{Arc, Once, OnceLock};
|
||||||
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use testcontainers::runners::AsyncBuilder;
|
||||||
|
use testcontainers::{GenericBuildableImage, GenericImage};
|
||||||
use tokio::net::TcpStream;
|
use tokio::net::TcpStream;
|
||||||
use tokio::sync::watch;
|
use tokio::sync::{Mutex, watch};
|
||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::net::acceptor::Listener;
|
use crate::net::acceptor::Listener;
|
||||||
@@ -34,11 +38,100 @@ pub mod toolbox_matrix;
|
|||||||
|
|
||||||
pub const HOST_GATEWAY: &str = "host.docker.internal";
|
pub const HOST_GATEWAY: &str = "host.docker.internal";
|
||||||
|
|
||||||
|
pub const OWNER_LABEL: &str = "art.stalw.proxy.test";
|
||||||
|
|
||||||
pub fn pick_free_port() -> u16 {
|
pub fn pick_free_port() -> u16 {
|
||||||
let l = StdTcpListener::bind("127.0.0.1:0").unwrap();
|
let l = StdTcpListener::bind("127.0.0.1:0").unwrap();
|
||||||
l.local_addr().unwrap().port()
|
l.local_addr().unwrap().port()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_id() -> &'static str {
|
||||||
|
static RUN_ID: OnceLock<String> = OnceLock::new();
|
||||||
|
RUN_ID.get_or_init(|| {
|
||||||
|
let nanos = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_nanos();
|
||||||
|
format!("{}-{nanos}", std::process::id())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn owner_labels() -> [(&'static str, &'static str); 1] {
|
||||||
|
sweep_abandoned_containers();
|
||||||
|
[(OWNER_LABEL, run_id())]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn docker_ids(filter: &str) -> Vec<String> {
|
||||||
|
Command::new("docker")
|
||||||
|
.args(["ps", "-aq", "--filter", filter])
|
||||||
|
.output()
|
||||||
|
.map(|out| {
|
||||||
|
String::from_utf8_lossy(&out.stdout)
|
||||||
|
.split_whitespace()
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sweep_abandoned_containers() {
|
||||||
|
static SWEPT: Once = Once::new();
|
||||||
|
SWEPT.call_once(|| {
|
||||||
|
let mine = docker_ids(&format!("label={OWNER_LABEL}={}", run_id()));
|
||||||
|
let stale: Vec<String> = docker_ids(&format!("label={OWNER_LABEL}"))
|
||||||
|
.into_iter()
|
||||||
|
.filter(|id| !mine.contains(id))
|
||||||
|
.collect();
|
||||||
|
if stale.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
eprintln!("removing {} abandoned test container(s)", stale.len());
|
||||||
|
let _ = Command::new("docker")
|
||||||
|
.args(["rm", "-f", "-v"])
|
||||||
|
.args(&stale)
|
||||||
|
.output();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn content_tag(dockerfile: &str, data: &[(&str, &[u8])]) -> String {
|
||||||
|
let mut hasher = ahash::RandomState::with_seeds(0x5354, 0x414c, 0x5741, 0x5254).build_hasher();
|
||||||
|
dockerfile.hash(&mut hasher);
|
||||||
|
for (target, bytes) in data {
|
||||||
|
target.hash(&mut hasher);
|
||||||
|
bytes.hash(&mut hasher);
|
||||||
|
}
|
||||||
|
format!("{:016x}", hasher.finish())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn image_present(reference: &str) -> bool {
|
||||||
|
tokio::process::Command::new("docker")
|
||||||
|
.args(["image", "inspect", reference])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.await
|
||||||
|
.map(|status| status.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn ensure_image(name: &str, dockerfile: &str, data: &[(&str, &[u8])]) -> GenericImage {
|
||||||
|
static BUILD_LOCK: Mutex<()> = Mutex::const_new(());
|
||||||
|
let tag = content_tag(dockerfile, data);
|
||||||
|
let _guard = BUILD_LOCK.lock().await;
|
||||||
|
if image_present(&format!("{name}:{tag}")).await {
|
||||||
|
return GenericImage::new(name.to_owned(), tag);
|
||||||
|
}
|
||||||
|
let mut builder =
|
||||||
|
GenericBuildableImage::new(name, &tag).with_dockerfile_string(dockerfile.to_string());
|
||||||
|
for (target, bytes) in data {
|
||||||
|
builder = builder.with_data(bytes.to_vec(), *target);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.build_image()
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|e| panic!("build image {name}:{tag}: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Proxy {
|
pub struct Proxy {
|
||||||
shutdown: watch::Sender<bool>,
|
shutdown: watch::Sender<bool>,
|
||||||
handles: Vec<JoinHandle<()>>,
|
handles: Vec<JoinHandle<()>>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use testcontainers::runners::AsyncRunner;
|
|||||||
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
||||||
|
|
||||||
use super::http::{self, basic_auth};
|
use super::http::{self, basic_auth};
|
||||||
use super::pick_free_port;
|
use super::{owner_labels, pick_free_port};
|
||||||
|
|
||||||
pub const NEW_IMAGE: &str = "stalwartlabs/stalwart";
|
pub const NEW_IMAGE: &str = "stalwartlabs/stalwart";
|
||||||
pub const NEW_TAG: &str = "latest";
|
pub const NEW_TAG: &str = "latest";
|
||||||
@@ -114,6 +114,7 @@ impl Stalwart {
|
|||||||
.with_mapped_port(ports.sieve, 4190u16.tcp())
|
.with_mapped_port(ports.sieve, 4190u16.tcp())
|
||||||
.with_mapped_port(ports.smtp, 25u16.tcp())
|
.with_mapped_port(ports.smtp, 25u16.tcp())
|
||||||
.with_startup_timeout(Duration::from_secs(180))
|
.with_startup_timeout(Duration::from_secs(180))
|
||||||
|
.with_labels(owner_labels())
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
.expect("start new stalwart");
|
.expect("start new stalwart");
|
||||||
@@ -196,6 +197,7 @@ impl Stalwart {
|
|||||||
.with_mapped_port(ports.sieve, 4190u16.tcp())
|
.with_mapped_port(ports.sieve, 4190u16.tcp())
|
||||||
.with_mapped_port(ports.smtp, 25u16.tcp())
|
.with_mapped_port(ports.smtp, 25u16.tcp())
|
||||||
.with_startup_timeout(Duration::from_secs(180))
|
.with_startup_timeout(Duration::from_secs(180))
|
||||||
|
.with_labels(owner_labels())
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
.expect("start old stalwart");
|
.expect("start old stalwart");
|
||||||
@@ -828,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 {
|
||||||
@@ -836,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
|
||||||
|
|||||||
@@ -7,11 +7,11 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use testcontainers::core::{ExecCommand, Host, WaitFor};
|
use testcontainers::core::{ExecCommand, Host, WaitFor};
|
||||||
use testcontainers::runners::{AsyncBuilder, AsyncRunner};
|
use testcontainers::runners::AsyncRunner;
|
||||||
use testcontainers::{ContainerAsync, GenericBuildableImage, GenericImage, ImageExt};
|
use testcontainers::{ContainerAsync, GenericImage, ImageExt};
|
||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
use super::HOST_GATEWAY;
|
use super::{HOST_GATEWAY, ensure_image, owner_labels};
|
||||||
|
|
||||||
const DOCKERFILE: &str = r#"FROM debian:trixie-slim
|
const DOCKERFILE: &str = r#"FROM debian:trixie-slim
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -54,16 +54,13 @@ impl Exec {
|
|||||||
|
|
||||||
impl Toolbox {
|
impl Toolbox {
|
||||||
pub async fn start() -> Toolbox {
|
pub async fn start() -> Toolbox {
|
||||||
let image: GenericImage = GenericBuildableImage::new("proxy-toolbox", "test")
|
let image = ensure_image("proxy-toolbox", DOCKERFILE, &[]).await;
|
||||||
.with_dockerfile_string(DOCKERFILE.to_string())
|
|
||||||
.build_image()
|
|
||||||
.await
|
|
||||||
.expect("build toolbox image");
|
|
||||||
|
|
||||||
let container = image
|
let container = image
|
||||||
.with_wait_for(WaitFor::seconds(1))
|
.with_wait_for(WaitFor::seconds(1))
|
||||||
.with_startup_timeout(Duration::from_secs(300))
|
.with_startup_timeout(Duration::from_secs(300))
|
||||||
.with_host(HOST_GATEWAY, Host::HostGateway)
|
.with_host(HOST_GATEWAY, Host::HostGateway)
|
||||||
|
.with_labels(owner_labels())
|
||||||
.start()
|
.start()
|
||||||
.await
|
.await
|
||||||
.expect("start toolbox container");
|
.expect("start toolbox container");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*/
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||||
|
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
|
use super::harness::*;
|
||||||
|
use crate::proto::common::Ctx;
|
||||||
|
|
||||||
|
fn config(port: u16, mapping_path: &str, threshold: u32, min_clients: u32) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
[server]
|
||||||
|
host_down_threshold = {threshold}
|
||||||
|
host_down_min_clients = {min_clients}
|
||||||
|
host_down_cooldown = "60s"
|
||||||
|
backend_connect_retries = 0
|
||||||
|
|
||||||
|
[mapping]
|
||||||
|
source = "file"
|
||||||
|
normalize = "lowercase"
|
||||||
|
[mapping.file]
|
||||||
|
path = "{mapping_path}"
|
||||||
|
|
||||||
|
[capabilities.imap]
|
||||||
|
allow_plain_auth_without_tls = true
|
||||||
|
|
||||||
|
[routing]
|
||||||
|
default_destination = "legacy"
|
||||||
|
|
||||||
|
[destination.legacy]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
proxy_protocol = false
|
||||||
|
allow_plaintext_auth = true
|
||||||
|
[destination.legacy.protocol.imap]
|
||||||
|
port = {port}
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
[listener.imap]
|
||||||
|
protocol = "imap"
|
||||||
|
bind = ["127.0.0.1:0"]
|
||||||
|
tls = "plain"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Backend {
|
||||||
|
port: u16,
|
||||||
|
serving: Arc<AtomicBool>,
|
||||||
|
accepted: Arc<AtomicU32>,
|
||||||
|
handle: JoinHandle<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn banning_backend() -> Backend {
|
||||||
|
let listener = TcpListener::bind(unused_addr()).await.unwrap();
|
||||||
|
let port = listener.local_addr().unwrap().port();
|
||||||
|
let serving = Arc::new(AtomicBool::new(false));
|
||||||
|
let accepted = Arc::new(AtomicU32::new(0));
|
||||||
|
let serving_task = serving.clone();
|
||||||
|
let accepted_task = accepted.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
let Ok((socket, _)) = listener.accept().await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let _ = socket.set_nodelay(true);
|
||||||
|
accepted_task.fetch_add(1, Ordering::Relaxed);
|
||||||
|
if serving_task.load(Ordering::Relaxed) {
|
||||||
|
tokio::spawn(serve_imap_auth(socket));
|
||||||
|
} else {
|
||||||
|
drop(socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Backend {
|
||||||
|
port,
|
||||||
|
serving,
|
||||||
|
accepted,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn serve_imap_auth(mut socket: TcpStream) {
|
||||||
|
let _ = socket
|
||||||
|
.write_all(b"* OK [CAPABILITY IMAP4rev2] backend ready\r\n")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut scratch = [0u8; 1024];
|
||||||
|
let tag = loop {
|
||||||
|
let Ok(n) = socket.read(&mut scratch).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&scratch[..n]);
|
||||||
|
if let Some(end) = find(&buf, b"\r\n") {
|
||||||
|
let line = String::from_utf8_lossy(&buf[..end]).into_owned();
|
||||||
|
break line.split(' ').next().unwrap_or("a1").to_string();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = socket
|
||||||
|
.write_all(format!("{tag} OK [CAPABILITY IMAP4rev2] authenticated\r\n").as_bytes())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
if find(&buf, b"NOOP").is_some() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let Ok(n) = socket.read(&mut scratch).await else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&scratch[..n]);
|
||||||
|
}
|
||||||
|
let _ = socket.write_all(b"a2 OK NOOP done\r\n").await;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn imap_login(ctx: &Arc<Ctx>, peer: &str, user: &str) -> Vec<u8> {
|
||||||
|
let listener = ctx.config.listener["imap"].clone();
|
||||||
|
let peer: SocketAddr = peer.parse().unwrap();
|
||||||
|
let (mut client, server) = tcp_pair().await;
|
||||||
|
let ctx_task = ctx.clone();
|
||||||
|
let handler = tokio::spawn(async move {
|
||||||
|
let _ = crate::proto::imap::handle(
|
||||||
|
&ctx_task,
|
||||||
|
&listener,
|
||||||
|
Box::new(server),
|
||||||
|
peer,
|
||||||
|
"127.0.0.1:143".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let _greeting = read_line(&mut client).await;
|
||||||
|
send(
|
||||||
|
&mut client,
|
||||||
|
format!("a1 LOGIN {user} secret\r\na2 NOOP\r\n").as_bytes(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let resp = read_to_eof(&mut client).await;
|
||||||
|
handler.abort();
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn one_banned_client_does_not_mark_the_destination_down() {
|
||||||
|
let backend = banning_backend().await;
|
||||||
|
let mapping = mapping_file("");
|
||||||
|
let toml = config(backend.port, &mapping.path().display().to_string(), 3, 2);
|
||||||
|
let ctx = make_ctx(&toml).await;
|
||||||
|
|
||||||
|
for i in 0..5 {
|
||||||
|
let resp = imap_login(&ctx, "198.51.100.7:40000", "banned@example.com").await;
|
||||||
|
assert!(
|
||||||
|
find(&resp, b"a1 NO [UNAVAILABLE]").is_some(),
|
||||||
|
"attempt {i} should report the backend unavailable, got {:?}",
|
||||||
|
String::from_utf8_lossy(&resp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let health = ctx.dest_health.get("legacy").unwrap().snapshot();
|
||||||
|
assert_eq!(health.consecutive_failures, 5);
|
||||||
|
assert_eq!(health.failure_sources, 1);
|
||||||
|
assert!(!health.host_level_failure);
|
||||||
|
assert!(
|
||||||
|
!health.down,
|
||||||
|
"a single client must not open the circuit breaker"
|
||||||
|
);
|
||||||
|
|
||||||
|
backend.serving.store(true, Ordering::Relaxed);
|
||||||
|
let resp = imap_login(&ctx, "203.0.113.4:40000", "alice@example.com").await;
|
||||||
|
assert!(
|
||||||
|
find(&resp, b"a1 OK").is_some(),
|
||||||
|
"an unrelated account must still reach the backend, got {:?}",
|
||||||
|
String::from_utf8_lossy(&resp)
|
||||||
|
);
|
||||||
|
assert!(!ctx.dest_health.get("legacy").unwrap().snapshot().down);
|
||||||
|
assert!(backend.accepted.load(Ordering::Relaxed) >= 6);
|
||||||
|
|
||||||
|
backend.handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejections_spanning_distinct_clients_mark_the_destination_down() {
|
||||||
|
let backend = banning_backend().await;
|
||||||
|
let mapping = mapping_file("");
|
||||||
|
let toml = config(backend.port, &mapping.path().display().to_string(), 3, 2);
|
||||||
|
let ctx = make_ctx(&toml).await;
|
||||||
|
|
||||||
|
for peer in [
|
||||||
|
"198.51.100.7:40000",
|
||||||
|
"198.51.100.7:40001",
|
||||||
|
"203.0.113.4:40000",
|
||||||
|
] {
|
||||||
|
let resp = imap_login(&ctx, peer, "user@example.com").await;
|
||||||
|
assert!(find(&resp, b"a1 NO [UNAVAILABLE]").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
let health = ctx.dest_health.get("legacy").unwrap().snapshot();
|
||||||
|
assert_eq!(health.failure_sources, 2);
|
||||||
|
assert!(
|
||||||
|
health.down,
|
||||||
|
"failures from distinct clients are evidence the host is unhealthy"
|
||||||
|
);
|
||||||
|
|
||||||
|
let accepted_before = backend.accepted.load(Ordering::Relaxed);
|
||||||
|
backend.serving.store(true, Ordering::Relaxed);
|
||||||
|
let resp = imap_login(&ctx, "192.0.2.9:40000", "alice@example.com").await;
|
||||||
|
assert!(find(&resp, b"a1 NO [UNAVAILABLE]").is_some());
|
||||||
|
assert_eq!(
|
||||||
|
backend.accepted.load(Ordering::Relaxed),
|
||||||
|
accepted_before,
|
||||||
|
"an open breaker must short-circuit before dialing the backend"
|
||||||
|
);
|
||||||
|
|
||||||
|
backend.handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn min_clients_zero_restores_counting_by_failures_alone() {
|
||||||
|
let backend = banning_backend().await;
|
||||||
|
let mapping = mapping_file("");
|
||||||
|
let toml = config(backend.port, &mapping.path().display().to_string(), 3, 0);
|
||||||
|
let ctx = make_ctx(&toml).await;
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
let resp = imap_login(&ctx, "198.51.100.7:40000", "banned@example.com").await;
|
||||||
|
assert!(find(&resp, b"a1 NO [UNAVAILABLE]").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
let health = ctx.dest_health.get("legacy").unwrap().snapshot();
|
||||||
|
assert_eq!(health.failure_sources, 1);
|
||||||
|
assert!(
|
||||||
|
health.down,
|
||||||
|
"without the breadth requirement a single client still opens the breaker"
|
||||||
|
);
|
||||||
|
|
||||||
|
backend.handle.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unreachable_host_marks_the_destination_down_from_one_client() {
|
||||||
|
let closed_port = {
|
||||||
|
let l = TcpListener::bind(unused_addr()).await.unwrap();
|
||||||
|
l.local_addr().unwrap().port()
|
||||||
|
};
|
||||||
|
let mapping = mapping_file("");
|
||||||
|
let toml = config(closed_port, &mapping.path().display().to_string(), 3, 2);
|
||||||
|
let ctx = make_ctx(&toml).await;
|
||||||
|
|
||||||
|
for _ in 0..3 {
|
||||||
|
let resp = imap_login(&ctx, "198.51.100.7:40000", "user@example.com").await;
|
||||||
|
assert!(find(&resp, b"a1 NO [UNAVAILABLE]").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
let health = ctx.dest_health.get("legacy").unwrap().snapshot();
|
||||||
|
assert!(health.host_level_failure);
|
||||||
|
assert!(
|
||||||
|
health.down,
|
||||||
|
"connect-stage failures are host evidence regardless of how many clients saw them"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
mod admin;
|
mod admin;
|
||||||
mod config_validation;
|
mod config_validation;
|
||||||
mod containers;
|
mod containers;
|
||||||
|
mod dest_health;
|
||||||
mod forwarding;
|
mod forwarding;
|
||||||
mod harness;
|
mod harness;
|
||||||
mod http_proxy;
|
mod http_proxy;
|
||||||
|
|||||||
+577
-1
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user