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 |
@@ -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__/
|
||||||
|
|||||||
+37
-7
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
|
## [1.0.5] - 2026-10-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Replaced `base64` with `encodify`.
|
||||||
|
- An HTTP route's `fallback` is now honoured: unmatched or unmapped requests on that route go to the fallback destination instead of `routing.default_destination`. A `fallback` naming an undeclared destination, or one that does not declare the `http` protocol, is rejected at startup.
|
||||||
|
- A login held by `POST /connections/delay` now resolves its destination when the hold ends instead of before it.
|
||||||
|
- `PUT /mappings`, `DELETE /mappings` and `POST /cache/invalidate?identifier=` end the login delay of that identifier; `POST /mappings/reload` and `POST /cache/invalidate` without an identifier end every login delay. Held logins proceed at once to the newly mapped destination.
|
||||||
|
- Calling `POST /connections/delay` again for an identifier also moves the hold deadline of logins already waiting (still capped at 120 seconds per login).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- The sample configuration written by `install.sh` and `resources/config.sample.toml` named an undeclared HTTP route fallback (`default`) and failed startup validation.
|
||||||
|
- `resources/config.stalwart-upgrade.toml` now documents the WebUI OAuth client identifiers (`https://<host>/admin/oauth-client.json`, `https://<host>/account/oauth-client.json`) and pins `/admin`, `/account`, `/api/auth/**` to the new deployment.
|
||||||
|
- The `POST /config/reload` response no longer claims that cache capacity and TTLs require a restart; a reload rebuilds the router and its cache.
|
||||||
|
- `POST /connections/delay` with a `seconds` value too large to represent now returns `400` instead of panicking.
|
||||||
|
|
||||||
|
## [1.0.4] - 2026-09-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- HTTP and JMAP connections are pinned to the account that authenticated them.
|
||||||
|
- HTTP and JMAP routing identifiers are split on `routing.master_user_separators`.
|
||||||
|
- A backend authentication failure now counts towards `listener.max_auth_attempts`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- A backend authentication failure no longer leaves the client bridged to the backend unparsed.
|
||||||
|
- `POST /connections/kick` now covers HTTP and JMAP sessions, including upgraded WebSocket bridges.
|
||||||
|
- `POST /connections/delay` now covers HTTP and JMAP logins.
|
||||||
|
- A rejected SASL response is no longer re-parsed as a command, which charged two authentication failures for one client mistake and could drop a command pipelined behind it.
|
||||||
|
- An empty routing identifier is no longer treated as an account, so it is neither registered nor used to pin an HTTP connection.
|
||||||
|
- A backend leg rejected at authentication is now closed with the protocol's logout command instead of having its socket dropped.
|
||||||
|
|
||||||
## [1.0.3] - 2026-08-30
|
## [1.0.3] - 2026-08-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -24,22 +58,18 @@ All notable changes to this project will be documented in this file. This projec
|
|||||||
## [1.0.1] - 2026-07-30
|
## [1.0.1] - 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- `server.host_down_min_clients`: number of distinct client addresses a
|
- `server.host_down_min_clients`: number of distinct client addresses a failure streak must span before client-attributable failures can mark a destination down.
|
||||||
failure streak must span before client-attributable failures can mark a destination
|
|
||||||
down.
|
|
||||||
- `GET /destinations` now also reports `failure_sources` and `host_level_failure`.
|
- `GET /destinations` now also reports `failure_sources` and `host_level_failure`.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- The circuit breaker no longer marks a destination down because a backend rejects
|
- The circuit breaker no longer marks a destination down because a backend rejects the connections of a single banned client (#2).
|
||||||
the connections of a single banned client (#2).
|
|
||||||
|
|
||||||
## [1.0.0] - 2026-06-08
|
## [1.0.0] - 2026-06-08
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Initial release of the migration proxy: pre-auth routing and socket bridging
|
- Initial release of the migration proxy: pre-auth routing and socket bridging for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
|
||||||
for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
Generated
+249
-253
File diff suppressed because it is too large
Load Diff
+3
-4
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "proxy"
|
name = "proxy"
|
||||||
version = "1.0.3"
|
version = "1.0.5"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
license = "AGPL-3.0-only OR LicenseRef-SEL"
|
||||||
description = "Multi-protocol e-mail proxy"
|
description = "Multi-protocol e-mail proxy"
|
||||||
@@ -22,10 +22,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc
|
|||||||
rustls-pemfile = "2.2"
|
rustls-pemfile = "2.2"
|
||||||
rustls-pki-types = "1.15"
|
rustls-pki-types = "1.15"
|
||||||
aws-lc-rs = "1.17"
|
aws-lc-rs = "1.17"
|
||||||
proxy-header = { version = "0.1.2", features = ["tokio"] }
|
proxy-header = { version = "0.1", features = ["tokio"] }
|
||||||
smtp-proto = "0.2.2"
|
smtp-proto = "0.2.2"
|
||||||
mail-parser = "0.11"
|
encodify = "1.0"
|
||||||
base64 = "0.23"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
|
|||||||
+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
|
||||||
|
|
||||||
# *****************
|
# *****************
|
||||||
|
|||||||
+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"
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ tls = "implicit"
|
|||||||
port = 995
|
port = 995
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[destination.stalwart.protocol.submission]
|
[destination.stalwart.protocol.submission]
|
||||||
port = 587
|
port = 465
|
||||||
tls = "starttls"
|
tls = "implicit"
|
||||||
[destination.stalwart.protocol.managesieve]
|
[destination.stalwart.protocol.managesieve]
|
||||||
port = 4190
|
port = 4190
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
@@ -129,38 +129,45 @@ tls = "plain"
|
|||||||
# here. Once accounts are on Stalwart, JMAP and the web interface are served by
|
# here. Once accounts are on Stalwart, JMAP and the web interface are served by
|
||||||
# the new server; an HTTP listener routing to "stalwart" can be added at that
|
# the new server; an HTTP listener routing to "stalwart" can be added at that
|
||||||
# point if a single hostname for web access is wanted.
|
# point if a single hostname for web access is wanted.
|
||||||
|
#
|
||||||
|
# Each listener binds [::] only: on Linux and macOS that socket also accepts
|
||||||
|
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
|
||||||
|
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
|
||||||
|
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
|
||||||
|
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
|
||||||
|
# use "0.0.0.0:<port>" instead.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
[listener.imaps]
|
[listener.imaps]
|
||||||
protocol = "imap"
|
protocol = "imap"
|
||||||
bind = ["0.0.0.0:993", "[::]:993"]
|
bind = ["[::]:993"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[listener.imap]
|
[listener.imap]
|
||||||
protocol = "imap"
|
protocol = "imap"
|
||||||
bind = ["0.0.0.0:143", "[::]:143"]
|
bind = ["[::]:143"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.pop3s]
|
[listener.pop3s]
|
||||||
protocol = "pop3"
|
protocol = "pop3"
|
||||||
bind = ["0.0.0.0:995", "[::]:995"]
|
bind = ["[::]:995"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[listener.pop3]
|
[listener.pop3]
|
||||||
protocol = "pop3"
|
protocol = "pop3"
|
||||||
bind = ["0.0.0.0:110", "[::]:110"]
|
bind = ["[::]:110"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.submission]
|
[listener.submission]
|
||||||
protocol = "submission"
|
protocol = "submission"
|
||||||
bind = ["0.0.0.0:587", "[::]:587"]
|
bind = ["[::]:587"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.submissions]
|
[listener.submissions]
|
||||||
protocol = "submission"
|
protocol = "submission"
|
||||||
bind = ["0.0.0.0:465", "[::]:465"]
|
bind = ["[::]:465"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[listener.managesieve]
|
[listener.managesieve]
|
||||||
protocol = "managesieve"
|
protocol = "managesieve"
|
||||||
bind = ["0.0.0.0:4190", "[::]:4190"]
|
bind = ["[::]:4190"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.smtp]
|
[listener.smtp]
|
||||||
protocol = "smtp"
|
protocol = "smtp"
|
||||||
bind = ["0.0.0.0:25", "[::]:25"]
|
bind = ["[::]:25"]
|
||||||
tls = "plain"
|
tls = "plain"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -100,8 +100,8 @@ tls = "implicit"
|
|||||||
port = 995
|
port = 995
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[destination.old.protocol.submission]
|
[destination.old.protocol.submission]
|
||||||
port = 587
|
port = 465
|
||||||
tls = "starttls"
|
tls = "implicit"
|
||||||
[destination.old.protocol.managesieve]
|
[destination.old.protocol.managesieve]
|
||||||
port = 4190
|
port = 4190
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
@@ -124,8 +124,8 @@ tls = "implicit"
|
|||||||
port = 995
|
port = 995
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[destination.new.protocol.submission]
|
[destination.new.protocol.submission]
|
||||||
port = 587
|
port = 465
|
||||||
tls = "starttls"
|
tls = "implicit"
|
||||||
[destination.new.protocol.managesieve]
|
[destination.new.protocol.managesieve]
|
||||||
port = 4190
|
port = 4190
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
@@ -165,12 +165,21 @@ destination = "old"
|
|||||||
match = "/api/auth"
|
match = "/api/auth"
|
||||||
destination = "new"
|
destination = "new"
|
||||||
[[http.route]]
|
[[http.route]]
|
||||||
|
match = "/api/auth/**"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
match = "/api/discover/**"
|
match = "/api/discover/**"
|
||||||
destination = "new"
|
destination = "new"
|
||||||
[[http.route]]
|
[[http.route]]
|
||||||
|
match = "/admin"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
match = "/admin/**"
|
match = "/admin/**"
|
||||||
destination = "new"
|
destination = "new"
|
||||||
[[http.route]]
|
[[http.route]]
|
||||||
|
match = "/account"
|
||||||
|
destination = "new"
|
||||||
|
[[http.route]]
|
||||||
match = "/account/**"
|
match = "/account/**"
|
||||||
destination = "new"
|
destination = "new"
|
||||||
[[http.route]]
|
[[http.route]]
|
||||||
@@ -181,53 +190,63 @@ match = "/device"
|
|||||||
destination = "new"
|
destination = "new"
|
||||||
|
|
||||||
# OAuth token exchange is shared; route it by the web client that requested it.
|
# OAuth token exchange is shared; route it by the web client that requested it.
|
||||||
# Add matching mapping entries: webadmin old and stalwart-webui new
|
# Add matching mapping entries (replace the hostname with the public one):
|
||||||
|
# webadmin old
|
||||||
|
# https://mail.example.org/admin/oauth-client.json new
|
||||||
|
# https://mail.example.org/account/oauth-client.json new
|
||||||
[[http.route]]
|
[[http.route]]
|
||||||
match = "/auth/token"
|
match = "/auth/token"
|
||||||
extract = { from = "body", regex = "client_id=([^&]+)" }
|
extract = { from = "body", regex = "client_id=([^&]+)" }
|
||||||
fallback = "old"
|
fallback = "old"
|
||||||
|
|
||||||
# Everything else: route by the authenticated account, fall back to default.
|
# Everything else: route by the authenticated account, fall back to the old deployment.
|
||||||
[[http.route]]
|
[[http.route]]
|
||||||
match = "/**"
|
match = "/**"
|
||||||
extract = { from = "authorization" }
|
extract = { from = "authorization" }
|
||||||
fallback = "default"
|
fallback = "old"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Listeners: the public ports clients connect to. These take over the ports the
|
# Listeners: the public ports clients connect to. These take over the ports the
|
||||||
# old server used to answer on directly.
|
# old server used to answer on directly.
|
||||||
|
#
|
||||||
|
# Each listener binds [::] only: on Linux and macOS that socket also accepts
|
||||||
|
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
|
||||||
|
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
|
||||||
|
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
|
||||||
|
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
|
||||||
|
# use "0.0.0.0:<port>" instead.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
[listener.imaps]
|
[listener.imaps]
|
||||||
protocol = "imap"
|
protocol = "imap"
|
||||||
bind = ["0.0.0.0:993", "[::]:993"]
|
bind = ["[::]:993"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[listener.imap]
|
[listener.imap]
|
||||||
protocol = "imap"
|
protocol = "imap"
|
||||||
bind = ["0.0.0.0:143", "[::]:143"]
|
bind = ["[::]:143"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.pop3s]
|
[listener.pop3s]
|
||||||
protocol = "pop3"
|
protocol = "pop3"
|
||||||
bind = ["0.0.0.0:995", "[::]:995"]
|
bind = ["[::]:995"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[listener.pop3]
|
[listener.pop3]
|
||||||
protocol = "pop3"
|
protocol = "pop3"
|
||||||
bind = ["0.0.0.0:110", "[::]:110"]
|
bind = ["[::]:110"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.submission]
|
[listener.submission]
|
||||||
protocol = "submission"
|
protocol = "submission"
|
||||||
bind = ["0.0.0.0:587", "[::]:587"]
|
bind = ["[::]:587"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.submissions]
|
[listener.submissions]
|
||||||
protocol = "submission"
|
protocol = "submission"
|
||||||
bind = ["0.0.0.0:465", "[::]:465"]
|
bind = ["[::]:465"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
[listener.managesieve]
|
[listener.managesieve]
|
||||||
protocol = "managesieve"
|
protocol = "managesieve"
|
||||||
bind = ["0.0.0.0:4190", "[::]:4190"]
|
bind = ["[::]:4190"]
|
||||||
tls = "starttls"
|
tls = "starttls"
|
||||||
[listener.https]
|
[listener.https]
|
||||||
protocol = "http"
|
protocol = "http"
|
||||||
bind = ["0.0.0.0:443", "[::]:443"]
|
bind = ["[::]:443"]
|
||||||
tls = "implicit"
|
tls = "implicit"
|
||||||
# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather
|
# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather
|
||||||
# than trusting them; the proxy sets its own based on the real peer.
|
# than trusting them; the proxy sets its own based on the real peer.
|
||||||
@@ -236,7 +255,7 @@ forwarded = "off"
|
|||||||
# a pass-through listener cannot offer STARTTLS.
|
# a pass-through listener cannot offer STARTTLS.
|
||||||
[listener.smtp]
|
[listener.smtp]
|
||||||
protocol = "smtp"
|
protocol = "smtp"
|
||||||
bind = ["0.0.0.0:25", "[::]:25"]
|
bind = ["[::]:25"]
|
||||||
tls = "plain"
|
tls = "plain"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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)
|
||||||
+13
-6
@@ -303,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?;
|
||||||
}
|
}
|
||||||
@@ -343,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?;
|
||||||
@@ -379,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?;
|
||||||
@@ -400,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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,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?;
|
||||||
}
|
}
|
||||||
@@ -435,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?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -920,6 +920,14 @@ impl Config {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(fallback) = &route.fallback {
|
||||||
|
let dest = self.destination(fallback)?;
|
||||||
|
if !dest.protocol.contains_key(&Protocol::Http) {
|
||||||
|
return Err(ProxyError::config(format!(
|
||||||
|
"http route fallback {fallback:?} does not declare the http protocol"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(extract) = &route.extract {
|
if let Some(extract) = &route.extract {
|
||||||
if extract.from == ExtractFrom::Body && extract.regex.is_none() {
|
if extract.from == ExtractFrom::Body && extract.regex.is_none() {
|
||||||
return Err(ProxyError::config(
|
return Err(ProxyError::config(
|
||||||
|
|||||||
+125
-18
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
use std::net::{IpAddr, SocketAddr};
|
use std::net::{IpAddr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
@@ -13,7 +14,7 @@ use tokio::time::timeout;
|
|||||||
use crate::config::{ForwardedMode, ListenerConfig, Protocol, TlsMode};
|
use crate::config::{ForwardedMode, ListenerConfig, Protocol, TlsMode};
|
||||||
use crate::error::{ProxyError, Result};
|
use crate::error::{ProxyError, Result};
|
||||||
use crate::net::BoxedStream;
|
use crate::net::BoxedStream;
|
||||||
use crate::proto::common::Ctx;
|
use crate::proto::common::{ConnGuard, Ctx};
|
||||||
|
|
||||||
use super::head::{BodyFraming, Head, ParseError, ParseOutcome, error_response};
|
use super::head::{BodyFraming, Head, ParseError, ParseOutcome, error_response};
|
||||||
use super::router::{HttpRouter, RouteOutcome};
|
use super::router::{HttpRouter, RouteOutcome};
|
||||||
@@ -379,12 +380,55 @@ fn response_no_body(req_method: &str, head: &Head) -> bool {
|
|||||||
matches!(head.framing, BodyFraming::None)
|
matches!(head.framing, BodyFraming::None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct Account {
|
||||||
|
cancel: Option<tokio::sync::watch::Sender<bool>>,
|
||||||
|
guard: Option<ConnGuard>,
|
||||||
|
pinned: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Account {
|
||||||
|
fn new(cancel: tokio::sync::watch::Sender<bool>) -> Self {
|
||||||
|
Account {
|
||||||
|
cancel: Some(cancel),
|
||||||
|
guard: None,
|
||||||
|
pinned: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accepts(&self, ctx: &Ctx, identifier: Option<&str>) -> bool {
|
||||||
|
match (&self.pinned, identifier) {
|
||||||
|
(Some(pinned), Some(id)) => *pinned == ctx.router.normalize(id),
|
||||||
|
_ => true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pin(&mut self, ctx: &Ctx, identifier: Option<&str>) -> bool {
|
||||||
|
if self.pinned.is_some() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(id) = identifier else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(cancel) = self.cancel.take() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
self.pinned = Some(ctx.router.normalize(id));
|
||||||
|
self.guard = Some(ctx.register_conn_cancel(id, cancel));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn into_guard(self) -> Option<ConnGuard> {
|
||||||
|
self.guard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum Disposition {
|
enum Disposition {
|
||||||
Close,
|
Close,
|
||||||
Hijack {
|
Hijack {
|
||||||
backend: BoxedStream,
|
backend: BoxedStream,
|
||||||
to_backend: Vec<u8>,
|
to_backend: Vec<u8>,
|
||||||
to_client: Vec<u8>,
|
to_client: Vec<u8>,
|
||||||
|
guard: Option<ConnGuard>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +444,16 @@ pub async fn handle(
|
|||||||
local: SocketAddr,
|
local: SocketAddr,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let bridge_idle = ctx.config.server.bridge_idle;
|
let bridge_idle = ctx.config.server.bridge_idle;
|
||||||
match serve(ctx, listener, &mut stream, peer, local).await {
|
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||||
|
|
||||||
|
let served = tokio::select! {
|
||||||
|
result = serve(ctx, listener, &mut stream, peer, local, cancel_tx) => result,
|
||||||
|
_ = crate::outbound::wait_kick(Some(cancel_rx)) => {
|
||||||
|
return Err(ProxyError::backend("disconnected by admin"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match served {
|
||||||
Ok(Disposition::Close) => {
|
Ok(Disposition::Close) => {
|
||||||
shutdown_client(&mut stream).await;
|
shutdown_client(&mut stream).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -409,9 +462,18 @@ pub async fn handle(
|
|||||||
backend,
|
backend,
|
||||||
to_backend,
|
to_backend,
|
||||||
to_client,
|
to_client,
|
||||||
|
guard,
|
||||||
}) => {
|
}) => {
|
||||||
crate::outbound::bridge(stream, backend, &to_backend, &to_client, bridge_idle, None)
|
let cancel = guard.as_ref().map(|g| g.cancel());
|
||||||
.await?;
|
crate::outbound::bridge(
|
||||||
|
stream,
|
||||||
|
backend,
|
||||||
|
&to_backend,
|
||||||
|
&to_client,
|
||||||
|
bridge_idle,
|
||||||
|
cancel,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => Err(e),
|
Err(e) => Err(e),
|
||||||
@@ -424,6 +486,7 @@ async fn serve(
|
|||||||
stream: &mut BoxedStream,
|
stream: &mut BoxedStream,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
local: SocketAddr,
|
local: SocketAddr,
|
||||||
|
cancel: tokio::sync::watch::Sender<bool>,
|
||||||
) -> Result<Disposition> {
|
) -> Result<Disposition> {
|
||||||
let router = ctx.http_router.as_ref();
|
let router = ctx.http_router.as_ref();
|
||||||
let max_head = router.max_head_size;
|
let max_head = router.max_head_size;
|
||||||
@@ -459,7 +522,7 @@ async fn serve(
|
|||||||
let mut first_head = first_head;
|
let mut first_head = first_head;
|
||||||
sanitize_inbound_forwarded(&mut first_head, listener);
|
sanitize_inbound_forwarded(&mut first_head, listener);
|
||||||
|
|
||||||
let (buffered_body, dest_id) = match prepare(
|
let prepared = match prepare(
|
||||||
ctx,
|
ctx,
|
||||||
router,
|
router,
|
||||||
stream,
|
stream,
|
||||||
@@ -477,6 +540,13 @@ async fn serve(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut account = Account::new(cancel);
|
||||||
|
if account.pin(ctx, prepared.identifier.as_deref()) {
|
||||||
|
crate::proto::common::hold_login(ctx, prepared.identifier.as_deref()).await;
|
||||||
|
}
|
||||||
|
let dest_id = prepared.destination(ctx).await;
|
||||||
|
let buffered_body = prepared.body;
|
||||||
|
|
||||||
let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) {
|
let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) {
|
||||||
Ok(t) => t,
|
Ok(t) => t,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -552,6 +622,7 @@ async fn serve(
|
|||||||
backend,
|
backend,
|
||||||
to_backend,
|
to_backend,
|
||||||
to_client,
|
to_client,
|
||||||
|
guard: account.into_guard(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Ok(ExchangeResult::Close) => return Ok(Disposition::Close),
|
Ok(ExchangeResult::Close) => return Ok(Disposition::Close),
|
||||||
@@ -576,7 +647,7 @@ async fn serve(
|
|||||||
let mut next = next;
|
let mut next = next;
|
||||||
sanitize_inbound_forwarded(&mut next, listener);
|
sanitize_inbound_forwarded(&mut next, listener);
|
||||||
|
|
||||||
let (next_body, next_dest) = match prepare(
|
let prepared = match prepare(
|
||||||
ctx,
|
ctx,
|
||||||
router,
|
router,
|
||||||
stream,
|
stream,
|
||||||
@@ -594,7 +665,14 @@ async fn serve(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if next_dest != dest_id {
|
let misdirected = !account.accepts(ctx, prepared.identifier.as_deref()) || {
|
||||||
|
if account.pin(ctx, prepared.identifier.as_deref()) {
|
||||||
|
crate::proto::common::hold_login(ctx, prepared.identifier.as_deref()).await;
|
||||||
|
}
|
||||||
|
prepared.destination(ctx).await != dest_id
|
||||||
|
};
|
||||||
|
let next_body = prepared.body;
|
||||||
|
if misdirected {
|
||||||
if let Some(body) = &next_body {
|
if let Some(body) = &next_body {
|
||||||
drain_buffered(stream, &mut reader, &next, body.len(), idle.body)
|
drain_buffered(stream, &mut reader, &next, body.len(), idle.body)
|
||||||
.await
|
.await
|
||||||
@@ -891,6 +969,34 @@ async fn drain_buffered(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum Target {
|
||||||
|
Pinned(String),
|
||||||
|
Account { fallback: Option<Arc<str>> },
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Prepared {
|
||||||
|
body: Option<Vec<u8>>,
|
||||||
|
target: Target,
|
||||||
|
identifier: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Prepared {
|
||||||
|
async fn destination(&self, ctx: &Ctx) -> String {
|
||||||
|
match &self.target {
|
||||||
|
Target::Pinned(dest_id) => dest_id.clone(),
|
||||||
|
Target::Account {
|
||||||
|
fallback: Some(fallback),
|
||||||
|
} => ctx
|
||||||
|
.resolve_or(self.identifier.as_deref(), fallback)
|
||||||
|
.await
|
||||||
|
.to_string(),
|
||||||
|
Target::Account { fallback: None } => {
|
||||||
|
ctx.resolve(self.identifier.as_deref()).await.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn prepare(
|
async fn prepare(
|
||||||
ctx: &Ctx,
|
ctx: &Ctx,
|
||||||
router: &HttpRouter,
|
router: &HttpRouter,
|
||||||
@@ -899,7 +1005,7 @@ async fn prepare(
|
|||||||
head: &mut Head,
|
head: &mut Head,
|
||||||
body_cap: usize,
|
body_cap: usize,
|
||||||
body_idle: Duration,
|
body_idle: Duration,
|
||||||
) -> Result<Option<(Option<Vec<u8>>, String)>> {
|
) -> Result<Option<Prepared>> {
|
||||||
let idx = match router.match_index(head) {
|
let idx = match router.match_index(head) {
|
||||||
Some(i) => i,
|
Some(i) => i,
|
||||||
None => return Ok(None),
|
None => return Ok(None),
|
||||||
@@ -915,21 +1021,22 @@ async fn prepare(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let dest_id = match router.route_at(idx, head, body.as_deref()) {
|
let (target, identifier) = match router.route_at(idx, head, body.as_deref()) {
|
||||||
RouteOutcome::Pin { dest_id } => dest_id,
|
RouteOutcome::Pin { dest_id } => (Target::Pinned(dest_id), None),
|
||||||
RouteOutcome::Identifier {
|
RouteOutcome::Identifier {
|
||||||
identifier,
|
identifier,
|
||||||
fallback,
|
fallback,
|
||||||
} => {
|
} => (
|
||||||
if identifier.is_none() && fallback.is_none() {
|
Target::Account { fallback },
|
||||||
ctx.resolve(None).await.to_string()
|
ctx.routing_identifier(identifier),
|
||||||
} else {
|
),
|
||||||
ctx.resolve(identifier.as_deref()).await.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
Ok(Some((body, dest_id)))
|
Ok(Some(Prepared {
|
||||||
|
body,
|
||||||
|
target,
|
||||||
|
identifier,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reason(status: u16) -> &'static str {
|
fn reason(status: u16) -> &'static str {
|
||||||
|
|||||||
+9
-9
@@ -4,9 +4,10 @@
|
|||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use encodify::base64::STANDARD as BASE64;
|
||||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@ pub enum RouteOutcome {
|
|||||||
},
|
},
|
||||||
Identifier {
|
Identifier {
|
||||||
identifier: Option<String>,
|
identifier: Option<String>,
|
||||||
fallback: Option<String>,
|
fallback: Option<Arc<str>>,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ struct CompiledExtract {
|
|||||||
struct CompiledRoute {
|
struct CompiledRoute {
|
||||||
destination: Option<String>,
|
destination: Option<String>,
|
||||||
extract: Option<CompiledExtract>,
|
extract: Option<CompiledExtract>,
|
||||||
fallback: Option<String>,
|
fallback: Option<Arc<str>>,
|
||||||
needs_body: bool,
|
needs_body: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,8 +145,7 @@ impl HttpRouter {
|
|||||||
let value = head.header("authorization")?;
|
let value = head.header("authorization")?;
|
||||||
let value = value.trim();
|
let value = value.trim();
|
||||||
if let Some(rest) = strip_scheme(value, "basic") {
|
if let Some(rest) = strip_scheme(value, "basic") {
|
||||||
let decoded = BASE64.decode(rest.trim()).ok()?;
|
let text = BASE64.decode_to_string(rest.trim()).ok()?;
|
||||||
let text = String::from_utf8(decoded).ok()?;
|
|
||||||
let login = match text.split_once(':') {
|
let login = match text.split_once(':') {
|
||||||
Some((login, _)) => login,
|
Some((login, _)) => login,
|
||||||
None => text.as_str(),
|
None => text.as_str(),
|
||||||
@@ -186,7 +186,7 @@ fn compile_route(route: &HttpRoute) -> Result<CompiledRoute> {
|
|||||||
Ok(CompiledRoute {
|
Ok(CompiledRoute {
|
||||||
destination: route.destination.clone(),
|
destination: route.destination.clone(),
|
||||||
extract,
|
extract,
|
||||||
fallback: route.fallback.clone(),
|
fallback: route.fallback.as_deref().map(Arc::from),
|
||||||
needs_body,
|
needs_body,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -320,7 +320,7 @@ destination = "legacy"
|
|||||||
[[route]]
|
[[route]]
|
||||||
match = "/**"
|
match = "/**"
|
||||||
extract = { from = "authorization" }
|
extract = { from = "authorization" }
|
||||||
fallback = "default"
|
fallback = "legacy"
|
||||||
"#,
|
"#,
|
||||||
);
|
);
|
||||||
let head = parse(b"GET /static HTTP/1.1\r\nHost: x\r\n\r\n");
|
let head = parse(b"GET /static HTTP/1.1\r\nHost: x\r\n\r\n");
|
||||||
@@ -331,7 +331,7 @@ fallback = "default"
|
|||||||
let head = parse(b"GET /dyn HTTP/1.1\r\nHost: x\r\n\r\n");
|
let head = parse(b"GET /dyn HTTP/1.1\r\nHost: x\r\n\r\n");
|
||||||
match route(&router, &head, None) {
|
match route(&router, &head, None) {
|
||||||
RouteOutcome::Identifier { fallback, .. } => {
|
RouteOutcome::Identifier { fallback, .. } => {
|
||||||
assert_eq!(fallback.as_deref(), Some("default"));
|
assert_eq!(fallback.as_deref(), Some("legacy"));
|
||||||
}
|
}
|
||||||
_ => panic!("expected identifier"),
|
_ => panic!("expected identifier"),
|
||||||
}
|
}
|
||||||
@@ -359,7 +359,7 @@ extract = { from = "authorization" }
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bearer_uses_bearer_identifier() {
|
fn bearer_uses_bearer_identifier() {
|
||||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
use encodify::base64::URL_SAFE_NO_PAD;
|
||||||
let router = router_from_toml(
|
let router = router_from_toml(
|
||||||
r#"
|
r#"
|
||||||
[[route]]
|
[[route]]
|
||||||
|
|||||||
+2
-2
@@ -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() {
|
||||||
|
|||||||
+230
-45
@@ -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),
|
||||||
@@ -117,13 +133,15 @@ pub fn sasl_cancelled(response: &[u8]) -> bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn login_challenge(username_b64: &[u8], password_b64: &[u8]) -> Option<Vec<u8>> {
|
pub fn login_challenge(username_b64: &[u8], password_b64: &[u8]) -> Option<Vec<u8>> {
|
||||||
let username = crate::sasl::decode_sasl_ir(username_b64)?;
|
let mut raw = Vec::with_capacity(
|
||||||
let password = crate::sasl::decode_sasl_ir(password_b64)?;
|
SASL_RESPONSE.decoded_len_estimate(username_b64.len())
|
||||||
let mut raw = Vec::with_capacity(username.len() + password.len() + 2);
|
+ SASL_RESPONSE.decoded_len_estimate(password_b64.len())
|
||||||
|
+ 2,
|
||||||
|
);
|
||||||
raw.push(0);
|
raw.push(0);
|
||||||
raw.extend_from_slice(&username);
|
SASL_RESPONSE.decode_append(username_b64, &mut raw).ok()?;
|
||||||
raw.push(0);
|
raw.push(0);
|
||||||
raw.extend_from_slice(&password);
|
SASL_RESPONSE.decode_append(password_b64, &mut raw).ok()?;
|
||||||
Some(raw)
|
Some(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,16 +295,22 @@ pub struct HealthSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REGISTRY_SHARDS: usize = 32;
|
const REGISTRY_SHARDS: usize = 32;
|
||||||
|
const MAX_LOGIN_HOLD: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
struct LiveConn {
|
struct LiveConn {
|
||||||
id: u64,
|
id: u64,
|
||||||
cancel: tokio::sync::watch::Sender<bool>,
|
cancel: tokio::sync::watch::Sender<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct LoginDelay {
|
||||||
|
until: Instant,
|
||||||
|
changed: tokio::sync::watch::Sender<()>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct Shard {
|
struct Shard {
|
||||||
live: HashMap<Arc<str>, Vec<LiveConn>>,
|
live: HashMap<Arc<str>, Vec<LiveConn>>,
|
||||||
delays: HashMap<Arc<str>, Instant>,
|
delays: HashMap<Arc<str>, LoginDelay>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -326,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]
|
||||||
@@ -362,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 {
|
||||||
@@ -509,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 {
|
||||||
@@ -611,15 +710,8 @@ 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,
|
|
||||||
"holding login (delay_until)"
|
|
||||||
);
|
|
||||||
tokio::time::sleep(capped).await;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,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,
|
||||||
@@ -810,7 +912,7 @@ pub async fn bridge_authenticated<F, Fut>(
|
|||||||
mut stream: BoxedStream,
|
mut stream: BoxedStream,
|
||||||
peer: SocketAddr,
|
peer: SocketAddr,
|
||||||
identifier: Option<&str>,
|
identifier: Option<&str>,
|
||||||
client_residual: &[u8],
|
client_residual: Vec<u8>,
|
||||||
started: Instant,
|
started: Instant,
|
||||||
label: &'static str,
|
label: &'static str,
|
||||||
wire: AuthWire,
|
wire: AuthWire,
|
||||||
@@ -818,7 +920,7 @@ pub async fn bridge_authenticated<F, Fut>(
|
|||||||
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<BackendLeg>>,
|
Fut: Future<Output = Result<BackendLeg>>,
|
||||||
@@ -832,7 +934,7 @@ 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 BackendLeg {
|
let BackendLeg {
|
||||||
@@ -844,10 +946,11 @@ where
|
|||||||
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);
|
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?;
|
||||||
|
|
||||||
@@ -872,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,
|
||||||
@@ -895,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<()> {
|
||||||
@@ -1049,7 +1162,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn login_challenge_builds_a_plain_authentication_frame() {
|
fn login_challenge_builds_a_plain_authentication_frame() {
|
||||||
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
|
use encodify::base64::STANDARD as BASE64;
|
||||||
let user = BASE64.encode("user@example.com");
|
let user = BASE64.encode("user@example.com");
|
||||||
let pass = BASE64.encode("secret");
|
let pass = BASE64.encode("secret");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1300,14 +1413,86 @@ mod tests {
|
|||||||
assert_eq!(reg.kick("user@example.com"), 0);
|
assert_eq!(reg.kick("user@example.com"), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conn_registry_release_ends_hold() {
|
||||||
|
// releasing a delay wakes a held login immediately
|
||||||
|
let reg = ConnRegistry::default();
|
||||||
|
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(60));
|
||||||
|
let held = reg.clone();
|
||||||
|
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
assert!(!hold.is_finished());
|
||||||
|
reg.release_delay("user@example.com");
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), hold)
|
||||||
|
.await
|
||||||
|
.expect("release did not end the hold")
|
||||||
|
.unwrap();
|
||||||
|
assert!(reg.delay("user@example.com").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conn_registry_release_all_ends_holds() {
|
||||||
|
// releasing every delay wakes all held logins
|
||||||
|
let reg = ConnRegistry::default();
|
||||||
|
let until = Instant::now() + Duration::from_secs(60);
|
||||||
|
reg.set_delay("a@example.com", until);
|
||||||
|
reg.set_delay("b@example.com", until);
|
||||||
|
let holds = ["a@example.com", "b@example.com"].map(|id| {
|
||||||
|
let held = reg.clone();
|
||||||
|
tokio::spawn(async move { held.hold_login(id).await })
|
||||||
|
});
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
reg.release_all_delays();
|
||||||
|
for hold in holds {
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), hold)
|
||||||
|
.await
|
||||||
|
.expect("release did not end the hold")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conn_registry_shortened_delay_ends_hold() {
|
||||||
|
// shortening a delay wakes a held login at the new deadline
|
||||||
|
let reg = ConnRegistry::default();
|
||||||
|
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(60));
|
||||||
|
let held = reg.clone();
|
||||||
|
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
|
||||||
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
|
reg.set_delay("user@example.com", Instant::now());
|
||||||
|
tokio::time::timeout(Duration::from_secs(2), hold)
|
||||||
|
.await
|
||||||
|
.expect("shortened delay did not end the hold")
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn conn_registry_extended_delay_keeps_hold() {
|
||||||
|
// extending a delay keeps a held login waiting past the original deadline
|
||||||
|
let reg = ConnRegistry::default();
|
||||||
|
reg.set_delay(
|
||||||
|
"user@example.com",
|
||||||
|
Instant::now() + Duration::from_millis(100),
|
||||||
|
);
|
||||||
|
let held = reg.clone();
|
||||||
|
let started = Instant::now();
|
||||||
|
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
|
||||||
|
reg.set_delay(
|
||||||
|
"user@example.com",
|
||||||
|
Instant::now() + Duration::from_millis(400),
|
||||||
|
);
|
||||||
|
hold.await.unwrap();
|
||||||
|
assert!(started.elapsed() >= Duration::from_millis(350));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn conn_registry_delay_window() {
|
fn conn_registry_delay_window() {
|
||||||
let reg = ConnRegistry::default();
|
let reg = ConnRegistry::default();
|
||||||
assert!(reg.delay_remaining("user@example.com").is_none());
|
assert!(reg.delay("user@example.com").is_none());
|
||||||
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(30));
|
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(30));
|
||||||
assert!(reg.delay_remaining("user@example.com").is_some());
|
assert!(reg.delay("user@example.com").is_some());
|
||||||
reg.set_delay("user@example.com", Instant::now());
|
reg.set_delay("user@example.com", Instant::now());
|
||||||
assert!(reg.delay_remaining("user@example.com").is_none());
|
assert!(reg.delay("user@example.com").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
+168
-101
@@ -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,72 +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 mech_name = mechanism_name(&mech);
|
|
||||||
let response = BASE64.encode(&raw_challenge);
|
|
||||||
let auth = common::AuthFrame::inline(
|
|
||||||
format!("{tag} AUTHENTICATE {mech_name} {response}\r\n").into_bytes(),
|
|
||||||
)
|
|
||||||
.with_deferred(
|
|
||||||
format!("{tag} AUTHENTICATE {mech_name}\r\n").into_bytes(),
|
|
||||||
format!("{response}\r\n").into_bytes(),
|
|
||||||
);
|
);
|
||||||
let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n");
|
write(&mut stream, greeting.as_bytes()).await?;
|
||||||
|
|
||||||
common::bridge_authenticated(
|
let mut seed = Vec::new();
|
||||||
ctx,
|
let mut auth_failures = 0u32;
|
||||||
&dest_id,
|
|
||||||
stream,
|
loop {
|
||||||
peer,
|
let decision = match timeout(
|
||||||
identifier.as_deref(),
|
listener.preauth_timeout,
|
||||||
&residual,
|
preauth(ctx, listener, caps, stream, seed, &mut auth_failures),
|
||||||
started,
|
)
|
||||||
"imap",
|
.await
|
||||||
common::AuthWire::Imap { tag },
|
{
|
||||||
&auth,
|
Ok(Ok(d)) => d,
|
||||||
unavailable.as_bytes(),
|
Ok(Err(e)) => return Err(e),
|
||||||
MAX_PREAUTH,
|
Err(_) => return Ok(()),
|
||||||
|| open_backend(ctx, &dest_id, &fwd),
|
};
|
||||||
)
|
|
||||||
.await
|
let Decision::Authenticate {
|
||||||
|
stream: client,
|
||||||
|
mech,
|
||||||
|
tag,
|
||||||
|
raw_challenge,
|
||||||
|
identifier,
|
||||||
|
residual,
|
||||||
|
} = decision
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
let identifier = ctx.routing_identifier(identifier);
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
common::hold_login(ctx, identifier.as_deref()).await;
|
||||||
|
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||||
|
tracing::debug!(destination = %dest_id, "imap routing decision");
|
||||||
|
|
||||||
|
let client_tls = client.is_tls();
|
||||||
|
let fwd =
|
||||||
|
forward::ForwardInfo::new(peer, local, client_tls, None, ctx.config.server.proxy_ttl);
|
||||||
|
|
||||||
|
let mech_name = mechanism_name(&mech);
|
||||||
|
let mut response = String::with_capacity(BASE64.encoded_len(raw_challenge.len()) + 2);
|
||||||
|
BASE64.encode_append(&raw_challenge, &mut response);
|
||||||
|
response.push_str("\r\n");
|
||||||
|
let auth = common::AuthFrame::inline(
|
||||||
|
format!("{tag} AUTHENTICATE {mech_name} {response}").into_bytes(),
|
||||||
|
)
|
||||||
|
.with_deferred(
|
||||||
|
format!("{tag} AUTHENTICATE {mech_name}\r\n").into_bytes(),
|
||||||
|
response.into_bytes(),
|
||||||
|
);
|
||||||
|
let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n");
|
||||||
|
|
||||||
|
let resume = common::bridge_authenticated(
|
||||||
|
ctx,
|
||||||
|
&dest_id,
|
||||||
|
client,
|
||||||
|
peer,
|
||||||
|
identifier.as_deref(),
|
||||||
|
residual,
|
||||||
|
started,
|
||||||
|
"imap",
|
||||||
|
common::AuthWire::Imap { tag },
|
||||||
|
&auth,
|
||||||
|
unavailable.as_bytes(),
|
||||||
|
MAX_PREAUTH,
|
||||||
|
|| open_backend(ctx, &dest_id, &fwd),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let Some(common::Resume {
|
||||||
|
stream: client,
|
||||||
|
buf,
|
||||||
|
}) = resume
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
if common::bump(&mut auth_failures, listener.max_auth_attempts) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
stream = client;
|
||||||
|
seed = buf;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn preauth(
|
async fn preauth(
|
||||||
@@ -112,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 {
|
||||||
@@ -119,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) {
|
||||||
@@ -234,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),
|
||||||
}
|
}
|
||||||
@@ -255,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),
|
||||||
}
|
}
|
||||||
@@ -280,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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,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,
|
||||||
@@ -332,7 +376,7 @@ impl Auth<'_> {
|
|||||||
Mechanism::Login if common::sasl_offered(&self.caps.sasl, "LOGIN") => {}
|
Mechanism::Login if common::sasl_offered(&self.caps.sasl, "LOGIN") => {}
|
||||||
_ => {
|
_ => {
|
||||||
return self
|
return self
|
||||||
.reject(stream, tag, "Unsupported authentication mechanism.")
|
.reject(stream, tag, "Unsupported authentication mechanism.", None)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -354,16 +398,21 @@ impl Auth<'_> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,18 +427,20 @@ impl Auth<'_> {
|
|||||||
it: &mut std::slice::Iter<'_, u8>,
|
it: &mut std::slice::Iter<'_, u8>,
|
||||||
) -> Result<Step> {
|
) -> Result<Step> {
|
||||||
let (user_b64, residual): (Vec<u8>, Vec<u8>) = if let Some(ir) = tokens.get(1) {
|
let (user_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(
|
write(
|
||||||
&mut *stream,
|
&mut *stream,
|
||||||
format!("+ {}\r\n", common::LOGIN_USERNAME_CHALLENGE).as_bytes(),
|
format!("+ {}\r\n", common::LOGIN_USERNAME_CHALLENGE).as_bytes(),
|
||||||
)
|
)
|
||||||
.await?;
|
.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?
|
||||||
};
|
};
|
||||||
if common::sasl_cancelled(&user_b64) {
|
if common::sasl_cancelled(&user_b64) {
|
||||||
return self.reject(stream, tag, "Authentication cancelled.").await;
|
return self
|
||||||
|
.reject(stream, tag, "Authentication cancelled.", Some(residual))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
write(
|
write(
|
||||||
@@ -399,12 +450,19 @@ impl Auth<'_> {
|
|||||||
.await?;
|
.await?;
|
||||||
let (pass_b64, residual) = read_crlf_line(stream, residual, MAX_PREAUTH).await?;
|
let (pass_b64, residual) = read_crlf_line(stream, residual, MAX_PREAUTH).await?;
|
||||||
if common::sasl_cancelled(&pass_b64) {
|
if common::sasl_cancelled(&pass_b64) {
|
||||||
return self.reject(stream, tag, "Authentication cancelled.").await;
|
return self
|
||||||
|
.reject(stream, tag, "Authentication cancelled.", Some(residual))
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
let Some(raw) = common::login_challenge(&user_b64, &pass_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;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -429,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());
|
||||||
@@ -438,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
|
||||||
@@ -453,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);
|
||||||
@@ -466,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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+174
-98
@@ -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,80 +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 = common::AuthFrame::inline(
|
|
||||||
format!(
|
|
||||||
"AUTHENTICATE \"{}\" \"{}\"\r\n",
|
|
||||||
mechanism_name(&mech),
|
|
||||||
BASE64.encode(&raw_challenge)
|
|
||||||
)
|
|
||||||
.into_bytes(),
|
|
||||||
);
|
|
||||||
|
|
||||||
common::bridge_authenticated(
|
|
||||||
ctx,
|
|
||||||
&dest_id,
|
|
||||||
stream,
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
||||||
@@ -111,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) {
|
||||||
@@ -179,7 +203,7 @@ async fn preauth(
|
|||||||
common::sasl_offered(&caps.sasl, "LOGIN"),
|
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?
|
||||||
@@ -198,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),
|
||||||
}
|
}
|
||||||
@@ -222,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(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,6 +272,9 @@ enum AuthStep {
|
|||||||
residual: Vec<u8>,
|
residual: Vec<u8>,
|
||||||
},
|
},
|
||||||
Continue,
|
Continue,
|
||||||
|
Resume {
|
||||||
|
residual: Vec<u8>,
|
||||||
|
},
|
||||||
Close,
|
Close,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,7 +297,16 @@ async fn handle_authenticate(
|
|||||||
match mech {
|
match mech {
|
||||||
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {}
|
Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {}
|
||||||
Mechanism::Login if login_offered => {}
|
Mechanism::Login if login_offered => {}
|
||||||
_ => return fail(stream, "Unsupported mechanism", auth_failures, max_attempts).await,
|
_ => {
|
||||||
|
return fail(
|
||||||
|
stream,
|
||||||
|
"Unsupported mechanism",
|
||||||
|
auth_failures,
|
||||||
|
max_attempts,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if matches!(mech, Mechanism::Plain | Mechanism::Login) && !plain_ok {
|
if matches!(mech, Mechanism::Plain | Mechanism::Login) && !plain_ok {
|
||||||
write(
|
write(
|
||||||
@@ -278,18 +322,32 @@ async fn handle_authenticate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -310,10 +368,10 @@ async fn authenticate_login(
|
|||||||
max_attempts: u32,
|
max_attempts: u32,
|
||||||
) -> Result<AuthStep> {
|
) -> Result<AuthStep> {
|
||||||
let (user_b64, residual): (Vec<u8>, Vec<u8>) = if let Some(ir) = tokens.get(1) {
|
let (user_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_challenge(stream, common::LOGIN_USERNAME_CHALLENGE).await?;
|
write_challenge(stream, common::LOGIN_USERNAME_CHALLENGE).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?
|
||||||
};
|
};
|
||||||
if common::sasl_cancelled(&user_b64) {
|
if common::sasl_cancelled(&user_b64) {
|
||||||
@@ -322,6 +380,7 @@ async fn authenticate_login(
|
|||||||
"Authentication cancelled",
|
"Authentication cancelled",
|
||||||
auth_failures,
|
auth_failures,
|
||||||
max_attempts,
|
max_attempts,
|
||||||
|
Some(residual),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
@@ -334,15 +393,30 @@ async fn authenticate_login(
|
|||||||
"Authentication cancelled",
|
"Authentication cancelled",
|
||||||
auth_failures,
|
auth_failures,
|
||||||
max_attempts,
|
max_attempts,
|
||||||
|
Some(residual),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
let Some(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
let Some(raw) = common::login_challenge(&user_b64, &pass_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(&Mechanism::Plain, &raw) else {
|
let Some(cred) = credential_from_sasl(&Mechanism::Plain, &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);
|
||||||
|
|
||||||
@@ -367,14 +441,16 @@ async fn fail(
|
|||||||
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>)> {
|
||||||
|
|||||||
+84
-74
@@ -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,69 +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,
|
|
||||||
local,
|
|
||||||
stream.is_tls(),
|
|
||||||
None,
|
|
||||||
ctx.config.server.proxy_ttl,
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth = common::AuthFrame::inline(
|
|
||||||
format!(
|
|
||||||
"AUTH {} {}\r\n",
|
|
||||||
mechanism_name(&mech),
|
|
||||||
BASE64.encode(&raw_challenge)
|
|
||||||
)
|
)
|
||||||
.into_bytes(),
|
.await
|
||||||
);
|
{
|
||||||
|
Ok(Ok(Some(h))) => h,
|
||||||
|
Ok(Ok(None)) => return Ok(()),
|
||||||
|
Ok(Err(e)) => return Err(e),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
common::bridge_authenticated(
|
let Handoff {
|
||||||
ctx,
|
stream: client,
|
||||||
&dest_id,
|
mech,
|
||||||
stream,
|
raw_challenge,
|
||||||
peer,
|
identifier,
|
||||||
identifier.as_deref(),
|
residual,
|
||||||
&residual,
|
} = handoff;
|
||||||
started,
|
|
||||||
"pop3",
|
let identifier = ctx.routing_identifier(identifier);
|
||||||
common::AuthWire::Pop3,
|
let started = std::time::Instant::now();
|
||||||
&auth,
|
common::hold_login(ctx, identifier.as_deref()).await;
|
||||||
b"-ERR backend unavailable\r\n",
|
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||||
MAX_PREAUTH,
|
let fwd = forward::ForwardInfo::new(
|
||||||
|| open_backend(ctx, &dest_id, &fwd),
|
peer,
|
||||||
)
|
local,
|
||||||
.await
|
client.is_tls(),
|
||||||
|
None,
|
||||||
|
ctx.config.server.proxy_ttl,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut frame = format!("AUTH {} ", 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,
|
||||||
|
"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(
|
||||||
@@ -97,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 {
|
||||||
@@ -227,7 +242,7 @@ async fn preauth(
|
|||||||
_ => {
|
_ => {
|
||||||
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;
|
||||||
@@ -259,7 +274,7 @@ async fn preauth(
|
|||||||
if common::sasl_cancelled(&user_b64) {
|
if common::sasl_cancelled(&user_b64) {
|
||||||
buf = rest;
|
buf = rest;
|
||||||
write(&mut stream, b"-ERR authentication cancelled\r\n").await?;
|
write(&mut stream, b"-ERR authentication cancelled\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;
|
||||||
@@ -274,21 +289,21 @@ async fn preauth(
|
|||||||
buf = rest;
|
buf = rest;
|
||||||
if common::sasl_cancelled(&pass_b64) {
|
if common::sasl_cancelled(&pass_b64) {
|
||||||
write(&mut stream, b"-ERR authentication cancelled\r\n").await?;
|
write(&mut stream, b"-ERR authentication cancelled\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(raw) = common::login_challenge(&user_b64, &pass_b64) else {
|
let Some(raw) = common::login_challenge(&user_b64, &pass_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(&Mechanism::Plain, &raw) else {
|
let Some(cred) = credential_from_sasl(&Mechanism::Plain, &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;
|
||||||
@@ -319,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;
|
||||||
@@ -352,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..])),
|
||||||
|
|||||||
+115
-83
@@ -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 smtp_proto::{
|
use smtp_proto::{
|
||||||
AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, Error as SmtpError, Request,
|
AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, Error as SmtpError, Request,
|
||||||
};
|
};
|
||||||
@@ -18,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;
|
||||||
@@ -32,73 +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,
|
|
||||||
local,
|
|
||||||
stream.is_tls(),
|
|
||||||
Some(ehlo_host),
|
|
||||||
ctx.config.server.proxy_ttl,
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth = common::AuthFrame::inline(
|
|
||||||
format!(
|
|
||||||
"AUTH {} {}\r\n",
|
|
||||||
mechanism_name(&mech),
|
|
||||||
BASE64.encode(&raw_challenge)
|
|
||||||
)
|
)
|
||||||
.into_bytes(),
|
.await
|
||||||
);
|
{
|
||||||
|
Ok(Ok(Some(h))) => h,
|
||||||
|
Ok(Ok(None)) => return Ok(()),
|
||||||
|
Ok(Err(e)) => return Err(e),
|
||||||
|
Err(_) => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
common::bridge_authenticated(
|
let SubmissionHandoff {
|
||||||
ctx,
|
handoff,
|
||||||
&dest_id,
|
ehlo_host: announced,
|
||||||
stream,
|
} = sh;
|
||||||
peer,
|
let Handoff {
|
||||||
identifier.as_deref(),
|
stream: client,
|
||||||
&residual,
|
mech,
|
||||||
started,
|
raw_challenge,
|
||||||
"submission",
|
identifier,
|
||||||
common::AuthWire::Smtp,
|
residual,
|
||||||
&auth,
|
} = handoff;
|
||||||
b"421 4.3.0 backend unavailable\r\n",
|
|
||||||
MAX_PREAUTH,
|
let identifier = ctx.routing_identifier(identifier);
|
||||||
|| open_backend(ctx, &dest_id, &fwd),
|
let started = std::time::Instant::now();
|
||||||
)
|
common::hold_login(ctx, identifier.as_deref()).await;
|
||||||
.await
|
let dest_id = ctx.resolve(identifier.as_deref()).await;
|
||||||
|
let fwd = forward::ForwardInfo::new(
|
||||||
|
peer,
|
||||||
|
local,
|
||||||
|
client.is_tls(),
|
||||||
|
Some(announced),
|
||||||
|
ctx.config.server.proxy_ttl,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut frame = format!("AUTH {} ", 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,
|
||||||
|
"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(
|
||||||
@@ -106,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();
|
||||||
@@ -133,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,
|
||||||
@@ -145,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);
|
||||||
}
|
}
|
||||||
@@ -187,17 +222,17 @@ async fn preauth(
|
|||||||
ctx,
|
ctx,
|
||||||
plain_ok,
|
plain_ok,
|
||||||
login_offered: common::sasl_offered(&caps.sasl, "LOGIN"),
|
login_offered: common::sasl_offered(&caps.sasl, "LOGIN"),
|
||||||
ehlo_host: &ehlo_host,
|
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?
|
||||||
{
|
{
|
||||||
@@ -233,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);
|
||||||
}
|
}
|
||||||
@@ -242,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -332,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,
|
||||||
@@ -448,8 +482,7 @@ async fn cancelled(
|
|||||||
max_attempts: u32,
|
max_attempts: u32,
|
||||||
) -> Result<AuthStep> {
|
) -> Result<AuthStep> {
|
||||||
write(&mut *stream, b"501 5.7.0 Authentication cancelled\r\n").await?;
|
write(&mut *stream, b"501 5.7.0 Authentication cancelled\r\n").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)
|
||||||
@@ -468,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)
|
||||||
|
|||||||
+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;
|
||||||
@@ -645,3 +653,164 @@ async fn admin_config_reload_rejects_invalid() {
|
|||||||
"legacy"
|
"legacy"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn switch_config(old_port: u16, new_port: u16, mapping_path: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
[mapping]
|
||||||
|
source = "file"
|
||||||
|
normalize = "lowercase"
|
||||||
|
[mapping.file]
|
||||||
|
path = "{mapping_path}"
|
||||||
|
|
||||||
|
[capabilities.pop3]
|
||||||
|
allow_plain_auth_without_tls = true
|
||||||
|
|
||||||
|
[routing]
|
||||||
|
default_destination = "legacy"
|
||||||
|
|
||||||
|
[destination.legacy]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
forwarding = "none"
|
||||||
|
allow_plaintext_auth = true
|
||||||
|
[destination.legacy.protocol.pop3]
|
||||||
|
port = {old_port}
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
[destination.next]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
forwarding = "none"
|
||||||
|
allow_plaintext_auth = true
|
||||||
|
[destination.next.protocol.pop3]
|
||||||
|
port = {new_port}
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
[listener.pop3]
|
||||||
|
protocol = "pop3"
|
||||||
|
bind = ["127.0.0.1:0"]
|
||||||
|
tls = "plain"
|
||||||
|
|
||||||
|
[admin]
|
||||||
|
bind = "127.0.0.1:0"
|
||||||
|
tls = "implicit"
|
||||||
|
bearer_token = "{TOKEN}"
|
||||||
|
min_token_len = 32
|
||||||
|
lockout_threshold = 100
|
||||||
|
lockout_duration = "5m"
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pop3_named_backend(name: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
|
dummy_backend(move |mut sock| async move {
|
||||||
|
sock.write_all(b"+OK ready\r\n").await.unwrap();
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
let mut scratch = [0u8; 1024];
|
||||||
|
while find(&buf, b"AUTH PLAIN").is_none() || find(&buf, b"\r\n").is_none() {
|
||||||
|
let n = sock.read(&mut scratch).await.unwrap();
|
||||||
|
if n == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
buf.extend_from_slice(&scratch[..n]);
|
||||||
|
}
|
||||||
|
sock.write_all(format!("+OK logged in at {name}\r\n").as_bytes())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let _ = sock.read(&mut scratch).await;
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn held_login_follows_mapping_change(method: &str, path: &str, mapping_update: Option<&str>) {
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let (old_port, old_backend) = pop3_named_backend("legacy").await;
|
||||||
|
let (new_port, new_backend) = pop3_named_backend("next").await;
|
||||||
|
let mapping = mapping_file("user@example.com\tlegacy\n");
|
||||||
|
let toml = switch_config(old_port, new_port, &mapping.path().display().to_string());
|
||||||
|
let ctx = make_ctx(&toml).await;
|
||||||
|
|
||||||
|
let admin_listener = TcpListener::bind(unused_addr()).await.unwrap();
|
||||||
|
let admin_addr = admin_listener.local_addr().unwrap();
|
||||||
|
let admin_ctx = ctx.clone();
|
||||||
|
let admin_config = ctx.config.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let shared = std::sync::Arc::new(arc_swap::ArcSwap::new(admin_ctx));
|
||||||
|
let _ = crate::admin::serve(shared, admin_config, None, admin_listener).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let (status, _) = admin_request(
|
||||||
|
admin_addr,
|
||||||
|
"POST",
|
||||||
|
"/connections/delay?identifier=user@example.com&seconds=60",
|
||||||
|
Some(TOKEN),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let listener = ctx.config.listener["pop3"].clone();
|
||||||
|
let (mut client, server) = tcp_pair().await;
|
||||||
|
let ctx2 = ctx.clone();
|
||||||
|
let handler = tokio::spawn(async move {
|
||||||
|
let _ = crate::proto::pop3::handle(
|
||||||
|
&ctx2,
|
||||||
|
&listener,
|
||||||
|
Box::new(server),
|
||||||
|
"203.0.113.9:40000".parse().unwrap(),
|
||||||
|
"127.0.0.1:110".parse().unwrap(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
let greeting = read_line(&mut client).await;
|
||||||
|
assert!(greeting.starts_with("+OK"), "greeting: {greeting}");
|
||||||
|
send(&mut client, b"USER user@example.com\r\nPASS secret\r\n").await;
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
|
||||||
|
|
||||||
|
if let Some(contents) = mapping_update {
|
||||||
|
std::fs::write(mapping.path(), contents).unwrap();
|
||||||
|
}
|
||||||
|
let (status, _) = admin_request(admin_addr, method, path, Some(TOKEN)).await;
|
||||||
|
assert_eq!(status, 200);
|
||||||
|
|
||||||
|
let mut seen = Vec::new();
|
||||||
|
let mut scratch = [0u8; 1024];
|
||||||
|
while find(&seen, b"+OK logged in at").is_none() {
|
||||||
|
let n = tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut scratch))
|
||||||
|
.await
|
||||||
|
.expect("held login was not released by the mapping change")
|
||||||
|
.unwrap();
|
||||||
|
assert!(n > 0, "client closed before login");
|
||||||
|
seen.extend_from_slice(&scratch[..n]);
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
find(&seen, b"+OK logged in at next").is_some(),
|
||||||
|
"held login must reach the new destination, got {:?}",
|
||||||
|
String::from_utf8_lossy(&seen)
|
||||||
|
);
|
||||||
|
|
||||||
|
drop(client);
|
||||||
|
let _ = new_backend.await;
|
||||||
|
old_backend.abort();
|
||||||
|
let _ = handler.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_mapping_put_releases_held_login_to_new_destination() {
|
||||||
|
// a login held by /connections/delay goes to the destination set by PUT /mappings at once
|
||||||
|
held_login_follows_mapping_change(
|
||||||
|
"PUT",
|
||||||
|
"/mappings?identifier=user@example.com&destination=next",
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn admin_mappings_reload_releases_held_login_to_new_destination() {
|
||||||
|
// a login held by /connections/delay goes to the destination loaded by /mappings/reload at once
|
||||||
|
held_login_follows_mapping_change("POST", "/mappings/reload", Some("user@example.com\tnext\n"))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|||||||
@@ -501,3 +501,79 @@ sasl = ["NTLM"]
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -830,7 +830,7 @@ fn path_of(url: &str) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use base64::Engine;
|
use encodify::base64::STANDARD as B64;
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
|
||||||
async fn imaps_auth(host: &str, port: u16, login: &str, password: &str) -> String {
|
async fn imaps_auth(host: &str, port: u16, login: &str, password: &str) -> String {
|
||||||
@@ -838,7 +838,7 @@ mod tests {
|
|||||||
let mut stream = crate::tests::harness::tls_connect_client(tcp).await;
|
let mut stream = crate::tests::harness::tls_connect_client(tcp).await;
|
||||||
let mut buf = [0u8; 512];
|
let mut buf = [0u8; 512];
|
||||||
let _ = stream.read(&mut buf).await.unwrap();
|
let _ = stream.read(&mut buf).await.unwrap();
|
||||||
let ir = base64::engine::general_purpose::STANDARD.encode(format!("\0{login}\0{password}"));
|
let ir = B64.encode(format!("\0{login}\0{password}"));
|
||||||
stream
|
stream
|
||||||
.write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes())
|
.write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
+173
-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::*;
|
||||||
@@ -801,3 +801,175 @@ async fn imap_keeps_inline_initial_response_when_backend_advertises_sasl_ir() {
|
|||||||
let _ = backend.await;
|
let _ = backend.await;
|
||||||
handler.abort();
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
+364
-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::*;
|
||||||
@@ -609,3 +609,366 @@ tls = "plain"
|
|||||||
String::from_utf8_lossy(&resp)
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
+1
-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::*;
|
||||||
|
|||||||
+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