commit 979022df61e346d29bdb302d70e1d5e14afb6291 Author: Maurus Decimus <11444311+mdecimus@users.noreply.github.com> Date: Mon Jun 8 16:57:10 2026 +0200 Initial commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..97fd07e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +# Ignore everything by default +* + +# Allow only what the build needs +!src +!Cargo.toml +!Cargo.lock diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3488ce8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,472 @@ +name: "CI" + +on: + workflow_dispatch: + inputs: + Docker: + required: false + default: false + type: boolean + Release: + required: false + default: false + type: boolean + push: + tags: ["v*.*.*"] + +env: + SCCACHE_GHA_ENABLED: true + RUSTC_WRAPPER: sccache + CARGO_TERM_COLOR: always + AWS_LC_SYS_PREBUILT_NASM: 1 + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + multiarch: + strategy: + fail-fast: false + matrix: + include: + - variant: gnu + - variant: musl + name: Merge image / ${{matrix.variant}} + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + attestations: write + packages: write + needs: [linux] + if: github.event_name == 'push' || inputs.Docker + steps: + - name: Install Cosign + uses: sigstore/cosign-installer@v4.1.1 + - name: Log In to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{github.repository_owner}} + password: ${{github.token}} + + - name: Log In to DockerHub + uses: docker/login-action@v4 + with: + username: ${{secrets.DOCKERHUB_USERNAME}} + password: ${{secrets.DOCKERHUB_TOKEN}} + + - name: Download ${{matrix.variant}} meta bake definition + uses: actions/download-artifact@v8 + with: + name: bake-meta-${{matrix.variant}} + path: ${{ runner.temp }}/${{matrix.variant}} + + - name: Download ${{matrix.variant}} digests + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/${{matrix.variant}}/digests + pattern: digests-${{matrix.variant}}-* + merge-multiple: true + + - name: Create ${{matrix.variant}} manifest list and push + working-directory: ${{ runner.temp }}/${{matrix.variant}}/digests + run: | + docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \ + $(printf 'ghcr.io/${{github.repository}}@sha256:%s ' *) + docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \ + $(printf 'index.docker.io/${{github.repository}}@sha256:%s ' *) + + - name: Inspect ${{matrix.variant}} image + id: manifest-digest + run: | + docker buildx imagetools inspect --format '{{json .Manifest}}' ghcr.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > GHCR_DIGEST_SHA + echo "GHCR_DIGEST_SHA=$(cat GHCR_DIGEST_SHA)" | tee -a "${GITHUB_ENV}" + docker buildx imagetools inspect --format '{{json .Manifest}}' index.docker.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > DOCKERHUB_DIGEST_SHA + echo "DOCKERHUB_DIGEST_SHA=$(cat DOCKERHUB_DIGEST_SHA)" | tee -a "${GITHUB_ENV}" + cosign sign --yes $(jq --arg GHCR_DIGEST_SHA "$(cat GHCR_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | . + "@" + $GHCR_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) + cosign sign --yes $(jq --arg DOCKERHUB_DIGEST_SHA "$(cat DOCKERHUB_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | . + "@" + $DOCKERHUB_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) + + - name: Attest GHCR + uses: actions/attest-build-provenance@v4 + with: + subject-name: ghcr.io/${{github.repository}} + subject-digest: ${{ env.GHCR_DIGEST_SHA }} + push-to-registry: true + + - name: Attest Dockerhub + uses: actions/attest-build-provenance@v4 + with: + subject-name: index.docker.io/${{github.repository}} + subject-digest: ${{ env.DOCKERHUB_DIGEST_SHA }} + push-to-registry: true + + linux: + permissions: + id-token: write + contents: write + attestations: write + packages: write + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + platform: linux/amd64 + suffix: "" + - target: x86_64-unknown-linux-musl + platform: linux/amd64 + suffix: "-alpine" + - target: aarch64-unknown-linux-gnu + platform: linux/arm64 + suffix: "" + - target: aarch64-unknown-linux-musl + platform: linux/arm64 + suffix: "-alpine" + - target: armv7-unknown-linux-gnueabihf + platform: linux/arm/v7 + suffix: "" + - target: armv7-unknown-linux-musleabihf + platform: linux/arm/v7 + suffix: "-alpine" + - target: arm-unknown-linux-gnueabihf + platform: linux/arm/v6 + suffix: "" + - target: arm-unknown-linux-musleabihf + platform: linux/arm/v6 + suffix: "-alpine" + name: Build / ${{matrix.target}} + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Add swap (heavy ARM targets) + if: contains(matrix.target, 'arm') + run: | + sudo fallocate -l 16G /mnt/swapfile + sudo chmod 600 /mnt/swapfile + sudo mkswap /mnt/swapfile + sudo swapon /mnt/swapfile + sudo sysctl vm.swappiness=80 + free -h + swapon --show + + - name: Set up QEMU + uses: docker/setup-qemu-action@v4 + with: + platforms: "arm64,arm" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + with: + buildkitd-config-inline: | + [registry."docker.io"] + mirrors = ["https://mirror.gcr.io"] + driver-opts: | + network=host + + - name: Log In to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{github.repository_owner}} + password: ${{github.token}} + + - name: Log In to DockerHub + uses: docker/login-action@v4 + with: + username: ${{secrets.DOCKERHUB_USERNAME}} + password: ${{secrets.DOCKERHUB_TOKEN}} + + - name: Calculate shasum of external deps + id: cal-dep-shasum + run: | + echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Cache apt + uses: actions/cache@v5.0.5 + id: apt-cache + with: + path: | + var-cache-apt + var-lib-apt + key: apt-cache-${{ hashFiles('Dockerfile.build') }} + + - name: Cache Cargo + uses: actions/cache@v5.0.5 + id: cargo-cache + with: + path: | + usr-local-cargo-registry + usr-local-cargo-git + key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }} + + - name: Inject cache into docker + uses: reproducible-containers/buildkit-cache-dance@v3.3.2 + with: + cache-map: | + { + "var-cache-apt": "/var/cache/apt", + "var-lib-apt": "/var/lib/apt", + "usr-local-cargo-registry": "/usr/local/cargo/registry", + "usr-local-cargo-git": "/usr/local/cargo/git" + } + skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }} && ${{ steps.apt-cache.outputs.cache-hit }} + + - name: Extract Metadata for Docker + uses: docker/metadata-action@v6 + id: meta + with: + images: | + index.docker.io/${{github.repository}} + ghcr.io/${{github.repository}} + flavor: | + suffix=${{matrix.suffix}},onlatest=true + tags: | + type=ref,event=tag + type=ref,event=branch,prefix=branch- + type=edge,branch=main + type=semver,pattern=v{{major}}.{{minor}} + + - name: Build Artifact + id: bake + uses: docker/bake-action@v7 + env: + DOCKER_BUILD_RECORD_UPLOAD: false + TARGET: ${{matrix.target}} + GHCR_REPO: ghcr.io/${{github.repository}} + BUILD_ENV: "" + DOCKER_PLATFORM: ${{matrix.platform}} + SUFFIX: ${{matrix.suffix}} + with: + source: . + set: | + *.tags= + image.output=type=image,"name=ghcr.io/${{github.repository}},index.docker.io/${{github.repository}}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true + files: | + docker-bake.hcl + ${{ steps.meta.outputs.bake-file }} + targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}} + + - name: Upload Artifacts + uses: actions/upload-artifact@v7 + with: + name: artifact-${{matrix.target}} + path: | + artifact + !artifact/*.json + + - name: Export digest & Rename meta bake definition file + if: github.event_name == 'push' || inputs.Docker + run: | + mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json" + mkdir -p ${{ runner.temp }}/digests + digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}" + touch "${{ runner.temp }}/digests/${digest#sha256:}" + + - name: Upload digest + if: github.event_name == 'push' || inputs.Docker + uses: actions/upload-artifact@v7 + with: + name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }} + path: ${{ runner.temp }}/digests/* + if-no-files-found: error + retention-days: 1 + + - name: Upload GNU meta bake definition + uses: actions/upload-artifact@v7 + if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86') + with: + name: bake-meta-gnu + path: ${{ runner.temp }}/bake-meta.json + if-no-files-found: error + retention-days: 1 + + - name: Upload musl meta bake definition + uses: actions/upload-artifact@v7 + if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86') + with: + name: bake-meta-musl + path: ${{ runner.temp }}/bake-meta.json + if-no-files-found: error + retention-days: 1 + + windows: + name: Build / ${{matrix.target}} + runs-on: windows-latest + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-pc-windows-msvc + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.9 + with: + disable_annotations: true + + - name: Build + run: | + rustup target add ${{matrix.target}} + cargo build --release --target ${{matrix.target}} -p proxy + mkdir -p artifacts + mv ./target/${{matrix.target}}/release/proxy.exe ./artifacts/proxy.exe + + - name: Upload Artifacts + uses: actions/upload-artifact@v7 + with: + name: artifact-${{matrix.target}} + path: artifacts + + macos: + name: Build / ${{matrix.target}} + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + include: + - target: aarch64-apple-darwin + - target: x86_64-apple-darwin + steps: + - name: Checkout + uses: actions/checkout@v6.0.2 + + - name: Run sccache-cache + uses: mozilla-actions/sccache-action@v0.0.9 + with: + disable_annotations: true + + - name: Build + run: | + rustup target add ${{matrix.target}} + cargo build --release --target ${{matrix.target}} -p proxy + mkdir -p artifacts + mv ./target/${{matrix.target}}/release/proxy ./artifacts/proxy + + - name: Upload Artifacts + uses: actions/upload-artifact@v7 + with: + name: artifact-${{matrix.target}} + path: artifacts + + release: + name: Release + permissions: + id-token: write + contents: write + attestations: write + if: github.event_name == 'push' || inputs.Release + needs: [linux, windows, macos] + runs-on: ubuntu-latest + steps: + # Must run before artifacts are downloaded: checkout cleans the workspace. + - name: Checkout (for CHANGELOG) + if: startsWith(github.ref, 'refs/tags/') + uses: actions/checkout@v6.0.2 + + - name: Download Artifacts + uses: actions/download-artifact@v8 + with: + path: archive + pattern: artifact-* + + - name: Compress + run: | + set -eux + BASE_DIR="$(pwd)/archive" + compress_files() { + local dir="$1" + local archive_dir_name="${dir#artifact-}" + cd "$dir" + # Process each file in the directory + for file in `ls`; do + filename="${file%.*}" + extension="${file##*.}" + if [ "$extension" = "exe" ]; then + 7z a -tzip "${filename}-${archive_dir_name}.zip" "$file" > /dev/null + else + tar -czf "${filename}-${archive_dir_name}.tar.gz" "$file" + fi + done + cd $BASE_DIR + } + cd $BASE_DIR + for arch_dir in `ls`; do + dir_name=$(basename "$arch_dir") + compress_files "$dir_name" + done + + - name: Attest binary + id: attest + uses: actions/attest-build-provenance@v4 + with: + subject-path: | + archive/**/*.tar.gz + archive/**/*.zip + + - name: Use cosign to sign existing artifacts + uses: sigstore/gh-action-sigstore-python@v3.3.0 + with: + inputs: | + archive/**/*.tar.gz + archive/**/*.zip + + - name: Build release body + run: | + if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then + awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md + echo "" >> release_body.md + else + : > release_body.md + fi + cat >> release_body.md < + + ### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }}) + EOF + + - name: Release + uses: softprops/action-gh-release@v3 + with: + files: | + archive/**/*.tar.gz + archive/**/*.zip + archive/**/*.sigstore.json + prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}} + tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}} + # Tag-push releases are created as drafts; the `publish` job un-drafts + # them only after all build jobs succeed, so watcher notifications + # don't fire on broken builds. + draft: ${{ startsWith(github.ref, 'refs/tags/') || null }} + body_path: release_body.md + + publish: + name: Publish release + needs: [linux, windows, macos, multiarch, release] + if: startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Un-draft release + env: + GH_TOKEN: ${{ github.token }} + run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}" + + cleanup: + name: Cleanup failed release + needs: [linux, windows, macos, multiarch, release] + if: failure() && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Delete draft release and tag + env: + GH_TOKEN: ${{ github.token }} + run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c0d0aef --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +.ignore +*.md +!CHANGELOG.md +!README.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7b63334 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Change Log + +All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). + +## [1.0.0] - 2026-06-08 + +### Added +- Initial release of the migration proxy: pre-auth routing and socket bridging + for SMTP, submission, LMTP, IMAP, POP3, ManageSieve and HTTP/HTTPS. + +### Changed + +### Fixed diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4f8f699 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4463 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + +[[package]] +name = "arcstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" + +[[package]] +name = "asn1-rs" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "astral-tokio-tar" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb50a7aae84a03bf55b067832bc376f4961b790c97e64d3eacee97d389b90277" +dependencies = [ + "filetime", + "futures-core", + "libc", + "portable-atomic", + "rustc-hash", + "tokio", + "tokio-stream", + "xattr", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blowfish" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32fa6a061124e37baba002e496d203e23ba3d7b73750be82dbfbc92913048a5b" +dependencies = [ + "byteorder", + "cipher", + "opaque-debug", +] + +[[package]] +name = "bollard" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee04c4c84f1f811b017f2fbb7dd8815c976e7ca98593de9c1e2afad0f636bff4" +dependencies = [ + "async-stream", + "base64", + "bitflags", + "bollard-buildkit-proto", + "bollard-stubs", + "bytes", + "futures-core", + "futures-util", + "hex", + "home", + "http", + "http-body-util", + "hyper", + "hyper-named-pipe", + "hyper-rustls", + "hyper-util", + "hyperlocal", + "log", + "num", + "pin-project-lite", + "rand 0.9.4", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "serde", + "serde_derive", + "serde_json", + "serde_urlencoded", + "thiserror", + "time", + "tokio", + "tokio-stream", + "tokio-util", + "tonic", + "tower-service", + "url", + "winapi", +] + +[[package]] +name = "bollard-buildkit-proto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a885520bf6249ab931a764ffdb87b0ceef48e6e7d807cfdb21b751e086e1ad" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", + "ureq", +] + +[[package]] +name = "bollard-stubs" +version = "1.52.1-rc.29.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f0a8ca8799131c1837d1282c3f81f31e76ceb0ce426e04a7fe1ccee3287c066" +dependencies = [ + "base64", + "bollard-buildkit-proto", + "bytes", + "prost", + "serde", + "serde_json", + "serde_repr", + "time", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f8e7987cbd042a63249497f41aed09f8e65add917ea6566effbc56578d6801" +dependencies = [ + "generic-array", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "crypto-mac" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4857fd85a0c34b3c3297875b747c1e02e06b6a0ea32dd892d8192b9ce0813ea6" +dependencies = [ + "generic-array", + "subtle", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "der-parser" +version = "10.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.6", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "docker_credential" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29547a1dc60885a552306986316bc9701ba120c1a8db6769fa68691529ad373d" +dependencies = [ + "base64", + "serde", + "serde_json", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "ferroid" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee93edf3c501f0035bbeffeccfed0b79e14c311f12195ec0e661e114a0f60da4" +dependencies = [ + "portable-atomic", + "rand 0.10.1", + "web-time", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasip2", + "wasip3", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashify" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd1246c0e5493286aeb2dde35b1f4eb9c4ce00e628641210a5e553fc001a1f26" +dependencies = [ + "indexmap 2.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "hashlink" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1441c6b1e930e2817404b5046f1f989899143a12bf92de603b69f4e0aee1e15" +dependencies = [ + "crypto-mac", + "digest 0.9.0", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-named-pipe" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73b7d8abf35697b81a825e386fc151e0d503e8cb5fcb93cc8669c376dfd6f278" +dependencies = [ + "hex", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", + "winapi", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "hyperlocal" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986c5ce3b994526b3cd75578e62554abd09f0899d6206de48b3e96ab34ccc8c7" +dependencies = [ + "hex", + "http-body-util", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jmap-client" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3357aac1ef357587e09f44262f351f7773120fb695e281b34628ef9d84028038" +dependencies = [ + "ahash", + "async-stream", + "base64", + "chrono", + "futures-util", + "maybe-async", + "parking_lot", + "reqwest", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mail-parser" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2420e9ce11c2b0583ca97ddff7ab2398c8a613154e9b72e3bafdbf767f1d7" +dependencies = [ + "hashify", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-async" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746873a384ad60adc5db74471dfaba74bd278afbdcfd81db93fafcdfc8b5ca0c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "md-5" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5a279bb9607f9f53c22d496eade00d138d1bdcccd07d74650387cf94942a15" +dependencies = [ + "block-buffer 0.9.0", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "mockito" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90820618712cab19cfc46b274c6c22546a82affcb3c3bdf0f29e3db8e1bb92c0" +dependencies = [ + "assert-json-diff", + "bytes", + "colored", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "log", + "pin-project-lite", + "rand 0.9.4", + "regex", + "serde_json", + "serde_urlencoded", + "similar", + "tokio", +] + +[[package]] +name = "moka" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" +dependencies = [ + "async-lock", + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "event-listener", + "futures-util", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "oid-registry" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parse-display" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a1c2265c98e2446911282c6ac86d8524f495792c38c5bd884f80499c7538a" +dependencies = [ + "parse-display-derive", + "regex", + "regex-syntax", +] + +[[package]] +name = "parse-display-derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ae7800a4c974efd12df917266338e79a7a74415173caf7e70aa0a0707345281" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "regex-syntax", + "structmeta", + "syn", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[package]] +name = "proxy" +version = "1.0.0" +dependencies = [ + "ahash", + "arc-swap", + "aws-lc-rs", + "base64", + "futures-util", + "globset", + "jmap-client", + "mail-parser", + "mockito", + "moka", + "num_cpus", + "parking_lot", + "percent-encoding", + "proxy-header", + "pwhash", + "rcgen", + "redis", + "regex", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "smtp-proto", + "sqlx", + "tempfile", + "testcontainers", + "testcontainers-modules", + "thiserror", + "tokio", + "tokio-rustls", + "toml", + "tracing", + "tracing-subscriber", + "x509-parser", +] + +[[package]] +name = "proxy-header" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1493f63ddddfba840c3169e997c2905d09538ace72d64e84af6324c6e0e065" +dependencies = [ + "pin-project-lite", + "tokio", +] + +[[package]] +name = "pwhash" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419a3ad8fa9f9d445e69d9b185a24878ae6e6f55c96e4512f4a0e28cd3bc5c56" +dependencies = [ + "blowfish", + "byteorder", + "hmac 0.10.1", + "md-5 0.9.1", + "rand 0.8.6", + "sha-1", + "sha2 0.9.9", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rcgen" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57f6d249aad744e274e682777a50283a225a32705394ee6d5fcc01efa25e4055" +dependencies = [ + "pem", + "ring", + "rustls-pki-types", + "time", + "x509-parser", + "yasna", +] + +[[package]] +name = "redis" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a12e6b5f4d8ef33944e833e2b1859ad478deab6e431d7337b30ee2efe21f7543" +dependencies = [ + "arc-swap", + "arcstr", + "async-lock", + "backon", + "bytes", + "cfg-if", + "combine", + "futures-channel", + "futures-util", + "itoa", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-util", + "url", + "xxhash-rust", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha-1" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99cd6713db3cf16b6c84e06321e049a9b9f699826e16096d23bbcc44d15d51a6" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smtp-proto" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97c9f5dd7ec5cc6d743f33fcb96de4eb91bb1cc51c5e0ba40cb285a9012043da" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core", + "thiserror", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac 0.13.0", + "itoa", + "log", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "testcontainers" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfd5785b5483672915ed5fe3cddf9f546802779fc1eceff0a6fb7321fac81c1e" +dependencies = [ + "astral-tokio-tar", + "async-trait", + "bollard", + "bytes", + "docker_credential", + "either", + "etcetera", + "ferroid", + "futures", + "http", + "itertools", + "log", + "memchr", + "parse-display", + "pin-project-lite", + "serde", + "serde_json", + "serde_with", + "thiserror", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "testcontainers-modules" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5985fde5befe4ffa77a052e035e16c2da86e8bae301baa9f9904ad3c494d357" +dependencies = [ + "testcontainers", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap 2.14.0", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.4", + "rustls", + "rustls-pki-types", + "sha1 0.10.6", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.99" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621441cfc37b84979402712047321980c178f299193a3589d05b99e8763436" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "x509-parser" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror", + "time", +] + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + +[[package]] +name = "yasna" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" +dependencies = [ + "bit-vec", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..46bc42d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,63 @@ +[package] +name = "proxy" +version = "1.0.0" +edition = "2024" +rust-version = "1.85" +license = "AGPL-3.0-only OR LicenseRef-SEL" +description = "Multi-protocol e-mail proxy" +authors = ["Stalwart Labs LLC "] +repository = "https://github.com/stalwartlabs/proxy" +homepage = "https://github.com/stalwartlabs/proxy" +readme = "README.md" +keywords = ["mail", "proxy", "imap", "smtp", "migration"] +categories = ["email", "network-programming"] + +[[bin]] +name = "proxy" +path = "src/main.rs" + +[dependencies] +tokio = { version = "1.52", features = ["net", "io-util", "rt-multi-thread", "macros", "signal", "sync", "time"] } +rustls = { version = "0.23", default-features = false, features = ["std", "aws_lc_rs", "tls12"] } +tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc_rs", "tls12"] } +rustls-pemfile = "2.2" +rustls-pki-types = "1.14" +aws-lc-rs = "1.17" +proxy-header = { version = "0.1.2", features = ["tokio"] } +smtp-proto = "0.2.1" +mail-parser = "0.11" +base64 = "0.22" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +toml = "1" +globset = "0.4" +regex = "1.12" +redis = { version = "1.2", features = ["tokio-comp", "aio", "connection-manager"] } +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-aws-lc-rs", "any", "postgres", "mysql", "sqlite"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +moka = { version = "0.12", features = ["future", "sync"] } +arc-swap = "1" +parking_lot = "0.12" +ahash = { version = "0.8", features = ["serde"] } +num_cpus = "1.17" +thiserror = "2" +percent-encoding = "2.3" +rcgen = "0.14" +x509-parser = "0.18" +rustls-platform-verifier = "0.7" + +[dev-dependencies] +tempfile = "3" +mockito = "1.7" +testcontainers = "0.27" +testcontainers-modules = { version = "0.15", features = ["redis", "postgres"] } +pwhash = "1" +jmap-client = { version = "0.4", default-features = false, features = ["websockets", "async", "aws_lc_rs"] } +futures-util = "0.3" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +panic = "abort" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3f9ca23 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie AS chef +WORKDIR /build + +FROM --platform=$BUILDPLATFORM chef AS planner +COPY . . +RUN cargo chef prepare --recipe-path /recipe.json + +FROM --platform=$BUILDPLATFORM chef AS builder +ARG TARGETPLATFORM +RUN case "${TARGETPLATFORM}" in \ + "linux/arm64") echo "aarch64-unknown-linux-gnu" > /target.txt && echo "-C linker=aarch64-linux-gnu-gcc" > /flags.txt ;; \ + "linux/amd64") echo "x86_64-unknown-linux-gnu" > /target.txt && echo "-C linker=x86_64-linux-gnu-gcc" > /flags.txt ;; \ + *) exit 1 ;; \ + esac +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends build-essential cmake libclang-19-dev \ + g++-aarch64-linux-gnu binutils-aarch64-linux-gnu \ + g++-x86-64-linux-gnu binutils-x86-64-linux-gnu +RUN rustup target add "$(cat /target.txt)" +COPY --from=planner /recipe.json /recipe.json +RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release -p proxy --recipe-path /recipe.json +COPY . . +RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p proxy +RUN mv "/build/target/$(cat /target.txt)/release" "/output" + +FROM docker.io/debian:trixie-slim +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r -g 2000 stalwart-proxy && \ + useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart-proxy && \ + mkdir -p /etc/proxy && \ + chown stalwart-proxy:stalwart-proxy /etc/proxy +COPY --from=builder --chmod=0755 /output/proxy /usr/local/bin/proxy +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/proxy +USER stalwart-proxy +WORKDIR /etc/proxy +VOLUME ["/etc/proxy"] +EXPOSE 24 25 110 143 465 587 993 995 4190 443 9443 +ENV PROXY_HEALTHCHECK_URL=https://127.0.0.1:9443/healthz +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk "$PROXY_HEALTHCHECK_URL" || exit 1 +ENTRYPOINT ["/usr/local/bin/proxy"] +CMD ["/etc/proxy/config.toml"] diff --git a/Dockerfile.build b/Dockerfile.build new file mode 100644 index 0000000..5a9acfe --- /dev/null +++ b/Dockerfile.build @@ -0,0 +1,140 @@ +# syntax=docker/dockerfile:1 +# check=skip=FromPlatformFlagConstDisallowed,RedundantTargetPlatform + +# ***************** +# Base image for planner & builder +# ***************** +FROM --platform=$BUILDPLATFORM rust:slim-trixie AS base + +ENV DEBIAN_FRONTEND="noninteractive" \ + BINSTALL_DISABLE_TELEMETRY=true \ + CARGO_TERM_COLOR=always \ + LANG=C.UTF-8 \ + TZ=UTC \ + TERM=xterm-256color \ + AWS_LC_SYS_PREBUILT_NASM=1 +# With zig, we only need libclang and make. ca-certificates is required for curl +# to verify HTTPS downloads (zig tarballs, crates.io, etc). +RUN \ + --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + rm -f /etc/apt/apt.conf.d/docker-clean && \ + echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' >/etc/apt/apt.conf.d/keep-cache && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl jq xz-utils make libclang-19-dev +# 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 \ + ZIG_VERSION=0.13.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 && \ + ln -s "/usr/local/zig-linux-$(uname -m)-${ZIG_VERSION}/zig" /usr/local/bin/zig && \ + zig version +# Install cargo-binstall +RUN curl --retry 5 -fL --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +# Install cargo-chef & sccache & cargo-zigbuild +RUN cargo binstall --no-confirm cargo-chef sccache cargo-zigbuild + +# ***************** +# Planner +# ***************** +FROM base AS planner +WORKDIR /app +COPY . . +# Generate recipe file +RUN cargo chef prepare --recipe-path recipe.json + +# ***************** +# Builder +# ***************** +FROM base AS builder +WORKDIR /app +COPY --from=planner /app/recipe.json recipe.json +ARG TARGET +ARG BUILD_ENV +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +# Install toolchain and specify some env variables +RUN \ + rustup set profile minimal && \ + rustup target add ${TARGET} && \ + mkdir -p artifact && \ + touch /env-cargo && \ + if [ ! -z "${BUILD_ENV}" ]; then \ + echo "export ${BUILD_ENV}" >> /env-cargo; \ + echo "Setting up ${BUILD_ENV}"; \ + fi +# Cargo-chef cache layer +RUN \ + --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ + --mount=type=secret,id=ACTIONS_RUNTIME_TOKEN,env=ACTIONS_RUNTIME_TOKEN \ + --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + source /env-cargo && \ + cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p proxy +# Copy the source code +COPY . . +ENV RUSTC_WRAPPER="sccache" \ + SCCACHE_GHA_ENABLED=true +# Build +RUN \ + --mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \ + --mount=type=secret,id=ACTIONS_RUNTIME_TOKEN,env=ACTIONS_RUNTIME_TOKEN \ + --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/local/cargo/git \ + source /env-cargo && \ + cargo zigbuild --release --target ${TARGET} -p proxy && \ + mv /app/target/${TARGET}/release/proxy /app/artifact/proxy + +# ***************** +# Binary stage +# ***************** +FROM scratch AS binaries +COPY --from=builder /app/artifact / + +# ***************** +# Runtime image for GNU targets +# ***************** +FROM --platform=$TARGETPLATFORM docker.io/library/debian:trixie-slim AS gnu +RUN export DEBIAN_FRONTEND=noninteractive && \ + apt-get update && \ + apt-get install -yq --no-install-recommends ca-certificates curl tzdata libcap2-bin && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd -r -g 2000 stalwart-proxy && \ + useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart-proxy && \ + mkdir -p /etc/proxy && \ + chown stalwart-proxy:stalwart-proxy /etc/proxy +COPY --from=builder --chmod=0755 /app/artifact/proxy /usr/local/bin/proxy +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/proxy +USER stalwart-proxy +WORKDIR /etc/proxy +VOLUME ["/etc/proxy"] +EXPOSE 24 25 110 143 465 587 993 995 4190 443 9443 +ENV PROXY_HEALTHCHECK_URL=https://127.0.0.1:9443/healthz +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk "$PROXY_HEALTHCHECK_URL" || exit 1 +ENTRYPOINT ["/usr/local/bin/proxy"] +CMD ["/etc/proxy/config.toml"] + +# ***************** +# Runtime image for musl targets +# ***************** +FROM --platform=$TARGETPLATFORM alpine AS musl +RUN apk add --update --no-cache ca-certificates curl tzdata libcap && \ + rm -rf /var/cache/apk/* && \ + addgroup -S -g 2000 stalwart-proxy && \ + adduser -S -D -H -u 2000 -G stalwart-proxy -s /sbin/nologin stalwart-proxy && \ + mkdir -p /etc/proxy && \ + chown stalwart-proxy:stalwart-proxy /etc/proxy +COPY --from=builder --chmod=0755 /app/artifact/proxy /usr/local/bin/proxy +RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/proxy +USER stalwart-proxy +WORKDIR /etc/proxy +VOLUME ["/etc/proxy"] +EXPOSE 24 25 110 143 465 587 993 995 4190 443 9443 +ENV PROXY_HEALTHCHECK_URL=https://127.0.0.1:9443/healthz +HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ + CMD curl -fsSk "$PROXY_HEALTHCHECK_URL" || exit 1 +ENTRYPOINT ["/usr/local/bin/proxy"] +CMD ["/etc/proxy/config.toml"] diff --git a/LICENSES/AGPL-3.0-only.txt b/LICENSES/AGPL-3.0-only.txt new file mode 100644 index 0000000..0c97efd --- /dev/null +++ b/LICENSES/AGPL-3.0-only.txt @@ -0,0 +1,235 @@ +GNU AFFERO GENERAL PUBLIC LICENSE +Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. + +Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. + +When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. + +A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. + +The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. + +An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. + +The precise terms and conditions for copying, distribution and modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based on the Program. + +To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. + +1. Source Code. +The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. + +A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. + +The Corresponding Source for a work in source code form is that same work. + +2. Basic Permissions. +All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. +No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. + +When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. + +4. Conveying Verbatim Copies. +You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. +You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". + + c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. + +A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. + +6. Conveying Non-Source Forms. +You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: + + a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. + + d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. + +If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). + +The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. + +7. Additional Terms. +"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or authors of the material; or + + e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. + +All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). + +However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. + +Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. + +If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. + +A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. + +Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. + +Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. + +Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. + +END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. + + This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. + +You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . diff --git a/LICENSES/LicenseRef-SEL.txt b/LICENSES/LicenseRef-SEL.txt new file mode 100644 index 0000000..1345ee1 --- /dev/null +++ b/LICENSES/LicenseRef-SEL.txt @@ -0,0 +1,213 @@ +# Stalwart Enterprise License 2.0 (SELv2) Agreement + +*Last Update: March 29, 2026* + +PLEASE CAREFULLY READ THIS STALWART ENTERPRISE LICENSE AGREEMENT ("AGREEMENT"). THIS AGREEMENT CONSTITUTES A LEGALLY BINDING AGREEMENT BETWEEN YOU AND STALWART LABS LLC AND GOVERNS YOUR USE OF THE SOFTWARE (DEFINED BELOW). IF YOU DO NOT AGREE WITH THIS AGREEMENT, YOU MAY NOT USE THE SOFTWARE. IF YOU ARE USING THE SOFTWARE ON BEHALF OF A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE AUTHORITY TO AGREE TO THIS AGREEMENT ON BEHALF OF SUCH ENTITY. IF YOU DO NOT HAVE SUCH AUTHORITY, DO NOT USE THE SOFTWARE IN ANY MANNER. + +This Agreement is entered into by and between Stalwart Labs LLC and you, or the legal entity on behalf of whom you are acting. + +--- + +## 1. DEFINITIONS + +1.1. "Software" refers to the Stalwart Server Enterprise Edition software, including all its versions, updates, modifications, accompanying documentation, and related materials. The Software is self-hosted by Licensee on its own infrastructure. + +1.2. "Subscription" refers to the paid access to the Software provided by Licensor to Licensee, billed on a monthly or annual basis. + +1.3. "Licensor" refers to Stalwart Labs LLC, the entity providing the Software. + +1.4. "Licensee" refers to the individual or entity installing, accessing, or using the Software with a valid Subscription. + +1.5. "License Key" refers to the unique code provided by Licensor upon purchasing a Subscription which activates the full features of the Software. Each License Key is bound to the domain name (including all subdomains) designated by Licensee at the time of purchase. + +1.6. "Source Code" refers to the human-readable version of the Software's code, as opposed to the compiled machine-readable version. + +1.7. "Mailbox" refers to each individual user account or group account provisioned within the Software. The total number of Mailboxes across all domains and tenants hosted by Licensee determines the applicable Subscription tier. + +1.8. "Confidential Information" refers to any non-public information disclosed by either party to the other in connection with this Agreement, whether in written, oral, electronic, or other form, that is designated as confidential or that a reasonable person would understand to be confidential given the nature of the information and circumstances of disclosure. + +## 2. GRANT OF LICENSE + +2.1. Licensor grants Licensee a non-exclusive, non-transferable, non-sublicensable, limited license to download, install, and use the Software during the Subscription term, subject to the terms and conditions of this Agreement. + +2.2. The use of the Software is conditioned upon Licensee maintaining an active and valid paid Subscription with Licensor. The paid Subscription covers all versions of the Software and all updates and modifications released during the Subscription term. + +2.3. This license grants Licensee the right to use the Software for both personal and commercial purposes. Licensee may install and operate the Software on an unlimited number of servers within its organization, host an unlimited number of domains, and host data for an unlimited number of external organizations (tenants) using the Software's multi-tenancy features. The Subscription tier is determined solely by the total number of Mailboxes provisioned. However, Licensee is expressly prohibited from reselling, leasing, sublicensing, or otherwise redistributing the Software itself. + +2.4. This license is further governed by the terms and conditions set forth in any licensing agreements separately executed between Licensor and Licensee. In the event of any conflict between the terms of this Agreement and the terms of a signed licensing agreement, the terms of the signed licensing agreement shall control. + +2.5. You are not granted any other rights beyond what is expressly stated herein. + +## 3. LICENSE KEYS + +3.1. The Software shall not be used without a valid License Key issued by Licensor. + +3.2. Licensee is required to use valid License Keys issued by Licensor to run the Software, including any modified versions. Any attempts to bypass the License Key requirement is a violation of this Agreement. + +3.3. Distribution or sharing of License Keys to third parties, not associated with Licensee, is strictly prohibited. + +3.4. License Keys are bound to the Subscription period. Should your Subscription expire or be cancelled, all License Keys will become invalid after fifteen (15) days from the Subscription expiration or cancellation date. + +3.5. Any instance of the Software using such an expired key will revert to the Community Edition functionality after the aforementioned fifteen (15) day period. + +## 4. SOURCE CODE USAGE + +4.1. Licensee is permitted to view, copy, and modify the Software's Source Code, as made available by Licensor, solely for Licensee's internal business use and in compliance with this Agreement's terms. + +4.2. Any modifications to the Source Code do not grant Licensee any ownership rights to the original Software or any modifications. All rights, title, and interest to the Software and its Source Code remain exclusively with Licensor. + +4.3. Licensee is strictly prohibited from altering, removing, or in any way tampering with the License Key validation system within the Software. Any such unauthorized modifications will be considered a material breach of this Agreement and may result in legal action. + +4.4. Notwithstanding the availability of the Software's Source Code for review and limited modification, the Software and its Source Code are not open source and remain proprietary to Licensor. The provision of access to the Source Code does not confer any rights typically associated with open source software, including but not limited to the right to freely sublicense, or create derivative works for public distribution. All rights not expressly granted herein are reserved by Licensor. + +4.5. Notwithstanding the foregoing, you may copy the Source Code for development and testing purposes, without requiring a Subscription. + +## 5. INTELLECTUAL PROPERTY RIGHTS + +5.1. The Licensor retains all rights, title, and interest in and to the Software, including all intellectual property rights therein. This Agreement does not transfer any ownership rights to the Licensee. + +5.2. The Licensee must not remove, alter, or obscure any proprietary notices (including copyright and trademark notices) on the Software. + +## 6. SUBSCRIPTION TERMS, RENEWAL, AND CANCELLATION + +6.1. Subscriptions are available on a monthly or annual basis. The applicable fees, Mailbox tier, and billing cycle will be as set forth at the time of purchase or as subsequently agreed in writing between the parties. + +6.2. Where Licensee has provided a valid payment method (such as a credit card) on file, the Subscription will automatically renew at the end of each billing cycle at the then-current rate, unless Licensee removes the payment method or cancels the Subscription prior to the renewal date. No advance cancellation notice period is required; Licensee may cancel at any time by removing the payment method on file or by notifying Licensor. + +6.3. Where Licensee pays by invoice (bank transfer), the Subscription will not automatically renew. Licensor will issue an invoice notification prior to the end of the billing cycle, and the Subscription will renew only upon receipt of payment. + +6.4. Upon cancellation of a Subscription by Licensee prior to the end of a paid billing cycle, Licensee is entitled to a prorated refund for the unused portion of the then-current billing period. Refunds will be calculated from the effective date of cancellation through the end of the billing cycle and will be issued within thirty (30) days of the cancellation date. + +6.5. Licensor reserves the right to modify Subscription fees upon renewal. Any fee changes will be communicated to Licensee at least thirty (30) days prior to the start of the next billing cycle. + +## 7. SUPPORT AND SERVICE LEVELS + +7.1. All Licensees with an active Subscription have access to standard community support resources, including documentation and community forums, as made available by Licensor. + +7.2. Priority email support is available exclusively to Licensees whose Subscription covers one hundred fifty (150) or more Mailboxes. Priority email support inquiries will receive an initial response within forty-eight (48) hours of receipt during Licensor's standard business hours. + +7.3. The forty-eight (48) hour response time set forth in Section 7.2 constitutes a service level commitment. In the event Licensor consistently fails to meet this commitment over a period of thirty (30) consecutive days, the affected Licensee's sole remedy shall be the right to terminate the Subscription and receive a prorated refund for the unused portion of the billing cycle. + +7.4. The Software is self-hosted by Licensee on Licensee's own infrastructure. Licensor does not provide hosting services and makes no guarantees regarding uptime, availability, or performance of Licensee's self-hosted deployment. + +## 8. TERMINATION + +8.1. Licensor may terminate this Agreement immediately upon written notice if Licensee commits a material breach of any term of this Agreement and fails to cure such breach within thirty (30) days of receiving written notice specifying the breach. + +8.2. Licensor may terminate this Agreement for convenience upon thirty (30) days' written notice to Licensee. In such event, Licensee shall receive a prorated refund for the unused portion of any prepaid Subscription fees. + +8.3. In the event of a termination, Licensee will be provided with written notice, sent to the email address used during Subscription registration, outlining the reasons for the termination. + +8.4. Upon termination, all rights granted to Licensee under this Agreement will cease, and Licensee must promptly cease all use of the Software and destroy or delete all copies in its possession, except that Licensee may retain copies of the Source Code obtained prior to termination solely for archival purposes, subject to the continuing obligations of confidentiality and intellectual property protection set forth herein. + +## 9. CONFIDENTIALITY + +9.1. Each party agrees to hold the other party's Confidential Information in strict confidence and not to disclose such information to any third party, except to employees, contractors, or agents who have a need to know and are bound by confidentiality obligations no less protective than those contained herein. + +9.2. Confidential Information does not include information that: (a) is or becomes publicly available through no fault of the receiving party; (b) was rightfully in the receiving party's possession prior to disclosure; (c) is independently developed by the receiving party without use of the disclosing party's Confidential Information; or (d) is rightfully obtained from a third party without restriction on disclosure. + +9.3. A receiving party may disclose Confidential Information to the extent required by applicable law, regulation, or court order, provided that the receiving party gives the disclosing party prompt written notice (where legally permissible) and cooperates with the disclosing party's efforts to seek protective treatment of such information. + +9.4. The obligations of confidentiality set forth in this Section shall survive the termination or expiration of this Agreement for a period of three (3) years. + +## 10. LIMITATION OF LIABILITY + +10.1. In no event will the Licensor be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from (i) your use or inability to use the Software; (ii) any unauthorized access to or use of your servers and/or any personal information stored therein. + +10.2. Except for liability arising from death or personal injury caused by negligence, fraud, willful misconduct, or a party's indemnification obligations under this Agreement, Licensor's total aggregate liability for any and all claims under this Agreement shall be limited to the total Subscription fees paid by Licensee to Licensor in the twelve (12) months immediately preceding the event giving rise to the claim. + +## 11. INDEMNIFICATION + +11.1. Licensee agrees to indemnify, defend, and hold harmless Licensor, its officers, directors, employees, agents, licensors, suppliers, and any third-party information providers from and against all claims, losses, expenses, damages, and costs, including reasonable attorneys' fees, resulting from any violation of this Agreement or any activity related to Licensee's use or misuse of the Software (including negligent or wrongful conduct). + +11.2. Licensor agrees to indemnify, defend, and hold harmless Licensee from and against any third-party claim that the Software, as provided by Licensor, infringes or misappropriates any patent, copyright, trademark, or trade secret of a third party, provided that Licensee: (a) gives Licensor prompt written notice of such claim; (b) grants Licensor sole control of the defense and settlement of such claim; and (c) provides reasonable cooperation at Licensor's expense. + +11.3. If the Software becomes, or in Licensor's opinion is likely to become, the subject of an infringement claim, Licensor may at its option and expense: (a) procure for Licensee the right to continue using the Software; (b) modify or replace the Software to make it non-infringing while maintaining substantially equivalent functionality; or (c) if neither (a) nor (b) is commercially practicable, terminate this Agreement and provide Licensee with a prorated refund of any prepaid Subscription fees. + +11.4. Licensor shall have no obligation under this Section for any claim arising from: (a) modifications to the Software made by Licensee; (b) use of the Software in combination with products, services, or technologies not provided by Licensor, where the infringement would not have occurred but for such combination; or (c) Licensee's continued use of a version of the Software after being notified of the availability of a non-infringing update. + +## 12. DATA PROTECTION AND PRIVACY + +12.1. The Software is self-hosted by Licensee, and Licensee retains sole responsibility for all data stored and processed within its deployment of the Software, including any personal data of its users or tenants. + +12.2. To the extent that Licensor processes any personal data on behalf of Licensee (for example, in connection with support services or license management), such processing shall be conducted in accordance with applicable data protection laws, including but not limited to the General Data Protection Regulation (GDPR) where applicable, the California Consumer Privacy Act (CCPA) where applicable, and any other relevant data protection legislation. + +12.3. Where required by applicable data protection law, the parties shall enter into a separate Data Processing Agreement ("DPA") that sets forth the terms and conditions governing Licensor's processing of personal data on behalf of Licensee. + +12.4. In the event of a data breach affecting personal data processed by Licensor in connection with this Agreement, Licensor shall notify Licensee without undue delay and in any event within seventy-two (72) hours of becoming aware of the breach, and shall cooperate with Licensee in investigating and remediating the breach. + +12.5. Additional details regarding Licensor's data handling practices are outlined in Licensor's Privacy Policy, which can be accessed on Licensor's website. + +## 13. EXPORT COMPLIANCE + +13.1. The Software may be subject to export control and sanctions laws of the United States and other jurisdictions. Licensee agrees to comply with all applicable export control laws, including without limitation the U.S. Export Administration Regulations (EAR) and the regulations administered by the U.S. Department of the Treasury's Office of Foreign Assets Control (OFAC). + +13.2. Licensee represents and warrants that: (a) Licensee is not located in, organized under the laws of, or a resident of any country or territory subject to comprehensive U.S. sanctions (currently including Cuba, Iran, North Korea, Syria, and the Crimea, Donetsk, and Luhansk regions of Ukraine); (b) Licensee is not listed on any U.S. government restricted party list; and (c) Licensee will not export, re-export, or transfer the Software to any prohibited destination, entity, or individual without the required governmental authorizations. + +## 14. ANTI-CORRUPTION + +14.1. Each party represents and warrants that it has not and will not, in connection with this Agreement, directly or indirectly offer, pay, promise to pay, or authorize the payment of any money or anything of value to any government official, political party, or candidate for political office for the purpose of influencing any act or decision, or securing any improper advantage. + +14.2. Each party shall comply with all applicable anti-corruption and anti-bribery laws, including without limitation the U.S. Foreign Corrupt Practices Act (FCPA) and the UK Bribery Act 2010. + +## 15. GOVERNING LAW AND DISPUTE RESOLUTION + +15.1. This Agreement shall be governed by and construed under the laws of the State of Wyoming, United States of America, without regard to its conflict of laws principles. + +15.2. Any dispute, controversy, or claim arising out of or relating to this Agreement, or the breach, termination, or invalidity thereof, shall first be attempted to be resolved through good faith negotiation between the parties for a period of thirty (30) days following written notice of the dispute. + +15.3. If the dispute is not resolved through negotiation within the thirty (30) day period, it shall be finally resolved by binding arbitration administered by the American Arbitration Association ("AAA") in accordance with its Commercial Arbitration Rules. The arbitration shall be conducted in Sheridan, Wyoming, before a single arbitrator. The language of the arbitration shall be English. + +15.4. The arbitrator's award shall be final and binding and may be entered as a judgment in any court of competent jurisdiction. Each party shall bear its own costs and attorneys' fees in connection with the arbitration, unless the arbitrator determines otherwise. + +15.5. Notwithstanding the foregoing, either party may seek injunctive or other equitable relief in any court of competent jurisdiction to protect its intellectual property rights or Confidential Information without first submitting to arbitration. + +## 16. NOTICES + +16.1. All notices required or permitted under this Agreement shall be in writing and shall be deemed effectively given: (a) upon personal delivery; (b) upon confirmed transmission by email; or (c) one (1) business day after deposit with a nationally recognized overnight courier service. + +16.2. Notices to Licensor shall be sent to the address and email set forth in Section 21 (Contact Information) of this Agreement. Notices to Licensee shall be sent to the email address provided during Subscription registration or as subsequently updated by Licensee in writing. + +## 17. ASSIGNMENT + +17.1. Licensee may not transfer or assign this Agreement or any rights or obligations hereunder without the prior written consent of Licensor, except that Licensee may assign this Agreement without consent in connection with a merger, acquisition, corporate reorganization, or sale of all or substantially all of its assets, provided that the assignee agrees in writing to be bound by the terms of this Agreement. + +17.2. Licensor may assign this Agreement without restriction. Any assignment in violation of this Section shall be null and void. + +## 18. DISCLAIMERS AND WARRANTIES + +18.1. The Software is provided "AS IS" and "AS AVAILABLE", without warranty of any kind, either express or implied, including, without limitation, warranties of merchantability, fitness for a particular purpose, and non-infringement. + +18.2. Licensor does not warrant that the Software will be error-free, that access thereto will be uninterrupted, or that defects will be corrected. + +18.3. Licensor warrants that, as of the date of delivery, the Software will perform substantially in accordance with the accompanying documentation for a period of ninety (90) days. Licensee's sole remedy for breach of this warranty shall be, at Licensor's option, repair or replacement of the non-conforming Software, or a refund of the Subscription fees paid for the period during which the Software was non-conforming. + +## 19. FORCE MAJEURE + +Neither party shall be in default or otherwise liable for any delay in or failure of its performance under this Agreement if such delay or failure arises by any reason of any event beyond the reasonable control of a party, including acts of God, the elements, earthquakes, floods, fires, epidemics, riots, failures or delays in transportation or communications, or any act or failure to act by the other party or such other party's officers, employees, agents, or contractors. The affected party shall give prompt notice to the other party and shall use commercially reasonable efforts to mitigate the effects of the force majeure event. If a force majeure event continues for more than ninety (90) days, either party may terminate this Agreement upon written notice, and Licensee shall receive a prorated refund of any prepaid Subscription fees. + +## 20. SURVIVAL + +The following Sections shall survive the termination or expiration of this Agreement: Section 1 (Definitions), Section 4.2 (Ownership of Modifications), Section 4.4 (Proprietary Nature of Software), Section 5 (Intellectual Property Rights), Section 9 (Confidentiality), Section 10 (Limitation of Liability), Section 11 (Indemnification), Section 12 (Data Protection and Privacy), Section 13 (Export Compliance), Section 15 (Governing Law and Dispute Resolution), and Section 20 (Survival). + +## 21. SEVERABILITY + +If any provision of this Agreement is held to be unenforceable or invalid for any reason, that provision shall be reformed to the extent necessary to make it enforceable and consistent with the intent of the parties, and the remaining provisions shall remain in full force and effect. + +## 22. ENTIRE AGREEMENT + +This Agreement constitutes the entire agreement between the Licensor and the Licensee with respect to the subject matter hereof and supersedes all prior or contemporaneous understandings regarding such subject matter. No amendment to or modification of this Agreement will be binding unless in writing and signed by the Licensor. + +## 23. ACCEPTANCE + +By downloading, installing, or using the Software, even without explicitly clicking on an "I Agree" button or a similar mechanism, you acknowledge that you have read, understood, and agreed to be bound by the terms and conditions of this Agreement. + +## 24. CONTACT INFORMATION + +If you have any questions about this Agreement, please contact Stalwart Labs LLC at: + +Stalwart Labs LLC +1309 Coffeen Avenue STE 1200 +Sheridan, Wyoming 82801 +USA +hello@stalw.art diff --git a/README.md b/README.md new file mode 100644 index 0000000..aee0776 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +

+ + + +

+ +

+ Multi-protocol e-mail migration proxy for Stalwart +

+ +
+ +

+ continuous integration +   + License: AGPL v3 +   + Documentation +

+

+ Mastodon +   + Twitter +

+

+ Discord +   + Reddit +

+ +## Features + +The migration proxy sits in front of one or more mail backends and decides, on a per-account basis, which backend a given connection belongs to. It terminates IMAP, POP3, ManageSieve, SMTP submission, SMTP/LMTP and HTTP (JMAP) sessions, identifies the account behind each connection from the credentials the client already presents, looks up the destination that account is assigned to, replays the authentication to that backend, and bridges the session. Because the routing decision is made from the existing credentials, no client reconfiguration is required: users keep the same server name, ports and passwords while the proxy routes them to the correct system. + +It is built for three scenarios: migrating from legacy servers (Dovecot, Cyrus and other IMAP/POP3/SMTP servers) onto Stalwart one mailbox at a time, migrating between Stalwart versions by running an old and a new deployment side by side, and acting as a cache-locality router in front of a Stalwart cluster so each account is consistently pinned to the same node. + +- **Multi-protocol.** Proxies IMAP, POP3, ManageSieve, SMTP submission, SMTP/LMTP pass-through and HTTP/JMAP, including JMAP-over-WebSocket. +- **Per-account routing.** Resolves each account to a backend through a mapping store backed by a flat file, Redis or a SQL database (PostgreSQL, MySQL or SQLite), with an in-memory cache and a configurable default destination for unmapped accounts. +- **Credential-aware.** Extracts the routing identifier from SASL `PLAIN`, `OAUTHBEARER` and `XOAUTH2` exchanges, from HTTP Basic and Bearer authentication, and from JWT and Stalwart access-token claims, then replays authentication to the backend without ever persisting passwords. +- **Backend-friendly forwarding.** Conveys the real client identity using the PROXY protocol for modern backends, or the `XCLIENT` and IMAP `ID` extensions for Dovecot and Postfix, with `Forwarded` and `X-Forwarded-For` headers for HTTP. +- **Flexible TLS.** Terminates inbound TLS with SNI-based certificate selection, and reaches backends over implicit TLS, STARTTLS or plaintext, with platform, pinned-certificate or mutual-TLS verification on the backend leg. +- **Resilient.** Per-destination health gating with a circuit breaker, connection retries, idle timeouts and graceful draining on shutdown. +- **Operable at runtime.** An authenticated HTTP management API exposes statistics, mapping management, cache invalidation, connection control and configuration reload without a restart. + +## Installing + +The proxy is distributed as a single self-contained binary and as a multi-architecture container image (`amd64`, `arm64`, `armv7` and `armv6`), in both glibc and Alpine/musl variants. + +The container image is published to the GitHub Container Registry and Docker Hub. Mount a configuration directory at `/etc/proxy` and run: + +```bash +docker run -d --name proxy \ + -v /srv/proxy:/etc/proxy \ + -p 993:993 -p 143:143 -p 995:995 \ + -p 587:587 -p 465:465 -p 4190:4190 -p 443:443 \ + ghcr.io/stalwartlabs/proxy:latest +``` + +To install directly on a host, the install script provisions the binary, a service account, a service unit and sample configuration files: + +```bash +curl --proto '=https' --tlsv1.2 -sSf \ + https://raw.githubusercontent.com/stalwartlabs/proxy/main/install.sh | sudo sh +``` + +The service is installed but not started, because the sample configuration must first be edited for the local destinations, listeners and TLS certificates. Full instructions are in the [documentation](https://stalw.art/docs/migration/proxy). + +## Documentation + +All documentation is available at [stalw.art/docs/migration/proxy](https://stalw.art/docs/migration/proxy). + +## Support + +If you are having problems running Stalwart, found a bug, or just have a question, please head to the [Stalwart Support Portal](https://support.stalw.art) at [support.stalw.art](https://support.stalw.art). +Additionally, you may purchase an [Enterprise License](https://stalw.art/enterprise) to obtain priority support from Stalwart Labs LLC, including response-time commitments and a private Priority Support area on the portal. + +## License + +This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v1 (SELv1)**: + +- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license. +- The [Stalwart Enterprise License v1 (SELv1)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements. + +Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory. + +## Copyright + +Copyright (C) 2020, Stalwart Labs LLC diff --git a/docker-bake.hcl b/docker-bake.hcl new file mode 100644 index 0000000..ec490a3 --- /dev/null +++ b/docker-bake.hcl @@ -0,0 +1,50 @@ +variable "TARGET" { + default = "$TARGET" +} +variable "GHCR_REPO" { + default = "$GHCR_REPO" +} +variable "BUILD_ENV" { + default = "$BUILD_ENV" +} +variable "SUFFIX" { + default = "$SUFFIX" +} +variable "DOCKER_PLATFORM" { + default = "$DOCKER_PLATFORM" +} +target "docker-metadata-action" {} +target "build" { + secret = [ + "type=env,id=ACTIONS_RESULTS_URL", + "type=env,id=ACTIONS_RUNTIME_TOKEN" + ] + args = { + TARGET = "${TARGET}" + BUILD_ENV = equal("", "${BUILD_ENV}") ? null : "${BUILD_ENV}" + } + target = "binaries" + cache-from = [ + "type=registry,ref=${GHCR_REPO}-buildcache:${TARGET}" + ] + cache-to = [ + "type=registry,ref=${GHCR_REPO}-buildcache:${TARGET},mode=max,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true,image-manifest=false" + ] + context = "./" + dockerfile = "Dockerfile.build" + output = ["./artifact"] +} +target "image" { + inherits = ["build","docker-metadata-action"] + cache-to = [""] + cache-from = [ + "type=registry,ref=${GHCR_REPO}-buildcache:${TARGET}" + ] + target = equal("", "${SUFFIX}") ? "gnu" : "musl" + platforms = [ + "${DOCKER_PLATFORM}" + ] + output = [ + "" + ] +} diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e7d861a --- /dev/null +++ b/install.sh @@ -0,0 +1,1109 @@ +#!/usr/bin/env sh +# shellcheck shell=dash + +# +# SPDX-FileCopyrightText: 2020 Stalwart Labs LLC +# +# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL +# + +# Migration proxy install script -- based on the rustup installation script. + +set -e +set -u + +readonly BASE_URL="https://github.com/stalwartlabs/proxy/releases/latest/download" + +main() { + downloader --check + need_cmd uname + need_cmd mktemp + need_cmd chmod + need_cmd chown + need_cmd mkdir + need_cmd rm + need_cmd tar + need_cmd cp + need_cmd tr + need_cmd hostname + + # Require root + if [ "$(id -u)" -ne 0 ]; then + err "Install failed: this program needs to run as root." + fi + + # Detect OS. The service account is "stalwart-proxy" rather than "proxy" + # because Debian ships a system "proxy" user (uid 13) that would collide. + local _os _uname _account + _uname="$(uname)" + _account="stalwart-proxy" + case "$_uname" in + Linux) _os="linux" ;; + Darwin) _os="macos"; _account="_stalwart-proxy" ;; + *) err "Install failed: unsupported OS: $_uname" ;; + esac + + # Parse arguments + local _prefix="" + while [ $# -gt 0 ]; do + case "$1" in + -h|--help) + print_usage + exit 0 + ;; + --*|-*) + err "Unknown flag: $1 (try --help)" + ;; + *) + if [ -n "$_prefix" ]; then + err "Only one prefix argument is allowed, got: $_prefix $1" + fi + _prefix="$1" + ;; + esac + shift + done + + # Derive install paths: FHS by default, self-contained under a custom prefix + local _bin_dir _bin_file _conf_dir _tls_dir _log_dir + local _config_file _mappings_file _token_file _env_file + if [ -z "$_prefix" ]; then + _bin_dir="/usr/local/bin" + _conf_dir="/etc/proxy" + _log_dir="/var/log/proxy" + else + _bin_dir="${_prefix}/bin" + _conf_dir="${_prefix}/etc" + _log_dir="${_prefix}/logs" + fi + _tls_dir="${_conf_dir}/tls" + _bin_file="${_bin_dir}/proxy" + _config_file="${_conf_dir}/config.toml" + _mappings_file="${_conf_dir}/mappings.tsv" + _token_file="${_conf_dir}/admin.token" + _env_file="${_conf_dir}/proxy.env" + + # Detect architecture + get_architecture || return 1 + local _arch="$RETVAL" + assert_nz "$_arch" "arch" + + # Create service account + create_account "$_os" "$_account" + + # Create directories + ensure mkdir -p "$_bin_dir" "$_conf_dir" "$_tls_dir" "$_log_dir" + + # Download and install the binary + say "Downloading proxy for ${_arch}..." + local _tmp _tar + _tmp="$(mktemp -d)" + _tar="${_tmp}/proxy.tar.gz" + ensure downloader "${BASE_URL}/proxy-${_arch}.tar.gz" "$_tar" "$_arch" + ensure tar zxf "$_tar" -C "$_tmp" + ensure cp "${_tmp}/proxy" "$_bin_file" + ensure chmod 0755 "$_bin_file" + ensure rm -rf "$_tmp" + + # Provision config, mappings, env file and admin token (preserve existing files) + if [ ! -e "$_config_file" ]; then + say "Writing sample config at ${_config_file}..." + write_config_file "$_config_file" + fi + if [ ! -e "$_mappings_file" ]; then + say "Writing sample mappings at ${_mappings_file}..." + write_mappings_file "$_mappings_file" + fi + if [ ! -e "$_env_file" ]; then + say "Writing env file at ${_env_file}..." + write_env_file "$_env_file" + fi + if [ ! -e "$_token_file" ]; then + say "Generating admin token at ${_token_file}..." + write_token_file "$_token_file" + fi + + # Ownership and permissions + say "Setting permissions..." + ensure chown -R "${_account}:${_account}" "$_conf_dir" "$_log_dir" + ensure chmod 0750 "$_conf_dir" "$_tls_dir" "$_log_dir" + ensure chmod 0640 "$_config_file" "$_mappings_file" + ensure chown "root:${_account}" "$_env_file" "$_token_file" + ensure chmod 0640 "$_env_file" "$_token_file" + + # Install the service (enabled, but not started: the proxy will not run + # until the sample config has been edited for the local deployment) + say "Installing service..." + local _service_type="" + local _start_cmd="" + case "$_os" in + linux) + if check_cmd systemctl; then + create_service_linux_systemd "$_bin_file" "$_config_file" "$_env_file" "$_account" + _service_type="systemd" + _start_cmd="systemctl start proxy" + else + create_service_linux_initd "$_bin_file" "$_config_file" "$_env_file" "$_account" + _service_type="initd" + _start_cmd="service proxy start" + fi + ;; + macos) + create_service_macos "$_bin_file" "$_config_file" "$_env_file" "$_account" + _service_type="launchd" + _start_cmd="launchctl bootstrap system /Library/LaunchDaemons/proxy.plist" + ;; + esac + + # Completion message + say "" + say "Installation complete." + say "" + say "Before the proxy can start you must:" + say " 1. Edit ${_config_file} (destinations, listeners, routing)." + say " 2. Install the inbound TLS certificate and key under ${_tls_dir}/." + say " 3. Populate ${_mappings_file} with '\\t' lines." + say "" + say "An admin bearer token was generated at ${_token_file}." + say "" + say "Once configured, start the service with:" + say " ${_start_cmd}" + say "" + + return 0 +} + +print_usage() { + cat <<'EOF' +Usage: install.sh [PREFIX] + +Install the migration proxy into standard FHS paths or under a custom prefix. + +Options: + -h, --help Show this help. + +With no PREFIX, the proxy is installed under standard FHS paths: + binary /usr/local/bin/proxy + config /etc/proxy/config.toml + mappings /etc/proxy/mappings.tsv + tls /etc/proxy/tls/ + token /etc/proxy/admin.token + env /etc/proxy/proxy.env + logs /var/log/proxy/ + +When PREFIX is provided, a self-contained layout is used instead: + binary $PREFIX/bin/proxy + config $PREFIX/etc/config.toml + mappings $PREFIX/etc/mappings.tsv + tls $PREFIX/etc/tls/ + token $PREFIX/etc/admin.token + env $PREFIX/etc/proxy.env + logs $PREFIX/logs/ +EOF +} + +write_config_file() { + cat > "$1" <<'EOF' +# Migration proxy configuration. Edit this template for your deployment. +# The proxy refuses to boot until every referenced destination, certificate +# and credential path is valid (see SPEC section 8.4). + +[server] +threads = 0 +hostname = "proxy.example.com" +shutdown_grace = "30s" +log_level = "info" + +[tls.certificate.default] +cert = "/etc/proxy/tls/fullchain.pem" +key = "/etc/proxy/tls/privkey.pem" +default = true + +[tls.protocols] +min_version = "1.2" + +[routing] +default_destination = "legacy" +# smtp_passthrough_destination = "stalwart" + +[mapping] +source = "file" +normalize = "lowercase" +negative_ttl = "30s" +positive_ttl = "10m" + +[mapping.file] +path = "/etc/proxy/mappings.tsv" + +[destination.legacy] +host = "10.0.1.10" +tls_server_name = "mail.legacy.example.com" +proxy_protocol = true +[destination.legacy.protocol.imap] +port = 993 +tls = "implicit" +[destination.legacy.protocol.pop3] +port = 995 +tls = "implicit" +[destination.legacy.protocol.submission] +port = 587 +tls = "starttls" +[destination.legacy.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.legacy.protocol.http] +port = 443 +tls = "implicit" + +[destination.stalwart] +host = "10.0.1.20" +tls_server_name = "mail.new.example.com" +proxy_protocol = true +[destination.stalwart.protocol.imap] +port = 993 +tls = "implicit" +[destination.stalwart.protocol.pop3] +port = 995 +tls = "implicit" +[destination.stalwart.protocol.submission] +port = 587 +tls = "starttls" +[destination.stalwart.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.stalwart.protocol.http] +port = 443 +tls = "implicit" + +[listener.imap] +protocol = "imap" +bind = ["0.0.0.0:993", "[::]:993"] +tls = "implicit" + +[listener.imap-starttls] +protocol = "imap" +bind = ["0.0.0.0:143", "[::]:143"] +tls = "starttls" + +[listener.pop3] +protocol = "pop3" +bind = ["0.0.0.0:995", "[::]:995"] +tls = "implicit" + +[listener.submission] +protocol = "submission" +bind = ["0.0.0.0:587", "[::]:587"] +tls = "starttls" + +[listener.submissions] +protocol = "submission" +bind = ["0.0.0.0:465", "[::]:465"] +tls = "implicit" + +[listener.managesieve] +protocol = "managesieve" +bind = ["0.0.0.0:4190", "[::]:4190"] +tls = "starttls" + +[listener.https] +protocol = "http" +bind = ["0.0.0.0:443", "[::]:443"] +tls = "implicit" +forwarded = "off" + +[[http.route]] +match = "/.well-known/**" +destination = "legacy" + +[[http.route]] +match = "/**" +extract = { from = "authorization" } +fallback = "default" + +[admin] +bind = "127.0.0.1:9443" +tls = "implicit" +bearer_token_file = "/etc/proxy/admin.token" +min_token_len = 32 +lockout_threshold = 5 +lockout_duration = "5m" +EOF +} + +write_mappings_file() { + cat > "$1" <<'EOF' +# identifierdestination-id, one per line. +# alice@legacy.example.com legacy +# carol@new.example.com stalwart +EOF +} + +write_env_file() { + cat > "$1" <<'EOF' +# Environment variables for the migration proxy service. +# Uncomment and edit an entry to override its default. + +# Admin bearer token. Overrides [admin].bearer_token_file when set. +#PROXY_ADMIN_TOKEN= + +# Tracing filter. Overrides [server].log_level when set. +#RUST_LOG=info +EOF +} + +write_token_file() { + local _token + _token="$(LC_ALL=C tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 48)" + assert_nz "$_token" "admin token" + printf '%s\n' "$_token" > "$1" +} + +create_account() { + local _os="$1" + local _account="$2" + if id -u "$_account" > /dev/null 2>&1; then + return 0 + fi + say "Creating '${_account}' account..." + if [ "$_os" = "macos" ]; then + local _last_uid _last_gid _uid _gid + _last_uid="$(dscacheutil -q user | grep uid | awk '{print $2}' | sort -n | tail -n 1)" + _last_gid="$(dscacheutil -q group | grep gid | awk '{print $2}' | sort -n | tail -n 1)" + _uid="$((_last_uid+1))" + _gid="$((_last_gid+1))" + + ensure dscl /Local/Default -create Groups/_stalwart-proxy + ensure dscl /Local/Default -create Groups/_stalwart-proxy Password \* + ensure dscl /Local/Default -create Groups/_stalwart-proxy PrimaryGroupID $_gid + ensure dscl /Local/Default -create Groups/_stalwart-proxy RealName "Migration proxy service" + ensure dscl /Local/Default -create Groups/_stalwart-proxy RecordName _stalwart-proxy stalwart-proxy + + ensure dscl /Local/Default -create Users/_stalwart-proxy + ensure dscl /Local/Default -create Users/_stalwart-proxy NFSHomeDirectory /var/empty + ensure dscl /Local/Default -create Users/_stalwart-proxy Password \* + ensure dscl /Local/Default -create Users/_stalwart-proxy PrimaryGroupID $_gid + ensure dscl /Local/Default -create Users/_stalwart-proxy RealName "Migration proxy service" + ensure dscl /Local/Default -create Users/_stalwart-proxy RecordName _stalwart-proxy stalwart-proxy + ensure dscl /Local/Default -create Users/_stalwart-proxy UniqueID $_uid + ensure dscl /Local/Default -create Users/_stalwart-proxy UserShell /usr/bin/false + + ensure dscl /Local/Default -delete /Users/_stalwart-proxy AuthenticationAuthority + ensure dscl /Local/Default -delete /Users/_stalwart-proxy PasswordPolicyOptions + else + ensure useradd "$_account" -s /usr/sbin/nologin -M -r -U + fi +} + +create_service_linux_systemd() { + local _bin="$1" _config="$2" _env="$3" _user="$4" + cat > /etc/systemd/system/proxy.service < /etc/init.d/proxy < /dev/null \\ + || return 1 + start-stop-daemon --start --quiet --pidfile \$PIDFILE --exec \$DAEMON \\ + --background --make-pidfile --chuid ${_user}:${_user} \\ + -- \$DAEMON_ARGS \\ + || return 2 +} + +do_stop() +{ + start-stop-daemon --stop --quiet --retry=INT/30/KILL/5 --pidfile \$PIDFILE --name proxy + RETVAL="\$?" + [ "\$RETVAL" = 2 ] && return 2 + start-stop-daemon --stop --quiet --oknodo --retry=0/30/KILL/5 --exec \$DAEMON + [ "\$?" = 2 ] && return 2 + rm -f \$PIDFILE + return "\$RETVAL" +} + +case "\$1" in + start) + [ "\$VERBOSE" != no ] && log_daemon_msg "Starting migration proxy" "proxy" + do_start + case "\$?" in + 0|1) [ "\$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "\$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + stop) + [ "\$VERBOSE" != no ] && log_daemon_msg "Stopping migration proxy" "proxy" + do_stop + case "\$?" in + 0|1) [ "\$VERBOSE" != no ] && log_end_msg 0 ;; + 2) [ "\$VERBOSE" != no ] && log_end_msg 1 ;; + esac + ;; + status) + status_of_proc "\$DAEMON" "proxy" && exit 0 || exit \$? + ;; + restart) + log_daemon_msg "Restarting migration proxy" "proxy" + do_stop + case "\$?" in + 0|1) + do_start + case "\$?" in + 0) log_end_msg 0 ;; + *) log_end_msg 1 ;; + esac + ;; + *) + log_end_msg 1 + ;; + esac + ;; + *) + echo "Usage: /etc/init.d/proxy {start|stop|status|restart}" >&2 + exit 3 + ;; +esac + +exit 0 +EOF + chmod +x /etc/init.d/proxy + update-rc.d proxy defaults +} + +create_service_macos() { + local _bin="$1" _config="$2" _env="$3" _user="$4" + local _plist="/Library/LaunchDaemons/proxy.plist" + + # launchd has no EnvironmentFile equivalent: wrap with sh to source the env file + cat > "$_plist" < + + + + Label + proxy + ServiceDescription + Migration proxy + UserName + ${_user} + GroupName + ${_user} + ProgramArguments + + /bin/sh + -c + set -a; if [ -r "${_env}" ]; then . "${_env}"; fi; set +a; exec "${_bin}" "${_config}" + + RunAtLoad + + KeepAlive + + + +EOF + chmod 0644 "$_plist" + chown root:wheel "$_plist" +} + + +get_architecture() { + local _ostype _cputype _bitness _arch _clibtype + _ostype="$(uname -s)" + _cputype="$(uname -m)" + _clibtype="gnu" + + if [ "$_ostype" = Linux ]; then + if [ "$(uname -o)" = Android ]; then + _ostype=Android + fi + if ldd --version 2>&1 | grep -q 'musl'; then + _clibtype="musl" + fi + fi + + if [ "$_ostype" = Darwin ] && [ "$_cputype" = i386 ]; then + # Darwin `uname -m` lies + if sysctl hw.optional.x86_64 | grep -q ': 1'; then + _cputype=x86_64 + fi + fi + + if [ "$_ostype" = SunOS ]; then + # Both Solaris and illumos presently announce as "SunOS" in "uname -s" + # so use "uname -o" to disambiguate. We use the full path to the + # system uname in case the user has coreutils uname first in PATH, + # which has historically sometimes printed the wrong value here. + if [ "$(/usr/bin/uname -o)" = illumos ]; then + _ostype=illumos + fi + + # illumos systems have multi-arch userlands, and "uname -m" reports the + # machine hardware name; e.g., "i86pc" on both 32- and 64-bit x86 + # systems. Check for the native (widest) instruction set on the + # running kernel: + if [ "$_cputype" = i86pc ]; then + _cputype="$(isainfo -n)" + fi + fi + + case "$_ostype" in + + Android) + _ostype=linux-android + ;; + + Linux) + check_proc + _ostype=unknown-linux-$_clibtype + _bitness=$(get_bitness) + ;; + + FreeBSD) + _ostype=unknown-freebsd + ;; + + NetBSD) + _ostype=unknown-netbsd + ;; + + DragonFly) + _ostype=unknown-dragonfly + ;; + + Darwin) + _ostype=apple-darwin + ;; + + illumos) + _ostype=unknown-illumos + ;; + + MINGW* | MSYS* | CYGWIN* | Windows_NT) + _ostype=pc-windows-gnu + ;; + + *) + err "unrecognized OS type: $_ostype" + ;; + + esac + + case "$_cputype" in + + i386 | i486 | i686 | i786 | x86) + _cputype=i686 + ;; + + xscale | arm) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + fi + ;; + + armv6l) + _cputype=arm + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + armv7l | armv8l) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + + aarch64 | arm64) + _cputype=aarch64 + ;; + + x86_64 | x86-64 | x64 | amd64) + _cputype=x86_64 + ;; + + mips) + _cputype=$(get_endianness mips '' el) + ;; + + mips64) + if [ "$_bitness" -eq 64 ]; then + # only n64 ABI is supported for now + _ostype="${_ostype}abi64" + _cputype=$(get_endianness mips64 '' el) + fi + ;; + + ppc) + _cputype=powerpc + ;; + + ppc64) + _cputype=powerpc64 + ;; + + ppc64le) + _cputype=powerpc64le + ;; + + s390x) + _cputype=s390x + ;; + riscv64) + _cputype=riscv64gc + ;; + *) + err "unknown CPU type: $_cputype" + + esac + + # Detect 64-bit linux with 32-bit userland + if [ "${_ostype}" = unknown-linux-gnu ] && [ "${_bitness}" -eq 32 ]; then + case $_cputype in + x86_64) + if [ -n "${RUSTUP_CPUTYPE:-}" ]; then + _cputype="$RUSTUP_CPUTYPE" + else { + # 32-bit executable for amd64 = x32 + if is_host_amd64_elf; then { + echo "This host is running an x32 userland; as it stands, x32 support is poor," 1>&2 + echo "and there isn't a native toolchain -- you will have to install" 1>&2 + echo "multiarch compatibility with i686 and/or amd64, then select one" 1>&2 + echo "by re-running this script with the RUSTUP_CPUTYPE environment variable" 1>&2 + echo "set to i686 or x86_64, respectively." 1>&2 + echo 1>&2 + echo "You will be able to add an x32 target after installation by running" 1>&2 + echo " rustup target add x86_64-unknown-linux-gnux32" 1>&2 + exit 1 + }; else + _cputype=i686 + fi + }; fi + ;; + mips64) + _cputype=$(get_endianness mips '' el) + ;; + powerpc64) + _cputype=powerpc + ;; + aarch64) + _cputype=armv7 + if [ "$_ostype" = "linux-android" ]; then + _ostype=linux-androideabi + else + _ostype="${_ostype}eabihf" + fi + ;; + riscv64gc) + err "riscv64 with 32-bit userland unsupported" + ;; + esac + fi + + # Detect armv7 but without the CPU features Rust needs in that build, + # and fall back to arm. + # See https://github.com/rust-lang/rustup.rs/issues/587. + if [ "$_ostype" = "unknown-linux-gnueabihf" ] && [ "$_cputype" = armv7 ]; then + if ensure grep '^Features' /proc/cpuinfo | grep -q -v neon; then + # At least one processor does not have NEON. + _cputype=arm + fi + fi + + _arch="${_cputype}-${_ostype}" + + RETVAL="$_arch" +} + +check_proc() { + # Check for /proc by looking for the /proc/self/exe link + # This is only run on Linux + if ! test -L /proc/self/exe ; then + err "fatal: Unable to find /proc/self/exe. Is /proc mounted? Installation cannot proceed without /proc." + fi +} + +get_bitness() { + need_cmd head + # Architecture detection without dependencies beyond coreutils. + # ELF files start out "\x7fELF", and the following byte is + # 0x01 for 32-bit and + # 0x02 for 64-bit. + # The printf builtin on some shells like dash only supports octal + # escape sequences, so we use those. + local _current_exe_head + _current_exe_head=$(head -c 5 /proc/self/exe ) + if [ "$_current_exe_head" = "$(printf '\177ELF\001')" ]; then + echo 32 + elif [ "$_current_exe_head" = "$(printf '\177ELF\002')" ]; then + echo 64 + else + err "unknown platform bitness" + fi +} + +is_host_amd64_elf() { + need_cmd head + need_cmd tail + # ELF e_machine detection without dependencies beyond coreutils. + # Two-byte field at offset 0x12 indicates the CPU, + # but we're interested in it being 0x3E to indicate amd64, or not that. + local _current_exe_machine + _current_exe_machine=$(head -c 19 /proc/self/exe | tail -c 1) + [ "$_current_exe_machine" = "$(printf '\076')" ] +} + +get_endianness() { + local cputype=$1 + local suffix_eb=$2 + local suffix_el=$3 + + # detect endianness without od/hexdump, like get_bitness() does. + need_cmd head + need_cmd tail + + local _current_exe_endianness + _current_exe_endianness="$(head -c 6 /proc/self/exe | tail -c 1)" + if [ "$_current_exe_endianness" = "$(printf '\001')" ]; then + echo "${cputype}${suffix_el}" + elif [ "$_current_exe_endianness" = "$(printf '\002')" ]; then + echo "${cputype}${suffix_eb}" + else + err "unknown platform endianness" + fi +} + +say() { + printf '%s\n' "$1" +} + +err() { + say "$1" >&2 + exit 1 +} + +need_cmd() { + if ! check_cmd "$1"; then + err "need '$1' (command not found)" + fi +} + +check_cmd() { + command -v "$1" > /dev/null 2>&1 +} + +assert_nz() { + if [ -z "$1" ]; then err "assert_nz $2"; fi +} + +# Run a command that should never fail. If the command fails execution +# will immediately terminate with an error showing the failing +# command. +ensure() { + if ! "$@"; then err "command failed: $*"; fi +} + +# This wraps curl or wget. Try curl first, if not installed, +# use wget instead. +downloader() { + local _dld + local _ciphersuites + local _err + local _status + local _retry + if check_cmd curl; then + _dld=curl + elif check_cmd wget; then + _dld=wget + else + _dld='curl or wget' # to be used in error message of need_cmd + fi + + if [ "$1" = --check ]; then + need_cmd "$_dld" + elif [ "$_dld" = curl ]; then + check_curl_for_retry_support + _retry="$RETVAL" + get_ciphersuites_for_curl + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(curl $_retry --proto '=https' --tlsv1.2 --ciphers "$_ciphersuites" --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" curl --proto --tlsv1.2; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(curl $_retry --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + else + _err=$(curl $_retry --proto '=https' --tlsv1.2 --silent --show-error --fail --location "$1" --output "$2" 2>&1) + _status=$? + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q 404; then + err "Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + elif [ "$_dld" = wget ]; then + if [ "$(wget -V 2>&1|head -2|tail -1|cut -f1 -d" ")" = "BusyBox" ]; then + echo "Warning: using the BusyBox version of wget. Not enforcing strong cipher suites for TLS or TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + get_ciphersuites_for_wget + _ciphersuites="$RETVAL" + if [ -n "$_ciphersuites" ]; then + _err=$(wget --https-only --secure-protocol=TLSv1_2 --ciphers "$_ciphersuites" "$1" -O "$2" 2>&1) + _status=$? + else + echo "Warning: Not enforcing strong cipher suites for TLS, this is potentially less secure" + if ! check_help_for "$3" wget --https-only --secure-protocol; then + echo "Warning: Not enforcing TLS v1.2, this is potentially less secure" + _err=$(wget "$1" -O "$2" 2>&1) + _status=$? + else + _err=$(wget --https-only --secure-protocol=TLSv1_2 "$1" -O "$2" 2>&1) + _status=$? + fi + fi + fi + if [ -n "$_err" ]; then + if echo "$_err" | grep -q ' 404 Not Found'; then + err "Binary for platform '$3' not found, this platform may be unsupported." + else + echo "$_err" >&2 + fi + fi + return $_status + else + err "Unknown downloader" # should not reach here + fi +} + +# Check if curl supports the --retry flag, then pass it to the curl invocation. +check_curl_for_retry_support() { + local _retry_supported="" + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--retry"; then + _retry_supported="--retry 3" + fi + + RETVAL="$_retry_supported" + +} + +check_help_for() { + local _arch + local _cmd + local _arg + _arch="$1" + shift + _cmd="$1" + shift + + local _category + if "$_cmd" --help | grep -q 'For all options use the manual or "--help all".'; then + _category="all" + else + _category="" + fi + + case "$_arch" in + + *darwin*) + if check_cmd sw_vers; then + case $(sw_vers -productVersion) in + 10.*) + # If we're running on macOS, older than 10.13, then we always + # fail to find these options to force fallback + if [ "$(sw_vers -productVersion | cut -d. -f2)" -lt 13 ]; then + # Older than 10.13 + echo "Warning: Detected macOS platform older than 10.13" + return 1 + fi + ;; + 11.*) + # We assume Big Sur will be OK for now + ;; + *) + # Unknown product version, warn and continue + echo "Warning: Detected unknown macOS major version: $(sw_vers -productVersion)" + echo "Warning TLS capabilities detection may fail" + ;; + esac + fi + ;; + + esac + + for _arg in "$@"; do + if ! "$_cmd" --help $_category | grep -q -- "$_arg"; then + return 1 + fi + done + + true # not strictly needed +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these curl backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_curl() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _openssl_syntax="no" + local _gnutls_syntax="no" + local _backend_supported="yes" + if curl -V | grep -q ' OpenSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' LibreSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' BoringSSL/'; then + _openssl_syntax="yes" + elif curl -V | grep -iq ' GnuTLS/'; then + _gnutls_syntax="yes" + else + _backend_supported="no" + fi + + local _args_supported="no" + if [ "$_backend_supported" = "yes" ]; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "curl" "--tlsv1.2" "--ciphers" "--proto"; then + _args_supported="yes" + fi + fi + + local _cs="" + if [ "$_args_supported" = "yes" ]; then + if [ "$_openssl_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "openssl") + elif [ "$_gnutls_syntax" = "yes" ]; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return cipher suite string specified by user, otherwise return strong TLS 1.2-1.3 cipher suites +# if support by local tools is detected. Detection currently supports these wget backends: +# GnuTLS and OpenSSL (possibly also LibreSSL and BoringSSL). Return value can be empty. +get_ciphersuites_for_wget() { + if [ -n "${RUSTUP_TLS_CIPHERSUITES-}" ]; then + # user specified custom cipher suites, assume they know what they're doing + RETVAL="$RUSTUP_TLS_CIPHERSUITES" + return + fi + + local _cs="" + if wget -V | grep -q '\-DHAVE_LIBSSL'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "openssl") + fi + elif wget -V | grep -q '\-DHAVE_LIBGNUTLS'; then + # "unspecified" is for arch, allows for possibility old OS using macports, homebrew, etc. + if check_help_for "notspecified" "wget" "TLSv1_2" "--ciphers" "--https-only" "--secure-protocol"; then + _cs=$(get_strong_ciphersuites_for "gnutls") + fi + fi + + RETVAL="$_cs" +} + +# Return strong TLS 1.2-1.3 cipher suites in OpenSSL or GnuTLS syntax. TLS 1.2 +# excludes non-ECDHE and non-AEAD cipher suites. DHE is excluded due to bad +# DH params often found on servers (see RFC 7919). Sequence matches or is +# similar to Firefox 68 ESR with weak cipher suites disabled via about:config. +# $1 must be openssl or gnutls. +get_strong_ciphersuites_for() { + if [ "$1" = "openssl" ]; then + # OpenSSL is forgiving of unknown values, no problems with TLS 1.3 values on versions that don't support it yet. + echo "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384" + elif [ "$1" = "gnutls" ]; then + # GnuTLS isn't forgiving of unknown values, so this may require a GnuTLS version that supports TLS 1.3 even if wget doesn't. + # Begin with SECURE128 (and higher) then remove/add to build cipher suites. Produces same 9 cipher suites as OpenSSL but in slightly different order. + echo "SECURE128:-VERS-SSL3.0:-VERS-TLS1.0:-VERS-TLS1.1:-VERS-DTLS-ALL:-CIPHER-ALL:-MAC-ALL:-KX-ALL:+AEAD:+ECDHE-ECDSA:+ECDHE-RSA:+AES-128-GCM:+CHACHA20-POLY1305:+AES-256-GCM" + fi +} + +# This is just for indicating that commands' results are being +# intentionally ignored. Usually, because it's being executed +# as part of error handling. +ignore() { + "$@" +} + +main "$@" || exit 1 diff --git a/resources/config.sample.toml b/resources/config.sample.toml new file mode 100644 index 0000000..c6a1c1b --- /dev/null +++ b/resources/config.sample.toml @@ -0,0 +1,167 @@ +# Migration proxy sample configuration. +# +# This is a starting template. Edit the destinations, mappings, TLS +# certificates and admin token to match your deployment, then start the +# service. The proxy refuses to boot until every referenced destination, +# certificate and credential path is valid (see SPEC section 8.4). +# +# Run with: proxy /etc/proxy/config.toml +# Or set: PROXY_CONFIG=/etc/proxy/config.toml + +[server] +# Worker threads (0 = one per CPU). +threads = 0 +# Hostname advertised in protocol greetings; defaults to the local IP. +hostname = "proxy.example.com" +shutdown_grace = "30s" +log_level = "info" + +# TLS certificates used to TERMINATE inbound implicit-TLS and STARTTLS. +# Multiple certificates are selected by SNI. Mark one as the default. +[tls.certificate.default] +cert = "/etc/proxy/tls/fullchain.pem" +key = "/etc/proxy/tls/privkey.pem" +default = true + +[tls.protocols] +min_version = "1.2" + +# --------------------------------------------------------------------------- +# Routing: which destination handles an identifier when it is missing or +# unmapped, and (optionally) where raw SMTP/25 and LMTP/24 traffic is sent. +# --------------------------------------------------------------------------- +[routing] +default_destination = "legacy" +# smtp_passthrough_destination = "stalwart" + +# --------------------------------------------------------------------------- +# Mappings: identifier -> destination id. Source is "file", "redis" or "sql". +# --------------------------------------------------------------------------- +[mapping] +source = "file" +normalize = "lowercase" +negative_ttl = "30s" +positive_ttl = "10m" + +[mapping.file] +path = "/etc/proxy/mappings.tsv" + +# [mapping.redis] +# url = "redis://10.0.0.5:6379" +# key_prefix = "route:" + +# [mapping.sql] +# url = "postgres://user:pass@10.0.0.6/proxy" +# query = "SELECT destination FROM routes WHERE identifier = $1" + +# --------------------------------------------------------------------------- +# Destinations. The default destination must support every listener protocol +# declared below. Each destination lists the per-protocol backend port and the +# TLS mode the proxy must use on the OUTBOUND leg. +# --------------------------------------------------------------------------- +[destination.legacy] +host = "10.0.1.10" +tls_server_name = "mail.legacy.example.com" +proxy_protocol = true + +[destination.legacy.protocol.imap] +port = 993 +tls = "implicit" +[destination.legacy.protocol.pop3] +port = 995 +tls = "implicit" +[destination.legacy.protocol.submission] +port = 587 +tls = "starttls" +[destination.legacy.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.legacy.protocol.http] +port = 443 +tls = "implicit" + +[destination.stalwart] +host = "10.0.1.20" +tls_server_name = "mail.new.example.com" +proxy_protocol = true + +[destination.stalwart.protocol.imap] +port = 993 +tls = "implicit" +[destination.stalwart.protocol.pop3] +port = 995 +tls = "implicit" +[destination.stalwart.protocol.submission] +port = 587 +tls = "starttls" +[destination.stalwart.protocol.managesieve] +port = 4190 +tls = "starttls" +[destination.stalwart.protocol.http] +port = 443 +tls = "implicit" + +# --------------------------------------------------------------------------- +# Listeners: one entry per protocol + TLS mode + bind set. +# tls = "implicit" | "starttls" | "plain". +# --------------------------------------------------------------------------- +[listener.imap] +protocol = "imap" +bind = ["0.0.0.0:993", "[::]:993"] +tls = "implicit" + +[listener.imap-starttls] +protocol = "imap" +bind = ["0.0.0.0:143", "[::]:143"] +tls = "starttls" + +[listener.pop3] +protocol = "pop3" +bind = ["0.0.0.0:995", "[::]:995"] +tls = "implicit" + +[listener.submission] +protocol = "submission" +bind = ["0.0.0.0:587", "[::]:587"] +tls = "starttls" + +[listener.submissions] +protocol = "submission" +bind = ["0.0.0.0:465", "[::]:465"] +tls = "implicit" + +[listener.managesieve] +protocol = "managesieve" +bind = ["0.0.0.0:4190", "[::]:4190"] +tls = "starttls" + +[listener.https] +protocol = "http" +bind = ["0.0.0.0:443", "[::]:443"] +tls = "implicit" +forwarded = "off" + +# --------------------------------------------------------------------------- +# HTTP routing rules. First match wins. A rule pins a destination by path, or +# extracts an account identifier and routes it through the mapping store. +# --------------------------------------------------------------------------- +[[http.route]] +match = "/.well-known/**" +destination = "legacy" + +[[http.route]] +match = "/**" +extract = { from = "authorization" } +fallback = "default" + +# --------------------------------------------------------------------------- +# Admin listener: the cache-integrity boundary. Bind to loopback or a dedicated +# management network only. TLS is mandatory; plaintext admin is rejected. +# --------------------------------------------------------------------------- +[admin] +bind = "127.0.0.1:9443" +tls = "implicit" +bearer_token_file = "/etc/proxy/admin.token" +min_token_len = 32 +lockout_threshold = 5 +lockout_duration = "5m" diff --git a/src/admin.rs b/src/admin.rs new file mode 100644 index 0000000..45e28c4 --- /dev/null +++ b/src/admin.rs @@ -0,0 +1,678 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::HashMap; +use std::net::IpAddr; +use std::sync::Arc; +use std::sync::atomic::Ordering::Relaxed; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use parking_lot::Mutex; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio_rustls::TlsAcceptor; + +use crate::config::{AdminConfig, Config}; +use crate::error::{ProxyError, Result}; +use crate::proto::common::Ctx; + +type Shared = Arc>; + +const MAX_HEAD: usize = 16 * 1024; + +const LOCKOUT_MAX_ENTRIES: usize = 16 * 1024; + +struct Lockout { + failures: HashMap, + threshold: u32, + duration: Duration, +} + +impl Lockout { + fn is_locked(&self, ip: &IpAddr) -> bool { + match self.failures.get(ip) { + Some((count, since)) => *count >= self.threshold && since.elapsed() < self.duration, + None => false, + } + } + + fn record_failure(&mut self, ip: IpAddr) { + if self.failures.len() >= LOCKOUT_MAX_ENTRIES { + let duration = self.duration; + self.failures + .retain(|_, (_, since)| since.elapsed() < duration); + } + let entry = self.failures.entry(ip).or_insert((0, Instant::now())); + if entry.1.elapsed() >= self.duration { + *entry = (0, Instant::now()); + } + entry.0 += 1; + entry.1 = Instant::now(); + } + + fn record_success(&mut self, ip: &IpAddr) { + self.failures.remove(ip); + } +} + +pub fn load_token(admin: &AdminConfig) -> Result { + let token = if let Ok(env) = std::env::var("PROXY_ADMIN_TOKEN") { + env + } else if let Some(path) = &admin.bearer_token_file { + std::fs::read_to_string(path) + .map_err(|e| ProxyError::config(format!("failed to read admin token {path}: {e}")))? + .trim() + .to_string() + } else if let Some(inline) = &admin.bearer_token { + inline.clone() + } else { + return Err(ProxyError::config("admin token not configured")); + }; + if token.len() < admin.min_token_len { + return Err(ProxyError::config(format!( + "admin token is shorter than min_token_len ({})", + admin.min_token_len + ))); + } + Ok(token) +} + +fn ct_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + let mut diff = 0u8; + for (x, y) in a.iter().zip(b) { + diff |= x ^ y; + } + diff == 0 +} + +pub async fn run(shared: Shared, config: Arc, config_path: Option>) -> Result<()> { + let admin = config + .admin + .as_ref() + .ok_or_else(|| ProxyError::config("admin not configured"))?; + let listener = TcpListener::bind(admin.bind) + .await + .map_err(|e| ProxyError::config(format!("admin bind {}: {e}", admin.bind)))?; + tracing::info!(bind = %admin.bind, "admin listener started"); + serve(shared, config, config_path, listener).await +} + +pub async fn serve( + shared: Shared, + config: Arc, + config_path: Option>, + listener: TcpListener, +) -> Result<()> { + let admin = config + .admin + .as_ref() + .ok_or_else(|| ProxyError::config("admin not configured"))?; + let token = Arc::new(load_token(admin)?); + let acceptor = shared + .load() + .tls_acceptor + .clone() + .ok_or_else(|| ProxyError::tls("admin requires TLS but no acceptor available"))?; + + let lockout = Arc::new(Mutex::new(Lockout { + failures: HashMap::new(), + threshold: admin.lockout_threshold, + duration: admin.lockout_duration, + })); + + loop { + let (tcp, peer) = match listener.accept().await { + Ok(p) => p, + Err(e) => { + tracing::warn!(error = %e, "admin accept failed"); + continue; + } + }; + let acceptor = acceptor.clone(); + let token = token.clone(); + let shared = shared.clone(); + let lockout = lockout.clone(); + let config_path = config_path.clone(); + tokio::spawn(async move { + if let Err(e) = handle( + acceptor, + tcp, + peer.ip(), + token, + shared, + lockout, + config_path, + ) + .await + { + tracing::debug!(error = %e, "admin request ended"); + } + }); + } +} + +async fn handle( + acceptor: TlsAcceptor, + tcp: tokio::net::TcpStream, + ip: IpAddr, + token: Arc, + shared: Shared, + lockout: Arc>, + config_path: Option>, +) -> Result<()> { + let mut stream = acceptor + .accept(tcp) + .await + .map_err(|e| ProxyError::tls(format!("admin TLS handshake: {e}")))?; + + if lockout.lock().is_locked(&ip) { + let _ = write_response(&mut stream, 429, "Too Many Requests", "locked out\n").await; + return Ok(()); + } + + let head = read_head(&mut stream).await?; + let request = match parse_request(&head) { + Some(r) => r, + None => { + write_response(&mut stream, 400, "Bad Request", "bad request\n").await?; + return Ok(()); + } + }; + + if request.path == "/healthz" { + match request.method.as_str() { + "GET" => write_response(&mut stream, 200, "OK", "ok\n").await?, + _ => { + write_response(&mut stream, 405, "Method Not Allowed", "method not allowed\n") + .await? + } + } + return Ok(()); + } + + if !authorized(&request, &token) { + lockout.lock().record_failure(ip); + tracing::warn!(%ip, path = %request.path, "admin 401 unauthorized"); + write_response(&mut stream, 401, "Unauthorized", "unauthorized\n").await?; + return Ok(()); + } + lockout.lock().record_success(&ip); + + let ctx = shared.load_full(); + + match (request.method.as_str(), request.path.as_str()) { + ("GET", "/stats") => { + let hits = ctx.router.stats.hits.load(Relaxed); + let misses = ctx.router.stats.misses.load(Relaxed); + let total = hits + misses; + let hit_rate = if total > 0 { + (hits as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + let body = serde_json::json!({ + "cache_hits": hits, + "cache_misses": misses, + "hit_rate_pct": hit_rate, + "cache_entries": ctx.router.cache_entries(), + "active_connections": ctx.metrics.active_connections.load(Relaxed), + }) + .to_string(); + write_json(&mut stream, 200, &body).await?; + } + ("GET", "/destinations") => { + let list: Vec<_> = ctx + .dest_health + .iter() + .map(|(id, health)| { + let s = health.snapshot(); + serde_json::json!({ + "destination": id, + "consecutive_failures": s.consecutive_failures, + "down": s.down, + }) + }) + .collect(); + let body = serde_json::json!({ "destinations": list }).to_string(); + write_json(&mut stream, 200, &body).await?; + } + ("POST", "/destinations/reset") => match request.query_param("destination") { + Some(dest) => match ctx.dest_health.get(&dest) { + Some(health) => { + health.reset(); + tracing::info!(destination = %dest, "admin reset destination health"); + write_response(&mut stream, 200, "OK", "ok\n").await?; + } + None => { + write_response(&mut stream, 404, "Not Found", "unknown destination\n").await?; + } + }, + None => { + write_response(&mut stream, 400, "Bad Request", "destination required\n").await?; + } + }, + ("GET", "/mappings") => match request.query_param("identifier") { + Some(id) => { + let d = ctx.router.diagnose(&id).await; + let body = serde_json::json!({ + "identifier": id, + "normalized": d.normalized, + "destination": d.destination, + "routed_to_default": d.routed_to_default, + "cached": d.cached, + }) + .to_string(); + write_json(&mut stream, 200, &body).await?; + } + None => { + write_response(&mut stream, 400, "Bad Request", "identifier required\n").await?; + } + }, + ("PUT", "/mappings") => { + match ( + request.query_param("identifier"), + request.query_param("destination"), + ) { + (Some(id), Some(dest)) => { + if !ctx.router.store_writable() { + write_response( + &mut stream, + 501, + "Not Implemented", + "mapping store is read-only\n", + ) + .await?; + } else if !ctx.router.is_valid_destination(&dest) { + write_response(&mut stream, 400, "Bad Request", "unknown destination\n") + .await?; + } else { + match ctx.router.upsert(&id, &dest).await { + Ok(()) => { + tracing::info!(identifier = %crate::observe::redact(&id), %dest, "admin upserted mapping"); + write_response(&mut stream, 200, "OK", "ok\n").await?; + } + Err(e) => { + tracing::warn!(error = %e, "admin mapping upsert failed"); + write_response( + &mut stream, + 500, + "Internal Server Error", + "upsert failed\n", + ) + .await?; + } + } + } + } + _ => { + write_response( + &mut stream, + 400, + "Bad Request", + "identifier and destination required\n", + ) + .await?; + } + } + } + ("DELETE", "/mappings") => match request.query_param("identifier") { + Some(id) => { + if !ctx.router.store_writable() { + write_response( + &mut stream, + 501, + "Not Implemented", + "mapping store is read-only\n", + ) + .await?; + } else { + match ctx.router.remove(&id).await { + Ok(existed) => { + tracing::info!(identifier = %crate::observe::redact(&id), existed, "admin removed mapping"); + let body = if existed { "removed\n" } else { "absent\n" }; + write_response(&mut stream, 200, "OK", body).await?; + } + Err(e) => { + tracing::warn!(error = %e, "admin mapping remove failed"); + write_response( + &mut stream, + 500, + "Internal Server Error", + "remove failed\n", + ) + .await?; + } + } + } + } + None => { + write_response(&mut stream, 400, "Bad Request", "identifier required\n").await?; + } + }, + ("POST", "/config/reload") => match &config_path { + None => { + write_response( + &mut stream, + 503, + "Service Unavailable", + "config reload not available\n", + ) + .await?; + } + Some(path) => match reload_config(&shared, path).await { + Ok(()) => { + tracing::info!("admin reloaded configuration"); + let body = serde_json::json!({ + "reloaded": true, + "note": "listener binds/protocols, admin bind/token, and cache capacity/TTLs require a restart to change", + }) + .to_string(); + write_json(&mut stream, 200, &body).await?; + } + Err(e) => { + tracing::warn!(error = %e, "admin config reload failed"); + write_response( + &mut stream, + 400, + "Bad Request", + &format!("reload failed: {e}\n"), + ) + .await?; + } + }, + }, + ("POST", "/cache/invalidate") => { + match request.query_param("identifier") { + Some(id) => { + ctx.router.invalidate(&id).await; + tracing::info!(identifier = %crate::observe::redact(&id), "admin invalidated entry"); + } + None => { + ctx.router.invalidate_all(); + tracing::info!("admin invalidated entire cache"); + } + } + write_response(&mut stream, 200, "OK", "ok\n").await?; + } + ("POST", "/mappings/reload") => match ctx.router.reload().await { + Ok(()) => { + tracing::info!("admin reloaded mappings"); + write_response(&mut stream, 200, "OK", "reloaded\n").await?; + } + Err(e) => { + tracing::warn!(error = %e, "admin reload failed"); + write_response(&mut stream, 500, "Internal Server Error", "reload failed\n") + .await?; + } + }, + ("POST", "/connections/kick") => match request.query_param("identifier") { + Some(id) => { + let count = ctx.conns.kick(&ctx.router.normalize(&id)); + tracing::info!(identifier = %crate::observe::redact(&id), count, "admin kicked connections"); + write_response(&mut stream, 200, "OK", &format!("kicked {count}\n")).await?; + } + None => { + write_response(&mut stream, 400, "Bad Request", "identifier required\n").await?; + } + }, + ("POST", "/connections/delay") => { + match ( + request.query_param("identifier"), + request.query_param("seconds"), + ) { + (Some(id), Some(secs)) => match secs.parse::() { + Ok(seconds) => { + let until = + std::time::Instant::now() + std::time::Duration::from_secs(seconds); + ctx.conns.set_delay(&ctx.router.normalize(&id), until); + tracing::info!(identifier = %crate::observe::redact(&id), seconds, "admin set login delay"); + write_response(&mut stream, 200, "OK", "ok\n").await?; + } + Err(_) => { + write_response(&mut stream, 400, "Bad Request", "invalid seconds\n") + .await?; + } + }, + _ => { + write_response( + &mut stream, + 400, + "Bad Request", + "identifier and seconds required\n", + ) + .await?; + } + } + } + (_, "/stats") + | (_, "/destinations") + | (_, "/destinations/reset") + | (_, "/mappings") + | (_, "/cache/invalidate") + | (_, "/mappings/reload") + | (_, "/connections/kick") + | (_, "/connections/delay") + | (_, "/config/reload") => { + write_response( + &mut stream, + 405, + "Method Not Allowed", + "method not allowed\n", + ) + .await?; + } + _ => { + write_response(&mut stream, 404, "Not Found", "not found\n").await?; + } + } + Ok(()) +} + +struct AdminRequest { + method: String, + path: String, + query: String, + auth: Option, +} + +impl AdminRequest { + fn query_param(&self, key: &str) -> Option { + for pair in self.query.split('&') { + if let Some((k, v)) = pair.split_once('=') + && k == key + { + return Some( + percent_encoding::percent_decode_str(v) + .decode_utf8_lossy() + .into_owned(), + ); + } + } + None + } +} + +fn authorized(request: &AdminRequest, token: &str) -> bool { + match &request.auth { + Some(value) => value + .strip_prefix("Bearer ") + .map(|t| ct_eq(t.trim().as_bytes(), token.as_bytes())) + .unwrap_or(false), + None => false, + } +} + +fn parse_request(head: &[u8]) -> Option { + let text = std::str::from_utf8(head).ok()?; + let mut lines = text.split("\r\n"); + let request_line = lines.next()?; + let mut parts = request_line.split(' '); + let method = parts.next()?.to_string(); + let target = parts.next()?; + let (raw_path, query) = match target.split_once('?') { + Some((p, q)) => (p, q.to_string()), + None => (target, String::new()), + }; + let trimmed = raw_path.trim_end_matches('/'); + let path = if trimmed.is_empty() { + "/".to_string() + } else { + trimmed.to_string() + }; + let mut auth = None; + for line in lines { + if let Some((name, value)) = line.split_once(':') + && name.eq_ignore_ascii_case("authorization") + { + auth = Some(value.trim().to_string()); + } + } + Some(AdminRequest { + method, + path, + query, + auth, + }) +} + +async fn read_head(stream: &mut S) -> Result> { + let mut buf = Vec::with_capacity(1024); + let mut scratch = [0u8; 1024]; + loop { + if let Some(pos) = find_double_crlf(&buf) { + buf.truncate(pos); + return Ok(buf); + } + if buf.len() > MAX_HEAD { + return Err(ProxyError::protocol("admin request head too large")); + } + let n = stream + .read(&mut scratch) + .await + .map_err(|e| ProxyError::protocol(format!("admin read: {e}")))?; + if n == 0 { + return Err(ProxyError::Closed); + } + buf.extend_from_slice(&scratch[..n]); + } +} + +fn find_double_crlf(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) +} + +async fn write_response( + stream: &mut S, + code: u16, + reason: &str, + body: &str, +) -> Result<()> { + write_body(stream, code, reason, "text/plain", body).await +} + +async fn write_json(stream: &mut S, code: u16, body: &str) -> Result<()> { + let reason = match code { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "OK", + }; + write_body(stream, code, reason, "application/json", body).await +} + +async fn write_body( + stream: &mut S, + code: u16, + reason: &str, + content_type: &str, + body: &str, +) -> Result<()> { + let response = format!( + "HTTP/1.1 {code} {reason}\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: {content_type}\r\n\r\n{body}", + body.len() + ); + stream + .write_all(response.as_bytes()) + .await + .map_err(|e| ProxyError::protocol(format!("admin write: {e}")))?; + let _ = stream.flush().await; + let _ = stream.shutdown().await; + Ok(()) +} + +async fn reload_config(shared: &Shared, path: &str) -> Result<()> { + let raw = tokio::fs::read_to_string(path) + .await + .map_err(|e| ProxyError::config(format!("read config {path}: {e}")))?; + let config = Arc::new(Config::parse_and_validate(&raw)?); + let provider = crate::net::tls::provider(); + let current = shared.load_full(); + let conns = current.conns.clone(); + let metrics = current.metrics.clone(); + let ctx = crate::server::build_ctx(config, &provider, conns, metrics).await?; + shared.store(Arc::new(ctx)); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constant_time_eq() { + assert!(ct_eq(b"abc", b"abc")); + assert!(!ct_eq(b"abc", b"abd")); + assert!(!ct_eq(b"abc", b"ab")); + } + + #[test] + fn parse_basic_request() { + let head = b"POST /cache/invalidate?identifier=user%40example.com HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer secret\r\n"; + let req = parse_request(head).unwrap(); + assert_eq!(req.method, "POST"); + assert_eq!(req.path, "/cache/invalidate"); + assert_eq!( + req.query_param("identifier").as_deref(), + Some("user@example.com") + ); + assert_eq!(req.auth.as_deref(), Some("Bearer secret")); + } + + #[test] + fn parse_trims_trailing_slash() { + let req = parse_request(b"GET /stats/ HTTP/1.1\r\nHost: x\r\n").unwrap(); + assert_eq!(req.path, "/stats"); + let req = parse_request(b"GET /cache/invalidate/?identifier=a HTTP/1.1\r\nHost: x\r\n") + .unwrap(); + assert_eq!(req.path, "/cache/invalidate"); + assert_eq!(req.query_param("identifier").as_deref(), Some("a")); + let req = parse_request(b"GET / HTTP/1.1\r\nHost: x\r\n").unwrap(); + assert_eq!(req.path, "/"); + } + + #[test] + fn lockout_after_threshold() { + let mut l = Lockout { + failures: HashMap::new(), + threshold: 2, + duration: Duration::from_secs(60), + }; + let ip: IpAddr = "10.0.0.1".parse().unwrap(); + assert!(!l.is_locked(&ip)); + l.record_failure(ip); + assert!(!l.is_locked(&ip)); + l.record_failure(ip); + assert!(l.is_locked(&ip)); + l.record_success(&ip); + assert!(!l.is_locked(&ip)); + } +} diff --git a/src/config/duration.rs b/src/config/duration.rs new file mode 100644 index 0000000..3dfb9cb --- /dev/null +++ b/src/config/duration.rs @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use serde::{Deserialize, Deserializer}; + +pub fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("empty duration".into()); + } + let split = s + .find(|c: char| c.is_ascii_alphabetic()) + .ok_or_else(|| format!("missing duration unit in {s:?}"))?; + let (num, unit) = s.split_at(split); + let value: u64 = num + .trim() + .parse() + .map_err(|_| format!("invalid duration value in {s:?}"))?; + let multiplier = match unit.trim() { + "ms" => return Ok(Duration::from_millis(value)), + "s" => 1, + "m" => 60, + "h" => 3600, + "d" => 86400, + other => return Err(format!("unknown duration unit {other:?}")), + }; + let secs = value + .checked_mul(multiplier) + .ok_or_else(|| format!("duration overflow in {s:?}"))?; + Ok(Duration::from_secs(secs)) +} + +pub fn deserialize<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer)?; + parse_duration(&s).map_err(serde::de::Error::custom) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_units() { + assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30)); + assert_eq!(parse_duration("10m").unwrap(), Duration::from_secs(600)); + assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300)); + assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600)); + assert_eq!(parse_duration("250ms").unwrap(), Duration::from_millis(250)); + assert!(parse_duration("").is_err()); + assert!(parse_duration("10x").is_err()); + assert!(parse_duration("abc").is_err()); + } + + #[test] + fn rejects_overflow_instead_of_wrapping() { + let err = parse_duration("100000000000000000000d").unwrap_err(); + assert!(err.contains("invalid duration value") || err.contains("overflow")); + let err = parse_duration("18446744073709551615d").unwrap_err(); + assert!(err.contains("overflow")); + } + + #[test] + fn trims_surrounding_whitespace() { + assert_eq!(parse_duration(" 30s ").unwrap(), Duration::from_secs(30)); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..3bc63da --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,965 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod duration; + +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use ahash::AHashMap; +use serde::Deserialize; + +use crate::error::{ProxyError, Result}; +use crate::net::cidr::Cidr; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + Smtp, + Submission, + Lmtp, + Imap, + Pop3, + #[serde(rename = "managesieve")] + ManageSieve, + Http, +} + +impl Protocol { + pub fn as_str(&self) -> &'static str { + match self { + Protocol::Smtp => "smtp", + Protocol::Submission => "submission", + Protocol::Lmtp => "lmtp", + Protocol::Imap => "imap", + Protocol::Pop3 => "pop3", + Protocol::ManageSieve => "managesieve", + Protocol::Http => "http", + } + } + + pub fn is_credential_bearing(&self) -> bool { + matches!( + self, + Protocol::Imap + | Protocol::Pop3 + | Protocol::ManageSieve + | Protocol::Submission + | Protocol::Http + ) + } + + pub fn is_passthrough(&self) -> bool { + matches!(self, Protocol::Smtp | Protocol::Lmtp) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TlsMode { + Implicit, + Starttls, + Plain, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ProxyProtocolMode { + Off, + Optional, + Required, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum ForwardedMode { + #[default] + Off, + Trust, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Forwarding { + Proxy, + Xclient, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Normalize { + #[default] + None, + Lowercase, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MappingSource { + File, + Redis, + Sql, +} + +#[derive(Debug, Deserialize)] +pub struct Config { + #[serde(default)] + pub server: ServerConfig, + #[serde(default)] + pub tls: TlsConfig, + #[serde(default)] + pub listener: AHashMap, + #[serde(default)] + pub destination: AHashMap, + pub routing: RoutingConfig, + pub mapping: MappingConfig, + #[serde(default)] + pub capabilities: CapabilitiesConfig, + #[serde(default)] + pub oauth: OAuthConfig, + #[serde(default)] + pub http: HttpConfig, + pub admin: Option, +} + +#[derive(Debug, Deserialize)] +pub struct ServerConfig { + #[serde(default)] + pub threads: usize, + #[serde(default = "default_shutdown_grace", with = "duration")] + pub shutdown_grace: Duration, + #[serde(default = "default_bridge_idle", with = "duration")] + pub bridge_idle: Duration, + #[serde(default = "default_backend_timeout", with = "duration")] + pub backend_timeout: Duration, + #[serde(default = "default_backend_connect_retries")] + pub backend_connect_retries: u32, + #[serde(default = "default_host_down_threshold")] + pub host_down_threshold: u32, + #[serde(default = "default_host_down_cooldown", with = "duration")] + pub host_down_cooldown: Duration, + #[serde(default = "default_proxy_ttl")] + pub proxy_ttl: u32, + #[serde(default = "default_log_level")] + pub log_level: String, + #[serde(default)] + pub hostname: Option, +} + +impl Default for ServerConfig { + fn default() -> Self { + ServerConfig { + threads: 0, + shutdown_grace: DEFAULT_SHUTDOWN_GRACE, + bridge_idle: DEFAULT_BRIDGE_IDLE, + backend_timeout: DEFAULT_BACKEND_TIMEOUT, + backend_connect_retries: DEFAULT_BACKEND_CONNECT_RETRIES, + host_down_threshold: DEFAULT_HOST_DOWN_THRESHOLD, + host_down_cooldown: DEFAULT_HOST_DOWN_COOLDOWN, + proxy_ttl: DEFAULT_PROXY_TTL, + log_level: DEFAULT_LOG_LEVEL.to_string(), + hostname: None, + } + } +} + +impl ServerConfig { + pub fn hostname_for(&self, local: SocketAddr) -> String { + match &self.hostname { + Some(h) => h.clone(), + None => local.ip().to_string(), + } + } +} + +const DEFAULT_SHUTDOWN_GRACE: Duration = Duration::from_secs(30); +const DEFAULT_BRIDGE_IDLE: Duration = Duration::from_secs(1800); +const DEFAULT_BACKEND_TIMEOUT: Duration = Duration::from_secs(10); +const DEFAULT_BACKEND_CONNECT_RETRIES: u32 = 1; +const DEFAULT_HOST_DOWN_THRESHOLD: u32 = 5; +const DEFAULT_HOST_DOWN_COOLDOWN: Duration = Duration::from_secs(30); +const DEFAULT_PROXY_TTL: u32 = 7; +const DEFAULT_LOG_LEVEL: &str = "info"; + +fn default_shutdown_grace() -> Duration { + DEFAULT_SHUTDOWN_GRACE +} +fn default_bridge_idle() -> Duration { + DEFAULT_BRIDGE_IDLE +} +fn default_backend_timeout() -> Duration { + DEFAULT_BACKEND_TIMEOUT +} +fn default_backend_connect_retries() -> u32 { + DEFAULT_BACKEND_CONNECT_RETRIES +} +fn default_host_down_threshold() -> u32 { + DEFAULT_HOST_DOWN_THRESHOLD +} +fn default_host_down_cooldown() -> Duration { + DEFAULT_HOST_DOWN_COOLDOWN +} +fn default_proxy_ttl() -> u32 { + DEFAULT_PROXY_TTL +} +fn default_log_level() -> String { + DEFAULT_LOG_LEVEL.to_string() +} + +#[derive(Debug, Default, Deserialize)] +pub struct TlsConfig { + #[serde(default)] + pub certificate: AHashMap, + #[serde(default)] + pub protocols: TlsProtocols, +} + +#[derive(Debug, Deserialize)] +pub struct CertificateConfig { + pub cert: String, + pub key: String, + #[serde(default)] + pub subjects: Vec, + #[serde(default)] + pub default: bool, +} + +#[derive(Debug, Deserialize)] +pub struct TlsProtocols { + #[serde(default = "default_min_version")] + pub min_version: String, + #[serde(default)] + pub ignore_client_order: bool, + #[serde(default)] + pub disable_cipher_suites: Vec, +} + +impl Default for TlsProtocols { + fn default() -> Self { + TlsProtocols { + min_version: default_min_version(), + ignore_client_order: false, + disable_cipher_suites: Vec::new(), + } + } +} + +const DEFAULT_MIN_VERSION: &str = "1.2"; +fn default_min_version() -> String { + DEFAULT_MIN_VERSION.to_string() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ListenerConfig { + pub protocol: Protocol, + pub bind: Vec, + pub tls: TlsMode, + #[serde(default = "default_proxy_off")] + pub proxy_protocol: ProxyProtocolMode, + #[serde(default)] + pub proxy_trusted: Vec, + #[serde(default = "default_max_connections")] + pub max_connections: usize, + #[serde(default = "default_preauth_timeout", with = "duration")] + pub preauth_timeout: Duration, + #[serde(default = "default_max_auth_attempts")] + pub max_auth_attempts: u32, + #[serde(default)] + pub forwarded: ForwardedMode, +} + +const DEFAULT_MAX_CONNECTIONS: usize = 8192; +const DEFAULT_PREAUTH_TIMEOUT: Duration = Duration::from_secs(30); +const DEFAULT_MAX_AUTH_ATTEMPTS: u32 = 3; + +fn default_proxy_off() -> ProxyProtocolMode { + ProxyProtocolMode::Off +} +fn default_max_connections() -> usize { + DEFAULT_MAX_CONNECTIONS +} +fn default_preauth_timeout() -> Duration { + DEFAULT_PREAUTH_TIMEOUT +} +fn default_max_auth_attempts() -> u32 { + DEFAULT_MAX_AUTH_ATTEMPTS +} + +#[derive(Debug, Deserialize)] +pub struct DestinationConfig { + pub host: String, + #[serde(default)] + pub tls_server_name: Option, + #[serde(default = "default_true")] + pub proxy_protocol: bool, + #[serde(default)] + pub forwarding: Option, + #[serde(default)] + pub forwarded: bool, + #[serde(default)] + pub tls_allow_invalid_certs: bool, + #[serde(default)] + pub tls_pinned_cert_sha256: Option, + #[serde(default)] + pub tls_client_cert: Option, + #[serde(default)] + pub tls_client_key: Option, + #[serde(default)] + pub source_ips: Vec, + #[serde(default)] + pub hide_auth_errors: bool, + #[serde(default)] + pub allow_plaintext_auth: bool, + #[serde(default)] + pub protocol: AHashMap, +} + +fn default_true() -> bool { + true +} + +impl DestinationConfig { + pub fn host_is_ip(&self) -> bool { + self.host.parse::().is_ok() + } + + pub fn forwarding_for(&self, protocol: Protocol) -> Forwarding { + if let Some(ep) = self.protocol.get(&protocol) + && let Some(f) = ep.forwarding + { + return f; + } + if let Some(f) = self.forwarding { + return f; + } + if self.proxy_protocol { + Forwarding::Proxy + } else { + Forwarding::None + } + } +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DestProtocol { + pub port: u16, + pub tls: TlsMode, + #[serde(default)] + pub forwarding: Option, +} + +#[derive(Debug, Deserialize)] +pub struct RoutingConfig { + pub default_destination: String, + #[serde(default)] + pub smtp_passthrough_destination: Option, + #[serde(default = "default_master_user_separators")] + pub master_user_separators: Vec, +} + +const DEFAULT_MASTER_USER_SEPARATORS: &[&str] = &["%"]; +fn default_master_user_separators() -> Vec { + DEFAULT_MASTER_USER_SEPARATORS + .iter() + .map(|s| s.to_string()) + .collect() +} + +#[derive(Debug, Deserialize)] +pub struct MappingConfig { + pub source: MappingSource, + #[serde(default)] + pub normalize: Normalize, + #[serde(default = "default_negative_ttl", with = "duration")] + pub negative_ttl: Duration, + #[serde(default = "default_positive_ttl", with = "duration")] + pub positive_ttl: Duration, + #[serde(default = "default_cache_max")] + pub cache_max_entries: u64, + #[serde(default = "default_lookup_timeout", with = "duration")] + pub lookup_timeout: Duration, + #[serde(default)] + pub file: Option, + #[serde(default)] + pub redis: Option, + #[serde(default)] + pub sql: Option, +} + +const DEFAULT_NEGATIVE_TTL: Duration = Duration::from_secs(30); +const DEFAULT_POSITIVE_TTL: Duration = Duration::from_secs(600); +const DEFAULT_CACHE_MAX: u64 = 1_000_000; +const DEFAULT_LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); + +fn default_negative_ttl() -> Duration { + DEFAULT_NEGATIVE_TTL +} +fn default_positive_ttl() -> Duration { + DEFAULT_POSITIVE_TTL +} +fn default_cache_max() -> u64 { + DEFAULT_CACHE_MAX +} +fn default_lookup_timeout() -> Duration { + DEFAULT_LOOKUP_TIMEOUT +} + +#[derive(Debug, Deserialize)] +pub struct FileMappingConfig { + pub path: String, +} + +#[derive(Debug, Deserialize)] +pub struct RedisMappingConfig { + pub url: String, + #[serde(default = "default_key_prefix")] + pub key_prefix: String, + #[serde(default = "default_pool_size")] + pub pool_size: usize, +} + +const DEFAULT_KEY_PREFIX: &str = "route:"; +const DEFAULT_POOL_SIZE: usize = 16; + +fn default_key_prefix() -> String { + DEFAULT_KEY_PREFIX.to_string() +} +fn default_pool_size() -> usize { + DEFAULT_POOL_SIZE +} + +#[derive(Debug, Deserialize)] +pub struct SqlMappingConfig { + pub url: String, + pub query: String, + #[serde(default = "default_pool_size")] + pub pool_size: usize, + #[serde(default)] + pub upsert_query: Option, + #[serde(default)] + pub delete_query: Option, +} + +#[derive(Debug, Default, Deserialize)] +pub struct CapabilitiesConfig { + #[serde(default)] + pub imap: ImapCapabilities, + #[serde(default)] + pub managesieve: ManageSieveCapabilities, + #[serde(default)] + pub submission: SubmissionCapabilities, + #[serde(default)] + pub pop3: Pop3Capabilities, +} + +#[derive(Debug, Deserialize)] +pub struct ImapCapabilities { + #[serde(default = "default_imap_advertise")] + pub advertise: Vec, + #[serde(default = "default_sasl")] + pub sasl: Vec, + #[serde(default)] + pub allow_plain_auth_without_tls: bool, + #[serde(default = "default_imap_banner")] + pub banner: String, +} + +impl Default for ImapCapabilities { + fn default() -> Self { + ImapCapabilities { + advertise: default_imap_advertise(), + sasl: default_sasl(), + allow_plain_auth_without_tls: false, + banner: default_imap_banner(), + } + } +} + +const DEFAULT_IMAP_ADVERTISE: &[&str] = &[ + "IMAP4rev2", + "IMAP4rev1", + "ENABLE", + "SASL-IR", + "LITERAL+", + "ID", + "UTF8=ACCEPT", + "JMAPACCESS", +]; +const DEFAULT_SASL: &[&str] = &["PLAIN", "OAUTHBEARER", "XOAUTH2"]; +const DEFAULT_IMAP_BANNER: &str = "Stalwart IMAP4rev2 at your service."; + +fn default_imap_advertise() -> Vec { + DEFAULT_IMAP_ADVERTISE + .iter() + .map(|s| s.to_string()) + .collect() +} +fn default_sasl() -> Vec { + DEFAULT_SASL.iter().map(|s| s.to_string()).collect() +} +fn default_imap_banner() -> String { + DEFAULT_IMAP_BANNER.to_string() +} + +#[derive(Debug, Deserialize)] +pub struct ManageSieveCapabilities { + #[serde(default = "default_sieve_ext")] + pub sieve: Vec, + #[serde(default = "default_sasl")] + pub sasl: Vec, + #[serde(default)] + pub allow_plain_auth_without_tls: bool, + #[serde(default = "default_sieve_impl")] + pub implementation: String, +} + +impl Default for ManageSieveCapabilities { + fn default() -> Self { + ManageSieveCapabilities { + sieve: default_sieve_ext(), + sasl: default_sasl(), + allow_plain_auth_without_tls: false, + implementation: default_sieve_impl(), + } + } +} + +const DEFAULT_SIEVE_EXT: &[&str] = &[ + "body", + "comparator-elbonia", + "comparator-i;ascii-casemap", + "comparator-i;ascii-numeric", + "comparator-i;octet", + "convert", + "copy", + "date", + "duplicate", + "editheader", + "enclose", + "encoded-character", + "enotify", + "envelope", + "envelope-deliverby", + "envelope-dsn", + "environment", + "ereject", + "extlists", + "extracttext", + "fcc", + "fileinto", + "foreverypart", + "ihave", + "imap4flags", + "imapsieve", + "include", + "index", + "mailbox", + "mailboxid", + "mboxmetadata", + "mime", + "redirect-deliverby", + "redirect-dsn", + "regex", + "reject", + "relational", + "replace", + "servermetadata", + "spamtest", + "spamtestplus", + "special-use", + "subaddress", + "vacation", + "vacation-seconds", + "variables", + "virustest", +]; +const DEFAULT_SIEVE_IMPL: &str = "Stalwart ManageSieve"; + +fn default_sieve_ext() -> Vec { + DEFAULT_SIEVE_EXT.iter().map(|s| s.to_string()).collect() +} +fn default_sieve_impl() -> String { + DEFAULT_SIEVE_IMPL.to_string() +} + +#[derive(Debug, Deserialize)] +pub struct SubmissionCapabilities { + #[serde(default = "default_ehlo")] + pub ehlo: Vec, + #[serde(default = "default_sasl")] + pub sasl: Vec, + #[serde(default)] + pub allow_plain_auth_without_tls: bool, + #[serde(default = "default_smtp_banner")] + pub banner: String, +} + +impl Default for SubmissionCapabilities { + fn default() -> Self { + SubmissionCapabilities { + ehlo: default_ehlo(), + sasl: default_sasl(), + allow_plain_auth_without_tls: false, + banner: default_smtp_banner(), + } + } +} + +const DEFAULT_EHLO: &[&str] = &[ + "ENHANCEDSTATUSCODES", + "8BITMIME", + "BINARYMIME", + "SMTPUTF8", + "PIPELINING", + "CHUNKING", + "REQUIRETLS", + "SIZE", +]; +const DEFAULT_SMTP_BANNER: &str = "at your service."; + +fn default_ehlo() -> Vec { + DEFAULT_EHLO.iter().map(|s| s.to_string()).collect() +} +fn default_smtp_banner() -> String { + DEFAULT_SMTP_BANNER.to_string() +} + +#[derive(Debug, Deserialize)] +pub struct Pop3Capabilities { + #[serde(default = "default_sasl")] + pub sasl: Vec, + #[serde(default)] + pub allow_plain_auth_without_tls: bool, + #[serde(default = "default_pop3_banner")] + pub banner: String, +} + +impl Default for Pop3Capabilities { + fn default() -> Self { + Pop3Capabilities { + sasl: default_sasl(), + allow_plain_auth_without_tls: false, + banner: default_pop3_banner(), + } + } +} +const DEFAULT_POP3_BANNER: &str = "Stalwart POP3 at your service."; +fn default_pop3_banner() -> String { + DEFAULT_POP3_BANNER.to_string() +} + +#[derive(Debug, Deserialize)] +pub struct OAuthConfig { + #[serde(default = "default_jwt_claim")] + pub jwt_username_claim: String, +} + +impl Default for OAuthConfig { + fn default() -> Self { + OAuthConfig { + jwt_username_claim: default_jwt_claim(), + } + } +} +const DEFAULT_JWT_CLAIM: &str = "email"; +fn default_jwt_claim() -> String { + DEFAULT_JWT_CLAIM.to_string() +} + +#[derive(Debug, Deserialize)] +pub struct HttpConfig { + #[serde(default)] + pub route: Vec, + #[serde(default = "default_body_cap")] + pub body_extract_cap: usize, + #[serde(default = "default_head_cap")] + pub max_head_size: usize, + #[serde(default = "default_relay_idle", with = "duration")] + pub relay_idle: Duration, + #[serde(default = "default_keepalive_timeout", with = "duration")] + pub keepalive_timeout: Duration, + #[serde(default = "default_max_keepalive_requests")] + pub max_keepalive_requests: u32, +} + +impl Default for HttpConfig { + fn default() -> Self { + HttpConfig { + route: Vec::new(), + body_extract_cap: DEFAULT_BODY_CAP, + max_head_size: DEFAULT_HEAD_CAP, + relay_idle: DEFAULT_RELAY_IDLE, + keepalive_timeout: DEFAULT_KEEPALIVE_TIMEOUT, + max_keepalive_requests: DEFAULT_MAX_KEEPALIVE_REQUESTS, + } + } +} + +const DEFAULT_BODY_CAP: usize = 64 * 1024; +const DEFAULT_HEAD_CAP: usize = 64 * 1024; +const DEFAULT_RELAY_IDLE: Duration = Duration::from_secs(60); +const DEFAULT_KEEPALIVE_TIMEOUT: Duration = Duration::from_secs(75); +const DEFAULT_MAX_KEEPALIVE_REQUESTS: u32 = 1000; + +fn default_body_cap() -> usize { + DEFAULT_BODY_CAP +} +fn default_head_cap() -> usize { + DEFAULT_HEAD_CAP +} +fn default_relay_idle() -> Duration { + DEFAULT_RELAY_IDLE +} +fn default_keepalive_timeout() -> Duration { + DEFAULT_KEEPALIVE_TIMEOUT +} +fn default_max_keepalive_requests() -> u32 { + DEFAULT_MAX_KEEPALIVE_REQUESTS +} + +#[derive(Debug, Deserialize)] +pub struct HttpRoute { + #[serde(rename = "match")] + pub match_glob: String, + #[serde(default)] + pub destination: Option, + #[serde(default)] + pub extract: Option, + #[serde(default)] + pub fallback: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct HttpExtract { + pub from: ExtractFrom, + #[serde(default)] + pub regex: Option, + #[serde(default)] + pub param: Option, + #[serde(default)] + pub header: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ExtractFrom { + Authorization, + Query, + Body, + Header, +} + +#[derive(Debug, Deserialize)] +pub struct AdminConfig { + pub bind: SocketAddr, + #[serde(default = "default_admin_tls")] + pub tls: TlsMode, + #[serde(default)] + pub bearer_token_file: Option, + #[serde(default)] + pub bearer_token: Option, + #[serde(default = "default_min_token_len")] + pub min_token_len: usize, + #[serde(default = "default_lockout_threshold")] + pub lockout_threshold: u32, + #[serde(default = "default_lockout_duration", with = "duration")] + pub lockout_duration: Duration, +} + +const DEFAULT_MIN_TOKEN_LEN: usize = 32; +const DEFAULT_LOCKOUT_THRESHOLD: u32 = 5; +const DEFAULT_LOCKOUT_DURATION: Duration = Duration::from_secs(300); + +fn default_admin_tls() -> TlsMode { + TlsMode::Implicit +} +fn default_min_token_len() -> usize { + DEFAULT_MIN_TOKEN_LEN +} +fn default_lockout_threshold() -> u32 { + DEFAULT_LOCKOUT_THRESHOLD +} +fn default_lockout_duration() -> Duration { + DEFAULT_LOCKOUT_DURATION +} + +impl Config { + pub fn parse_and_validate(raw: &str) -> Result { + let config: Config = + toml::from_str(raw).map_err(|e| ProxyError::config(format!("TOML parse: {e}")))?; + config.validate()?; + Ok(config) + } + + fn destination(&self, id: &str) -> Result<&DestinationConfig> { + self.destination + .get(id) + .ok_or_else(|| ProxyError::config(format!("undeclared destination {id:?}"))) + } + + fn validate(&self) -> Result<()> { + let has_lmtp_listener = self.listener.values().any(|l| l.protocol == Protocol::Lmtp); + + let default_dest = self.destination(&self.routing.default_destination)?; + + for (id, listener) in &self.listener { + if listener.bind.is_empty() { + return Err(ProxyError::config(format!( + "listener {id:?} has no bind addresses" + ))); + } + if matches!( + listener.proxy_protocol, + ProxyProtocolMode::Optional | ProxyProtocolMode::Required + ) && listener.proxy_trusted.is_empty() + { + return Err(ProxyError::config(format!( + "listener {id:?} uses proxy_protocol but proxy_trusted is empty" + ))); + } + if listener.protocol.is_passthrough() { + if listener.tls == TlsMode::Starttls { + return Err(ProxyError::config(format!( + "listener {id:?} is a pass-through ({}) and cannot use tls=starttls (the proxy bridges raw and does not terminate STARTTLS); use plain or implicit", + listener.protocol.as_str() + ))); + } + continue; + } + if listener.protocol == Protocol::Http { + continue; + } + if !default_dest.protocol.contains_key(&listener.protocol) { + return Err(ProxyError::config(format!( + "default_destination {:?} does not declare protocol {} required by listener {id:?}", + self.routing.default_destination, + listener.protocol.as_str() + ))); + } + } + + if let Some(pt) = &self.routing.smtp_passthrough_destination { + let dest = self.destination(pt)?; + if !dest.protocol.contains_key(&Protocol::Smtp) { + return Err(ProxyError::config(format!( + "smtp_passthrough_destination {pt:?} must declare the smtp protocol" + ))); + } + if has_lmtp_listener && !dest.protocol.contains_key(&Protocol::Lmtp) { + return Err(ProxyError::config(format!( + "smtp_passthrough_destination {pt:?} must declare lmtp (an lmtp listener exists)" + ))); + } + } else if self.listener.values().any(|l| l.protocol.is_passthrough()) { + return Err(ProxyError::config( + "smtp/lmtp listener configured but routing.smtp_passthrough_destination is unset", + )); + } + + for (id, dest) in &self.destination { + self.validate_destination(id, dest)?; + } + + for route in &self.http.route { + globset::Glob::new(&route.match_glob).map_err(|e| { + ProxyError::config(format!( + "invalid http route glob {:?}: {e}", + route.match_glob + )) + })?; + if let Some(dest_id) = &route.destination { + let dest = self.destination(dest_id)?; + if !dest.protocol.contains_key(&Protocol::Http) { + return Err(ProxyError::config(format!( + "http route destination {dest_id:?} does not declare the http protocol" + ))); + } + } + if let Some(extract) = &route.extract { + if extract.from == ExtractFrom::Body && extract.regex.is_none() { + return Err(ProxyError::config( + "http extract from=body requires a regex", + )); + } + if extract.from == ExtractFrom::Query && extract.param.is_none() { + return Err(ProxyError::config( + "http extract from=query requires a param", + )); + } + if extract.from == ExtractFrom::Header && extract.header.is_none() { + return Err(ProxyError::config( + "http extract from=header requires a header name", + )); + } + if let Some(re) = &extract.regex { + regex::Regex::new(re).map_err(|e| { + ProxyError::config(format!("invalid http extract regex {re:?}: {e}")) + })?; + } + } + } + + match self.mapping.source { + MappingSource::File if self.mapping.file.is_none() => { + return Err(ProxyError::config( + "mapping.source=file requires [mapping.file]", + )); + } + MappingSource::Redis if self.mapping.redis.is_none() => { + return Err(ProxyError::config( + "mapping.source=redis requires [mapping.redis]", + )); + } + MappingSource::Sql if self.mapping.sql.is_none() => { + return Err(ProxyError::config( + "mapping.source=sql requires [mapping.sql]", + )); + } + _ => {} + } + + if let Some(admin) = &self.admin { + if admin.tls != TlsMode::Implicit { + return Err(ProxyError::config( + "admin.tls must be implicit (plaintext admin is rejected)", + )); + } + if admin.bearer_token_file.is_none() + && admin.bearer_token.is_none() + && std::env::var("PROXY_ADMIN_TOKEN").is_err() + { + return Err(ProxyError::config( + "admin requires bearer_token_file, bearer_token, or PROXY_ADMIN_TOKEN", + )); + } + } + + Ok(()) + } + + fn validate_destination(&self, id: &str, dest: &DestinationConfig) -> Result<()> { + let host_is_ip = dest.host_is_ip(); + for (proto, ep) in &dest.protocol { + if proto.is_credential_bearing() + && ep.tls == TlsMode::Plain + && !dest.allow_plaintext_auth + { + return Err(ProxyError::config(format!( + "destination {id:?} protocol {} uses plaintext TLS but carries credentials; set allow_plaintext_auth to override", + proto.as_str() + ))); + } + if host_is_ip && ep.tls != TlsMode::Plain && dest.tls_server_name.is_none() { + return Err(ProxyError::config(format!( + "destination {id:?} has an IP host and a TLS leg ({}) but no tls_server_name", + proto.as_str() + ))); + } + } + Ok(()) + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..d5b171e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,66 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum ProxyError { + #[error("configuration error: {0}")] + Config(Cow<'static, str>), + + #[error("i/o error: {0}")] + Io(#[from] std::io::Error), + + #[error("tls error: {0}")] + Tls(Cow<'static, str>), + + #[error("backend unavailable: {0}")] + BackendUnavailable(Cow<'static, str>), + + #[error("backend connection failed: {0}")] + BackendConnect(Cow<'static, str>), + + #[error("protocol error: {0}")] + Protocol(Cow<'static, str>), + + #[error("the pre-auth dialogue exceeded its limits: {0}")] + PreAuth(Cow<'static, str>), + + #[error("connection closed")] + Closed, +} + +impl ProxyError { + pub fn config(msg: impl Into>) -> Self { + ProxyError::Config(msg.into()) + } + + pub fn tls(msg: impl Into>) -> Self { + ProxyError::Tls(msg.into()) + } + + pub fn backend(msg: impl Into>) -> Self { + ProxyError::BackendUnavailable(msg.into()) + } + + pub fn backend_connect(msg: impl Into>) -> Self { + ProxyError::BackendConnect(msg.into()) + } + + pub fn is_retryable(&self) -> bool { + matches!(self, ProxyError::BackendConnect(_) | ProxyError::Io(_)) + } + + pub fn protocol(msg: impl Into>) -> Self { + ProxyError::Protocol(msg.into()) + } + + pub fn preauth(msg: impl Into>) -> Self { + ProxyError::PreAuth(msg.into()) + } +} diff --git a/src/http/head.rs b/src/http/head.rs new file mode 100644 index 0000000..b5a94ae --- /dev/null +++ b/src/http/head.rs @@ -0,0 +1,654 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +const MAX_HEADERS: usize = 100; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyFraming { + None, + Length(u64), + Chunked, + UntilClose, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConnFlags { + pub is_websocket_upgrade: bool, + pub close: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Header { + pub name: String, + pub value: String, +} + +const HOP_BY_HOP: &[&str] = &[ + "connection", + "keep-alive", + "transfer-encoding", + "te", + "upgrade", + "proxy-connection", + "trailer", + "proxy-authenticate", + "proxy-authorization", +]; + +fn is_hop_by_hop(name: &str) -> bool { + HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h)) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Head { + pub is_response: bool, + pub method: String, + pub target: String, + pub version: String, + pub status: u16, + pub reason: String, + pub headers: Vec
, + pub framing: BodyFraming, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ParseError { + BadRequest, + MethodNotAllowed, + TooLarge, + VersionNotSupported, +} + +impl ParseError { + pub fn status(self) -> u16 { + match self { + ParseError::BadRequest => 400, + ParseError::MethodNotAllowed => 405, + ParseError::TooLarge => 431, + ParseError::VersionNotSupported => 505, + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub enum ParseOutcome { + NeedMore, + Done { head: Head, consumed: usize }, +} + +fn find_head_end(buf: &[u8]) -> Option { + buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4) +} + +fn reject_bare_cr_lf(head: &[u8]) -> bool { + let mut i = 0; + while i < head.len() { + match head[i] { + b'\r' => { + if head.get(i + 1) != Some(&b'\n') { + return true; + } + i += 2; + } + b'\n' => return true, + _ => i += 1, + } + } + false +} + +fn split_lines(head: &[u8]) -> Vec<&[u8]> { + let mut lines = Vec::new(); + let mut start = 0; + let mut i = 0; + while i < head.len() { + if head[i] == b'\n' { + let mut end = i; + if end > start && head[end - 1] == b'\r' { + end -= 1; + } + lines.push(&head[start..end]); + start = i + 1; + } + i += 1; + } + lines +} + +fn header_value_has_forbidden(value: &str) -> bool { + value.bytes().any(|b| b == 0 || b == b'\r' || b == b'\n') +} + +fn parse_content_length(value: &str) -> std::result::Result { + value + .trim() + .parse::() + .map_err(|_| ParseError::BadRequest) +} + +fn transfer_encoding_is_chunked(value: &str) -> std::result::Result { + let codings: Vec<&str> = value + .split(',') + .map(|c| c.trim()) + .filter(|c| !c.is_empty()) + .collect(); + if codings.is_empty() { + return Ok(false); + } + let last = codings[codings.len() - 1]; + if last.eq_ignore_ascii_case("chunked") { + Ok(true) + } else { + Err(ParseError::BadRequest) + } +} + +fn status_has_no_body(status: u16) -> bool { + (100..200).contains(&status) || status == 204 || status == 205 || status == 304 +} + +impl Head { + pub fn parse( + buf: &[u8], + max_head_size: usize, + ) -> std::result::Result { + Self::parse_inner(buf, max_head_size, false) + } + + pub fn parse_response( + buf: &[u8], + max_head_size: usize, + ) -> std::result::Result { + Self::parse_inner(buf, max_head_size, true) + } + + fn parse_inner( + buf: &[u8], + max_head_size: usize, + is_response: bool, + ) -> std::result::Result { + let end = match find_head_end(buf) { + Some(e) => e, + None => { + if buf.len() > max_head_size { + return Err(ParseError::TooLarge); + } + return Ok(ParseOutcome::NeedMore); + } + }; + if end > max_head_size { + return Err(ParseError::TooLarge); + } + + let head_bytes = &buf[..end]; + if reject_bare_cr_lf(head_bytes) { + return Err(ParseError::BadRequest); + } + + let lines = split_lines(head_bytes); + if lines.is_empty() || lines[0].is_empty() { + return Err(ParseError::BadRequest); + } + + let start_line = std::str::from_utf8(lines[0]).map_err(|_| ParseError::BadRequest)?; + + let mut head = if is_response { + Self::parse_status_line(start_line)? + } else { + Self::parse_request_line(start_line)? + }; + + if lines.len() - 1 > MAX_HEADERS { + return Err(ParseError::TooLarge); + } + + let mut content_length: Option = None; + let mut has_te = false; + let mut te_chunked = false; + let mut host_count = 0u32; + + for raw in &lines[1..] { + if raw.is_empty() { + continue; + } + if raw[0] == b' ' || raw[0] == b'\t' { + return Err(ParseError::BadRequest); + } + let line = std::str::from_utf8(raw).map_err(|_| ParseError::BadRequest)?; + let (name, value) = match line.split_once(':') { + Some((n, v)) => (n, v), + None => return Err(ParseError::BadRequest), + }; + if name.is_empty() || name.bytes().any(|b| b == b' ' || b == b'\t') { + return Err(ParseError::BadRequest); + } + let value = value.trim(); + if header_value_has_forbidden(value) { + return Err(ParseError::BadRequest); + } + + if name.eq_ignore_ascii_case("content-length") { + let parsed = parse_content_length(value)?; + if let Some(existing) = content_length { + if existing != parsed { + return Err(ParseError::BadRequest); + } + } else { + content_length = Some(parsed); + } + } else if name.eq_ignore_ascii_case("transfer-encoding") { + has_te = true; + if transfer_encoding_is_chunked(value)? { + te_chunked = true; + } + } else if name.eq_ignore_ascii_case("host") { + host_count += 1; + } + + head.headers.push(Header { + name: name.to_string(), + value: value.to_string(), + }); + } + + if has_te && content_length.is_some() { + return Err(ParseError::BadRequest); + } + + if !is_response && host_count != 1 { + return Err(ParseError::BadRequest); + } + + head.framing = if is_response && status_has_no_body(head.status) { + BodyFraming::None + } else if has_te { + if !te_chunked { + return Err(ParseError::BadRequest); + } + BodyFraming::Chunked + } else if let Some(n) = content_length { + BodyFraming::Length(n) + } else if is_response { + BodyFraming::UntilClose + } else { + BodyFraming::None + }; + + Ok(ParseOutcome::Done { + head, + consumed: end, + }) + } + + fn parse_request_line(line: &str) -> std::result::Result { + let mut parts = line.split(' '); + let method = parts.next().ok_or(ParseError::BadRequest)?; + let target = parts.next().ok_or(ParseError::BadRequest)?; + let version = parts.next().ok_or(ParseError::BadRequest)?; + if parts.next().is_some() { + return Err(ParseError::BadRequest); + } + if method.is_empty() || target.is_empty() { + return Err(ParseError::BadRequest); + } + if target.bytes().any(|b| b < 0x20 || b == 0x7f) { + return Err(ParseError::BadRequest); + } + if version != "HTTP/1.1" { + return Err(ParseError::VersionNotSupported); + } + if method.eq_ignore_ascii_case("CONNECT") { + return Err(ParseError::MethodNotAllowed); + } + Ok(Head { + is_response: false, + method: method.to_string(), + target: target.to_string(), + version: version.to_string(), + status: 0, + reason: String::new(), + headers: Vec::new(), + framing: BodyFraming::None, + }) + } + + fn parse_status_line(line: &str) -> std::result::Result { + let mut parts = line.splitn(3, ' '); + let version = parts.next().ok_or(ParseError::BadRequest)?; + let code = parts.next().ok_or(ParseError::BadRequest)?; + let reason = parts.next().unwrap_or(""); + if !version.starts_with("HTTP/1.") { + return Err(ParseError::BadRequest); + } + let status = code.parse::().map_err(|_| ParseError::BadRequest)?; + if !(100..=599).contains(&status) { + return Err(ParseError::BadRequest); + } + Ok(Head { + is_response: true, + method: String::new(), + target: String::new(), + version: version.to_string(), + status, + reason: reason.to_string(), + headers: Vec::new(), + framing: BodyFraming::None, + }) + } + + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case(name)) + .map(|h| h.value.as_str()) + } + + pub fn set_header(&mut self, name: &str, value: &str) { + self.headers.retain(|h| !h.name.eq_ignore_ascii_case(name)); + self.headers.push(Header { + name: name.to_string(), + value: value.to_string(), + }); + } + + pub fn remove_header(&mut self, name: &str) { + self.headers.retain(|h| !h.name.eq_ignore_ascii_case(name)); + } + + pub fn path(&self) -> &str { + match self.target.split_once('?') { + Some((p, _)) => p, + None => self.target.as_str(), + } + } + + pub fn query(&self) -> Option<&str> { + self.target.split_once('?').map(|(_, q)| q) + } + + pub fn conn_flags(&self) -> ConnFlags { + let tokens = self.connection_tokens(); + let is_websocket_upgrade = self + .header("upgrade") + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")) + && tokens.iter().any(|t| t.eq_ignore_ascii_case("upgrade")); + let close = tokens.iter().any(|t| t.eq_ignore_ascii_case("close")); + ConnFlags { + is_websocket_upgrade, + close, + } + } + + pub fn is_sse(&self) -> bool { + self.header("content-type").is_some_and(|v| { + v.trim_start() + .to_ascii_lowercase() + .starts_with("text/event-stream") + }) + } + + pub fn expects_continue(&self) -> bool { + self.header("expect") + .is_some_and(|v| v.eq_ignore_ascii_case("100-continue")) + } + + fn connection_tokens(&self) -> Vec<&str> { + let mut tokens = Vec::new(); + for h in &self.headers { + if h.name.eq_ignore_ascii_case("connection") { + for token in h.value.split(',') { + let token = token.trim(); + if !token.is_empty() { + tokens.push(token); + } + } + } + } + tokens + } + + pub fn serialize(&self, force_close: bool, preserve_upgrade: bool) -> Vec { + let mut out = Vec::with_capacity(256); + if self.is_response { + out.extend_from_slice(b"HTTP/1.1 "); + out.extend_from_slice(self.status.to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(self.reason.as_bytes()); + out.extend_from_slice(b"\r\n"); + } else { + out.extend_from_slice(self.method.as_bytes()); + out.push(b' '); + out.extend_from_slice(self.target.as_bytes()); + out.extend_from_slice(b" HTTP/1.1\r\n"); + } + + let conn_tokens = self.connection_tokens(); + + for header in &self.headers { + if is_hop_by_hop(&header.name) + || conn_tokens + .iter() + .any(|t| header.name.eq_ignore_ascii_case(t)) + { + continue; + } + if header.name.eq_ignore_ascii_case("content-length") { + continue; + } + out.extend_from_slice(header.name.as_bytes()); + out.extend_from_slice(b": "); + out.extend_from_slice(header.value.as_bytes()); + out.extend_from_slice(b"\r\n"); + } + + match self.framing { + BodyFraming::Chunked => { + out.extend_from_slice(b"Transfer-Encoding: chunked\r\n"); + } + BodyFraming::Length(n) => { + out.extend_from_slice(b"Content-Length: "); + out.extend_from_slice(n.to_string().as_bytes()); + out.extend_from_slice(b"\r\n"); + } + BodyFraming::None | BodyFraming::UntilClose => {} + } + + if preserve_upgrade { + if let Some(v) = self.header("upgrade") { + out.extend_from_slice(b"Upgrade: "); + out.extend_from_slice(v.as_bytes()); + out.extend_from_slice(b"\r\n"); + } + out.extend_from_slice(b"Connection: Upgrade\r\n"); + } else if force_close { + out.extend_from_slice(b"Connection: close\r\n"); + } + + out.extend_from_slice(b"\r\n"); + out + } +} + +pub fn error_response(status: u16, reason: &str) -> Vec { + use std::fmt::Write; + let body = format!("{status} {reason}"); + let mut out = String::with_capacity(body.len() + 96); + let _ = write!( + out, + "HTTP/1.1 {status} {reason}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + out.into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn done(buf: &[u8]) -> Head { + match Head::parse(buf, 64 * 1024).unwrap() { + ParseOutcome::Done { head, .. } => head, + ParseOutcome::NeedMore => panic!("need more"), + } + } + + #[test] + fn parse_simple_get() { + let buf = b"GET /foo?x=1 HTTP/1.1\r\nHost: example.com\r\n\r\n"; + let head = done(buf); + assert!(!head.is_response); + assert_eq!(head.method, "GET"); + assert_eq!(head.target, "/foo?x=1"); + assert_eq!(head.path(), "/foo"); + assert_eq!(head.query(), Some("x=1")); + assert_eq!(head.header("host"), Some("example.com")); + assert_eq!(head.framing, BodyFraming::None); + } + + #[test] + fn need_more_until_crlfcrlf() { + let buf = b"GET / HTTP/1.1\r\nHost: x\r\n"; + assert!(matches!( + Head::parse(buf, 64 * 1024).unwrap(), + ParseOutcome::NeedMore + )); + } + + #[test] + fn reject_te_and_cl() { + let buf = b"POST / HTTP/1.1\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn reject_double_conflicting_cl() { + let buf = b"POST / HTTP/1.1\r\nContent-Length: 5\r\nContent-Length: 6\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn accept_duplicate_equal_cl() { + let buf = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nContent-Length: 5\r\n\r\n"; + let head = done(buf); + assert_eq!(head.framing, BodyFraming::Length(5)); + } + + #[test] + fn reject_te_not_chunked() { + let buf = b"POST / HTTP/1.1\r\nTransfer-Encoding: gzip\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn accept_te_gzip_then_chunked() { + let buf = b"POST / HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"; + let head = done(buf); + assert_eq!(head.framing, BodyFraming::Chunked); + } + + #[test] + fn reject_bare_lf() { + let buf = b"GET / HTTP/1.1\nHost: x\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn reject_nul_in_value() { + let buf = b"GET / HTTP/1.1\r\nHost: a\x00b\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn reject_obs_fold() { + let buf = b"GET / HTTP/1.1\r\nHost: x\r\n more\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn reject_connect() { + let buf = b"CONNECT example.com:443 HTTP/1.1\r\n\r\n"; + assert_eq!( + Head::parse(buf, 64 * 1024), + Err(ParseError::MethodNotAllowed) + ); + } + + #[test] + fn reject_missing_host() { + let buf = b"GET / HTTP/1.1\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn reject_duplicate_host() { + let buf = b"GET / HTTP/1.1\r\nHost: a\r\nHost: b\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn reject_control_in_target() { + let buf = b"GET /a\x01b HTTP/1.1\r\nHost: x\r\n\r\n"; + assert_eq!(Head::parse(buf, 64 * 1024), Err(ParseError::BadRequest)); + } + + #[test] + fn serialize_strips_hop_by_hop() { + let buf = b"GET / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive, X-Drop\r\nKeep-Alive: timeout=5\r\nX-Drop: secret\r\nX-Keep: yes\r\n\r\n"; + let head = done(buf); + let out = head.serialize(false, false); + let text = String::from_utf8(out).unwrap(); + assert!(!text.to_lowercase().contains("keep-alive")); + assert!(!text.contains("X-Drop")); + assert!(!text.to_lowercase().contains("connection:")); + assert!(text.contains("X-Keep: yes")); + assert!(text.contains("Host: x")); + } + + #[test] + fn serialize_single_framing_header() { + let buf = b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 3\r\n\r\n"; + let head = done(buf); + let text = String::from_utf8(head.serialize(false, false)).unwrap(); + assert_eq!(text.matches("Content-Length:").count(), 1); + assert!(text.contains("Content-Length: 3")); + } + + #[test] + fn serialize_force_close() { + let buf = b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"; + let head = done(buf); + let text = String::from_utf8(head.serialize(true, false)).unwrap(); + assert!(text.contains("Connection: close")); + } + + #[test] + fn response_until_close() { + let buf = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n"; + let head = match Head::parse_response(buf, 64 * 1024).unwrap() { + ParseOutcome::Done { head, .. } => head, + _ => panic!(), + }; + assert_eq!(head.framing, BodyFraming::UntilClose); + } + + #[test] + fn response_204_no_body() { + let buf = b"HTTP/1.1 204 No Content\r\n\r\n"; + let head = match Head::parse_response(buf, 64 * 1024).unwrap() { + ParseOutcome::Done { head, .. } => head, + _ => panic!(), + }; + assert_eq!(head.framing, BodyFraming::None); + } + + #[test] + fn websocket_detection() { + let buf = + b"GET /ws HTTP/1.1\r\nHost: x\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n"; + let head = done(buf); + assert!(head.conn_flags().is_websocket_upgrade); + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..6215e6a --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod head; +pub mod proxy; +pub mod router; diff --git a/src/http/proxy.rs b/src/http/proxy.rs new file mode 100644 index 0000000..7ae979e --- /dev/null +++ b/src/http/proxy.rs @@ -0,0 +1,947 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::time::timeout; + +use crate::config::{ForwardedMode, ListenerConfig, Protocol, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::net::BoxedStream; +use crate::proto::common::Ctx; + +use super::head::{BodyFraming, Head, ParseError, ParseOutcome, error_response}; +use super::router::{HttpRouter, RouteOutcome}; + +const READ_CHUNK: usize = 16 * 1024; + +#[derive(Clone, Copy)] +struct Idle { + body: Duration, + stream: Duration, +} + +struct Reader { + buf: Vec, + pos: usize, + eof: bool, +} + +impl Reader { + fn new() -> Self { + Reader { + buf: Vec::with_capacity(READ_CHUNK), + pos: 0, + eof: false, + } + } + + fn data(&self) -> &[u8] { + &self.buf[self.pos..] + } + + fn is_empty(&self) -> bool { + self.pos >= self.buf.len() + } + + fn consume(&mut self, n: usize) { + self.pos += n; + if self.pos >= self.buf.len() { + self.buf.clear(); + self.pos = 0; + } else if self.pos >= READ_CHUNK { + self.buf.drain(..self.pos); + self.pos = 0; + } + } + + fn take_data(&mut self) -> Vec { + let out = self.buf[self.pos..].to_vec(); + self.buf.clear(); + self.pos = 0; + out + } + + async fn fill(&mut self, stream: &mut BoxedStream, idle: Duration) -> Result { + let start = self.buf.len(); + self.buf.resize(start + READ_CHUNK, 0); + let n = match timeout(idle, stream.read(&mut self.buf[start..])).await { + Ok(r) => r.map_err(|e| ProxyError::protocol(format!("reading stream: {e}")))?, + Err(_) => { + self.buf.truncate(start); + return Err(ProxyError::protocol("read idle timeout")); + } + }; + self.buf.truncate(start + n); + if n == 0 { + self.eof = true; + } + Ok(n) + } +} + +async fn read_client_head( + stream: &mut BoxedStream, + reader: &mut Reader, + max_head: usize, + idle: Duration, +) -> std::result::Result, ParseError> { + loop { + match Head::parse(reader.data(), max_head)? { + ParseOutcome::Done { head, consumed } => { + reader.consume(consumed); + return Ok(Some(head)); + } + ParseOutcome::NeedMore => {} + } + if reader.eof { + if reader.is_empty() { + return Ok(None); + } + return Err(ParseError::BadRequest); + } + let n = reader + .fill(stream, idle) + .await + .map_err(|_| ParseError::BadRequest)?; + if n == 0 && reader.is_empty() { + return Ok(None); + } + } +} + +async fn read_backend_head( + stream: &mut BoxedStream, + reader: &mut Reader, + max_head: usize, + idle: Duration, +) -> Result { + loop { + match Head::parse_response(reader.data(), max_head) + .map_err(|_| ProxyError::backend("malformed backend response head"))? + { + ParseOutcome::Done { head, consumed } => { + reader.consume(consumed); + return Ok(head); + } + ParseOutcome::NeedMore => {} + } + let n = reader + .fill(stream, idle) + .await + .map_err(|_| ProxyError::backend("reading backend"))?; + if n == 0 { + return Err(ProxyError::backend("backend closed before response head")); + } + } +} + +async fn push_client(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> { + stream + .write_all(bytes) + .await + .map_err(|e| ProxyError::protocol(format!("writing client: {e}"))) +} + +async fn flush_client(stream: &mut BoxedStream) -> Result<()> { + stream + .flush() + .await + .map_err(|e| ProxyError::protocol(format!("flushing client: {e}"))) +} + +async fn push_backend(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> { + stream + .write_all(bytes) + .await + .map_err(|e| ProxyError::backend(format!("writing backend: {e}"))) +} + +async fn flush_backend(stream: &mut BoxedStream) -> Result<()> { + stream + .flush() + .await + .map_err(|e| ProxyError::backend(format!("flushing backend: {e}"))) +} + +async fn write_client(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> { + push_client(stream, bytes).await?; + flush_client(stream).await +} + +async fn transfer_length( + src: &mut BoxedStream, + reader: &mut Reader, + dst: &mut BoxedStream, + to_backend: bool, + mut remaining: u64, + idle: Duration, +) -> Result<()> { + while remaining > 0 { + if reader.is_empty() { + let n = reader.fill(src, idle).await?; + if n == 0 { + return Err(if to_backend { + ProxyError::protocol("client closed mid body") + } else { + ProxyError::backend("backend closed mid body") + }); + } + } + let take = { + let avail = reader.data(); + let take = (remaining as usize).min(avail.len()); + forward(dst, &avail[..take], to_backend).await?; + take + }; + reader.consume(take); + remaining -= take as u64; + } + Ok(()) +} + +async fn transfer_chunked( + src: &mut BoxedStream, + reader: &mut Reader, + dst: &mut BoxedStream, + to_backend: bool, + idle: Duration, +) -> Result<()> { + loop { + let line = read_chunk_line(src, reader, idle).await?; + let size_str = line.split(';').next().unwrap_or("").trim(); + let size = u64::from_str_radix(size_str, 16) + .map_err(|_| ProxyError::protocol("invalid chunk size"))?; + + if size == 0 { + let mut frame = format!("{size:x}\r\n").into_bytes(); + loop { + let trailer = read_chunk_line(src, reader, idle).await?; + frame.extend_from_slice(trailer.as_bytes()); + frame.extend_from_slice(b"\r\n"); + if trailer.is_empty() { + break; + } + } + forward(dst, &frame, to_backend).await?; + return Ok(()); + } + + let header = format!("{size:x}\r\n"); + let mut remaining = size; + let mut header_pending = true; + while remaining > 0 { + if reader.is_empty() { + let n = reader.fill(src, idle).await?; + if n == 0 { + return Err(ProxyError::protocol("closed mid chunk")); + } + } + let take = (remaining as usize).min(reader.data().len()); + let last = remaining - take as u64 == 0; + { + let avail = reader.data(); + if header_pending || last { + let mut frame = + Vec::with_capacity(header.len() * header_pending as usize + take + 2); + if header_pending { + frame.extend_from_slice(header.as_bytes()); + } + frame.extend_from_slice(&avail[..take]); + if last { + frame.extend_from_slice(b"\r\n"); + } + forward(dst, &frame, to_backend).await?; + } else { + forward(dst, &avail[..take], to_backend).await?; + } + } + header_pending = false; + reader.consume(take); + remaining -= take as u64; + } + + let crlf = read_chunk_line(src, reader, idle).await?; + if !crlf.is_empty() { + return Err(ProxyError::protocol("malformed chunk terminator")); + } + } +} + +async fn forward(dst: &mut BoxedStream, bytes: &[u8], to_backend: bool) -> Result<()> { + if to_backend { + push_backend(dst, bytes).await + } else { + push_client(dst, bytes).await + } +} + +async fn flush_side(dst: &mut BoxedStream, to_backend: bool) -> Result<()> { + if to_backend { + flush_backend(dst).await + } else { + flush_client(dst).await + } +} + +async fn read_chunk_line( + src: &mut BoxedStream, + reader: &mut Reader, + idle: Duration, +) -> Result { + loop { + let found = { + let data = reader.data(); + if let Some(pos) = data.iter().position(|&b| b == b'\n') { + let mut end = pos; + if end > 0 && data[end - 1] == b'\r' { + end -= 1; + } + let line = String::from_utf8(data[..end].to_vec()) + .map_err(|_| ProxyError::protocol("invalid chunk line"))?; + Some((line, pos + 1)) + } else { + None + } + }; + if let Some((line, consumed)) = found { + reader.consume(consumed); + return Ok(line); + } + if reader.data().len() > 16 * 1024 { + return Err(ProxyError::protocol("chunk line too long")); + } + let n = reader.fill(src, idle).await?; + if n == 0 { + return Err(ProxyError::protocol("closed reading chunk line")); + } + } +} + +async fn stream_until_close( + src: &mut BoxedStream, + reader: &mut Reader, + dst: &mut BoxedStream, + to_backend: bool, + idle: Duration, +) -> Result<()> { + if !reader.is_empty() { + let chunk = reader.take_data(); + forward(dst, &chunk, to_backend).await?; + flush_side(dst, to_backend).await?; + } + loop { + let n = reader.fill(src, idle).await?; + if n == 0 { + return Ok(()); + } + let chunk = reader.take_data(); + forward(dst, &chunk, to_backend).await?; + flush_side(dst, to_backend).await?; + } +} + +fn sanitize_inbound_forwarded(head: &mut Head, listener: &ListenerConfig) { + if listener.forwarded != ForwardedMode::Trust { + head.remove_header("forwarded"); + head.remove_header("x-forwarded-for"); + } +} + +fn apply_outbound_forwarded(head: &mut Head, dest_forwarded: bool, peer: IpAddr) { + if !dest_forwarded { + return; + } + let for_node = match peer { + IpAddr::V4(v4) => format!("for={v4}"), + IpAddr::V6(v6) => format!("for=\"[{v6}]\""), + }; + match head.header("forwarded").map(|v| v.to_string()) { + Some(existing) => head.set_header("forwarded", &format!("{existing}, {for_node}")), + None => head.set_header("forwarded", &for_node), + } + let xff = match head.header("x-forwarded-for").map(|v| v.to_string()) { + Some(existing) => format!("{existing}, {}", peer), + None => peer.to_string(), + }; + head.set_header("x-forwarded-for", &xff); +} + +fn response_no_body(req_method: &str, head: &Head) -> bool { + if req_method.eq_ignore_ascii_case("HEAD") { + return true; + } + matches!(head.framing, BodyFraming::None) +} + +enum Disposition { + Close, + Hijack { + backend: BoxedStream, + to_backend: Vec, + to_client: Vec, + }, +} + +async fn shutdown_client(stream: &mut BoxedStream) { + let _ = timeout(Duration::from_secs(3), stream.shutdown()).await; +} + +pub async fn handle( + ctx: &Ctx, + listener: &ListenerConfig, + mut stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + let bridge_idle = ctx.config.server.bridge_idle; + match serve(ctx, listener, &mut stream, peer, local).await { + Ok(Disposition::Close) => { + shutdown_client(&mut stream).await; + Ok(()) + } + Ok(Disposition::Hijack { + backend, + to_backend, + to_client, + }) => { + crate::outbound::bridge(stream, backend, &to_backend, &to_client, bridge_idle, None) + .await?; + Ok(()) + } + Err(e) => Err(e), + } +} + +async fn serve( + ctx: &Ctx, + listener: &ListenerConfig, + stream: &mut BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result { + let router = ctx.http_router.as_ref(); + let max_head = router.max_head_size; + let body_cap = router.body_extract_cap; + let bridge_idle = ctx.config.server.bridge_idle; + let idle = Idle { + body: router.relay_idle, + stream: bridge_idle, + }; + let keepalive_timeout = router.keepalive_timeout; + let max_requests = router.max_keepalive_requests; + + let mut reader = Reader::new(); + + let first_head = match timeout( + listener.preauth_timeout, + read_client_head(stream, &mut reader, max_head, listener.preauth_timeout), + ) + .await + { + Ok(Ok(Some(h))) => h, + Ok(Ok(None)) => return Ok(Disposition::Close), + Ok(Err(e)) => { + let _ = write_client(stream, &error_response(e.status(), reason(e.status()))).await; + return Ok(Disposition::Close); + } + Err(_) => { + let _ = write_client(stream, &error_response(408, "Request Timeout")).await; + return Ok(Disposition::Close); + } + }; + + let mut first_head = first_head; + sanitize_inbound_forwarded(&mut first_head, listener); + + let (buffered_body, dest_id) = match prepare( + ctx, + router, + stream, + &mut reader, + &mut first_head, + body_cap, + idle.body, + ) + .await? + { + Some(t) => t, + None => { + let _ = write_client(stream, &error_response(404, "Not Found")).await; + return Ok(Disposition::Close); + } + }; + + let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) { + Ok(t) => t, + Err(_) => { + let _ = write_client(stream, &error_response(502, "Bad Gateway")).await; + return Ok(Disposition::Close); + } + }; + + if ep.tls == TlsMode::Starttls { + let _ = write_client(stream, &error_response(502, "Bad Gateway")).await; + return Ok(Disposition::Close); + } + + let fwd = dest.forwarding_for(Protocol::Http); + if let Err(e) = crate::proto::forward::guard_loop( + &ctx.self_binds, + dest, + ep, + fwd, + ctx.config.server.proxy_ttl, + ) { + tracing::warn!(error = %e, destination = %dest_id, "http loop guard"); + let _ = write_client(stream, &error_response(502, "Bad Gateway")).await; + return Ok(Disposition::Close); + } + let mut backend = match crate::proto::common::establish(ctx, &dest_id, || { + crate::outbound::dial(dest, ep, fwd, &tls_cfg, peer, local) + }) + .await + { + Ok(b) => b, + Err(_) => { + let _ = write_client(stream, &error_response(502, "Bad Gateway")).await; + return Ok(Disposition::Close); + } + }; + + let mut opts = ExchangeOpts { + dest_forwarded: dest.forwarded, + peer, + max_head, + idle, + force_close: false, + }; + let mut backend_reader = Reader::new(); + + let mut current_head = first_head; + let mut current_body = buffered_body; + let mut served: u32 = 0; + + loop { + served += 1; + opts.force_close = max_requests != 0 && served >= max_requests; + + let outcome = exchange( + stream, + &mut reader, + &mut backend, + &mut backend_reader, + &mut current_head, + current_body.take(), + &opts, + ) + .await; + + match outcome { + Ok(ExchangeResult::KeepAlive) => {} + Ok(ExchangeResult::Hijack { + to_backend, + to_client, + }) => { + return Ok(Disposition::Hijack { + backend, + to_backend, + to_client, + }); + } + Ok(ExchangeResult::Close) => return Ok(Disposition::Close), + Err(e) => return Err(e), + } + + let next = match timeout( + keepalive_timeout, + read_client_head(stream, &mut reader, max_head, idle.body), + ) + .await + { + Ok(Ok(Some(h))) => h, + Ok(Ok(None)) => return Ok(Disposition::Close), + Ok(Err(e)) => { + let _ = write_client(stream, &error_response(e.status(), reason(e.status()))).await; + return Ok(Disposition::Close); + } + Err(_) => return Ok(Disposition::Close), + }; + + let mut next = next; + sanitize_inbound_forwarded(&mut next, listener); + + let (next_body, next_dest) = match prepare( + ctx, + router, + stream, + &mut reader, + &mut next, + body_cap, + idle.body, + ) + .await? + { + Some(t) => t, + None => { + let _ = write_client(stream, &error_response(404, "Not Found")).await; + return Ok(Disposition::Close); + } + }; + + if next_dest != dest_id { + if let Some(body) = &next_body { + drain_buffered(stream, &mut reader, &next, body.len(), idle.body) + .await + .ok(); + } + let _ = write_client(stream, &error_response(421, "Misdirected Request")).await; + return Ok(Disposition::Close); + } + + current_head = next; + current_body = next_body; + } +} + +enum ExchangeResult { + KeepAlive, + Hijack { + to_backend: Vec, + to_client: Vec, + }, + Close, +} + +#[derive(Clone, Copy)] +struct ExchangeOpts { + dest_forwarded: bool, + peer: SocketAddr, + max_head: usize, + idle: Idle, + force_close: bool, +} + +async fn exchange( + client: &mut BoxedStream, + client_reader: &mut Reader, + backend: &mut BoxedStream, + backend_reader: &mut Reader, + head: &mut Head, + buffered_body: Option>, + opts: &ExchangeOpts, +) -> Result { + let max_head = opts.max_head; + let idle = opts.idle; + + let flags = head.conn_flags(); + let is_ws = flags.is_websocket_upgrade; + let client_close = opts.force_close || flags.close; + + let expects_continue = head.expects_continue(); + let req_method = head.method.clone(); + + apply_outbound_forwarded(head, opts.dest_forwarded, opts.peer.ip()); + + let serialized = head.serialize(false, is_ws); + push_backend(backend, &serialized).await?; + + let buffered_len = buffered_body.as_ref().map(|b| b.len()); + if let Some(body) = &buffered_body { + push_backend(backend, body).await?; + } + + if is_ws { + flush_backend(backend).await?; + let resp = read_backend_head(backend, backend_reader, max_head, idle.body).await?; + if resp.status == 101 { + let resp_bytes = resp.serialize(false, true); + write_client(client, &resp_bytes).await?; + let to_backend = client_reader.take_data(); + let to_client = backend_reader.take_data(); + return Ok(ExchangeResult::Hijack { + to_backend, + to_client, + }); + } + return finish_response( + client, + backend, + backend_reader, + &req_method, + resp, + client_close, + idle, + ) + .await; + } + + if expects_continue { + flush_backend(backend).await?; + loop { + let interim = read_backend_head(backend, backend_reader, max_head, idle.body).await?; + if interim.status == 100 { + write_client(client, &interim.serialize(false, false)).await?; + stream_request_remainder(client, client_reader, backend, head, buffered_len, idle) + .await?; + flush_backend(backend).await?; + return relay_response( + client, + backend, + backend_reader, + &req_method, + max_head, + client_close, + idle, + ) + .await; + } + if (100..200).contains(&interim.status) { + write_client(client, &interim.serialize(false, false)).await?; + continue; + } + return finish_response( + client, + backend, + backend_reader, + &req_method, + interim, + true, + idle, + ) + .await; + } + } + + stream_request_remainder(client, client_reader, backend, head, buffered_len, idle).await?; + flush_backend(backend).await?; + + relay_response( + client, + backend, + backend_reader, + &req_method, + max_head, + client_close, + idle, + ) + .await +} + +async fn stream_request_remainder( + client: &mut BoxedStream, + client_reader: &mut Reader, + backend: &mut BoxedStream, + head: &Head, + already_forwarded: Option, + idle: Idle, +) -> Result<()> { + match head.framing { + BodyFraming::None | BodyFraming::UntilClose => Ok(()), + BodyFraming::Length(n) => { + let remaining = n.saturating_sub(already_forwarded.unwrap_or(0) as u64); + transfer_length(client, client_reader, backend, true, remaining, idle.body).await + } + BodyFraming::Chunked => { + transfer_chunked(client, client_reader, backend, true, idle.body).await + } + } +} + +async fn relay_response( + client: &mut BoxedStream, + backend: &mut BoxedStream, + backend_reader: &mut Reader, + req_method: &str, + max_head: usize, + client_close: bool, + idle: Idle, +) -> Result { + loop { + let resp = read_backend_head(backend, backend_reader, max_head, idle.body).await?; + if (100..200).contains(&resp.status) { + write_client(client, &resp.serialize(false, false)).await?; + continue; + } + return finish_response( + client, + backend, + backend_reader, + req_method, + resp, + client_close, + idle, + ) + .await; + } +} + +async fn finish_response( + client: &mut BoxedStream, + backend: &mut BoxedStream, + backend_reader: &mut Reader, + req_method: &str, + resp: Head, + force_close: bool, + idle: Idle, +) -> Result { + let force_close = force_close || resp.conn_flags().close; + let is_sse = resp.is_sse(); + let no_body = response_no_body(req_method, &resp); + let bytes = resp.serialize(force_close, false); + push_client(client, &bytes).await?; + + let done = || { + if force_close { + ExchangeResult::Close + } else { + ExchangeResult::KeepAlive + } + }; + + if no_body { + flush_client(client).await?; + return Ok(done()); + } + + if is_sse { + flush_client(client).await?; + stream_until_close(backend, backend_reader, client, false, idle.stream).await?; + return Ok(ExchangeResult::Close); + } + + match resp.framing { + BodyFraming::None => { + flush_client(client).await?; + Ok(done()) + } + BodyFraming::Length(n) => { + transfer_length(backend, backend_reader, client, false, n, idle.body).await?; + flush_client(client).await?; + Ok(done()) + } + BodyFraming::Chunked => { + transfer_chunked(backend, backend_reader, client, false, idle.body).await?; + flush_client(client).await?; + Ok(done()) + } + BodyFraming::UntilClose => { + flush_client(client).await?; + stream_until_close(backend, backend_reader, client, false, idle.stream).await?; + Ok(ExchangeResult::Close) + } + } +} + +async fn buffer_body( + client: &mut BoxedStream, + reader: &mut Reader, + head: &Head, + cap: usize, + idle: Duration, +) -> Result> { + let mut body = Vec::new(); + if let BodyFraming::Length(n) = head.framing { + let want = (n as usize).min(cap); + while body.len() < want { + if reader.is_empty() { + let got = reader.fill(client, idle).await?; + if got == 0 { + break; + } + } + let take = { + let avail = reader.data(); + let take = (want - body.len()).min(avail.len()); + body.extend_from_slice(&avail[..take]); + take + }; + reader.consume(take); + } + } + Ok(body) +} + +async fn drain_buffered( + client: &mut BoxedStream, + reader: &mut Reader, + head: &Head, + already: usize, + idle: Duration, +) -> Result<()> { + if let BodyFraming::Length(n) = head.framing { + let mut remaining = (n as usize).saturating_sub(already) as u64; + while remaining > 0 { + if reader.is_empty() { + let got = reader.fill(client, idle).await?; + if got == 0 { + break; + } + } + let take = (remaining as usize).min(reader.data().len()); + reader.consume(take); + remaining -= take as u64; + } + } + Ok(()) +} + +async fn prepare( + ctx: &Ctx, + router: &HttpRouter, + client: &mut BoxedStream, + reader: &mut Reader, + head: &mut Head, + body_cap: usize, + body_idle: Duration, +) -> Result>, String)>> { + let idx = match router.match_index(head) { + Some(i) => i, + None => return Ok(None), + }; + + let body = if router.needs_body_at(idx) { + if head.expects_continue() { + write_client(client, b"HTTP/1.1 100 Continue\r\n\r\n").await?; + head.remove_header("expect"); + } + Some(buffer_body(client, reader, head, body_cap, body_idle).await?) + } else { + None + }; + + let dest_id = match router.route_at(idx, head, body.as_deref()) { + RouteOutcome::Pin { dest_id } => dest_id, + RouteOutcome::Identifier { + identifier, + fallback, + } => { + if identifier.is_none() && fallback.is_none() { + ctx.resolve(None).await.to_string() + } else { + ctx.resolve(identifier.as_deref()).await.to_string() + } + } + }; + + Ok(Some((body, dest_id))) +} + +fn reason(status: u16) -> &'static str { + match status { + 400 => "Bad Request", + 404 => "Not Found", + 405 => "Method Not Allowed", + 408 => "Request Timeout", + 421 => "Misdirected Request", + 431 => "Request Header Fields Too Large", + 502 => "Bad Gateway", + 505 => "HTTP Version Not Supported", + _ => "Error", + } +} diff --git a/src/http/router.rs b/src/http/router.rs new file mode 100644 index 0000000..69d8e77 --- /dev/null +++ b/src/http/router.rs @@ -0,0 +1,439 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use globset::{Glob, GlobSet, GlobSetBuilder}; +use regex::Regex; + +use crate::config::{ExtractFrom, HttpConfig, HttpExtract, HttpRoute}; +use crate::error::{ProxyError, Result}; +use crate::http::head::Head; +use crate::token::bearer_identifier; + +pub enum RouteOutcome { + Pin { + dest_id: String, + }, + Identifier { + identifier: Option, + fallback: Option, + }, +} + +struct CompiledExtract { + from: ExtractFrom, + regex: Option, + param: Option, + header: Option, +} + +struct CompiledRoute { + destination: Option, + extract: Option, + fallback: Option, + needs_body: bool, +} + +pub struct HttpRouter { + set: GlobSet, + routes: Vec, + jwt_username_claim: String, + pub body_extract_cap: usize, + pub max_head_size: usize, + pub relay_idle: Duration, + pub keepalive_timeout: Duration, + pub max_keepalive_requests: u32, +} + +impl HttpRouter { + pub fn build(http: &HttpConfig, jwt_username_claim: String) -> Result { + let mut builder = GlobSetBuilder::new(); + let mut routes = Vec::with_capacity(http.route.len()); + for route in &http.route { + builder.add( + Glob::new(&route.match_glob) + .map_err(|e| ProxyError::config(format!("invalid http route glob: {e}")))?, + ); + routes.push(compile_route(route)?); + } + let set = builder + .build() + .map_err(|e| ProxyError::config(format!("invalid http route set: {e}")))?; + Ok(HttpRouter { + set, + routes, + jwt_username_claim, + body_extract_cap: http.body_extract_cap, + max_head_size: http.max_head_size, + relay_idle: http.relay_idle, + keepalive_timeout: http.keepalive_timeout, + max_keepalive_requests: http.max_keepalive_requests, + }) + } + + pub fn match_index(&self, head: &Head) -> Option { + let path = head.path(); + let raw = self.set.matches(path).into_iter().min(); + let trimmed = path.trim_end_matches('/'); + let normalized = if trimmed.len() != path.len() && !trimmed.is_empty() { + self.set.matches(trimmed).into_iter().min() + } else { + None + }; + match (raw, normalized) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + } + } + + pub fn needs_body_at(&self, idx: usize) -> bool { + self.routes[idx].needs_body + } + + pub fn route_at(&self, idx: usize, head: &Head, body: Option<&[u8]>) -> RouteOutcome { + let route = &self.routes[idx]; + if let Some(dest) = &route.destination { + return RouteOutcome::Pin { + dest_id: dest.clone(), + }; + } + let identifier = match &route.extract { + Some(extract) => self.extract(extract, head, body), + None => None, + }; + RouteOutcome::Identifier { + identifier, + fallback: route.fallback.clone(), + } + } + + fn extract( + &self, + extract: &CompiledExtract, + head: &Head, + body: Option<&[u8]>, + ) -> Option { + match extract.from { + ExtractFrom::Authorization => self.extract_authorization(head), + ExtractFrom::Query => { + let param = extract.param.as_deref()?; + let query = head.query()?; + query_param(query, param) + } + ExtractFrom::Body => { + let re = extract.regex.as_ref()?; + let body = body?; + let text = std::str::from_utf8(body).ok()?; + let caps = re.captures(text)?; + let raw = caps.get(1)?.as_str(); + Some(form_decode(raw)) + } + ExtractFrom::Header => { + let name = extract.header.as_deref()?; + head.header(name).map(|v| v.to_string()) + } + } + } + + fn extract_authorization(&self, head: &Head) -> Option { + let value = head.header("authorization")?; + let value = value.trim(); + if let Some(rest) = strip_scheme(value, "basic") { + let decoded = BASE64.decode(rest.trim()).ok()?; + let text = String::from_utf8(decoded).ok()?; + let login = match text.split_once(':') { + Some((login, _)) => login, + None => text.as_str(), + }; + return Some(login.trim().to_lowercase()); + } + if let Some(rest) = strip_scheme(value, "bearer") { + return bearer_identifier(rest.trim(), &self.jwt_username_claim); + } + None + } +} + +fn strip_scheme<'a>(value: &'a str, scheme: &str) -> Option<&'a str> { + if value.len() < scheme.len() + 1 { + return None; + } + let (head, rest) = value.split_at(scheme.len()); + if head.eq_ignore_ascii_case(scheme) && rest.starts_with(' ') { + Some(&rest[1..]) + } else { + None + } +} + +fn compile_route(route: &HttpRoute) -> Result { + let extract = match &route.extract { + Some(e) => Some(compile_extract(e)?), + None => None, + }; + let needs_body = matches!( + &route.extract, + Some(HttpExtract { + from: ExtractFrom::Body, + .. + }) + ); + Ok(CompiledRoute { + destination: route.destination.clone(), + extract, + fallback: route.fallback.clone(), + needs_body, + }) +} + +fn compile_extract(extract: &HttpExtract) -> Result { + let regex = match &extract.regex { + Some(re) => Some( + Regex::new(re) + .map_err(|e| ProxyError::config(format!("invalid http extract regex: {e}")))?, + ), + None => None, + }; + Ok(CompiledExtract { + from: extract.from, + regex, + param: extract.param.clone(), + header: extract.header.clone(), + }) +} + +fn query_param(query: &str, param: &str) -> Option { + for pair in query.split('&') { + let (key, value) = match pair.split_once('=') { + Some((k, v)) => (k, v), + None => (pair, ""), + }; + if form_decode(key) == param { + return Some(form_decode(value)); + } + } + None +} + +fn form_decode(input: &str) -> String { + let replaced = input.replace('+', " "); + percent_encoding::percent_decode_str(&replaced) + .decode_utf8_lossy() + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::HttpConfig; + use crate::http::head::{Head, ParseOutcome}; + + fn parse(buf: &[u8]) -> Head { + match Head::parse(buf, 64 * 1024).unwrap() { + ParseOutcome::Done { head, .. } => head, + _ => panic!("need more"), + } + } + + fn route(router: &HttpRouter, head: &Head, body: Option<&[u8]>) -> RouteOutcome { + let idx = router.match_index(head).expect("route should match"); + router.route_at(idx, head, body) + } + + fn router_from_toml(toml: &str) -> HttpRouter { + let http: HttpConfig = toml::from_str(toml).unwrap(); + HttpRouter::build(&http, "email".to_string()).unwrap() + } + + #[test] + fn glob_first_match_wins() { + let router = router_from_toml( + r#" +[[route]] +match = "/.well-known/**" +destination = "legacy" +[[route]] +match = "/**" +destination = "other" +"#, + ); + let head = parse(b"GET /.well-known/jmap HTTP/1.1\r\nHost: x\r\n\r\n"); + match route(&router, &head, None) { + RouteOutcome::Pin { dest_id } => assert_eq!(dest_id, "legacy"), + _ => panic!("expected pin"), + } + let head = parse(b"GET /other/path HTTP/1.1\r\nHost: x\r\n\r\n"); + match route(&router, &head, None) { + RouteOutcome::Pin { dest_id } => assert_eq!(dest_id, "other"), + _ => panic!("expected pin"), + } + } + + #[test] + fn trailing_slash_matches_specific_route_over_catchall() { + let router = router_from_toml( + r#" +[[route]] +match = "/api" +destination = "legacy" +[[route]] +match = "/**" +destination = "other" +"#, + ); + let head = parse(b"GET /api/ HTTP/1.1\r\nHost: x\r\n\r\n"); + match route(&router, &head, None) { + RouteOutcome::Pin { dest_id } => assert_eq!(dest_id, "legacy"), + _ => panic!("expected pin"), + } + assert_eq!(head.path(), "/api/"); + } + + #[test] + fn root_path_not_emptied_by_trim() { + let router = router_from_toml( + r#" +[[route]] +match = "/" +destination = "legacy" +"#, + ); + let head = parse(b"GET / HTTP/1.1\r\nHost: x\r\n\r\n"); + match route(&router, &head, None) { + RouteOutcome::Pin { dest_id } => assert_eq!(dest_id, "legacy"), + _ => panic!("expected pin"), + } + } + + #[test] + fn static_pin_vs_identifier() { + let router = router_from_toml( + r#" +[[route]] +match = "/static" +destination = "legacy" +[[route]] +match = "/**" +extract = { from = "authorization" } +fallback = "default" +"#, + ); + let head = parse(b"GET /static HTTP/1.1\r\nHost: x\r\n\r\n"); + assert!(matches!( + route(&router, &head, None), + RouteOutcome::Pin { .. } + )); + let head = parse(b"GET /dyn HTTP/1.1\r\nHost: x\r\n\r\n"); + match route(&router, &head, None) { + RouteOutcome::Identifier { fallback, .. } => { + assert_eq!(fallback.as_deref(), Some("default")); + } + _ => panic!("expected identifier"), + } + } + + #[test] + fn basic_auth_lowercased() { + let router = router_from_toml( + r#" +[[route]] +match = "/**" +extract = { from = "authorization" } +"#, + ); + let creds = BASE64.encode("Alice@Example.COM:secret"); + let req = format!("GET / HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\n\r\n"); + let head = parse(req.as_bytes()); + match route(&router, &head, None) { + RouteOutcome::Identifier { identifier, .. } => { + assert_eq!(identifier.as_deref(), Some("alice@example.com")); + } + _ => panic!("expected identifier"), + } + } + + #[test] + fn bearer_uses_bearer_identifier() { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let router = router_from_toml( + r#" +[[route]] +match = "/**" +extract = { from = "authorization" } +"#, + ); + let claims = URL_SAFE_NO_PAD.encode(r#"{"email":"bob@example.com"}"#); + let token = format!("hdr.{claims}.sig"); + let req = format!("GET / HTTP/1.1\r\nHost: x\r\nAuthorization: Bearer {token}\r\n\r\n"); + let head = parse(req.as_bytes()); + match route(&router, &head, None) { + RouteOutcome::Identifier { identifier, .. } => { + assert_eq!(identifier.as_deref(), Some("bob@example.com")); + } + _ => panic!("expected identifier"), + } + } + + #[test] + fn query_param_url_decoded() { + let router = router_from_toml( + r#" +[[route]] +match = "/oauth/**" +extract = { from = "query", param = "login_hint" } +"#, + ); + let head = parse( + b"GET /oauth/authorize?login_hint=user%40example.com&x=1 HTTP/1.1\r\nHost: x\r\n\r\n", + ); + match route(&router, &head, None) { + RouteOutcome::Identifier { identifier, .. } => { + assert_eq!(identifier.as_deref(), Some("user@example.com")); + } + _ => panic!("expected identifier"), + } + } + + #[test] + fn body_regex_capture() { + let router = router_from_toml( + r#" +[[route]] +match = "/auth/token" +extract = { from = "body", regex = "username=([^&]+)" } +"#, + ); + let head = parse(b"POST /auth/token HTTP/1.1\r\nHost: x\r\nContent-Length: 30\r\n\r\n"); + assert!(router.needs_body_at(router.match_index(&head).unwrap())); + let body = b"grant=x&username=joe%40corp.com"; + match route(&router, &head, Some(body)) { + RouteOutcome::Identifier { identifier, .. } => { + assert_eq!(identifier.as_deref(), Some("joe@corp.com")); + } + _ => panic!("expected identifier"), + } + } + + #[test] + fn header_extraction() { + let router = router_from_toml( + r#" +[[route]] +match = "/**" +extract = { from = "header", header = "X-User" } +"#, + ); + let head = parse(b"GET / HTTP/1.1\r\nHost: x\r\nX-User: someone\r\n\r\n"); + match route(&router, &head, None) { + RouteOutcome::Identifier { identifier, .. } => { + assert_eq!(identifier.as_deref(), Some("someone")); + } + _ => panic!("expected identifier"), + } + } +} diff --git a/src/imap_receiver.rs b/src/imap_receiver.rs new file mode 100644 index 0000000..7928f9c --- /dev/null +++ b/src/imap_receiver.rs @@ -0,0 +1,657 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::fmt::Display; + +#[derive(Debug, Clone)] +pub enum Error { + NeedsMoreData, + NeedsLiteral { size: u32 }, + Parse { message: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Request { + pub tag: String, + pub command: T, + pub tokens: Vec, +} + +pub trait CommandParser: Sized + Default { + fn parse(bytes: &[u8], is_uid: bool) -> Option; + fn tokenize_brackets(&self) -> bool; +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Token { + Argument(Vec), + ParenthesisOpen, + ParenthesisClose, + BracketOpen, + BracketClose, + Lt, + Gt, + Dot, + Nil, +} + +impl Default for Request { + fn default() -> Self { + Self { + tag: String::new(), + command: T::default(), + tokens: Vec::new(), + } + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum State { + Start, + Tag, + Command { is_uid: bool }, + Argument { last_ch: u8 }, + ArgumentQuoted { escaped: bool }, + Literal { non_sync: bool }, + LiteralSeek { size: u32, non_sync: bool }, + LiteralData { remaining: u32 }, +} + +pub struct Receiver { + buf: ArgumentBuffer, + pub request: Request, + pub state: State, + pub max_request_size: usize, + pub current_request_size: usize, + pub start_state: State, +} + +const ARG_MAX_LEN: usize = 8000; + +struct ArgumentBuffer { + buf: Vec, +} + +impl Receiver { + pub fn new() -> Self { + Receiver { + max_request_size: 25 * 1024 * 1024, + ..Default::default() + } + } + + pub fn with_start_state(mut self, state: State) -> Self { + self.state = state; + self.start_state = state; + self + } + + pub fn error_reset(&mut self, message: impl Into) -> Error { + let _request = std::mem::take(&mut self.request); + let err = Error::Parse { + message: message.into(), + }; + self.buf = ArgumentBuffer::default(); + self.state = self.start_state; + self.current_request_size = 0; + err + } + + fn push_argument(&mut self, in_quote: bool) -> Result<(), Error> { + if !self.buf.is_empty() { + self.current_request_size += self.buf.len(); + if self.current_request_size > self.max_request_size { + return Err(self.error_reset(format!( + "Request exceeds maximum limit of {} bytes.", + self.max_request_size + ))); + } + self.request.tokens.push(Token::Argument(self.buf.take())); + } else if in_quote { + self.request.tokens.push(Token::Nil); + } + Ok(()) + } + + fn push_token(&mut self, token: Token) -> Result<(), Error> { + self.current_request_size += 1; + if self.current_request_size > self.max_request_size { + return Err(self.error_reset(format!( + "Request exceeds maximum limit of {} bytes.", + self.max_request_size + ))); + } + self.request.tokens.push(token); + Ok(()) + } + + pub fn parse(&mut self, bytes: &mut std::slice::Iter<'_, u8>) -> Result, Error> { + for &ch in bytes.by_ref() { + match self.state { + State::Start => { + if !ch.is_ascii_whitespace() { + self.buf.push_unchecked(ch); + self.state = State::Tag; + } + } + State::Tag => match ch { + b' ' => { + if !self.buf.is_empty() { + self.request.tag = + String::from_utf8(self.buf.take()).map_err(|_| { + self.error_reset("Tag is not a valid UTF-8 string.") + })?; + self.state = State::Command { is_uid: false }; + } + } + b'\t' | b'\r' => {} + b'\n' => { + return Err(self.error_reset(format!( + "Missing command after tag {:?}, found CRLF instead.", + self.buf.as_str() + ))); + } + _ => { + self.buf.push_checked(ch, 128).map_err(|_| { + self.error_reset("Tag exceeds maximum length of 128 characters.") + })?; + } + }, + State::Command { is_uid } => { + if ch.is_ascii_alphanumeric() { + self.buf + .push_checked(ch.to_ascii_uppercase(), 15) + .map_err(|_| { + self.error_reset("Command exceeds maximum length of 15 characters.") + })?; + } else if ch.is_ascii_whitespace() { + if !self.buf.is_empty() { + if !self.buf.as_ref().eq_ignore_ascii_case(b"UID") { + self.request.command = T::parse(self.buf.as_ref(), is_uid) + .ok_or_else(|| { + let err = format!( + "Unrecognized command '{}'.", + String::from_utf8_lossy(self.buf.as_ref()) + ); + self.error_reset(err) + })?; + self.buf.clear(); + if ch != b'\n' { + self.state = State::Argument { last_ch: b' ' }; + } else { + self.state = self.start_state; + self.current_request_size = 0; + return Ok(std::mem::take(&mut self.request)); + } + } else { + self.buf.clear(); + self.state = State::Command { is_uid: true }; + } + } + } else { + return Err(self.error_reset(format!( + "Invalid character {:?} in command name.", + ch as char + ))); + } + } + State::Argument { last_ch } => match ch { + b'\"' if last_ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::ArgumentQuoted { escaped: false }; + } + b'{' if last_ch.is_ascii_whitespace() + || (last_ch == b'~' && self.buf.len() == 1) => + { + if last_ch != b'~' { + self.push_argument(false)?; + } else { + self.buf.clear(); + } + self.state = State::Literal { non_sync: false }; + } + b'(' => { + self.push_argument(false)?; + self.push_token(Token::ParenthesisOpen)?; + } + b')' => { + self.push_argument(false)?; + self.push_token(Token::ParenthesisClose)?; + } + b'[' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::BracketOpen)?; + } + b']' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::BracketClose)?; + } + b'<' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Lt)?; + } + b'>' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Gt)?; + } + b'.' if self.request.command.tokenize_brackets() => { + self.push_argument(false)?; + self.push_token(Token::Dot)?; + } + b'\n' => { + self.push_argument(false)?; + self.state = self.start_state; + self.current_request_size = 0; + return Ok(std::mem::take(&mut self.request)); + } + _ if ch.is_ascii_whitespace() => { + self.push_argument(false)?; + self.state = State::Argument { last_ch: ch }; + } + _ => { + self.buf.push_checked(ch, ARG_MAX_LEN).map_err(|_| { + self.error_reset("Argument exceeds maximum length of 8000 bytes.") + })?; + self.state = State::Argument { last_ch: ch }; + } + }, + State::ArgumentQuoted { escaped } => match ch { + b'\"' => { + if !escaped { + self.push_argument(true)?; + self.state = State::Argument { last_ch: b' ' }; + } else { + self.buf + .push_checked(ch, ARG_MAX_LEN) + .map_err(|_| self.error_reset("Quoted argument too long."))?; + self.state = State::ArgumentQuoted { escaped: false }; + } + } + b'\\' => { + if escaped { + self.buf + .push_checked(ch, ARG_MAX_LEN) + .map_err(|_| self.error_reset("Quoted argument too long."))?; + } + self.state = State::ArgumentQuoted { escaped: !escaped }; + } + b'\n' => { + return Err(self.error_reset("Unterminated quoted argument.")); + } + _ => { + if escaped { + self.buf.push_unchecked(b'\\'); + } + self.buf + .push_checked(ch, ARG_MAX_LEN) + .map_err(|_| self.error_reset("Quoted argument too long."))?; + self.state = State::ArgumentQuoted { escaped: false }; + } + }, + State::Literal { non_sync } => match ch { + b'}' => { + if !self.buf.is_empty() { + let size = self.buf.as_str().parse::().map_err(|_| { + self.error_reset("Literal size is not a valid number.") + })?; + if self.current_request_size + size as usize > self.max_request_size { + return Err(self.error_reset(format!( + "Literal exceeds the maximum request size of {} bytes.", + self.max_request_size + ))); + } + self.state = State::LiteralSeek { size, non_sync }; + self.buf.resize_buffer(size as usize); + self.buf.clear(); + } else { + return Err(self.error_reset("Invalid empty literal.")); + } + } + b'+' => { + if !self.buf.is_empty() { + self.state = State::Literal { non_sync: true }; + } else { + return Err(self.error_reset("Invalid non-sync literal.")); + } + } + _ if ch.is_ascii_digit() => { + if !non_sync { + self.buf.push_checked(ch, 15).map_err(|_| { + self.error_reset("Literal size exceeds maximum of 15 digits.") + })?; + } else { + return Err(self.error_reset("Invalid literal.")); + } + } + _ => { + return Err(self.error_reset(format!( + "Invalid character {:?} in literal.", + ch as char + ))); + } + }, + State::LiteralSeek { size, non_sync } => { + if ch == b'\n' { + if size > 0 { + self.state = State::LiteralData { remaining: size }; + } else { + self.state = State::Argument { last_ch: b' ' }; + self.push_token(Token::Nil)?; + } + if !non_sync { + return Err(Error::NeedsLiteral { size }); + } + } else if !ch.is_ascii_whitespace() { + return Err( + self.error_reset("Expected CRLF after literal, found an invalid char.") + ); + } + } + State::LiteralData { remaining } => { + self.buf.push_unchecked(ch); + + if remaining > 1 { + self.state = State::LiteralData { + remaining: remaining - 1, + }; + } else { + self.push_argument(false)?; + self.state = State::Argument { last_ch: b' ' }; + } + } + } + } + + Err(Error::NeedsMoreData) + } +} + +impl ArgumentBuffer { + pub fn new() -> Self { + ArgumentBuffer { + buf: Vec::with_capacity(10), + } + } + + pub fn resize_buffer(&mut self, size: usize) { + if self.buf.capacity() < size { + self.buf.reserve(size - self.buf.capacity()); + } + } + + #[inline(always)] + pub fn push_checked(&mut self, byte: u8, limit: usize) -> Result<(), ()> { + if self.buf.len() < limit { + self.buf.push(byte); + Ok(()) + } else { + Err(()) + } + } + + #[inline(always)] + pub fn push_unchecked(&mut self, byte: u8) { + self.buf.push(byte); + } + + pub fn take(&mut self) -> Vec { + std::mem::take(&mut self.buf) + } + + #[inline(always)] + pub fn len(&self) -> usize { + self.buf.len() + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.buf.is_empty() + } + + #[inline(always)] + pub fn clear(&mut self) { + self.buf.clear(); + } + + #[inline(always)] + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.buf).unwrap_or_default() + } +} + +impl AsRef<[u8]> for ArgumentBuffer { + fn as_ref(&self) -> &[u8] { + &self.buf + } +} + +impl Default for ArgumentBuffer { + fn default() -> Self { + Self::new() + } +} + +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str(&String::from_utf8_lossy(self.as_bytes())) + } +} + +impl Token { + pub fn as_bytes(&self) -> &[u8] { + match self { + Token::Argument(value) => value, + Token::ParenthesisOpen => b"(", + Token::ParenthesisClose => b")", + Token::BracketOpen => b"[", + Token::BracketClose => b"]", + Token::Gt => b">", + Token::Lt => b"<", + Token::Dot => b".", + Token::Nil => b"", + } + } +} + +impl Default for Receiver { + fn default() -> Self { + Self { + buf: Default::default(), + request: Default::default(), + state: State::Start, + start_state: State::Start, + max_request_size: 25 * 1024 * 1024, + current_request_size: 0, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Command { + #[default] + Other, + Capability, + Noop, + Logout, + Id, + StartTls, + Authenticate, + Login, + Enable, + Unauthenticate, +} + +impl CommandParser for Command { + fn parse(bytes: &[u8], _is_uid: bool) -> Option { + Some(match bytes { + b if b.eq_ignore_ascii_case(b"CAPABILITY") => Command::Capability, + b if b.eq_ignore_ascii_case(b"NOOP") => Command::Noop, + b if b.eq_ignore_ascii_case(b"LOGOUT") => Command::Logout, + b if b.eq_ignore_ascii_case(b"ID") => Command::Id, + b if b.eq_ignore_ascii_case(b"STARTTLS") => Command::StartTls, + b if b.eq_ignore_ascii_case(b"AUTHENTICATE") => Command::Authenticate, + b if b.eq_ignore_ascii_case(b"LOGIN") => Command::Login, + b if b.eq_ignore_ascii_case(b"ENABLE") => Command::Enable, + b if b.eq_ignore_ascii_case(b"UNAUTHENTICATE") => Command::Unauthenticate, + _ => Command::Other, + }) + } + + fn tokenize_brackets(&self) -> bool { + false + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Mechanism { + Plain, + OAuthBearer, + XOauth2, + Login, + Other(String), +} + +impl Mechanism { + pub fn parse(bytes: &[u8]) -> Mechanism { + if bytes.eq_ignore_ascii_case(b"PLAIN") { + Mechanism::Plain + } else if bytes.eq_ignore_ascii_case(b"OAUTHBEARER") { + Mechanism::OAuthBearer + } else if bytes.eq_ignore_ascii_case(b"XOAUTH2") { + Mechanism::XOauth2 + } else if bytes.eq_ignore_ascii_case(b"LOGIN") { + Mechanism::Login + } else { + Mechanism::Other(String::from_utf8_lossy(bytes).to_ascii_uppercase()) + } + } +} + +#[cfg(test)] +mod tests { + use super::{Command, Error, Receiver, Request, Token}; + + #[test] + fn receiver_parse_ok() { + let mut receiver = Receiver::new(); + + for (frames, expected_requests) in [ + ( + vec!["abcd CAPABILITY\r\n"], + vec![Request { + tag: "abcd".into(), + command: Command::Capability, + tokens: vec![], + }], + ), + ( + vec!["A023 LO", "GOUT\r\n"], + vec![Request { + tag: "A023".into(), + command: Command::Logout, + tokens: vec![], + }], + ), + ( + vec![" A001 AUTHENTICATE GSSAPI \r\n"], + vec![Request { + tag: "A001".into(), + command: Command::Authenticate, + tokens: vec![Token::Argument(b"GSSAPI".to_vec())], + }], + ), + ( + vec!["A03 AUTHENTICATE ", "PLAIN dGVzdAB0ZXN", "0AHRlc3Q=\r\n"], + vec![Request { + tag: "A03".into(), + command: Command::Authenticate, + tokens: vec![ + Token::Argument(b"PLAIN".to_vec()), + Token::Argument(b"dGVzdAB0ZXN0AHRlc3Q=".to_vec()), + ], + }], + ), + ( + vec!["A001 LOGIN {11}\r\n", "FRED FOOBAR {7}\r\n", "fat man\r\n"], + vec![Request { + tag: "A001".into(), + command: Command::Login, + tokens: vec![ + Token::Argument(b"FRED FOOBAR".to_vec()), + Token::Argument(b"fat man".to_vec()), + ], + }], + ), + ( + vec!["abc LOGIN {0}\r\n", "\r\n"], + vec![Request { + tag: "abc".into(), + command: Command::Login, + tokens: vec![Token::Nil], + }], + ), + ( + vec!["abc LOGIN {0+}\r\n\r\n"], + vec![Request { + tag: "abc".into(), + command: Command::Login, + tokens: vec![Token::Nil], + }], + ), + ( + vec!["001 NOOP\r\n002 CAPABILITY\r\nabc LOGIN hello world\r\n"], + vec![ + Request { + tag: "001".into(), + command: Command::Noop, + tokens: vec![], + }, + Request { + tag: "002".into(), + command: Command::Capability, + tokens: vec![], + }, + Request { + tag: "abc".into(), + command: Command::Login, + tokens: vec![ + Token::Argument(b"hello".to_vec()), + Token::Argument(b"world".to_vec()), + ], + }, + ], + ), + ] { + let mut requests = Vec::new(); + for frame in &frames { + let mut bytes = frame.as_bytes().iter(); + loop { + match receiver.parse(&mut bytes) { + Ok(request) => requests.push(request), + Err(Error::NeedsMoreData | Error::NeedsLiteral { .. }) => break, + Err(err) => panic!("{:?} for frames {:#?}", err, frames), + } + } + } + assert_eq!(requests, expected_requests, "{:#?}", frames); + } + } + + #[test] + fn receiver_parse_invalid() { + let mut receiver = Receiver::::new(); + for invalid in [ + "a001 login {abc}\r\n", + "a001 login {+30}\r\n", + "a001 login {30} junk\r\n", + ] { + match receiver.parse(&mut invalid.as_bytes().iter()) { + Err(Error::Parse { .. }) => {} + result => panic!("Expecter error, got: {:?}", result), + } + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f8a6e1e --- /dev/null +++ b/src/main.rs @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +mod admin; +mod config; +mod error; +mod http; +mod imap_receiver; +mod net; +mod observe; +mod outbound; +mod proto; +mod route; +mod sasl; +mod server; +mod token; + +#[cfg(test)] +mod tests; + +pub use error::{ProxyError, Result}; + +use std::sync::Arc; + +fn main() -> std::process::ExitCode { + let config_path = std::env::args() + .nth(1) + .or_else(|| std::env::var("PROXY_CONFIG").ok()); + let config_path = match config_path { + Some(p) => p, + None => { + eprintln!("usage: proxy (or set PROXY_CONFIG)"); + return std::process::ExitCode::from(2); + } + }; + + let raw = match std::fs::read_to_string(&config_path) { + Ok(s) => s, + Err(e) => { + eprintln!("failed to read config {config_path}: {e}"); + return std::process::ExitCode::FAILURE; + } + }; + + let config = match config::Config::parse_and_validate(&raw) { + Ok(c) => Arc::new(c), + Err(e) => { + eprintln!("invalid configuration: {e}"); + return std::process::ExitCode::FAILURE; + } + }; + + observe::init(&config.server.log_level); + + let threads = if config.server.threads == 0 { + num_cpus::get() + } else { + config.server.threads + }; + + let runtime = match tokio::runtime::Builder::new_multi_thread() + .worker_threads(threads) + .enable_all() + .build() + { + Ok(rt) => rt, + Err(e) => { + eprintln!("failed to build tokio runtime: {e}"); + return std::process::ExitCode::FAILURE; + } + }; + + match runtime.block_on(server::run(config, Arc::from(config_path))) { + Ok(()) => std::process::ExitCode::SUCCESS, + Err(e) => { + tracing::error!(error = %e, "fatal error"); + std::process::ExitCode::FAILURE + } + } +} diff --git a/src/net/acceptor.rs b/src/net/acceptor.rs new file mode 100644 index 0000000..51c4359 --- /dev/null +++ b/src/net/acceptor.rs @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use arc_swap::ArcSwap; +use tokio::net::TcpListener; +use tokio::sync::Semaphore; +use tracing::Instrument; + +use crate::config::{ListenerConfig, TlsMode}; +use crate::net::proxy_protocol::resolve_inbound; +use crate::proto::common::Ctx; +use crate::proto::{self}; + +struct ConnGuard { + ctx: Arc, +} + +impl ConnGuard { + fn new(ctx: &Arc) -> Self { + ctx.metrics + .active_connections + .fetch_add(1, Ordering::Relaxed); + ConnGuard { ctx: ctx.clone() } + } +} + +impl Drop for ConnGuard { + fn drop(&mut self) { + self.ctx + .metrics + .active_connections + .fetch_sub(1, Ordering::Relaxed); + } +} + +pub struct Listener { + pub id: Arc, + pub config: Arc, + pub semaphore: Arc, +} + +impl Listener { + pub fn new(id: Arc, config: Arc) -> Self { + let semaphore = Arc::new(Semaphore::new(config.max_connections)); + Listener { + id, + config, + semaphore, + } + } + + pub async fn run( + self: Arc, + ctx: Arc>, + shutdown: tokio::sync::watch::Receiver, + ) { + let mut handles = Vec::new(); + for &addr in &self.config.bind { + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + tracing::error!(listener = %self.id, %addr, error = %e, "failed to bind"); + continue; + } + }; + tracing::info!(listener = %self.id, %addr, protocol = self.config.protocol.as_str(), "listening"); + let this = self.clone(); + let ctx = ctx.clone(); + let shutdown = shutdown.clone(); + handles.push(tokio::spawn(async move { + this.accept_loop(ctx, listener, shutdown).await; + })); + } + for h in handles { + let _ = h.await; + } + } + + async fn accept_loop( + self: Arc, + ctx: Arc>, + listener: TcpListener, + mut shutdown: tokio::sync::watch::Receiver, + ) { + loop { + let (tcp, peer) = tokio::select! { + biased; + _ = shutdown.changed() => { + tracing::debug!(listener = %self.id, "accept loop stopping"); + return; + } + accepted = listener.accept() => match accepted { + Ok(pair) => pair, + Err(e) => { + tracing::warn!(listener = %self.id, error = %e, "accept failed"); + continue; + } + }, + }; + let permit = match self.semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + tracing::warn!(listener = %self.id, "max_connections reached; dropping connection"); + drop(tcp); + continue; + } + }; + let local = tcp.local_addr().unwrap_or(peer); + let _ = tcp.set_nodelay(true); + let ctx = ctx.load_full(); + let this = self.clone(); + let span = tracing::info_span!( + "conn", + listener = %this.id, + protocol = this.config.protocol.as_str(), + peer = %peer.ip(), + ); + tokio::spawn( + async move { + let _permit = permit; + let _guard = ConnGuard::new(&ctx); + if let Err(e) = this.serve(ctx.clone(), tcp, peer, local).await { + tracing::debug!(error = %e, "connection ended"); + } + } + .instrument(span), + ); + } + } + + async fn serve( + &self, + ctx: Arc, + tcp: tokio::net::TcpStream, + peer: std::net::SocketAddr, + local: std::net::SocketAddr, + ) -> crate::error::Result<()> { + let cfg = &self.config; + let resolved = match tokio::time::timeout( + cfg.preauth_timeout, + resolve_inbound(tcp, peer, local, cfg.proxy_protocol, &cfg.proxy_trusted), + ) + .await + { + Ok(r) => r?, + Err(_) => { + return Err(crate::error::ProxyError::preauth( + "inbound PROXY header timeout", + )); + } + }; + + let mut stream = resolved.stream; + let peer = resolved.peer; + let local = resolved.local; + + if cfg.tls == TlsMode::Implicit { + let Some(acceptor) = ctx.tls_acceptor.as_ref() else { + return Err(crate::error::ProxyError::tls( + "implicit TLS listener but no TLS acceptor configured", + )); + }; + stream = match tokio::time::timeout( + cfg.preauth_timeout, + crate::proto::common::upgrade_tls_inbound(stream, acceptor), + ) + .await + { + Ok(r) => r?, + Err(_) => { + return Err(crate::error::ProxyError::tls( + "inbound TLS handshake timed out", + )); + } + }; + } + + if stream.is_tls() { + let (version, cipher) = stream.tls_version_and_cipher(); + tracing::debug!(listener = %self.id, %peer, %version, %cipher, "tls connection"); + } + + proto::dispatch(&ctx, cfg, stream, peer, local).await + } +} diff --git a/src/net/cidr.rs b/src/net/cidr.rs new file mode 100644 index 0000000..b2127f3 --- /dev/null +++ b/src/net/cidr.rs @@ -0,0 +1,131 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::{IpAddr, Ipv6Addr}; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Cidr { + network: u128, + mask: u128, + is_v6: bool, +} + +fn to_u128(ip: IpAddr) -> (u128, bool) { + match ip { + IpAddr::V4(v4) => (u128::from(v4.to_ipv6_mapped()), false), + IpAddr::V6(v6) => (u128::from(v6), true), + } +} + +impl Cidr { + pub fn contains(&self, ip: IpAddr) -> bool { + let (mut addr, is_v6) = to_u128(ip); + if self.is_v6 != is_v6 { + if !self.is_v6 && is_v6 { + if let IpAddr::V6(v6) = ip { + if let Some(v4) = v6.to_ipv4_mapped() { + addr = u128::from(v4.to_ipv6_mapped()); + } else { + return false; + } + } + } else { + return false; + } + } + (addr & self.mask) == self.network + } +} + +impl FromStr for Cidr { + type Err = String; + + fn from_str(s: &str) -> Result { + let (addr_str, prefix_str) = match s.split_once('/') { + Some((a, p)) => (a, Some(p)), + None => (s, None), + }; + let ip: IpAddr = addr_str + .trim() + .parse() + .map_err(|_| format!("invalid IP address in CIDR {s:?}"))?; + let (network_raw, is_v6) = to_u128(ip); + let max_prefix = if is_v6 { 128 } else { 32 }; + let prefix: u32 = match prefix_str { + Some(p) => p + .trim() + .parse() + .map_err(|_| format!("invalid prefix in CIDR {s:?}"))?, + None => max_prefix, + }; + if prefix > max_prefix { + return Err(format!("prefix /{prefix} too large for address in {s:?}")); + } + let total_bits = if is_v6 { 128 } else { 32 }; + let host_bits = total_bits - prefix; + let mask = if host_bits == 0 { + u128::MAX + } else { + (!0u128).checked_shl(host_bits).unwrap_or(0) + }; + let mask = if is_v6 { + mask + } else { + mask | u128::from(Ipv6Addr::new(0, 0, 0, 0, 0, 0xffff, 0, 0)) + }; + Ok(Cidr { + network: network_raw & mask, + mask, + is_v6, + }) + } +} + +impl<'de> Deserialize<'de> for Cidr { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +pub fn any_contains(nets: &[Cidr], ip: IpAddr) -> bool { + nets.iter().any(|n| n.contains(ip)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn matches_v4() { + let c: Cidr = "10.0.0.0/8".parse().unwrap(); + assert!(c.contains("10.1.2.3".parse().unwrap())); + assert!(c.contains("10.255.255.255".parse().unwrap())); + assert!(!c.contains("11.0.0.1".parse().unwrap())); + let c: Cidr = "192.168.1.1".parse().unwrap(); + assert!(c.contains("192.168.1.1".parse().unwrap())); + assert!(!c.contains("192.168.1.2".parse().unwrap())); + } + + #[test] + fn matches_v6() { + let c: Cidr = "2001:db8::/32".parse().unwrap(); + assert!(c.contains("2001:db8::1".parse().unwrap())); + assert!(!c.contains("2001:db9::1".parse().unwrap())); + } + + #[test] + fn mapped_v4_in_v4_net() { + let c: Cidr = "10.0.0.0/8".parse().unwrap(); + assert!(c.contains("::ffff:10.0.0.5".parse().unwrap())); + } +} diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..4d95a1e --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,13 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod acceptor; +pub mod cidr; +pub mod proxy_protocol; +pub mod stream; +pub mod tls; + +pub use stream::BoxedStream; diff --git a/src/net/proxy_protocol.rs b/src/net/proxy_protocol.rs new file mode 100644 index 0000000..b17fb4a --- /dev/null +++ b/src/net/proxy_protocol.rs @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::SocketAddr; + +use proxy_header::{ProxiedAddress, ProxyHeader}; +use tokio::io::AsyncReadExt; +use tokio::net::TcpStream; + +use crate::config::ProxyProtocolMode; +use crate::error::{ProxyError, Result}; +use crate::net::cidr::{Cidr, any_contains}; +use crate::net::stream::{BoxedStream, PrefacedStream}; + +const V2_MAGIC: [u8; 12] = [ + 0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A, +]; +const V1_PREFIX: &[u8] = b"PROXY "; +const MAX_HEADER_PROBE: usize = 1024; + +fn could_be_proxy(buf: &[u8]) -> bool { + let n = buf.len(); + let v1 = &V1_PREFIX[..n.min(V1_PREFIX.len())]; + let v2 = &V2_MAGIC[..n.min(V2_MAGIC.len())]; + buf.starts_with(v1) || buf.starts_with(v2) +} + +fn normalize(addr: SocketAddr) -> SocketAddr { + match addr { + SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { + Some(v4) => SocketAddr::new(v4.into(), v6.port()), + None => addr, + }, + _ => addr, + } +} + +pub struct InboundResolution { + pub stream: BoxedStream, + pub peer: SocketAddr, + pub local: SocketAddr, +} + +pub async fn resolve_inbound( + stream: TcpStream, + tcp_peer: SocketAddr, + tcp_local: SocketAddr, + mode: ProxyProtocolMode, + trusted: &[Cidr], +) -> Result { + if mode == ProxyProtocolMode::Off { + return Ok(InboundResolution { + stream: Box::new(stream), + peer: tcp_peer, + local: tcp_local, + }); + } + + let is_trusted = any_contains(trusted, tcp_peer.ip()); + if !is_trusted { + if mode == ProxyProtocolMode::Required { + return Err(ProxyError::protocol( + "PROXY header required but peer is not trusted", + )); + } + return Ok(InboundResolution { + stream: Box::new(stream), + peer: tcp_peer, + local: tcp_local, + }); + } + + parse_inbound_header(stream, tcp_peer, tcp_local, mode).await +} + +async fn parse_inbound_header( + mut stream: TcpStream, + tcp_peer: SocketAddr, + tcp_local: SocketAddr, + mode: ProxyProtocolMode, +) -> Result { + let required = mode == ProxyProtocolMode::Required; + let mut buf: Vec = Vec::with_capacity(256); + + loop { + let n = stream + .read_buf(&mut buf) + .await + .map_err(|e| ProxyError::protocol(format!("reading PROXY header: {e}")))?; + if n == 0 { + if required { + return Err(ProxyError::protocol( + "PROXY header required but connection closed before one arrived", + )); + } + return Ok(InboundResolution { + stream: Box::new(PrefacedStream::new(buf, stream)), + peer: tcp_peer, + local: tcp_local, + }); + } + + if !could_be_proxy(&buf) { + if required { + return Err(ProxyError::protocol( + "PROXY header required from trusted peer but none present", + )); + } + return Ok(InboundResolution { + stream: Box::new(PrefacedStream::new(buf, stream)), + peer: tcp_peer, + local: tcp_local, + }); + } + + match ProxyHeader::parse(&buf, Default::default()) { + Ok((header, consumed)) => { + let (peer, local) = match header.proxied_address() { + Some(addr) => (normalize(addr.source), normalize(addr.destination)), + None => (tcp_peer, tcp_local), + }; + let leftover = buf.split_off(consumed); + return Ok(InboundResolution { + stream: Box::new(PrefacedStream::new(leftover, stream)), + peer, + local, + }); + } + Err(proxy_header::Error::BufferTooShort) if buf.len() < MAX_HEADER_PROBE => { + continue; + } + Err(_) => { + if required { + return Err(ProxyError::protocol( + "invalid PROXY header from trusted peer", + )); + } + return Ok(InboundResolution { + stream: Box::new(PrefacedStream::new(buf, stream)), + peer: tcp_peer, + local: tcp_local, + }); + } + } + } +} + +pub fn build_outbound_header(peer: SocketAddr, local: SocketAddr) -> Result> { + let src = normalize(peer); + let dst = normalize(local); + let mut buf = Vec::with_capacity(64); + if src.is_ipv4() != dst.is_ipv4() { + buf.extend_from_slice(b"PROXY UNKNOWN\r\n"); + return Ok(buf); + } + let header = ProxyHeader::with_address(ProxiedAddress::stream(src, dst)); + header + .encode_v2(&mut buf) + .map_err(|e| ProxyError::backend(format!("PROXY header encode failed: {e}")))?; + Ok(buf) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalizes_mapped_v4() { + let mapped: SocketAddr = "[::ffff:10.0.0.1]:25".parse().unwrap(); + assert_eq!(normalize(mapped), "10.0.0.1:25".parse().unwrap()); + } + + #[test] + fn outbound_v2_for_matching_families() { + let h = build_outbound_header( + "10.0.0.1:12345".parse().unwrap(), + "10.0.0.2:993".parse().unwrap(), + ) + .unwrap(); + assert_eq!(&h[..12], &V2_MAGIC); + } + + #[test] + fn outbound_unknown_for_family_mismatch() { + let h = build_outbound_header( + "10.0.0.1:12345".parse().unwrap(), + "[2001:db8::1]:993".parse().unwrap(), + ) + .unwrap(); + assert_eq!(&h, b"PROXY UNKNOWN\r\n"); + } +} diff --git a/src/net/stream.rs b/src/net/stream.rs new file mode 100644 index 0000000..88a683a --- /dev/null +++ b/src/net/stream.rs @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::borrow::Cow; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; + +pub trait SessionStream: AsyncRead + AsyncWrite + Unpin + Send + 'static { + fn is_tls(&self) -> bool; + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>); +} + +pub type BoxedStream = Box; + +impl SessionStream for TcpStream { + fn is_tls(&self) -> bool { + false + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + (Cow::Borrowed(""), Cow::Borrowed("")) + } +} + +impl SessionStream for BoxedStream { + fn is_tls(&self) -> bool { + (**self).is_tls() + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + (**self).tls_version_and_cipher() + } +} + +fn protocol_version_str(v: Option) -> Cow<'static, str> { + Cow::Borrowed(match v { + Some(rustls::ProtocolVersion::SSLv2) => "SSLv2", + Some(rustls::ProtocolVersion::SSLv3) => "SSLv3", + Some(rustls::ProtocolVersion::TLSv1_0) => "TLSv1.0", + Some(rustls::ProtocolVersion::TLSv1_1) => "TLSv1.1", + Some(rustls::ProtocolVersion::TLSv1_2) => "TLSv1.2", + Some(rustls::ProtocolVersion::TLSv1_3) => "TLSv1.3", + _ => "unknown", + }) +} + +fn cipher_str(cs: Option) -> Cow<'static, str> { + Cow::Borrowed(cs.and_then(|c| c.suite().as_str()).unwrap_or("unknown")) +} + +macro_rules! impl_tls_stream { + ($side:path) => { + impl SessionStream for $side { + fn is_tls(&self) -> bool { + true + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + let (_, conn) = self.get_ref(); + ( + protocol_version_str(conn.protocol_version()), + cipher_str(conn.negotiated_cipher_suite()), + ) + } + } + }; +} + +impl_tls_stream!(tokio_rustls::server::TlsStream); +impl_tls_stream!(tokio_rustls::client::TlsStream); + +pub struct PrefacedStream { + prefix: Vec, + pos: usize, + io: IO, +} + +impl PrefacedStream { + pub fn new(prefix: Vec, io: IO) -> Self { + PrefacedStream { prefix, pos: 0, io } + } +} + +impl AsyncRead for PrefacedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let me = self.get_mut(); + if me.pos < me.prefix.len() { + let remaining = &me.prefix[me.pos..]; + let n = remaining.len().min(buf.remaining()); + buf.put_slice(&remaining[..n]); + me.pos += n; + if me.pos == me.prefix.len() { + me.prefix = Vec::new(); + me.pos = 0; + } + return Poll::Ready(Ok(())); + } + Pin::new(&mut me.io).poll_read(cx, buf) + } +} + +impl AsyncWrite for PrefacedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().io).poll_shutdown(cx) + } +} + +impl SessionStream for PrefacedStream { + fn is_tls(&self) -> bool { + false + } + + fn tls_version_and_cipher(&self) -> (Cow<'static, str>, Cow<'static, str>) { + (Cow::Borrowed(""), Cow::Borrowed("")) + } +} diff --git a/src/net/tls.rs b/src/net/tls.rs new file mode 100644 index 0000000..d482e53 --- /dev/null +++ b/src/net/tls.rs @@ -0,0 +1,473 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use ahash::AHashMap; + +use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; +use rustls::crypto::{CryptoProvider, verify_tls12_signature, verify_tls13_signature}; +use rustls::pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime}; +use rustls::server::{ClientHello, ResolvesServerCert}; +use rustls::sign::CertifiedKey; +use rustls::version::{TLS12, TLS13}; +use rustls::{ + ClientConfig, DigitallySignedStruct, ServerConfig, SignatureScheme, SupportedProtocolVersion, +}; +use rustls_platform_verifier::BuilderVerifierExt; + +use crate::config::{Config, DestinationConfig}; +use crate::error::{ProxyError, Result}; + +pub fn provider() -> Arc { + Arc::new(rustls::crypto::aws_lc_rs::default_provider()) +} + +static TLS13_ONLY: &[&SupportedProtocolVersion] = &[&TLS13]; +static TLS12_PLUS: &[&SupportedProtocolVersion] = &[&TLS12, &TLS13]; + +fn protocol_versions(min_version: &str) -> Result<&'static [&'static SupportedProtocolVersion]> { + match min_version { + "1.3" => Ok(TLS13_ONLY), + "1.2" => Ok(TLS12_PLUS), + other => Err(ProxyError::config(format!( + "unsupported tls min_version {other:?} (use \"1.2\" or \"1.3\")" + ))), + } +} + +fn load_certs(path: &str) -> Result>> { + let data = std::fs::read(path) + .map_err(|e| ProxyError::config(format!("failed to read cert {path}: {e}")))?; + let certs: std::result::Result, _> = + rustls_pemfile::certs(&mut data.as_slice()).collect(); + let certs = certs.map_err(|e| ProxyError::config(format!("invalid cert {path}: {e}")))?; + if certs.is_empty() { + return Err(ProxyError::config(format!( + "no certificates found in {path}" + ))); + } + Ok(certs) +} + +fn load_key(path: &str) -> Result> { + let data = std::fs::read(path) + .map_err(|e| ProxyError::config(format!("failed to read key {path}: {e}")))?; + rustls_pemfile::private_key(&mut data.as_slice()) + .map_err(|e| ProxyError::config(format!("invalid key {path}: {e}")))? + .ok_or_else(|| ProxyError::config(format!("no private key found in {path}"))) +} + +fn san_dns_names(cert: &CertificateDer<'_>) -> Vec { + use x509_parser::prelude::*; + let mut names = Vec::new(); + if let Ok((_, parsed)) = X509Certificate::from_der(cert.as_ref()) { + if let Ok(Some(san)) = parsed.subject_alternative_name() { + for gn in &san.value.general_names { + if let GeneralName::DNSName(name) = gn { + names.push(name.to_string()); + } + } + } + if names.is_empty() { + for cn in parsed.subject().iter_common_name() { + if let Ok(s) = cn.as_str() { + names.push(s.to_string()); + } + } + } + } + names +} + +#[derive(Debug)] +pub struct CertificateResolver { + certs: AHashMap>, + self_signed: Arc, +} + +impl CertificateResolver { + pub fn resolve_certificate(&self, name: Option<&str>) -> Arc { + let pick = name + .and_then(|name| { + self.certs.get(name).or_else(|| { + name.split_once('.') + .and_then(|(_, parent)| self.certs.get(parent)) + }) + }) + .or_else(|| self.certs.get("*")) + .or_else(|| { + if self.certs.len() == 1 { + self.certs.values().next() + } else { + None + } + }); + pick.cloned().unwrap_or_else(|| self.self_signed.clone()) + } +} + +impl ResolvesServerCert for CertificateResolver { + fn resolve(&self, hello: ClientHello<'_>) -> Option> { + Some(self.resolve_certificate(hello.server_name())) + } +} + +fn certified_key( + provider: &CryptoProvider, + certs: Vec>, + key: PrivateKeyDer<'static>, +) -> Result> { + let signing_key = provider + .key_provider + .load_private_key(key) + .map_err(|e| ProxyError::tls(format!("invalid signing key: {e}")))?; + Ok(Arc::new(CertifiedKey::new(certs, signing_key))) +} + +fn self_signed_default(provider: &CryptoProvider) -> Result> { + let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]) + .map_err(|e| ProxyError::tls(format!("self-signed cert generation failed: {e}")))?; + let cert_der = CertificateDer::from(cert.cert.der().to_vec()); + let key_der = PrivateKeyDer::try_from(cert.signing_key.serialize_der()) + .map_err(|e| ProxyError::tls(format!("self-signed key error: {e}")))?; + certified_key(provider, vec![cert_der], key_der) +} + +pub fn build_resolver(config: &Config, provider: &CryptoProvider) -> Result { + let mut certs: AHashMap> = AHashMap::new(); + for cert_cfg in config.tls.certificate.values() { + let chain = load_certs(&cert_cfg.cert)?; + let key = load_key(&cert_cfg.key)?; + let names = if cert_cfg.subjects.is_empty() { + san_dns_names(&chain[0]) + } else { + cert_cfg.subjects.clone() + }; + let ck = certified_key(provider, chain, key)?; + for name in names { + let key = name + .strip_prefix("*.") + .unwrap_or(&name) + .to_ascii_lowercase(); + certs.insert(key, ck.clone()); + } + if cert_cfg.default { + certs.insert("*".to_string(), ck.clone()); + } + } + Ok(CertificateResolver { + certs, + self_signed: self_signed_default(provider)?, + }) +} + +fn provider_without_disabled( + base: &CryptoProvider, + disabled: &[String], +) -> Result> { + if disabled.is_empty() { + return Ok(Arc::new(base.clone())); + } + let mut provider = base.clone(); + provider.cipher_suites.retain(|cs| { + cs.suite() + .as_str() + .map(|name| !disabled.iter().any(|d| d.eq_ignore_ascii_case(name))) + .unwrap_or(true) + }); + if provider.cipher_suites.is_empty() { + return Err(ProxyError::config( + "disable_cipher_suites removed every available cipher suite", + )); + } + Ok(Arc::new(provider)) +} + +pub fn build_server_config( + config: &Config, + provider: Arc, +) -> Result> { + let provider = + provider_without_disabled(&provider, &config.tls.protocols.disable_cipher_suites)?; + let resolver = build_resolver(config, &provider)?; + let versions = protocol_versions(&config.tls.protocols.min_version)?; + let mut server_config = ServerConfig::builder_with_provider(provider) + .with_protocol_versions(versions) + .map_err(|e| ProxyError::tls(format!("tls server config: {e}")))? + .with_no_client_auth() + .with_cert_resolver(Arc::new(resolver)); + server_config.ignore_client_order = config.tls.protocols.ignore_client_order; + Ok(Arc::new(server_config)) +} + +#[derive(Debug)] +struct NoVerifier; + +fn all_schemes() -> Vec { + vec![ + SignatureScheme::RSA_PKCS1_SHA256, + SignatureScheme::RSA_PKCS1_SHA384, + SignatureScheme::RSA_PKCS1_SHA512, + SignatureScheme::ECDSA_NISTP256_SHA256, + SignatureScheme::ECDSA_NISTP384_SHA384, + SignatureScheme::ECDSA_NISTP521_SHA512, + SignatureScheme::RSA_PSS_SHA256, + SignatureScheme::RSA_PSS_SHA384, + SignatureScheme::RSA_PSS_SHA512, + SignatureScheme::ED25519, + ] +} + +impl ServerCertVerifier for NoVerifier { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> std::result::Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + all_schemes() + } +} + +#[derive(Debug)] +struct PinnedVerifier { + pin: [u8; 32], + provider: Arc, +} + +fn sha256(bytes: &[u8]) -> [u8; 32] { + let digest = aws_lc_rs::digest::digest(&aws_lc_rs::digest::SHA256, bytes); + let mut out = [0u8; 32]; + out.copy_from_slice(digest.as_ref()); + out +} + +impl ServerCertVerifier for PinnedVerifier { + fn verify_server_cert( + &self, + end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp_response: &[u8], + _now: UnixTime, + ) -> std::result::Result { + if sha256(end_entity.as_ref()) == self.pin { + Ok(ServerCertVerified::assertion()) + } else { + Err(rustls::Error::InvalidCertificate( + rustls::CertificateError::ApplicationVerificationFailure, + )) + } + } + + fn verify_tls12_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + verify_tls12_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn verify_tls13_signature( + &self, + message: &[u8], + cert: &CertificateDer<'_>, + dss: &DigitallySignedStruct, + ) -> std::result::Result { + verify_tls13_signature( + message, + cert, + dss, + &self.provider.signature_verification_algorithms, + ) + } + + fn supported_verify_schemes(&self) -> Vec { + self.provider + .signature_verification_algorithms + .supported_schemes() + } +} + +fn parse_pin(hex: &str) -> Result<[u8; 32]> { + let hex = hex.trim().replace(':', ""); + if hex.len() != 64 { + return Err(ProxyError::config( + "tls_pinned_cert_sha256 must be 32 bytes (64 hex chars)", + )); + } + let mut out = [0u8; 32]; + for (i, byte) in out.iter_mut().enumerate() { + *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16) + .map_err(|_| ProxyError::config("tls_pinned_cert_sha256 is not valid hex"))?; + } + Ok(out) +} + +pub fn build_client_config( + dest: &DestinationConfig, + provider: Arc, + is_http: bool, +) -> Result> { + let builder = ClientConfig::builder_with_provider(provider.clone()) + .with_protocol_versions(TLS12_PLUS) + .map_err(|e| ProxyError::tls(format!("tls client config: {e}")))?; + + let verified = if dest.tls_allow_invalid_certs { + builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerifier)) + } else if let Some(pin) = &dest.tls_pinned_cert_sha256 { + let verifier = PinnedVerifier { + pin: parse_pin(pin)?, + provider, + }; + builder + .dangerous() + .with_custom_certificate_verifier(Arc::new(verifier)) + } else { + builder + .with_platform_verifier() + .map_err(|e| ProxyError::tls(format!("platform verifier: {e}")))? + }; + + let mut config = match (&dest.tls_client_cert, &dest.tls_client_key) { + (Some(cert), Some(key)) => { + let chain = load_certs(cert)?; + let der = load_key(key)?; + verified + .with_client_auth_cert(chain, der) + .map_err(|e| ProxyError::tls(format!("backend client certificate: {e}")))? + } + (None, None) => verified.with_no_client_auth(), + _ => { + return Err(ProxyError::config( + "tls_client_cert and tls_client_key must both be set", + )); + } + }; + + if is_http { + config.alpn_protocols = vec![b"http/1.1".to_vec()]; + } + + Ok(Arc::new(config)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> Arc { + let provider = provider(); + self_signed_default(&provider).unwrap() + } + + fn resolver( + entries: Vec<(&str, Arc)>, + ) -> (CertificateResolver, Arc) { + let self_signed = key(); + let mut certs = AHashMap::new(); + for (name, ck) in entries { + certs.insert(name.to_string(), ck); + } + ( + CertificateResolver { + certs, + self_signed: self_signed.clone(), + }, + self_signed, + ) + } + + #[test] + fn sni_exact_then_parent_then_star() { + let exact = key(); + let wild = key(); + let star = key(); + let (r, _) = resolver(vec![ + ("mail.example.com", exact.clone()), + ("example.com", wild.clone()), + ("*", star.clone()), + ]); + + assert!(Arc::ptr_eq( + &r.resolve_certificate(Some("mail.example.com")), + &exact + )); + assert!(Arc::ptr_eq( + &r.resolve_certificate(Some("host.example.com")), + &wild + )); + assert!(Arc::ptr_eq( + &r.resolve_certificate(Some("nomatch.org")), + &star + )); + assert!(Arc::ptr_eq(&r.resolve_certificate(None), &star)); + } + + #[test] + fn sni_single_cert_fallback() { + let only = key(); + let (r, _) = resolver(vec![("mail.example.com", only.clone())]); + assert!(Arc::ptr_eq( + &r.resolve_certificate(Some("other.org")), + &only + )); + } + + #[test] + fn sni_self_signed_when_nothing_matches() { + let a = key(); + let b = key(); + let (r, self_signed) = resolver(vec![("a.com", a), ("b.com", b)]); + assert!(Arc::ptr_eq( + &r.resolve_certificate(Some("z.org")), + &self_signed + )); + } + + #[test] + fn parse_pin_accepts_hex_and_colons() { + let raw = "a".repeat(64); + assert!(parse_pin(&raw).is_ok()); + let colon = "AA:".repeat(31) + "AA"; + assert!(parse_pin(&colon).is_ok()); + assert!(parse_pin("tooshort").is_err()); + assert!(parse_pin(&"z".repeat(64)).is_err()); + } +} diff --git a/src/observe.rs b/src/observe.rs new file mode 100644 index 0000000..50d6ff7 --- /dev/null +++ b/src/observe.rs @@ -0,0 +1,30 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicBool, Ordering}; + +static INITIALIZED: AtomicBool = AtomicBool::new(false); + +pub fn init(level: &str) { + if INITIALIZED.swap(true, Ordering::SeqCst) { + return; + } + use tracing_subscriber::{EnvFilter, fmt}; + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); + let _ = fmt().with_env_filter(filter).with_target(false).try_init(); +} + +pub fn redact(value: &str) -> String { + if value.is_empty() { + return "".to_string(); + } + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash(&mut hasher); + let tag = hasher.finish() & 0xffff; + let visible: String = value.chars().take(2).collect(); + format!("{visible}***#{tag:04x}") +} diff --git a/src/outbound.rs b/src/outbound.rs new file mode 100644 index 0000000..9ac2ea7 --- /dev/null +++ b/src/outbound.rs @@ -0,0 +1,300 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::SocketAddr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use rustls::ClientConfig; +use rustls::pki_types::ServerName; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::{Instant, timeout}; +use tokio_rustls::TlsConnector; + +use crate::config::{DestProtocol, DestinationConfig, Forwarding, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::net::BoxedStream; +use crate::net::proxy_protocol::build_outbound_header; + +pub fn server_name(dest: &DestinationConfig) -> Result> { + let name = dest + .tls_server_name + .clone() + .unwrap_or_else(|| dest.host.clone()); + ServerName::try_from(name).map_err(|_| { + ProxyError::backend("destination has no valid TLS server name (set tls_server_name)") + }) +} + +pub async fn dial( + dest: &DestinationConfig, + ep: &DestProtocol, + forwarding: Forwarding, + client_config: &Arc, + peer: SocketAddr, + local: SocketAddr, +) -> Result { + let mut stream = connect_with_source(dest, ep).await?; + let _ = stream.set_nodelay(true); + + if forwarding == Forwarding::Proxy { + let header = build_outbound_header(peer, local)?; + stream + .write_all(&header) + .await + .map_err(|e| ProxyError::backend(format!("writing PROXY header: {e}")))?; + } + + match ep.tls { + TlsMode::Implicit => tls_connect(Box::new(stream), client_config, dest).await, + TlsMode::Plain | TlsMode::Starttls => Ok(Box::new(stream)), + } +} + +async fn connect_with_source(dest: &DestinationConfig, ep: &DestProtocol) -> Result { + use std::net::{IpAddr, SocketAddr as StdSocketAddr}; + use tokio::net::TcpSocket; + + if dest.source_ips.is_empty() { + return TcpStream::connect((dest.host.as_str(), ep.port)) + .await + .map_err(|e| ProxyError::backend_connect(format!("backend connect failed: {e}"))); + } + + let addrs = tokio::net::lookup_host((dest.host.as_str(), ep.port)) + .await + .map_err(|e| ProxyError::backend_connect(format!("backend DNS lookup failed: {e}")))?; + + let mut last_err: Option = None; + for addr in addrs { + let Some(src) = pick_source_ip(&dest.source_ips, addr.is_ipv4()) else { + continue; + }; + let socket = match if addr.is_ipv4() { + TcpSocket::new_v4() + } else { + TcpSocket::new_v6() + } { + Ok(s) => s, + Err(e) => { + last_err = Some(ProxyError::backend_connect(format!("socket: {e}"))); + continue; + } + }; + let bind_addr = StdSocketAddr::new(src, 0); + if let Err(e) = socket.bind(bind_addr) { + last_err = Some(ProxyError::backend_connect(format!( + "binding source {src}: {e}" + ))); + continue; + } + match socket.connect(addr).await { + Ok(s) => return Ok(s), + Err(e) => { + let _: IpAddr = src; + last_err = Some(ProxyError::backend_connect(format!( + "backend connect failed: {e}" + ))); + } + } + } + Err(last_err.unwrap_or_else(|| ProxyError::backend_connect("no usable source IP for backend"))) +} + +fn pick_source_ip(source_ips: &[std::net::IpAddr], want_v4: bool) -> Option { + use std::sync::atomic::{AtomicUsize, Ordering}; + static RR: AtomicUsize = AtomicUsize::new(0); + let count = source_ips + .iter() + .filter(|ip| ip.is_ipv4() == want_v4) + .count(); + if count == 0 { + return None; + } + let idx = RR.fetch_add(1, Ordering::Relaxed) % count; + source_ips + .iter() + .copied() + .filter(|ip| ip.is_ipv4() == want_v4) + .nth(idx) +} + +pub async fn tls_connect( + stream: BoxedStream, + client_config: &Arc, + dest: &DestinationConfig, +) -> Result { + let connector = TlsConnector::from(client_config.clone()); + let name = server_name(dest)?; + let tls = connector + .connect(name, stream) + .await + .map_err(|e| ProxyError::backend_connect(format!("backend TLS handshake failed: {e}")))?; + Ok(Box::new(tls)) +} + +pub async fn read_line( + stream: &mut BoxedStream, + residual: &mut Vec, + max: usize, +) -> Result> { + let mut line = std::mem::take(residual); + let mut search_from = 0; + loop { + if let Some(pos) = find_crlf(&line, search_from) { + let rest = line.split_off(pos + 2); + *residual = rest; + line.truncate(pos); + return Ok(line); + } + search_from = line.len().saturating_sub(1); + if line.len() > max { + return Err(ProxyError::backend("backend line exceeded maximum length")); + } + let mut buf = [0u8; 4096]; + let n = stream + .read(&mut buf) + .await + .map_err(|e| ProxyError::backend_connect(format!("reading backend: {e}")))?; + if n == 0 { + return Err(ProxyError::backend_connect( + "backend closed before sending a complete line", + )); + } + line.extend_from_slice(&buf[..n]); + } +} + +fn find_crlf(buf: &[u8], from: usize) -> Option { + if buf.len() < 2 { + return None; + } + (from..buf.len() - 1).find(|&i| buf[i] == b'\r' && buf[i + 1] == b'\n') +} + +pub async fn write_all(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> { + stream + .write_all(bytes) + .await + .map_err(|e| ProxyError::backend(format!("writing backend: {e}")))?; + stream + .flush() + .await + .map_err(|e| ProxyError::backend(format!("flushing backend: {e}"))) +} + +const BRIDGE_BUF: usize = 16 * 1024; + +const SHUTDOWN_GRACE: Duration = Duration::from_secs(3); + +pub async fn bridge( + mut client: BoxedStream, + mut backend: BoxedStream, + to_backend: &[u8], + to_client: &[u8], + idle: Duration, + cancel: Option>, +) -> Result<(u64, u64)> { + if !to_backend.is_empty() { + write_all(&mut backend, to_backend).await?; + } + if !to_client.is_empty() { + client + .write_all(to_client) + .await + .map_err(|e| ProxyError::backend(format!("seeding client: {e}")))?; + let _ = client.flush().await; + } + + let (mut cr, mut cw) = tokio::io::split(client); + let (mut br, mut bw) = tokio::io::split(backend); + + let base = Instant::now(); + let activity = Arc::new(AtomicU64::new(0)); + + let mut c2b = std::pin::pin!(pump(&mut cr, &mut bw, base, activity.clone())); + let mut b2c = std::pin::pin!(pump(&mut br, &mut cw, base, activity.clone())); + let watchdog = std::pin::pin!(idle_watchdog(base, activity, idle)); + + let mut c2b_done: Option = None; + let mut b2c_done: Option = None; + let drain = async { + loop { + tokio::select! { + r = &mut c2b, if c2b_done.is_none() => c2b_done = Some(r?), + r = &mut b2c, if b2c_done.is_none() => b2c_done = Some(r?), + } + if c2b_done.is_some() && b2c_done.is_some() { + return Ok::<(u64, u64), std::io::Error>(( + c2b_done.unwrap_or(0), + b2c_done.unwrap_or(0), + )); + } + } + }; + + tokio::select! { + result = drain => result.map_err(|e| ProxyError::backend(format!("bridge: {e}"))), + _ = watchdog => Err(ProxyError::backend("bridge idle timeout")), + _ = wait_kick(cancel) => Err(ProxyError::backend("disconnected by admin")), + } +} + +async fn wait_kick(cancel: Option>) { + match cancel { + Some(mut rx) => { + while rx.changed().await.is_ok() { + if *rx.borrow() { + return; + } + } + std::future::pending::<()>().await + } + None => std::future::pending::<()>().await, + } +} + +async fn pump( + reader: &mut R, + writer: &mut W, + base: Instant, + activity: Arc, +) -> std::io::Result +where + R: AsyncReadExt + Unpin, + W: AsyncWriteExt + Unpin, +{ + let mut buf = vec![0u8; BRIDGE_BUF]; + let mut total = 0u64; + loop { + let n = reader.read(&mut buf).await?; + if n == 0 { + let _ = timeout(SHUTDOWN_GRACE, writer.shutdown()).await; + return Ok(total); + } + writer.write_all(&buf[..n]).await?; + if n < buf.len() { + let _ = writer.flush().await; + } + total += n as u64; + activity.store(base.elapsed().as_millis() as u64, Ordering::Relaxed); + } +} + +async fn idle_watchdog(base: Instant, activity: Arc, idle: Duration) { + let idle_ms = (idle.as_millis() as u64).max(1); + loop { + let last = activity.load(Ordering::Relaxed); + let now = base.elapsed().as_millis() as u64; + let elapsed = now.saturating_sub(last); + if elapsed >= idle_ms { + return; + } + tokio::time::sleep(Duration::from_millis(idle_ms - elapsed)).await; + } +} diff --git a/src/proto/common.rs b/src/proto/common.rs new file mode 100644 index 0000000..5859405 --- /dev/null +++ b/src/proto/common.rs @@ -0,0 +1,919 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::HashMap; +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use ahash::{AHashMap, AHashSet}; +use parking_lot::Mutex; + +use rustls::ClientConfig; +use rustls::crypto::CryptoProvider; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio_rustls::TlsAcceptor; + +use crate::config::{Config, DestProtocol, DestinationConfig, Forwarding, Protocol, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::imap_receiver::Mechanism; +use crate::net::BoxedStream; +use crate::net::tls; +use crate::sasl::Credential; + +pub struct Handoff { + pub stream: BoxedStream, + pub mech: Mechanism, + pub raw_challenge: Vec, + pub identifier: Option, + pub residual: Vec, +} + +pub fn credential_from_sasl(mech: &Mechanism, raw: &[u8]) -> Option { + match mech { + Mechanism::Plain => Credential::decode_sasl_challenge_plain(raw), + Mechanism::OAuthBearer | Mechanism::XOauth2 => Credential::decode_sasl_challenge_oauth(raw), + _ => None, + } +} + +pub fn mechanism_name(mech: &Mechanism) -> &'static str { + match mech { + Mechanism::OAuthBearer => "OAUTHBEARER", + Mechanism::XOauth2 => "XOAUTH2", + _ => "PLAIN", + } +} + +#[derive(Default)] +pub struct Greetings { + pub imap: GreetingVariants, + pub pop3: GreetingVariants, + pub managesieve: GreetingVariants, +} + +#[derive(Default)] +pub struct GreetingVariants { + variants: [Arc; 4], +} + +impl GreetingVariants { + fn build(mut f: impl FnMut(bool, bool) -> String) -> Self { + GreetingVariants { + variants: [ + Arc::from(f(false, false).as_str()), + Arc::from(f(false, true).as_str()), + Arc::from(f(true, false).as_str()), + Arc::from(f(true, true).as_str()), + ], + } + } + + pub fn get(&self, is_tls: bool, offer_starttls: bool) -> &Arc { + &self.variants[(is_tls as usize) * 2 + (offer_starttls as usize)] + } +} + +pub fn build_greetings(config: &Config) -> Greetings { + let caps = &config.capabilities; + Greetings { + imap: GreetingVariants::build(|is_tls, off| { + crate::proto::imap::capability_string(&caps.imap, off, is_tls) + }), + pop3: GreetingVariants::build(|is_tls, off| { + let plain_ok = plain_auth_allowed(caps.pop3.allow_plain_auth_without_tls, is_tls); + crate::proto::pop3::capa_response(&caps.pop3, off, plain_ok) + }), + managesieve: GreetingVariants::build(|is_tls, off| { + crate::proto::managesieve::capability_block(&caps.managesieve, off, is_tls) + }), + } +} + +pub fn advertised_sasl(sasl: &[String], plain_ok: bool) -> Vec<&str> { + sasl.iter() + .filter(|m| !m.eq_ignore_ascii_case("LOGIN")) + .filter(|m| plain_ok || !m.eq_ignore_ascii_case("PLAIN")) + .map(|s| s.as_str()) + .collect() +} + +pub fn advertises_token(haystack: &[u8], token: &str) -> bool { + haystack + .split(|b| *b == b'\n' || *b == b'\r') + .map(strip_smtp_status) + .any(|line| { + line.split(|b| b.is_ascii_whitespace() || *b == b'[' || *b == b']' || *b == b'"') + .any(|word| word.eq_ignore_ascii_case(token.as_bytes())) + }) +} + +fn strip_smtp_status(line: &[u8]) -> &[u8] { + if line.len() >= 4 + && line[..3].iter().all(u8::is_ascii_digit) + && (line[3] == b'-' || line[3] == b' ') + { + &line[4..] + } else { + line + } +} + +pub struct DestRuntime { + pub mail_tls: Arc, + pub http_tls: Arc, +} + +#[derive(Default)] +pub struct Metrics { + pub active_connections: std::sync::atomic::AtomicI64, +} + +#[derive(Default)] +pub struct DestHealth { + inner: Mutex, +} + +#[derive(Default)] +struct HealthState { + consecutive_failures: u32, + down_until: Option, +} + +impl DestHealth { + fn gate(&self) -> Result<()> { + let mut st = self.inner.lock(); + if let Some(until) = st.down_until { + if Instant::now() < until { + return Err(ProxyError::backend( + "destination temporarily marked down after repeated failures", + )); + } + st.down_until = None; + } + Ok(()) + } + + fn record(&self, ok: bool, threshold: u32, cooldown: Duration) { + let mut st = self.inner.lock(); + if ok { + st.consecutive_failures = 0; + st.down_until = None; + } else { + st.consecutive_failures = st.consecutive_failures.saturating_add(1); + if threshold > 0 && st.consecutive_failures >= threshold { + st.down_until = Some(Instant::now() + cooldown); + } + } + } + + pub fn reset(&self) { + let mut st = self.inner.lock(); + st.consecutive_failures = 0; + st.down_until = None; + } + + pub fn snapshot(&self) -> HealthSnapshot { + let st = self.inner.lock(); + let down = matches!(st.down_until, Some(until) if Instant::now() < until); + HealthSnapshot { + consecutive_failures: st.consecutive_failures, + down, + } + } +} + +pub struct HealthSnapshot { + pub consecutive_failures: u32, + pub down: bool, +} + +const REGISTRY_SHARDS: usize = 32; + +struct LiveConn { + id: u64, + cancel: tokio::sync::watch::Sender, +} + +#[derive(Default)] +struct Shard { + live: HashMap, Vec>, + delays: HashMap, Instant>, +} + +#[derive(Clone)] +pub struct ConnRegistry { + shards: Arc<[Mutex]>, +} + +impl Default for ConnRegistry { + fn default() -> Self { + let shards = (0..REGISTRY_SHARDS) + .map(|_| Mutex::new(Shard::default())) + .collect::>(); + ConnRegistry { + shards: shards.into(), + } + } +} + +pub struct ConnGuard { + shards: Arc<[Mutex]>, + shard_idx: usize, + identifier: Arc, + id: u64, + rx: tokio::sync::watch::Receiver, +} + +fn shard_index(identifier: &str, shards: usize) -> usize { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + identifier.hash(&mut hasher); + (hasher.finish() as usize) % shards +} + +impl ConnRegistry { + fn shard_idx(&self, identifier: &str) -> usize { + shard_index(identifier, self.shards.len()) + } + + pub fn register(&self, identifier: &str) -> ConnGuard { + static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let id = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let (cancel, rx) = tokio::sync::watch::channel(false); + let key: Arc = Arc::from(identifier); + let shard_idx = self.shard_idx(&key); + self.shards[shard_idx] + .lock() + .live + .entry(key.clone()) + .or_default() + .push(LiveConn { id, cancel }); + ConnGuard { + shards: self.shards.clone(), + shard_idx, + identifier: key, + id, + rx, + } + } + + pub fn kick(&self, identifier: &str) -> usize { + let shard = self.shards[self.shard_idx(identifier)].lock(); + match shard.live.get(identifier) { + Some(conns) => { + for c in conns { + let _ = c.cancel.send(true); + } + conns.len() + } + None => 0, + } + } + + pub fn set_delay(&self, identifier: &str, until: Instant) { + let mut shard = self.shards[self.shard_idx(identifier)].lock(); + let now = Instant::now(); + shard.delays.retain(|_, &mut u| u > now); + shard.delays.insert(Arc::from(identifier), until); + } + + pub fn delay_remaining(&self, identifier: &str) -> Option { + let mut shard = self.shards[self.shard_idx(identifier)].lock(); + if let Some(&until) = shard.delays.get(identifier) { + let now = Instant::now(); + if now < until { + return Some(until - now); + } + shard.delays.remove(identifier); + } + None + } +} + +impl ConnGuard { + pub fn cancel(&self) -> tokio::sync::watch::Receiver { + self.rx.clone() + } +} + +impl Drop for ConnGuard { + fn drop(&mut self) { + let mut shard = self.shards[self.shard_idx].lock(); + if let Some(conns) = shard.live.get_mut(self.identifier.as_ref()) { + conns.retain(|c| c.id != self.id); + if conns.is_empty() { + shard.live.remove(self.identifier.as_ref()); + } + } + } +} + +pub struct DialedEndpoint<'a> { + pub dest: &'a DestinationConfig, + pub ep: &'a DestProtocol, + pub tls_cfg: Arc, + pub forwarding: Forwarding, + pub backend: BoxedStream, +} + +pub struct Ctx { + pub config: Arc, + pub router: Arc, + pub http_router: Arc, + pub tls_acceptor: Option, + pub dests: AHashMap, + pub dest_health: AHashMap, + pub self_binds: AHashSet, + pub conns: Arc, + pub metrics: Arc, + pub greetings: Greetings, +} + +impl Ctx { + pub fn build_dest_runtimes( + config: &Config, + provider: &Arc, + ) -> Result> { + let mut map = AHashMap::new(); + for (id, dest) in &config.destination { + let mail_tls = tls::build_client_config(dest, provider.clone(), false)?; + let http_tls = tls::build_client_config(dest, provider.clone(), true)?; + map.insert(id.clone(), DestRuntime { mail_tls, http_tls }); + } + Ok(map) + } + + pub fn build_dest_health(config: &Config) -> AHashMap { + config + .destination + .keys() + .map(|id| (id.clone(), DestHealth::default())) + .collect() + } + + pub fn build_self_binds(config: &Config) -> AHashSet { + config + .listener + .values() + .flat_map(|l| l.bind.iter().copied()) + .collect() + } + + pub fn destination(&self, id: &str) -> Result<&DestinationConfig> { + self.config + .destination + .get(id) + .ok_or_else(|| ProxyError::backend(format!("unknown destination {id:?}"))) + } + + pub fn endpoint( + &self, + dest_id: &str, + protocol: Protocol, + ) -> Result<(&DestinationConfig, &DestProtocol, Arc)> { + let dest = self.destination(dest_id)?; + let ep = dest.protocol.get(&protocol).ok_or_else(|| { + ProxyError::backend(format!( + "destination {dest_id:?} does not support protocol {}", + protocol.as_str() + )) + })?; + let runtime = self + .dests + .get(dest_id) + .ok_or_else(|| ProxyError::backend("missing destination runtime"))?; + let tls_cfg = if protocol == Protocol::Http { + runtime.http_tls.clone() + } else { + runtime.mail_tls.clone() + }; + Ok((dest, ep, tls_cfg)) + } + + pub async fn dial_endpoint( + &self, + dest_id: &str, + protocol: Protocol, + peer: SocketAddr, + local: SocketAddr, + ) -> Result> { + let (dest, ep, tls_cfg) = self.endpoint(dest_id, protocol)?; + let forwarding = dest.forwarding_for(protocol); + crate::proto::forward::guard_loop( + &self.self_binds, + dest, + ep, + forwarding, + self.config.server.proxy_ttl, + )?; + let backend = crate::outbound::dial(dest, ep, forwarding, &tls_cfg, peer, local).await?; + Ok(DialedEndpoint { + dest, + ep, + tls_cfg, + forwarding, + backend, + }) + } + + pub async fn resolve(&self, identifier: Option<&str>) -> Arc { + self.router.resolve(identifier).await + } + + pub fn register_conn(&self, identifier: &str) -> ConnGuard { + self.conns.register(&self.router.normalize(identifier)) + } + + pub fn routing_identifier(&self, identifier: Option) -> Option { + identifier.map(|id| { + crate::route::master_account(&id, &self.config.routing.master_user_separators) + .to_string() + }) + } + + pub fn hide_auth_errors(&self, dest_id: &str) -> bool { + self.destination(dest_id) + .map(|d| d.hide_auth_errors) + .unwrap_or(false) + } +} + +pub fn plain_auth_allowed(allow_without_tls: bool, is_tls: bool) -> bool { + allow_without_tls || is_tls +} + +pub fn assert_egress_encrypted( + dest: &DestinationConfig, + ep: &DestProtocol, + leg_is_tls: bool, +) -> Result<()> { + if leg_is_tls { + return Ok(()); + } + if ep.tls == TlsMode::Plain && dest.allow_plaintext_auth { + return Ok(()); + } + Err(ProxyError::backend( + "refusing to send credentials over an unencrypted backend leg", + )) +} + +const BACKEND_RETRY_DELAY: Duration = Duration::from_millis(200); + +pub async fn establish(ctx: &Ctx, dest_id: &str, mut open: F) -> Result +where + F: FnMut() -> Fut, + Fut: Future>, +{ + if let Some(health) = ctx.dest_health.get(dest_id) { + health.gate()?; + } + + let server = &ctx.config.server; + let mut attempt: u32 = 0; + loop { + let outcome = match tokio::time::timeout(server.backend_timeout, open()).await { + Ok(res) => res, + Err(_) => Err(ProxyError::backend_connect( + "backend establishment timed out", + )), + }; + + match outcome { + Ok(value) => { + if let Some(health) = ctx.dest_health.get(dest_id) { + health.record(true, server.host_down_threshold, server.host_down_cooldown); + } + return Ok(value); + } + Err(err) => { + if err.is_retryable() && attempt < server.backend_connect_retries { + attempt += 1; + tracing::debug!( + destination = %dest_id, + attempt, + error = %err, + "retrying backend connection" + ); + tokio::time::sleep(BACKEND_RETRY_DELAY).await; + continue; + } + if let Some(health) = ctx.dest_health.get(dest_id) { + health.record(false, server.host_down_threshold, server.host_down_cooldown); + } + return Err(err); + } + } + } +} + +pub async fn hold_login(ctx: &Ctx, identifier: Option<&str>) { + if let Some(id) = identifier + && let Some(remaining) = ctx.conns.delay_remaining(&ctx.router.normalize(id)) + { + 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; + } +} + +pub enum AuthWire { + Imap { tag: String }, + Pop3, + Smtp, + ManageSieve, +} + +enum AuthLine { + Continuation, + Passthrough, + FinalOk, + FinalFail, +} + +const SASL_EMPTY_RESPONSE: &[u8] = b"AQ==\r\n"; + +fn classify_auth_line(line: &[u8], wire: &AuthWire) -> AuthLine { + match wire { + AuthWire::Imap { tag } => { + if line.first() == Some(&b'+') { + AuthLine::Continuation + } else if line.len() > tag.len() + && line[..tag.len()].eq_ignore_ascii_case(tag.as_bytes()) + && line.get(tag.len()) == Some(&b' ') + { + let rest = line[tag.len() + 1..].trim_ascii_start(); + if rest.len() >= 2 && rest[..2].eq_ignore_ascii_case(b"OK") { + AuthLine::FinalOk + } else { + AuthLine::FinalFail + } + } else { + AuthLine::Passthrough + } + } + AuthWire::Pop3 => { + if line.starts_with(b"+ ") { + AuthLine::Continuation + } else if line.starts_with(b"+OK") { + AuthLine::FinalOk + } else { + AuthLine::FinalFail + } + } + AuthWire::Smtp => { + if line.len() < 3 { + return AuthLine::FinalFail; + } + let code = std::str::from_utf8(&line[..3]) + .ok() + .and_then(|s| s.parse::().ok()); + let sep = line.get(3).copied(); + match code { + Some(334) => AuthLine::Continuation, + _ if sep == Some(b'-') => AuthLine::Passthrough, + Some(c) if (200..300).contains(&c) => AuthLine::FinalOk, + _ => AuthLine::FinalFail, + } + } + AuthWire::ManageSieve => { + if line.first() == Some(&b'{') || line.first() == Some(&b'+') { + AuthLine::Continuation + } else { + let upper = line.trim_ascii_start().to_ascii_uppercase(); + if upper.starts_with(b"OK") { + AuthLine::FinalOk + } else if upper.starts_with(b"NO") || upper.starts_with(b"BYE") { + AuthLine::FinalFail + } else { + AuthLine::Passthrough + } + } + } + } +} + +fn generic_auth_failure(wire: &AuthWire) -> Vec { + match wire { + AuthWire::Imap { tag } => { + format!("{tag} NO [AUTHENTICATIONFAILED] Authentication failed.\r\n").into_bytes() + } + AuthWire::Pop3 => b"-ERR Authentication failed.\r\n".to_vec(), + AuthWire::Smtp => b"535 5.7.8 Authentication failed.\r\n".to_vec(), + AuthWire::ManageSieve => b"NO \"Authentication failed.\"\r\n".to_vec(), + } +} + +pub async fn complete_backend_auth( + client: &mut BoxedStream, + backend: &mut BoxedStream, + residual: &mut Vec, + wire: AuthWire, + hide: bool, + max: usize, +) -> Result { + let mut client_out: Vec = Vec::new(); + for _ in 0..64 { + let line = crate::outbound::read_line(backend, residual, max).await?; + match classify_auth_line(&line, &wire) { + AuthLine::Continuation => { + crate::outbound::write_all(backend, SASL_EMPTY_RESPONSE).await?; + } + AuthLine::Passthrough => { + client_out.extend_from_slice(&line); + client_out.extend_from_slice(b"\r\n"); + } + AuthLine::FinalOk => { + client_out.extend_from_slice(&line); + client_out.extend_from_slice(b"\r\n"); + write(client, &client_out).await?; + return Ok(true); + } + AuthLine::FinalFail => { + if hide { + client_out.extend_from_slice(&generic_auth_failure(&wire)); + } else { + client_out.extend_from_slice(&line); + client_out.extend_from_slice(b"\r\n"); + } + write(client, &client_out).await?; + return Ok(false); + } + } + } + Err(ProxyError::backend( + "backend auth dialogue did not complete", + )) +} + +#[allow(clippy::too_many_arguments)] +pub async fn bridge_authenticated( + ctx: &Ctx, + dest_id: &str, + mut stream: BoxedStream, + identifier: Option<&str>, + client_residual: &[u8], + started: Instant, + label: &'static str, + wire: AuthWire, + auth_frame: &[u8], + unavailable: &[u8], + max: usize, + open: F, +) -> Result<()> +where + F: FnMut() -> Fut, + Fut: Future)>>, +{ + if let Some(id) = identifier + && !crate::token::valid_routing_identifier(id) + { + tracing::warn!( + protocol = label, + destination = %dest_id, + "rejecting login: routing identifier contains invalid characters" + ); + let _ = write(&mut stream, unavailable).await; + return Ok(()); + } + + let (mut backend, mut backend_residual) = match establish(ctx, dest_id, open).await { + Ok(b) => b, + Err(e) => { + tracing::warn!(error = %e, protocol = label, destination = %dest_id, "backend unavailable"); + let _ = write(&mut stream, unavailable).await; + return Ok(()); + } + }; + + crate::outbound::write_all(&mut backend, auth_frame).await?; + + let hide = ctx.hide_auth_errors(dest_id); + let auth_ok = match tokio::time::timeout( + ctx.config.server.backend_timeout, + complete_backend_auth( + &mut stream, + &mut backend, + &mut backend_residual, + wire, + hide, + max, + ), + ) + .await + { + Ok(res) => res?, + Err(_) => { + return Err(ProxyError::backend("backend auth completion timed out")); + } + }; + + let guard = if auth_ok { + identifier.map(|id| ctx.register_conn(id)) + } else { + None + }; + let cancel = guard.as_ref().map(|g| g.cancel()); + let (to_backend, to_client) = crate::outbound::bridge( + stream, + backend, + client_residual, + &backend_residual, + ctx.config.server.bridge_idle, + cancel, + ) + .await?; + tracing::info!( + protocol = label, + destination = %dest_id, + bytes_to_backend = to_backend, + bytes_to_client = to_client, + secs = started.elapsed().as_secs_f64(), + "session closed" + ); + Ok(()) +} + +pub async fn write(stream: &mut BoxedStream, bytes: &[u8]) -> Result<()> { + stream + .write_all(bytes) + .await + .map_err(|e| ProxyError::Protocol(format!("writing client: {e}").into()))?; + stream + .flush() + .await + .map_err(|e| ProxyError::Protocol(format!("flushing client: {e}").into())) +} + +pub async fn upgrade_tls_inbound( + stream: BoxedStream, + acceptor: &TlsAcceptor, +) -> Result { + let tls = acceptor + .accept(stream) + .await + .map_err(|e| ProxyError::tls(format!("inbound STARTTLS handshake failed: {e}")))?; + Ok(Box::new(tls)) +} + +pub async fn fill(stream: &mut BoxedStream, buf: &mut Vec, max: usize) -> Result { + if buf.len() >= max { + return Err(ProxyError::preauth("pre-auth buffer exceeded maximum size")); + } + let mut scratch = [0u8; 8192]; + let want = scratch.len().min(max - buf.len()); + let n = stream + .read(&mut scratch[..want]) + .await + .map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?; + buf.extend_from_slice(&scratch[..n]); + Ok(n) +} + +pub async fn read_crlf_line( + stream: &mut BoxedStream, + seed: Vec, + max: usize, +) -> Result<(Vec, Vec)> { + let mut buf = seed; + let mut from = 0; + loop { + if let Some(pos) = buf[from..].iter().position(|&b| b == b'\n') { + let nl = from + pos; + let mut line = buf; + let residual = line.split_off(nl + 1); + line.truncate(nl); + if line.last() == Some(&b'\r') { + line.pop(); + } + return Ok((line, residual)); + } + from = buf.len(); + if buf.len() > max { + return Err(ProxyError::preauth("client line exceeded maximum length")); + } + let n = fill(stream, &mut buf, max + 8192).await?; + if n == 0 { + return Err(ProxyError::Closed); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advertises_token_handles_smtp_ehlo_continuation_lines() { + let ehlo = b"250-mail.example\r\n250-PIPELINING\r\n250-STARTTLS\r\n250-AUTH PLAIN LOGIN\r\n250 CHUNKING\r\n"; + assert!(advertises_token(ehlo, "STARTTLS")); + assert!(advertises_token(ehlo, "PIPELINING")); + assert!(advertises_token(ehlo, "CHUNKING")); + assert!(advertises_token(ehlo, "PLAIN")); + assert!(!advertises_token(ehlo, "BURL")); + } + + #[test] + fn advertises_token_handles_imap_and_pop3_capability_lines() { + let imap = b"* OK [CAPABILITY IMAP4rev2 SASL-IR STARTTLS AUTH=PLAIN] ready\r\n"; + assert!(advertises_token(imap, "STARTTLS")); + assert!(advertises_token(imap, "IMAP4rev2")); + let pop3 = b"+OK ready [XCLIENT]\r\n"; + assert!(advertises_token(pop3, "XCLIENT")); + } + + #[test] + fn smtp_auth_line_classification() { + assert!(matches!( + classify_auth_line(b"334", &AuthWire::Smtp), + AuthLine::Continuation + )); + assert!(matches!( + classify_auth_line(b"235", &AuthWire::Smtp), + AuthLine::FinalOk + )); + assert!(matches!( + classify_auth_line(b"235 2.7.0 Authentication successful", &AuthWire::Smtp), + AuthLine::FinalOk + )); + assert!(matches!( + classify_auth_line(b"535 5.7.8 nope", &AuthWire::Smtp), + AuthLine::FinalFail + )); + assert!(matches!( + classify_auth_line(b"250-PIPELINING", &AuthWire::Smtp), + AuthLine::Passthrough + )); + } + + #[test] + fn dest_health_opens_after_threshold_and_recovers() { + let h = DestHealth::default(); + let cooldown = Duration::from_secs(60); + + h.record(false, 3, cooldown); + h.record(false, 3, cooldown); + assert!(h.gate().is_ok()); + + h.record(false, 3, cooldown); + assert!(h.gate().is_err()); + + h.record(true, 3, cooldown); + assert!(h.gate().is_ok()); + } + + #[test] + fn dest_health_cooldown_elapses_into_a_probe() { + let h = DestHealth::default(); + h.record(false, 1, Duration::from_secs(0)); + assert!(h.gate().is_ok()); + } + + #[tokio::test] + async fn conn_registry_kick_signals_live_sessions() { + let reg = ConnRegistry::default(); + let guard = reg.register("user@example.com"); + let mut rx = guard.cancel(); + assert_eq!(reg.kick("user@example.com"), 1); + assert!(rx.changed().await.is_ok()); + assert!(*rx.borrow()); + assert_eq!(reg.kick("nobody@example.com"), 0); + } + + #[test] + fn conn_registry_deregisters_on_drop() { + let reg = ConnRegistry::default(); + { + let _g = reg.register("user@example.com"); + assert_eq!(reg.kick("user@example.com"), 1); + } + assert_eq!(reg.kick("user@example.com"), 0); + } + + #[test] + fn conn_registry_delay_window() { + let reg = ConnRegistry::default(); + assert!(reg.delay_remaining("user@example.com").is_none()); + reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(30)); + assert!(reg.delay_remaining("user@example.com").is_some()); + reg.set_delay("user@example.com", Instant::now()); + assert!(reg.delay_remaining("user@example.com").is_none()); + } + + #[test] + fn dest_health_threshold_zero_never_trips() { + let h = DestHealth::default(); + for _ in 0..100 { + h.record(false, 0, Duration::from_secs(60)); + } + assert!(h.gate().is_ok()); + } +} diff --git a/src/proto/forward.rs b/src/proto/forward.rs new file mode 100644 index 0000000..5b9acda --- /dev/null +++ b/src/proto/forward.rs @@ -0,0 +1,320 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::config::{DestProtocol, DestinationConfig, Forwarding}; +use crate::error::{ProxyError, Result}; +use crate::net::BoxedStream; +use crate::outbound; +use crate::proto::common::advertises_token; + +const CLIENT_TRANSPORT_TLS: &str = "TLS"; +const CLIENT_TRANSPORT_INSECURE: &str = "insecure"; + +pub struct ForwardInfo { + pub peer: SocketAddr, + pub local: SocketAddr, + pub client_tls: bool, + pub helo: Option, + pub session: String, + pub ttl: u32, +} + +impl ForwardInfo { + pub fn new( + peer: SocketAddr, + local: SocketAddr, + client_tls: bool, + helo: Option, + inbound_ttl: u32, + ) -> Self { + ForwardInfo { + peer, + local, + client_tls, + helo, + session: new_session_id(), + ttl: inbound_ttl.saturating_sub(1), + } + } + + fn transport(&self) -> &'static str { + if self.client_tls { + CLIENT_TRANSPORT_TLS + } else { + CLIENT_TRANSPORT_INSECURE + } + } +} + +fn new_session_id() -> String { + static SEQ: AtomicU64 = AtomicU64::new(0); + let n = SEQ.fetch_add(1, Ordering::Relaxed); + format!("P{n:x}") +} + +pub fn guard_loop( + self_binds: &ahash::AHashSet, + dest: &DestinationConfig, + ep: &DestProtocol, + forwarding: Forwarding, + inbound_ttl: u32, +) -> Result<()> { + if let Ok(ip) = dest.host.parse::() + && self_binds.contains(&SocketAddr::new(ip, ep.port)) + { + return Err(ProxyError::backend( + "proxying loop: destination is one of our own listeners", + )); + } + if forwarding == Forwarding::Xclient && inbound_ttl <= 1 { + return Err(ProxyError::backend( + "proxying loop: forwarded TTL exhausted", + )); + } + Ok(()) +} + +fn addr_for_smtp(ip: IpAddr) -> String { + match ip { + IpAddr::V4(v4) => v4.to_string(), + IpAddr::V6(v6) => format!("IPV6:{v6}"), + } +} + +pub fn imap_id_fields(fwd: &ForwardInfo) -> String { + format!( + "(\"x-originating-ip\" \"{}\" \"x-originating-port\" \"{}\" \ + \"x-connected-ip\" \"{}\" \"x-connected-port\" \"{}\" \ + \"x-proxy-ttl\" \"{}\" \"x-session-id\" \"{}\" \ + \"x-client-transport\" \"{}\")", + fwd.peer.ip(), + fwd.peer.port(), + fwd.local.ip(), + fwd.local.port(), + fwd.ttl, + fwd.session, + fwd.transport(), + ) +} + +fn xclient_pairs(fwd: &ForwardInfo, smtp: bool) -> Vec<(&'static str, String)> { + let (addr, destaddr) = if smtp { + (addr_for_smtp(fwd.peer.ip()), addr_for_smtp(fwd.local.ip())) + } else { + (fwd.peer.ip().to_string(), fwd.local.ip().to_string()) + }; + let mut pairs = vec![ + ("ADDR", addr), + ("PORT", fwd.peer.port().to_string()), + ("DESTADDR", destaddr), + ("DESTPORT", fwd.local.port().to_string()), + ("SESSION", fwd.session.clone()), + ("TTL", fwd.ttl.to_string()), + ("CLIENT-TRANSPORT", fwd.transport().to_string()), + ]; + if smtp { + if let Some(helo) = &fwd.helo + && !helo.is_empty() + && crate::token::valid_routing_identifier(helo) + { + pairs.push(("HELO", helo.clone())); + } + pairs.push(("PROTO", "ESMTP".to_string())); + } + pairs +} + +fn join_pairs(pairs: &[(&'static str, String)]) -> String { + pairs + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join(" ") +} + +pub fn xclient_params(fwd: &ForwardInfo, smtp: bool) -> String { + join_pairs(&xclient_pairs(fwd, smtp)) +} + +pub fn xclient_params_advertised(fwd: &ForwardInfo, advertised: &[String]) -> String { + let pairs: Vec<(&'static str, String)> = xclient_pairs(fwd, true) + .into_iter() + .filter(|(k, _)| advertised.iter().any(|a| a.eq_ignore_ascii_case(k))) + .collect(); + join_pairs(&pairs) +} + +pub async fn send_imap_id( + backend: &mut BoxedStream, + residual: &mut Vec, + banner: &[u8], + fwd: &ForwardInfo, + max: usize, +) -> Result<()> { + if !advertises_token(banner, "ID") { + return Ok(()); + } + let cmd = format!("F1 ID {}\r\n", imap_id_fields(fwd)); + outbound::write_all(backend, cmd.as_bytes()).await?; + for _ in 0..16 { + let line = outbound::read_line(backend, residual, max).await?; + if line + .trim_ascii_start() + .to_ascii_uppercase() + .starts_with(b"F1 ") + { + return Ok(()); + } + } + Err(ProxyError::backend("backend ID reply was not tagged")) +} + +pub async fn send_pop3_xclient( + backend: &mut BoxedStream, + residual: &mut Vec, + advertised: bool, + fwd: &ForwardInfo, + max: usize, +) -> Result<()> { + if !advertised { + return Ok(()); + } + let cmd = format!("XCLIENT {}\r\n", xclient_params(fwd, false)); + outbound::write_all(backend, cmd.as_bytes()).await?; + let resp = outbound::read_line(backend, residual, max).await?; + if !resp.starts_with(b"+OK") { + return Err(ProxyError::backend("backend rejected XCLIENT")); + } + Ok(()) +} + +pub async fn send_managesieve_xclient( + backend: &mut BoxedStream, + residual: &mut Vec, + advertised: bool, + fwd: &ForwardInfo, + max: usize, +) -> Result<()> { + if !advertised { + return Ok(()); + } + let cmd = format!("XCLIENT {}\r\n", xclient_params(fwd, false)); + outbound::write_all(backend, cmd.as_bytes()).await?; + let resp = outbound::read_line(backend, residual, max).await?; + if !resp.to_ascii_uppercase().starts_with(b"OK") { + return Err(ProxyError::backend("backend rejected XCLIENT")); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn fwd() -> ForwardInfo { + ForwardInfo { + peer: "203.0.113.5:40000".parse().unwrap(), + local: "192.0.2.10:993".parse().unwrap(), + client_tls: true, + helo: Some("client.example".into()), + session: "Pdead".into(), + ttl: 6, + } + } + + #[test] + fn imap_id_has_core_fields() { + let s = imap_id_fields(&fwd()); + assert!(s.contains("\"x-originating-ip\" \"203.0.113.5\"")); + assert!(s.contains("\"x-proxy-ttl\" \"6\"")); + assert!(s.contains("\"x-client-transport\" \"TLS\"")); + assert!(s.starts_with('(') && s.ends_with(')')); + } + + #[test] + fn xclient_pop3_omits_login_and_helo() { + let s = xclient_params(&fwd(), false); + assert!(s.contains("ADDR=203.0.113.5 PORT=40000")); + assert!(s.contains("CLIENT-TRANSPORT=TLS")); + assert!(!s.contains("LOGIN=")); + assert!(!s.contains("HELO=")); + } + + #[test] + fn xclient_smtp_includes_helo_proto_and_v6_prefix_but_never_login() { + let mut f = fwd(); + f.peer = "[2001:db8::5]:40000".parse().unwrap(); + let s = xclient_params(&f, true); + assert!(s.contains("ADDR=IPV6:2001:db8::5")); + assert!(s.contains("HELO=client.example")); + assert!(s.contains("PROTO=ESMTP")); + assert!( + !s.contains("LOGIN="), + "LOGIN must not be forwarded: the proxy replays AUTH; XCLIENT LOGIN would pre-authenticate the backend" + ); + } + + #[test] + fn xclient_advertised_filters_unsupported_attributes() { + let advertised: Vec = ["ADDR", "PORT", "HELO", "PROTO", "DESTADDR", "DESTPORT"] + .iter() + .map(|s| s.to_string()) + .collect(); + let s = xclient_params_advertised(&fwd(), &advertised); + assert!(s.contains("ADDR=203.0.113.5")); + assert!(s.contains("PROTO=ESMTP")); + assert!(!s.contains("SESSION="), "Postfix rejects SESSION: {s}"); + assert!(!s.contains("TTL="), "Postfix rejects TTL: {s}"); + assert!( + !s.contains("CLIENT-TRANSPORT="), + "Postfix rejects CLIENT-TRANSPORT: {s}" + ); + } + + #[test] + fn guard_loop_rejects_self_and_exhausted_ttl() { + use crate::config::{DestProtocol, TlsMode}; + use ahash::AHashSet; + + let mut binds = AHashSet::new(); + binds.insert("127.0.0.1:993".parse().unwrap()); + + let mut dest: DestinationConfig = toml::from_str("host = \"127.0.0.1\"").expect("dest"); + let ep = DestProtocol { + port: 993, + tls: TlsMode::Implicit, + forwarding: None, + }; + assert!(guard_loop(&binds, &dest, &ep, Forwarding::Proxy, 7).is_err()); + + let ep2 = DestProtocol { + port: 143, + tls: TlsMode::Implicit, + forwarding: None, + }; + assert!(guard_loop(&binds, &dest, &ep2, Forwarding::Proxy, 7).is_ok()); + + dest.host = "10.0.0.9".to_string(); + assert!(guard_loop(&binds, &dest, &ep2, Forwarding::Xclient, 1).is_err()); + assert!(guard_loop(&binds, &dest, &ep2, Forwarding::Proxy, 1).is_ok()); + } + + #[test] + fn ttl_decrements_on_construction() { + let f = ForwardInfo::new( + "203.0.113.5:40000".parse().unwrap(), + "192.0.2.10:993".parse().unwrap(), + false, + None, + 7, + ); + assert_eq!(f.ttl, 6); + assert_eq!(f.transport(), "insecure"); + } +} diff --git a/src/proto/imap.rs b/src/proto/imap.rs new file mode 100644 index 0000000..0706a92 --- /dev/null +++ b/src/proto/imap.rs @@ -0,0 +1,497 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::SocketAddr; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::AsyncReadExt; +use tokio::time::timeout; + +use crate::config::{Forwarding, ImapCapabilities, ListenerConfig, Protocol, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::imap_receiver::{Command, Error as RecvError, Mechanism, Receiver, Request, Token}; +use crate::net::BoxedStream; +use crate::outbound; +use crate::proto::common::{ + self, Ctx, assert_egress_encrypted, mechanism_name, plain_auth_allowed, read_crlf_line, write, +}; +use crate::proto::forward; +use crate::sasl::decode_sasl_ir; +use crate::token::identifier_from_credential; + +const MAX_PREAUTH: usize = 64 * 1024; + +enum Decision { + Authenticate { + stream: BoxedStream, + mech: Mechanism, + tag: String, + raw_challenge: Vec, + identifier: Option, + residual: Vec, + }, + Closed, +} + +pub async fn handle( + ctx: &Ctx, + listener: &ListenerConfig, + stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + let caps = &ctx.config.capabilities.imap; + + let decision = match timeout( + listener.preauth_timeout, + preauth(ctx, listener, caps, stream), + ) + .await + { + Ok(Ok(d)) => d, + Ok(Err(e)) => return Err(e), + Err(_) => return Ok(()), + }; + + let Decision::Authenticate { + stream, + mech, + tag, + raw_challenge, + identifier, + residual, + } = decision + else { + return Ok(()); + }; + + let identifier = ctx.routing_identifier(identifier); + let dest_id = ctx.resolve(identifier.as_deref()).await; + tracing::debug!(destination = %dest_id, "imap routing decision"); + + let started = std::time::Instant::now(); + common::hold_login(ctx, identifier.as_deref()).await; + + let client_tls = stream.is_tls(); + let fwd = forward::ForwardInfo::new(peer, local, client_tls, None, ctx.config.server.proxy_ttl); + + let auth_frame = format!( + "{tag} AUTHENTICATE {} {}\r\n", + mechanism_name(&mech), + BASE64.encode(&raw_challenge) + ); + let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n"); + + common::bridge_authenticated( + ctx, + &dest_id, + stream, + identifier.as_deref(), + &residual, + started, + "imap", + common::AuthWire::Imap { tag }, + auth_frame.as_bytes(), + unavailable.as_bytes(), + MAX_PREAUTH, + || open_backend(ctx, &dest_id, &fwd), + ) + .await +} + +async fn preauth( + ctx: &Ctx, + listener: &ListenerConfig, + caps: &ImapCapabilities, + mut stream: BoxedStream, +) -> Result { + let starttls_listener = listener.tls == TlsMode::Starttls; + let mut auth = Auth { + ctx, + caps, + is_tls: stream.is_tls(), + max_attempts: listener.max_auth_attempts, + auth_failures: 0, + }; + + 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::::new(); + receiver.max_request_size = MAX_PREAUTH; + let mut read = [0u8; 8192]; + + loop { + let n = stream + .read(&mut read) + .await + .map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?; + if n == 0 { + return Ok(Decision::Closed); + } + let mut it = read[..n].iter(); + + loop { + match receiver.parse(&mut it) { + Ok(req) => { + let Request { + tag, + command, + tokens, + } = req; + match command { + Command::Capability => { + let resp = format!( + "* CAPABILITY {}\r\n{tag} OK CAPABILITY completed.\r\n", + ctx.greetings + .imap + .get(auth.is_tls, starttls_listener && !auth.is_tls) + ); + write(&mut stream, resp.as_bytes()).await?; + } + Command::Noop => { + write( + &mut stream, + format!("{tag} OK NOOP completed.\r\n").as_bytes(), + ) + .await?; + } + Command::Id => { + write( + &mut stream, + format!("* ID NIL\r\n{tag} OK ID completed.\r\n").as_bytes(), + ) + .await?; + } + Command::Logout => { + write( + &mut stream, + format!("* BYE Logging out\r\n{tag} OK LOGOUT completed.\r\n") + .as_bytes(), + ) + .await?; + return Ok(Decision::Closed); + } + Command::StartTls => { + if !starttls_listener || auth.is_tls { + write( + &mut stream, + format!("{tag} BAD STARTTLS not available.\r\n").as_bytes(), + ) + .await?; + continue; + } + let Some(acceptor) = ctx.tls_acceptor.as_ref() else { + write( + &mut stream, + format!("{tag} NO STARTTLS not configured.\r\n").as_bytes(), + ) + .await?; + continue; + }; + write( + &mut stream, + format!("{tag} OK Begin TLS negotiation now.\r\n").as_bytes(), + ) + .await?; + stream = common::upgrade_tls_inbound(stream, acceptor).await?; + auth.is_tls = true; + receiver = Receiver::::new(); + receiver.max_request_size = MAX_PREAUTH; + break; + } + Command::Authenticate => { + match auth + .authenticate(&mut stream, &tokens, &tag, &mut it) + .await? + { + Step::Finalize { + mech, + raw_challenge, + identifier, + residual, + } => { + return Ok(Decision::Authenticate { + stream, + mech, + tag, + raw_challenge, + identifier, + residual, + }); + } + Step::Continue => {} + Step::Close => return Ok(Decision::Closed), + } + } + Command::Login => { + match auth.login(&mut stream, &tokens, &tag, &mut it).await? { + Step::Finalize { + mech, + raw_challenge, + identifier, + residual, + } => { + return Ok(Decision::Authenticate { + stream, + mech, + tag, + raw_challenge, + identifier, + residual, + }); + } + Step::Continue => {} + Step::Close => return Ok(Decision::Closed), + } + } + Command::Enable | Command::Unauthenticate | Command::Other => { + write( + &mut stream, + format!("{tag} BAD Command not allowed in this state.\r\n") + .as_bytes(), + ) + .await?; + } + } + } + Err(RecvError::NeedsMoreData) => break, + Err(RecvError::NeedsLiteral { size }) => { + write( + &mut stream, + format!("+ Ready for {size} bytes.\r\n").as_bytes(), + ) + .await?; + break; + } + Err(RecvError::Parse { message }) => { + write(&mut stream, format!("* BAD {message}\r\n").as_bytes()).await?; + auth.auth_failures += 1; + if auth.auth_failures >= auth.max_attempts { + return Ok(Decision::Closed); + } + } + } + } + } +} + +struct Auth<'a> { + ctx: &'a Ctx, + caps: &'a ImapCapabilities, + is_tls: bool, + max_attempts: u32, + auth_failures: u32, +} + +enum Step { + Continue, + Close, + Finalize { + mech: Mechanism, + raw_challenge: Vec, + identifier: Option, + residual: Vec, + }, +} + +impl Auth<'_> { + async fn authenticate( + &mut self, + stream: &mut BoxedStream, + tokens: &[Token], + tag: &str, + it: &mut std::slice::Iter<'_, u8>, + ) -> Result { + let Some(mech_tok) = tokens.first() else { + write( + &mut *stream, + format!("{tag} BAD Missing mechanism.\r\n").as_bytes(), + ) + .await?; + return Ok(Step::Continue); + }; + let mech = Mechanism::parse(mech_tok.as_bytes()); + + match mech { + Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {} + Mechanism::Login => { + return self + .reject(stream, tag, "AUTH LOGIN is not supported.") + .await; + } + Mechanism::Other(_) => { + return self + .reject(stream, tag, "Unsupported authentication mechanism.") + .await; + } + } + + if matches!(mech, Mechanism::Plain) + && !plain_auth_allowed(self.caps.allow_plain_auth_without_tls, self.is_tls) + { + write( + &mut *stream, + format!("{tag} NO [PRIVACYREQUIRED] Cleartext authentication requires TLS.\r\n") + .as_bytes(), + ) + .await?; + return Ok(Step::Continue); + } + + let (raw_b64, residual): (Vec, Vec) = if let Some(ir) = tokens.get(1) { + (ir.as_bytes().to_vec(), it.as_slice().to_vec()) + } else { + write(&mut *stream, b"+ \r\n").await?; + let seed = it.as_slice().to_vec(); + read_crlf_line(stream, seed, MAX_PREAUTH).await? + }; + + let Some(raw) = decode_sasl_ir(&raw_b64) else { + return self + .reject(stream, tag, "Invalid base64 in SASL response.") + .await; + }; + + self.finish(stream, mech, tag, raw, residual).await + } + + async fn login( + &mut self, + stream: &mut BoxedStream, + tokens: &[Token], + tag: &str, + it: &mut std::slice::Iter<'_, u8>, + ) -> Result { + if !plain_auth_allowed(self.caps.allow_plain_auth_without_tls, self.is_tls) { + write( + &mut *stream, + format!("{tag} NO [PRIVACYREQUIRED] Cleartext login requires TLS.\r\n").as_bytes(), + ) + .await?; + return Ok(Step::Continue); + } + let (Some(user), Some(pass)) = (tokens.first(), tokens.get(1)) else { + return self + .reject(stream, tag, "LOGIN requires a username and password.") + .await; + }; + let (user, pass) = (user.as_bytes(), pass.as_bytes()); + let mut raw = Vec::with_capacity(user.len() + pass.len() + 2); + raw.push(0); + raw.extend_from_slice(user); + raw.push(0); + raw.extend_from_slice(pass); + let residual = it.as_slice().to_vec(); + + self.finish(stream, Mechanism::Plain, tag, raw, residual) + .await + } + + async fn finish( + &mut self, + stream: &mut BoxedStream, + mech: Mechanism, + tag: &str, + raw: Vec, + residual: Vec, + ) -> Result { + let Some(cred) = common::credential_from_sasl(&mech, &raw) else { + return self.reject(stream, tag, "Malformed credentials.").await; + }; + let identifier = + identifier_from_credential(&cred, &self.ctx.config.oauth.jwt_username_claim); + + Ok(Step::Finalize { + mech, + raw_challenge: raw, + identifier, + residual, + }) + } + + async fn reject(&mut self, stream: &mut BoxedStream, tag: &str, message: &str) -> Result { + write(&mut *stream, format!("{tag} NO {message}\r\n").as_bytes()).await?; + self.auth_failures += 1; + if self.auth_failures >= self.max_attempts { + Ok(Step::Close) + } else { + Ok(Step::Continue) + } + } +} + +async fn open_backend( + ctx: &Ctx, + dest_id: &str, + fwd: &forward::ForwardInfo, +) -> Result<(BoxedStream, Vec)> { + let common::DialedEndpoint { + dest, + ep, + tls_cfg, + forwarding, + mut backend, + } = ctx + .dial_endpoint(dest_id, Protocol::Imap, fwd.peer, fwd.local) + .await?; + let mut residual = Vec::new(); + + let banner = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?; + + if ep.tls == TlsMode::Starttls { + if banner.contains(&b'[') && !common::advertises_token(&banner, "STARTTLS") { + return Err(ProxyError::backend("backend did not advertise STARTTLS")); + } + outbound::write_all(&mut backend, b"P1 STARTTLS\r\n").await?; + let resp = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?; + if !tagged_ok(&resp, b"P1") { + return Err(ProxyError::backend("backend refused STARTTLS")); + } + backend = outbound::tls_connect(backend, &tls_cfg, dest).await?; + } + + assert_egress_encrypted(dest, ep, backend.is_tls())?; + + if forwarding == Forwarding::Xclient { + forward::send_imap_id(&mut backend, &mut residual, &banner, fwd, MAX_PREAUTH).await?; + } + + Ok((backend, residual)) +} + +fn tagged_ok(line: &[u8], tag: &[u8]) -> bool { + let line = line.trim_ascii(); + line.len() > tag.len() + && line[..tag.len()].eq_ignore_ascii_case(tag) + && line[tag.len()..] + .iter() + .skip_while(|b| b.is_ascii_whitespace()) + .take(2) + .copied() + .eq(b"OK".iter().copied()) +} + +pub fn capability_string(caps: &ImapCapabilities, offer_starttls: bool, is_tls: bool) -> String { + let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls); + let mut toks: Vec = caps.advertise.clone(); + for m in common::advertised_sasl(&caps.sasl, plain_ok) { + toks.push(format!("AUTH={m}")); + } + if !plain_ok { + toks.push("LOGINDISABLED".to_string()); + } + if offer_starttls { + toks.push("STARTTLS".to_string()); + } + toks.join(" ") +} diff --git a/src/proto/managesieve.rs b/src/proto/managesieve.rs new file mode 100644 index 0000000..fe264fe --- /dev/null +++ b/src/proto/managesieve.rs @@ -0,0 +1,453 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::SocketAddr; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::AsyncReadExt; +use tokio::time::timeout; + +use crate::config::{Forwarding, ListenerConfig, ManageSieveCapabilities, Protocol, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::imap_receiver::{Command, Error as RecvError, Mechanism, Receiver, State}; +use crate::net::BoxedStream; +use crate::outbound; +use crate::proto::common::{ + self, Ctx, Handoff, assert_egress_encrypted, credential_from_sasl, mechanism_name, + plain_auth_allowed, write, +}; +use crate::proto::forward; +use crate::sasl::decode_sasl_ir; +use crate::token::identifier_from_credential; + +const MAX_PREAUTH: usize = 64 * 1024; + +pub async fn handle( + ctx: &Ctx, + listener: &ListenerConfig, + stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + let caps = &ctx.config.capabilities.managesieve; + + let handoff = match timeout( + listener.preauth_timeout, + preauth(ctx, listener, caps, stream), + ) + .await + { + Ok(Ok(Some(h))) => h, + Ok(Ok(None)) => return Ok(()), + Ok(Err(e)) => return Err(e), + Err(_) => return Ok(()), + }; + + let Handoff { + stream, + mech, + raw_challenge, + identifier, + residual, + } = handoff; + + let identifier = ctx.routing_identifier(identifier); + let dest_id = ctx.resolve(identifier.as_deref()).await; + let started = std::time::Instant::now(); + common::hold_login(ctx, identifier.as_deref()).await; + let fwd = forward::ForwardInfo::new( + peer, + local, + stream.is_tls(), + None, + ctx.config.server.proxy_ttl, + ); + + let auth_frame = format!( + "AUTHENTICATE \"{}\" \"{}\"\r\n", + mechanism_name(&mech), + BASE64.encode(&raw_challenge) + ); + + common::bridge_authenticated( + ctx, + &dest_id, + stream, + identifier.as_deref(), + &residual, + started, + "managesieve", + common::AuthWire::ManageSieve, + auth_frame.as_bytes(), + b"NO \"backend unavailable\"\r\n", + MAX_PREAUTH, + || open_backend(ctx, &dest_id, &fwd), + ) + .await +} + +async fn preauth( + ctx: &Ctx, + listener: &ListenerConfig, + caps: &ManageSieveCapabilities, + mut stream: BoxedStream, +) -> Result> { + let mut is_tls = stream.is_tls(); + let starttls_listener = listener.tls == TlsMode::Starttls; + + write( + &mut stream, + ctx.greetings + .managesieve + .get(is_tls, starttls_listener && !is_tls) + .as_bytes(), + ) + .await?; + + let mut receiver = + Receiver::::new().with_start_state(State::Command { is_uid: false }); + receiver.max_request_size = MAX_PREAUTH; + let mut auth_failures = 0u32; + let mut read = [0u8; 8192]; + + loop { + let n = stream + .read(&mut read) + .await + .map_err(|e| ProxyError::Protocol(format!("reading client: {e}").into()))?; + if n == 0 { + return Ok(None); + } + let mut it = read[..n].iter(); + + loop { + match receiver.parse(&mut it) { + Ok(req) => { + let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls); + match req.command { + Command::Capability => { + write( + &mut stream, + ctx.greetings + .managesieve + .get(is_tls, starttls_listener && !is_tls) + .as_bytes(), + ) + .await?; + } + Command::Noop => { + write(&mut stream, &noop_response(&req.tokens)).await?; + } + Command::Logout => { + write(&mut stream, b"OK \"Logout completed.\"\r\n").await?; + return Ok(None); + } + Command::StartTls => { + if !starttls_listener || is_tls { + write(&mut stream, b"NO \"STARTTLS not available\"\r\n").await?; + continue; + } + let Some(acceptor) = ctx.tls_acceptor.as_ref() else { + write(&mut stream, b"NO \"STARTTLS not configured\"\r\n").await?; + continue; + }; + write(&mut stream, b"OK \"Begin TLS negotiation now.\"\r\n").await?; + stream = common::upgrade_tls_inbound(stream, acceptor).await?; + is_tls = true; + receiver = Receiver::::new() + .with_start_state(State::Command { is_uid: false }); + receiver.max_request_size = MAX_PREAUTH; + write( + &mut stream, + ctx.greetings.managesieve.get(is_tls, false).as_bytes(), + ) + .await?; + break; + } + Command::Authenticate => { + match handle_authenticate( + ctx, + &mut stream, + plain_ok, + &req.tokens, + &mut it, + &mut auth_failures, + listener.max_auth_attempts, + ) + .await? + { + AuthStep::Done { + mech, + raw_challenge, + identifier, + residual, + } => { + return Ok(Some(Handoff { + stream, + mech, + raw_challenge, + identifier, + residual, + })); + } + AuthStep::Continue => {} + AuthStep::Close => return Ok(None), + } + } + _ => { + write( + &mut stream, + b"NO \"Command not allowed in this state.\"\r\n", + ) + .await?; + } + } + } + Err(RecvError::NeedsMoreData) => break, + Err(RecvError::NeedsLiteral { size }) => { + write(&mut stream, format!("{{{size}}}\r\n").as_bytes()).await?; + break; + } + Err(RecvError::Parse { message }) => { + write( + &mut stream, + format!("NO \"{}\"\r\n", sanitize(&message)).as_bytes(), + ) + .await?; + auth_failures += 1; + if auth_failures >= listener.max_auth_attempts { + return Ok(None); + } + } + } + } + } +} + +enum AuthStep { + Done { + mech: Mechanism, + raw_challenge: Vec, + identifier: Option, + residual: Vec, + }, + Continue, + Close, +} + +async fn handle_authenticate( + ctx: &Ctx, + stream: &mut BoxedStream, + plain_ok: bool, + tokens: &[crate::imap_receiver::Token], + it: &mut std::slice::Iter<'_, u8>, + auth_failures: &mut u32, + max_attempts: u32, +) -> Result { + let Some(mech_tok) = tokens.first() else { + write(&mut *stream, b"NO \"Missing mechanism\"\r\n").await?; + return Ok(AuthStep::Continue); + }; + let mech = Mechanism::parse(mech_tok.as_bytes()); + match mech { + Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {} + _ => return fail(stream, "Unsupported mechanism", auth_failures, max_attempts).await, + } + if matches!(mech, Mechanism::Plain) && !plain_ok { + write( + &mut *stream, + b"NO \"Cleartext authentication requires TLS\"\r\n", + ) + .await?; + return Ok(AuthStep::Continue); + } + + let (raw_b64, residual): (Vec, Vec) = if let Some(ir) = tokens.get(1) { + (ir.as_bytes().to_vec(), it.as_slice().to_vec()) + } else { + write(&mut *stream, b"{0}\r\n").await?; + let seed = it.as_slice().to_vec(); + read_sasl_response(stream, seed).await? + }; + + let Some(raw) = decode_sasl_ir(&raw_b64) else { + return fail(stream, "Invalid base64", auth_failures, max_attempts).await; + }; + let Some(cred) = credential_from_sasl(&mech, &raw) else { + return fail(stream, "Malformed credentials", auth_failures, max_attempts).await; + }; + let identifier = identifier_from_credential(&cred, &ctx.config.oauth.jwt_username_claim); + + Ok(AuthStep::Done { + mech, + raw_challenge: raw, + identifier, + residual, + }) +} + +async fn fail( + stream: &mut BoxedStream, + message: &str, + auth_failures: &mut u32, + max_attempts: u32, +) -> Result { + write(&mut *stream, format!("NO \"{message}\"\r\n").as_bytes()).await?; + *auth_failures += 1; + if *auth_failures >= max_attempts { + Ok(AuthStep::Close) + } else { + Ok(AuthStep::Continue) + } +} + +async fn read_sasl_response(stream: &mut BoxedStream, seed: Vec) -> Result<(Vec, Vec)> { + let (line, residual) = common::read_crlf_line(stream, seed, MAX_PREAUTH).await?; + if line.first() == Some(&b'{') && line.last() == Some(&b'}') { + let inner = &line[1..line.len() - 1]; + let inner = inner.strip_suffix(b"+").unwrap_or(inner); + let size: usize = std::str::from_utf8(inner) + .ok() + .and_then(|s| s.trim().parse().ok()) + .ok_or_else(|| ProxyError::preauth("invalid literal size"))?; + if size > MAX_PREAUTH { + return Err(ProxyError::preauth("literal too large")); + } + let (data, mut rest) = read_exact_from(stream, residual, size).await?; + if rest.starts_with(b"\r\n") { + rest.drain(..2); + } else if rest.starts_with(b"\n") { + rest.drain(..1); + } + Ok((data, rest)) + } else { + Ok((line, residual)) + } +} + +async fn read_exact_from( + stream: &mut BoxedStream, + mut seed: Vec, + n: usize, +) -> Result<(Vec, Vec)> { + while seed.len() < n { + let got = common::fill(stream, &mut seed, n + 8192).await?; + if got == 0 { + return Err(ProxyError::Closed); + } + } + let residual = seed.split_off(n); + Ok((seed, residual)) +} + +fn sanitize(s: &str) -> String { + s.replace(['"', '\r', '\n'], " ") +} + +fn noop_response(tokens: &[crate::imap_receiver::Token]) -> Vec { + match tokens + .first() + .map(|t| t.as_bytes()) + .filter(|t| !t.is_empty()) + { + Some(tag) => { + let mut out = Vec::with_capacity(tag.len() + 24); + out.extend_from_slice(b"OK (TAG {"); + out.extend_from_slice(tag.len().to_string().as_bytes()); + out.extend_from_slice(b"}\r\n"); + out.extend_from_slice(tag); + out.extend_from_slice(b") \"Done\"\r\n"); + out + } + None => b"OK \"NOOP completed.\"\r\n".to_vec(), + } +} + +pub(crate) fn capability_block( + caps: &ManageSieveCapabilities, + offer_starttls: bool, + is_tls: bool, +) -> String { + let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls); + use std::fmt::Write; + let sasl = common::advertised_sasl(&caps.sasl, plain_ok); + let mut s = String::new(); + let _ = write!(s, "\"IMPLEMENTATION\" \"{}\"\r\n", caps.implementation); + s.push_str("\"VERSION\" \"1.0\"\r\n"); + let _ = write!(s, "\"SASL\" \"{}\"\r\n", sasl.join(" ")); + let _ = write!(s, "\"SIEVE\" \"{}\"\r\n", caps.sieve.join(" ")); + if offer_starttls { + s.push_str("\"STARTTLS\"\r\n"); + } + s.push_str("OK \"Stalwart ManageSieve ready.\"\r\n"); + s +} + +async fn open_backend( + ctx: &Ctx, + dest_id: &str, + fwd: &forward::ForwardInfo, +) -> Result<(BoxedStream, Vec)> { + let common::DialedEndpoint { + dest, + ep, + tls_cfg, + forwarding, + mut backend, + } = ctx + .dial_endpoint(dest_id, Protocol::ManageSieve, fwd.peer, fwd.local) + .await?; + let mut residual = Vec::new(); + + let mut capabilities = read_capabilities(&mut backend, &mut residual).await?; + + if ep.tls == TlsMode::Starttls { + if !common::advertises_token(&capabilities, "STARTTLS") { + return Err(ProxyError::backend("backend did not advertise STARTTLS")); + } + outbound::write_all(&mut backend, b"STARTTLS\r\n").await?; + let resp = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?; + if !resp.to_ascii_uppercase().starts_with(b"OK") { + return Err(ProxyError::backend("backend refused STARTTLS")); + } + backend = outbound::tls_connect(backend, &tls_cfg, dest).await?; + capabilities = read_capabilities(&mut backend, &mut residual).await?; + } + + assert_egress_encrypted(dest, ep, backend.is_tls())?; + + if forwarding == Forwarding::Xclient { + let advertised = common::advertises_token(&capabilities, "XCLIENT"); + forward::send_managesieve_xclient( + &mut backend, + &mut residual, + advertised, + fwd, + MAX_PREAUTH, + ) + .await?; + } + + Ok((backend, residual)) +} + +async fn read_capabilities(stream: &mut BoxedStream, residual: &mut Vec) -> Result> { + let mut capabilities = Vec::new(); + for _ in 0..256 { + let line = outbound::read_line(stream, residual, MAX_PREAUTH).await?; + let upper = line.to_ascii_uppercase(); + let done = + upper.starts_with(b"OK") || upper.starts_with(b"NO") || upper.starts_with(b"BYE"); + capabilities.extend_from_slice(&line); + capabilities.push(b'\n'); + if done { + return Ok(capabilities); + } + } + Err(ProxyError::backend( + "backend sent too many capability lines", + )) +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs new file mode 100644 index 0000000..11fe5ba --- /dev/null +++ b/src/proto/mod.rs @@ -0,0 +1,38 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod common; +pub mod forward; +pub mod imap; +pub mod managesieve; +pub mod pop3; +pub mod smtp; + +use std::net::SocketAddr; + +use crate::config::{ListenerConfig, Protocol}; +use crate::error::Result; +use crate::net::BoxedStream; +use common::Ctx; + +pub async fn dispatch( + ctx: &Ctx, + listener: &ListenerConfig, + stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + match listener.protocol { + Protocol::Imap => imap::handle(ctx, listener, stream, peer, local).await, + Protocol::Pop3 => pop3::handle(ctx, listener, stream, peer, local).await, + Protocol::ManageSieve => managesieve::handle(ctx, listener, stream, peer, local).await, + Protocol::Submission => smtp::handle_submission(ctx, listener, stream, peer, local).await, + Protocol::Smtp | Protocol::Lmtp => { + smtp::handle_passthrough(ctx, listener, stream, peer, local).await + } + Protocol::Http => crate::http::proxy::handle(ctx, listener, stream, peer, local).await, + } +} diff --git a/src/proto/pop3.rs b/src/proto/pop3.rs new file mode 100644 index 0000000..26033b2 --- /dev/null +++ b/src/proto/pop3.rs @@ -0,0 +1,408 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::SocketAddr; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::time::timeout; + +use crate::config::{Forwarding, ListenerConfig, Pop3Capabilities, Protocol, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::imap_receiver::Mechanism; +use crate::net::BoxedStream; +use crate::outbound; +use crate::proto::common::{ + self, Ctx, Handoff, assert_egress_encrypted, credential_from_sasl, mechanism_name, + plain_auth_allowed, write, +}; +use crate::proto::forward; +use crate::sasl::decode_sasl_ir; +use crate::token::identifier_from_credential; + +const MAX_PREAUTH: usize = 64 * 1024; + +pub async fn handle( + ctx: &Ctx, + listener: &ListenerConfig, + stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + let caps = &ctx.config.capabilities.pop3; + + let handoff = match timeout( + listener.preauth_timeout, + preauth(ctx, listener, caps, stream), + ) + .await + { + Ok(Ok(Some(h))) => h, + Ok(Ok(None)) => return Ok(()), + Ok(Err(e)) => return Err(e), + Err(_) => return Ok(()), + }; + + let Handoff { + stream, + mech, + raw_challenge, + identifier, + residual, + } = handoff; + + let identifier = ctx.routing_identifier(identifier); + let dest_id = ctx.resolve(identifier.as_deref()).await; + let started = std::time::Instant::now(); + common::hold_login(ctx, identifier.as_deref()).await; + let fwd = forward::ForwardInfo::new( + peer, + local, + stream.is_tls(), + None, + ctx.config.server.proxy_ttl, + ); + + let auth_frame = format!( + "AUTH {} {}\r\n", + mechanism_name(&mech), + BASE64.encode(&raw_challenge) + ); + + common::bridge_authenticated( + ctx, + &dest_id, + stream, + identifier.as_deref(), + &residual, + started, + "pop3", + common::AuthWire::Pop3, + auth_frame.as_bytes(), + b"-ERR backend unavailable\r\n", + MAX_PREAUTH, + || open_backend(ctx, &dest_id, &fwd), + ) + .await +} + +async fn preauth( + ctx: &Ctx, + listener: &ListenerConfig, + caps: &Pop3Capabilities, + mut stream: BoxedStream, +) -> Result> { + let mut is_tls = stream.is_tls(); + let starttls_listener = listener.tls == TlsMode::Starttls; + + write(&mut stream, format!("+OK {}\r\n", caps.banner).as_bytes()).await?; + + let mut buf: Vec = Vec::with_capacity(1024); + let mut pending_user: Option> = None; + let mut auth_failures = 0u32; + + loop { + let Some(nl) = buf.iter().position(|&b| b == b'\n') else { + if buf.len() > MAX_PREAUTH { + return Err(ProxyError::preauth("pop3 line too long")); + } + let n = common::fill(&mut stream, &mut buf, MAX_PREAUTH + 8192).await?; + if n == 0 { + return Ok(None); + } + continue; + }; + + let mut line = buf[..nl].to_vec(); + if line.last() == Some(&b'\r') { + line.pop(); + } + let consumed = nl + 1; + + let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls); + let (verb, arg) = split_verb(&line); + + match verb.to_ascii_uppercase().as_slice() { + b"QUIT" => { + write(&mut stream, b"+OK Bye\r\n").await?; + return Ok(None); + } + b"NOOP" | b"UTF8" => { + write(&mut stream, b"+OK\r\n").await?; + buf.drain(..consumed); + } + b"CAPA" => { + write( + &mut stream, + ctx.greetings + .pop3 + .get(is_tls, starttls_listener && !is_tls) + .as_bytes(), + ) + .await?; + buf.drain(..consumed); + } + b"APOP" => { + write(&mut stream, b"-ERR APOP not supported\r\n").await?; + buf.drain(..consumed); + } + b"STLS" => { + if !starttls_listener || is_tls { + write(&mut stream, b"-ERR STLS not available\r\n").await?; + buf.drain(..consumed); + continue; + } + let Some(acceptor) = ctx.tls_acceptor.as_ref() else { + write(&mut stream, b"-ERR STLS not configured\r\n").await?; + buf.drain(..consumed); + continue; + }; + write(&mut stream, b"+OK Begin TLS negotiation\r\n").await?; + buf.clear(); + stream = common::upgrade_tls_inbound(stream, acceptor).await?; + is_tls = true; + } + b"USER" => { + if !plain_ok { + write( + &mut stream, + b"-ERR cleartext authentication requires TLS\r\n", + ) + .await?; + } else if arg.is_empty() { + write(&mut stream, b"-ERR USER requires an argument\r\n").await?; + } else { + pending_user = Some(arg.to_vec()); + write(&mut stream, b"+OK\r\n").await?; + } + buf.drain(..consumed); + } + b"PASS" => { + if !plain_ok { + write( + &mut stream, + b"-ERR cleartext authentication requires TLS\r\n", + ) + .await?; + buf.drain(..consumed); + continue; + } + let Some(user) = pending_user.take() else { + write(&mut stream, b"-ERR USER required before PASS\r\n").await?; + buf.drain(..consumed); + continue; + }; + let mut raw = Vec::with_capacity(user.len() + arg.len() + 2); + raw.push(0); + raw.extend_from_slice(&user); + raw.push(0); + raw.extend_from_slice(arg); + let identifier = Some(String::from_utf8_lossy(&user).into_owned()); + let residual = buf[nl + 1..].to_vec(); + return Ok(Some(Handoff { + stream, + mech: Mechanism::Plain, + raw_challenge: raw, + identifier, + residual, + })); + } + b"AUTH" => { + if arg.is_empty() { + write(&mut stream, auth_list(caps, plain_ok).as_bytes()).await?; + buf.drain(..consumed); + continue; + } + let (mech_bytes, ir) = split_verb(arg); + let mech = Mechanism::parse(mech_bytes); + match &mech { + Mechanism::Plain | Mechanism::OAuthBearer | Mechanism::XOauth2 => {} + _ => { + write(&mut stream, b"-ERR unsupported mechanism\r\n").await?; + buf.drain(..consumed); + if bump(&mut auth_failures, listener.max_auth_attempts) { + return Ok(None); + } + continue; + } + } + if matches!(mech, Mechanism::Plain) && !plain_ok { + write( + &mut stream, + b"-ERR cleartext authentication requires TLS\r\n", + ) + .await?; + buf.drain(..consumed); + continue; + } + + let raw_b64: Vec = if !ir.is_empty() { + let ir = ir.to_vec(); + buf.drain(..consumed); + ir + } else { + write(&mut stream, b"+ \r\n").await?; + buf.drain(..consumed); + let seed = std::mem::take(&mut buf); + let (line, rest) = + common::read_crlf_line(&mut stream, seed, MAX_PREAUTH).await?; + buf = rest; + line + }; + + let Some(raw) = decode_sasl_ir(&raw_b64) else { + write(&mut stream, b"-ERR invalid base64\r\n").await?; + if bump(&mut auth_failures, listener.max_auth_attempts) { + return Ok(None); + } + continue; + }; + let Some(cred) = credential_from_sasl(&mech, &raw) else { + write(&mut stream, b"-ERR malformed credentials\r\n").await?; + if bump(&mut auth_failures, listener.max_auth_attempts) { + return Ok(None); + } + continue; + }; + let identifier = + identifier_from_credential(&cred, &ctx.config.oauth.jwt_username_claim); + let residual = std::mem::take(&mut buf); + return Ok(Some(Handoff { + stream, + mech, + raw_challenge: raw, + identifier, + residual, + })); + } + _ => { + write(&mut stream, b"-ERR unknown command\r\n").await?; + buf.drain(..consumed); + } + } + } +} + +fn bump(failures: &mut u32, max: u32) -> bool { + *failures += 1; + *failures >= max +} + +fn split_verb(line: &[u8]) -> (&[u8], &[u8]) { + match line.iter().position(|&b| b == b' ') { + Some(pos) => (&line[..pos], trim_leading_space(&line[pos + 1..])), + None => (line, &[]), + } +} + +fn trim_leading_space(b: &[u8]) -> &[u8] { + let start = b.iter().position(|&c| c != b' ').unwrap_or(b.len()); + &b[start..] +} + +pub(crate) fn capa_response(caps: &Pop3Capabilities, offer_stls: bool, plain_ok: bool) -> String { + let mut lines = String::from("+OK Capability list follows\r\n"); + if plain_ok { + lines.push_str("USER\r\n"); + } + lines.push_str(&sasl_line(caps, plain_ok)); + if offer_stls { + lines.push_str("STLS\r\n"); + } + lines.push_str("TOP\r\nRESP-CODES\r\nPIPELINING\r\nEXPIRE NEVER\r\nUIDL\r\nUTF8\r\n"); + lines.push_str("IMPLEMENTATION Stalwart Server\r\n"); + lines.push_str(".\r\n"); + lines +} + +fn sasl_line(caps: &Pop3Capabilities, plain_ok: bool) -> String { + let mechs = common::advertised_sasl(&caps.sasl, plain_ok); + if mechs.is_empty() { + String::new() + } else { + format!("SASL {}\r\n", mechs.join(" ")) + } +} + +fn auth_list(caps: &Pop3Capabilities, plain_ok: bool) -> String { + let mut out = String::from("+OK\r\n"); + for m in common::advertised_sasl(&caps.sasl, plain_ok) { + out.push_str(m); + out.push_str("\r\n"); + } + out.push_str(".\r\n"); + out +} + +async fn open_backend( + ctx: &Ctx, + dest_id: &str, + fwd: &forward::ForwardInfo, +) -> Result<(BoxedStream, Vec)> { + let common::DialedEndpoint { + dest, + ep, + tls_cfg, + forwarding, + mut backend, + } = ctx + .dial_endpoint(dest_id, Protocol::Pop3, fwd.peer, fwd.local) + .await?; + let mut residual = Vec::new(); + + let greeting = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?; + if !greeting.starts_with(b"+OK") { + return Err(ProxyError::backend("backend POP3 greeting was not +OK")); + } + let xclient_advertised = common::advertises_token(&greeting, "XCLIENT"); + + if ep.tls == TlsMode::Starttls { + if !backend_advertises_stls(&mut backend, &mut residual).await? { + return Err(ProxyError::backend("backend did not advertise STLS")); + } + outbound::write_all(&mut backend, b"STLS\r\n").await?; + let resp = outbound::read_line(&mut backend, &mut residual, MAX_PREAUTH).await?; + if !resp.starts_with(b"+OK") { + return Err(ProxyError::backend("backend refused STLS")); + } + backend = outbound::tls_connect(backend, &tls_cfg, dest).await?; + } + + assert_egress_encrypted(dest, ep, backend.is_tls())?; + + if forwarding == Forwarding::Xclient { + forward::send_pop3_xclient( + &mut backend, + &mut residual, + xclient_advertised, + fwd, + MAX_PREAUTH, + ) + .await?; + } + + Ok((backend, residual)) +} + +async fn backend_advertises_stls( + backend: &mut BoxedStream, + residual: &mut Vec, +) -> Result { + outbound::write_all(backend, b"CAPA\r\n").await?; + let mut advertised = false; + for _ in 0..256 { + let line = outbound::read_line(backend, residual, MAX_PREAUTH).await?; + if line.starts_with(b"-ERR") { + return Ok(false); + } + if line == b"." { + return Ok(advertised); + } + if line.eq_ignore_ascii_case(b"STLS") { + advertised = true; + } + } + Err(ProxyError::backend("backend CAPA had too many lines")) +} diff --git a/src/proto/smtp.rs b/src/proto/smtp.rs new file mode 100644 index 0000000..42c7b8a --- /dev/null +++ b/src/proto/smtp.rs @@ -0,0 +1,589 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::net::SocketAddr; + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use smtp_proto::{AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, Error as SmtpError, Request}; +use tokio::time::timeout; + +use crate::config::{Forwarding, ListenerConfig, Protocol, SubmissionCapabilities, TlsMode}; +use crate::error::{ProxyError, Result}; +use crate::imap_receiver::Mechanism; +use crate::net::BoxedStream; +use crate::outbound; +use crate::proto::common::{ + self, Ctx, Handoff, assert_egress_encrypted, credential_from_sasl, mechanism_name, + plain_auth_allowed, write, +}; +use crate::proto::forward; +use crate::sasl::decode_sasl_ir; +use crate::token::identifier_from_credential; + +const MAX_PREAUTH: usize = 64 * 1024; + +struct SubmissionHandoff { + handoff: Handoff, + ehlo_host: String, +} + +pub async fn handle_submission( + ctx: &Ctx, + listener: &ListenerConfig, + stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + let caps = &ctx.config.capabilities.submission; + + let sh = match timeout( + listener.preauth_timeout, + preauth(ctx, listener, caps, stream, local), + ) + .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 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(), + Some(ehlo_host), + ctx.config.server.proxy_ttl, + ); + + let auth_frame = format!( + "AUTH {} {}\r\n", + mechanism_name(&mech), + BASE64.encode(&raw_challenge) + ); + + common::bridge_authenticated( + ctx, + &dest_id, + stream, + identifier.as_deref(), + &residual, + started, + "submission", + common::AuthWire::Smtp, + auth_frame.as_bytes(), + b"421 4.3.0 backend unavailable\r\n", + MAX_PREAUTH, + || open_backend(ctx, &dest_id, &fwd), + ) + .await +} + +async fn preauth( + ctx: &Ctx, + listener: &ListenerConfig, + caps: &SubmissionCapabilities, + mut stream: BoxedStream, + local: SocketAddr, +) -> Result> { + let mut is_tls = stream.is_tls(); + let starttls_listener = listener.tls == TlsMode::Starttls; + let hostname = ctx.config.server.hostname_for(local); + + write( + &mut stream, + format!("220 {hostname} ESMTP {}\r\n", caps.banner).as_bytes(), + ) + .await?; + + let mut buf: Vec = Vec::with_capacity(1024); + let mut ehlo_host = String::new(); + let mut auth_failures = 0u32; + + loop { + let mut it = buf.iter(); + let parsed = Request::parse(&mut it); + let consumed = buf.len() - it.as_slice().len(); + drop(it); + + match parsed { + Ok(req) => { + let plain_ok = plain_auth_allowed(caps.allow_plain_auth_without_tls, is_tls); + match req { + Request::Ehlo { host } | Request::Lhlo { host } => { + ehlo_host = host.to_string(); + let resp = ehlo_response( + &hostname, + &ehlo_host, + caps, + starttls_listener && !is_tls, + plain_ok, + ); + write(&mut stream, resp.as_bytes()).await?; + buf.drain(..consumed); + } + Request::Helo { host } => { + ehlo_host = host.to_string(); + write(&mut stream, format!("250 {hostname}\r\n").as_bytes()).await?; + buf.drain(..consumed); + } + Request::Noop { .. } => { + write(&mut stream, b"250 2.0.0 OK\r\n").await?; + buf.drain(..consumed); + } + Request::Rset => { + write(&mut stream, b"250 2.0.0 OK\r\n").await?; + buf.drain(..consumed); + } + Request::Quit => { + write(&mut stream, b"221 2.0.0 Bye\r\n").await?; + return Ok(None); + } + Request::StartTls => { + if !starttls_listener || is_tls { + write(&mut stream, b"503 5.5.1 STARTTLS not available\r\n").await?; + buf.drain(..consumed); + continue; + } + let Some(acceptor) = ctx.tls_acceptor.as_ref() else { + write(&mut stream, b"454 4.7.0 STARTTLS not configured\r\n").await?; + buf.drain(..consumed); + continue; + }; + write(&mut stream, b"220 2.0.0 Ready to start TLS\r\n").await?; + buf.clear(); + stream = common::upgrade_tls_inbound(stream, acceptor).await?; + is_tls = true; + ehlo_host.clear(); + } + Request::Auth { + mechanism, + initial_response, + } => { + let ir = initial_response.into_owned(); + let params = AuthParams { + ctx, + plain_ok, + ehlo_host: &ehlo_host, + max_attempts: listener.max_auth_attempts, + }; + match handle_auth( + ¶ms, + &mut stream, + &mut buf, + consumed, + mechanism, + &ir, + &mut auth_failures, + ) + .await? + { + AuthStep::Done { + mech, + raw_challenge, + identifier, + residual, + ehlo_host, + } => { + return Ok(Some(SubmissionHandoff { + handoff: Handoff { + stream, + mech, + raw_challenge, + identifier, + residual, + }, + ehlo_host, + })); + } + AuthStep::Continue => {} + AuthStep::Close => return Ok(None), + } + } + _ => { + write(&mut stream, b"503 5.5.1 Command not allowed here\r\n").await?; + buf.drain(..consumed); + } + } + } + Err(SmtpError::NeedsMoreData { .. }) => { + if buf.len() > MAX_PREAUTH { + return Err(ProxyError::preauth("smtp line too long")); + } + let n = common::fill(&mut stream, &mut buf, MAX_PREAUTH + 8192).await?; + if n == 0 { + return Ok(None); + } + } + Err(_) => { + let drop_to = if consumed == 0 { buf.len() } else { consumed }; + buf.drain(..drop_to); + write(&mut stream, b"500 5.5.2 Syntax error\r\n").await?; + auth_failures += 1; + if auth_failures >= listener.max_auth_attempts { + return Ok(None); + } + } + } + } +} + +enum AuthStep { + Done { + mech: Mechanism, + raw_challenge: Vec, + identifier: Option, + residual: Vec, + ehlo_host: String, + }, + Continue, + Close, +} + +struct AuthParams<'a> { + ctx: &'a Ctx, + plain_ok: bool, + ehlo_host: &'a str, + max_attempts: u32, +} + +async fn handle_auth( + params: &AuthParams<'_>, + stream: &mut BoxedStream, + buf: &mut Vec, + consumed: usize, + mechanism: u64, + initial_response: &str, + auth_failures: &mut u32, +) -> Result { + let max_attempts = params.max_attempts; + + let Some(mech) = mech_from_flag(mechanism) else { + return fail( + stream, + buf, + consumed, + "5.7.0 Unsupported authentication mechanism", + auth_failures, + max_attempts, + ) + .await; + }; + if matches!(mech, Mechanism::Plain) && !params.plain_ok { + write( + &mut *stream, + b"538 5.7.11 Encryption required for requested authentication mechanism\r\n", + ) + .await?; + buf.drain(..consumed); + return Ok(AuthStep::Continue); + } + + let raw_b64: Vec = if !initial_response.is_empty() { + let ir = initial_response.as_bytes().to_vec(); + buf.drain(..consumed); + ir + } else { + write(&mut *stream, b"334 \r\n").await?; + buf.drain(..consumed); + let seed = std::mem::take(buf); + let (line, rest) = common::read_crlf_line(stream, seed, MAX_PREAUTH).await?; + *buf = rest; + line + }; + + let Some(raw) = decode_sasl_ir(&raw_b64) else { + return fail( + stream, + buf, + 0, + "5.7.8 Invalid base64", + auth_failures, + max_attempts, + ) + .await; + }; + let Some(cred) = credential_from_sasl(&mech, &raw) else { + return fail( + stream, + buf, + 0, + "5.7.8 Malformed credentials", + auth_failures, + max_attempts, + ) + .await; + }; + let identifier = identifier_from_credential(&cred, ¶ms.ctx.config.oauth.jwt_username_claim); + let residual = std::mem::take(buf); + + Ok(AuthStep::Done { + mech, + raw_challenge: raw, + identifier, + residual, + ehlo_host: params.ehlo_host.to_string(), + }) +} + +async fn fail( + stream: &mut BoxedStream, + buf: &mut Vec, + drain: usize, + message: &str, + auth_failures: &mut u32, + max_attempts: u32, +) -> Result { + if drain > 0 { + buf.drain(..drain); + } + write(&mut *stream, format!("535 {message}\r\n").as_bytes()).await?; + *auth_failures += 1; + if *auth_failures >= max_attempts { + Ok(AuthStep::Close) + } else { + Ok(AuthStep::Continue) + } +} + +fn mech_from_flag(flag: u64) -> Option { + if flag & AUTH_PLAIN != 0 { + Some(Mechanism::Plain) + } else if flag & AUTH_OAUTHBEARER != 0 { + Some(Mechanism::OAuthBearer) + } else if flag & AUTH_XOAUTH2 != 0 { + Some(Mechanism::XOauth2) + } else { + None + } +} + +fn ehlo_response( + hostname: &str, + client_host: &str, + caps: &SubmissionCapabilities, + offer_starttls: bool, + plain_ok: bool, +) -> String { + use std::fmt::Write; + + let greeting = format!("{hostname} at your service, [{client_host}]"); + let mechs = common::advertised_sasl(&caps.sasl, plain_ok); + let auth_line = (!mechs.is_empty()).then(|| format!("AUTH {}", mechs.join(" "))); + + let mut lines: Vec<&str> = Vec::with_capacity(caps.ehlo.len() + 3); + lines.push(&greeting); + lines.extend(caps.ehlo.iter().map(String::as_str)); + if offer_starttls { + lines.push("STARTTLS"); + } + if let Some(auth) = &auth_line { + lines.push(auth); + } + + let last = lines.len().saturating_sub(1); + let mut out = String::new(); + for (i, line) in lines.iter().enumerate() { + let sep = if i == last { ' ' } else { '-' }; + let _ = write!(out, "250{sep}{line}\r\n"); + } + out +} + +async fn open_backend( + ctx: &Ctx, + dest_id: &str, + fwd: &forward::ForwardInfo, +) -> Result<(BoxedStream, Vec)> { + let common::DialedEndpoint { + dest, + ep, + tls_cfg, + forwarding, + mut backend, + } = ctx + .dial_endpoint(dest_id, Protocol::Submission, fwd.peer, fwd.local) + .await?; + let mut residual = Vec::new(); + let fallback_helo = ctx.config.server.hostname.as_deref().unwrap_or("proxy"); + let ehlo = match fwd.helo.as_deref() { + Some(h) if !h.is_empty() => h, + _ => fallback_helo, + }; + + read_smtp_reply(&mut backend, &mut residual, 220).await?; + let mut ehlo_reply = send_ehlo_expect(&mut backend, &mut residual, ehlo).await?; + + if ep.tls == TlsMode::Starttls { + if !common::advertises_token(&ehlo_reply, "STARTTLS") { + return Err(ProxyError::backend("backend did not advertise STARTTLS")); + } + outbound::write_all(&mut backend, b"STARTTLS\r\n").await?; + read_smtp_reply(&mut backend, &mut residual, 220).await?; + backend = outbound::tls_connect(backend, &tls_cfg, dest).await?; + ehlo_reply = send_ehlo_expect(&mut backend, &mut residual, ehlo).await?; + } + + assert_egress_encrypted(dest, ep, backend.is_tls())?; + + if forwarding == Forwarding::Xclient { + let attrs = advertised_xclient_attrs(&ehlo_reply); + if !attrs.is_empty() { + let cmd = format!( + "XCLIENT {}\r\n", + forward::xclient_params_advertised(fwd, &attrs) + ); + outbound::write_all(&mut backend, cmd.as_bytes()).await?; + read_smtp_reply_any_2xx(&mut backend, &mut residual).await?; + send_ehlo_expect(&mut backend, &mut residual, ehlo).await?; + } + } + + Ok((backend, residual)) +} + +fn advertised_xclient_attrs(ehlo_reply: &[u8]) -> Vec { + for line in ehlo_reply.split(|&b| b == b'\n') { + let text = String::from_utf8_lossy(line); + let rest = text.trim().get(4..).unwrap_or(""); + let mut tokens = rest.split_whitespace(); + if tokens + .next() + .is_some_and(|t| t.eq_ignore_ascii_case("XCLIENT")) + { + return tokens.map(|t| t.to_string()).collect(); + } + } + Vec::new() +} + +async fn send_ehlo_expect( + backend: &mut BoxedStream, + residual: &mut Vec, + ehlo_host: &str, +) -> Result> { + outbound::write_all(backend, format!("EHLO {ehlo_host}\r\n").as_bytes()).await?; + let mut reply = Vec::new(); + read_smtp_reply_with(backend, residual, Some(&mut reply), |code| { + if code == 250 { + Ok(()) + } else { + Err(ProxyError::backend(format!( + "backend SMTP replied {code}, expected 250" + ))) + } + }) + .await?; + Ok(reply) +} + +async fn read_smtp_reply( + stream: &mut BoxedStream, + residual: &mut Vec, + expected: u16, +) -> Result<()> { + read_smtp_reply_with(stream, residual, None, |code| { + if code == expected { + Ok(()) + } else { + Err(ProxyError::backend(format!( + "backend SMTP replied {code}, expected {expected}" + ))) + } + }) + .await +} + +async fn read_smtp_reply_any_2xx(stream: &mut BoxedStream, residual: &mut Vec) -> Result<()> { + read_smtp_reply_with(stream, residual, None, |code| { + if (200..300).contains(&code) { + Ok(()) + } else { + Err(ProxyError::backend("backend rejected XCLIENT")) + } + }) + .await +} + +async fn read_smtp_reply_with( + stream: &mut BoxedStream, + residual: &mut Vec, + mut capture: Option<&mut Vec>, + mut check: F, +) -> Result<()> +where + F: FnMut(u16) -> Result<()>, +{ + for _ in 0..256 { + let line = outbound::read_line(stream, residual, MAX_PREAUTH).await?; + if line.len() < 3 { + return Err(ProxyError::backend("malformed SMTP reply")); + } + let code: u16 = std::str::from_utf8(&line[..3]) + .ok() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| ProxyError::backend("malformed SMTP reply code"))?; + check(code)?; + if let Some(reply) = capture.as_deref_mut() { + reply.extend_from_slice(&line); + reply.push(b'\n'); + } + if line.get(3) != Some(&b'-') { + return Ok(()); + } + } + Err(ProxyError::backend("SMTP reply had too many lines")) +} + +pub async fn handle_passthrough( + ctx: &Ctx, + listener: &ListenerConfig, + stream: BoxedStream, + peer: SocketAddr, + local: SocketAddr, +) -> Result<()> { + let dest_id = ctx + .config + .routing + .smtp_passthrough_destination + .clone() + .ok_or_else(|| ProxyError::config("smtp_passthrough_destination not configured"))?; + let proto = if listener.protocol == Protocol::Lmtp { + Protocol::Lmtp + } else { + Protocol::Smtp + }; + let (dest, ep, tls_cfg) = ctx.endpoint(&dest_id, proto)?; + let fwd = dest.forwarding_for(proto); + forward::guard_loop(&ctx.self_binds, dest, ep, fwd, ctx.config.server.proxy_ttl)?; + let backend = common::establish(ctx, &dest_id, || { + outbound::dial(dest, ep, fwd, &tls_cfg, peer, local) + }) + .await?; + outbound::bridge( + stream, + backend, + &[], + &[], + ctx.config.server.bridge_idle, + None, + ) + .await?; + Ok(()) +} diff --git a/src/route/mod.rs b/src/route/mod.rs new file mode 100644 index 0000000..dbf796a --- /dev/null +++ b/src/route/mod.rs @@ -0,0 +1,350 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod store; + +use std::borrow::Cow; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +use ahash::AHashSet; +use moka::future::Cache; +use moka::policy::Expiry; + +use crate::config::{Config, MappingConfig, MappingSource, Normalize}; +use crate::error::{ProxyError, Result}; +use store::MappingStore; +use store::file::FileStore; +use store::redis::RedisStore; +use store::sql::SqlStore; + +type Store = Arc; + +pub fn normalize_key(id: &str, normalize: Normalize) -> String { + match normalize { + Normalize::None => id.to_string(), + Normalize::Lowercase => id.to_lowercase(), + } +} + +pub fn master_account<'a>(id: &'a str, separators: &[String]) -> &'a str { + let mut cut = id.len(); + for sep in separators { + if sep.is_empty() { + continue; + } + if let Some(pos) = id.find(sep.as_str()) { + cut = cut.min(pos); + } + } + &id[..cut] +} + +#[derive(Clone)] +enum Route { + Mapped(Arc), + Default, + Transient, +} + +impl Route { + fn dest(&self, default: &Arc) -> Arc { + match self { + Route::Mapped(d) => d.clone(), + Route::Default | Route::Transient => default.clone(), + } + } +} + +const TRANSIENT_TTL: Duration = Duration::from_secs(5); + +struct TtlExpiry { + positive: Duration, + negative: Duration, + transient: Duration, +} + +impl TtlExpiry { + fn ttl(&self, value: &Route) -> Duration { + match value { + Route::Mapped(_) => self.positive, + Route::Default => self.negative, + Route::Transient => self.transient, + } + } +} + +impl Expiry, Route> for TtlExpiry { + fn expire_after_create( + &self, + _key: &Box, + value: &Route, + _created_at: std::time::Instant, + ) -> Option { + Some(self.ttl(value)) + } + + fn expire_after_update( + &self, + _key: &Box, + value: &Route, + _updated_at: std::time::Instant, + _duration_until_expiry: Option, + ) -> Option { + Some(self.ttl(value)) + } +} + +#[derive(Default)] +pub struct RouterStats { + pub hits: AtomicU64, + pub misses: AtomicU64, +} + +pub struct MappingDiagnosis { + pub normalized: String, + pub destination: String, + pub routed_to_default: bool, + pub cached: bool, +} + +pub struct Router { + cache: Cache, Route>, + store: Store, + default_destination: Arc, + valid_destinations: AHashSet>, + normalize: Normalize, + lookup_timeout: Duration, + pub stats: RouterStats, +} + +impl Router { + pub async fn build(config: &Config, valid_destinations: Vec) -> Result { + let valid: AHashSet> = valid_destinations + .iter() + .map(|s| s.as_str().into()) + .collect(); + let store = build_store(&config.mapping, valid_destinations).await?; + Ok(Self::with_store( + &config.mapping, + store, + config.routing.default_destination.as_str().into(), + valid, + )) + } + + pub fn with_store( + mapping: &MappingConfig, + store: Store, + default_destination: Arc, + valid_destinations: AHashSet>, + ) -> Self { + let expiry = TtlExpiry { + positive: mapping.positive_ttl, + negative: mapping.negative_ttl, + transient: TRANSIENT_TTL.min(mapping.negative_ttl), + }; + let cache = Cache::builder() + .max_capacity(mapping.cache_max_entries) + .expire_after(expiry) + .build(); + Router { + cache, + store, + default_destination, + valid_destinations, + normalize: mapping.normalize, + lookup_timeout: mapping.lookup_timeout, + stats: RouterStats::default(), + } + } + + pub async fn resolve(&self, identifier: Option<&str>) -> Arc { + let key: Cow<'_, str> = match identifier { + Some(id) => match self.normalize { + Normalize::None => Cow::Borrowed(id), + Normalize::Lowercase => Cow::Owned(id.to_lowercase()), + }, + None => return self.default_destination.clone(), + }; + + if let Some(hit) = self.cache.get(key.as_ref()).await { + self.stats.hits.fetch_add(1, Ordering::Relaxed); + return hit.dest(&self.default_destination); + } + self.stats.misses.fetch_add(1, Ordering::Relaxed); + + let key = key.into_owned(); + let store = self.store.clone(); + let valid = &self.valid_destinations; + let lookup_timeout = self.lookup_timeout; + let lookup_key = key.clone(); + let init = async move { + let route = match tokio::time::timeout(lookup_timeout, store.lookup(&lookup_key)).await + { + Ok(Ok(Some(dest))) => { + if !valid.is_empty() && !valid.contains(dest.as_str()) { + tracing::warn!( + destination = %dest, + "mapping store returned an undeclared destination; routing to default" + ); + Route::Default + } else { + Route::Mapped(Arc::from(dest)) + } + } + Ok(Ok(None)) => Route::Default, + Ok(Err(e)) => { + tracing::warn!(error = %e, "mapping store lookup failed; routing to default"); + Route::Transient + } + Err(_) => { + tracing::warn!("mapping store lookup timed out; routing to default"); + Route::Transient + } + }; + Ok::(route) + }; + + let route = self + .cache + .try_get_with(key.into_boxed_str(), init) + .await + .unwrap_or(Route::Transient); + route.dest(&self.default_destination) + } + + pub fn normalize(&self, identifier: &str) -> String { + normalize_key(identifier, self.normalize) + } + + pub async fn invalidate(&self, identifier: &str) { + let key = normalize_key(identifier, self.normalize); + self.cache.invalidate(key.as_str()).await; + } + + pub fn invalidate_all(&self) { + self.cache.invalidate_all(); + } + + pub async fn reload(&self) -> Result<()> { + self.store.reload().await + } + + pub fn mapped_destinations(&self) -> Vec { + self.store.mapped_destinations() + } + + pub fn store_writable(&self) -> bool { + self.store.writable() + } + + pub fn is_valid_destination(&self, dest: &str) -> bool { + self.valid_destinations.is_empty() || self.valid_destinations.contains(dest) + } + + pub async fn upsert(&self, identifier: &str, dest: &str) -> Result<()> { + if !self.is_valid_destination(dest) { + return Err(ProxyError::config(format!("unknown destination {dest:?}"))); + } + let key = normalize_key(identifier, self.normalize); + self.store.upsert(&key, dest).await?; + self.cache.invalidate(key.as_str()).await; + Ok(()) + } + + pub async fn remove(&self, identifier: &str) -> Result { + let key = normalize_key(identifier, self.normalize); + let existed = self.store.remove(&key).await?; + self.cache.invalidate(key.as_str()).await; + Ok(existed) + } + + pub async fn diagnose(&self, identifier: &str) -> MappingDiagnosis { + let key = normalize_key(identifier, self.normalize); + let cached = self.cache.get(key.as_str()).await.is_some(); + let destination = self.resolve(Some(identifier)).await; + let routed_to_default = destination == self.default_destination; + MappingDiagnosis { + normalized: key, + destination: destination.to_string(), + routed_to_default, + cached, + } + } + + pub fn cache_entries(&self) -> u64 { + self.cache.entry_count() + } + + #[cfg(test)] + pub async fn cache_entry_count(&self) -> u64 { + self.cache.run_pending_tasks().await; + self.cache.entry_count() + } +} + +async fn build_store(mapping: &MappingConfig, valid_destinations: Vec) -> Result { + let valid = valid_destinations.into_iter().collect(); + let store = + match mapping.source { + MappingSource::File => { + let cfg = mapping.file.as_ref().ok_or_else(|| { + ProxyError::config("mapping.source=file requires [mapping.file]") + })?; + MappingStore::File(FileStore::open(cfg.path.clone(), mapping.normalize, valid)?) + } + MappingSource::Redis => { + let cfg = mapping.redis.as_ref().ok_or_else(|| { + ProxyError::config("mapping.source=redis requires [mapping.redis]") + })?; + MappingStore::Redis(RedisStore::connect(cfg).await?) + } + MappingSource::Sql => { + let cfg = mapping.sql.as_ref().ok_or_else(|| { + ProxyError::config("mapping.source=sql requires [mapping.sql]") + })?; + MappingStore::Sql(SqlStore::connect(cfg).await?) + } + }; + Ok(Arc::new(store)) +} + +#[cfg(test)] +mod tests { + use super::master_account; + + #[test] + fn master_account_splits_on_first_separator() { + let seps = vec!["%".to_string()]; + assert_eq!( + master_account("user@example.com%admin", &seps), + "user@example.com" + ); + assert_eq!( + master_account("user@example.com", &seps), + "user@example.com" + ); + } + + #[test] + fn master_account_tries_multiple_separators() { + let seps = vec!["%".to_string(), "*".to_string()]; + assert_eq!(master_account("alice*master", &seps), "alice"); + assert_eq!(master_account("bob%root", &seps), "bob"); + assert_eq!(master_account("a%b*c", &seps), "a"); + } + + #[test] + fn master_account_ignores_empty_separator() { + let seps = vec![String::new()]; + assert_eq!( + master_account("user@example.com", &seps), + "user@example.com" + ); + } +} diff --git a/src/route/store/file.rs b/src/route/store/file.rs new file mode 100644 index 0000000..65a8a52 --- /dev/null +++ b/src/route/store/file.rs @@ -0,0 +1,200 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use ahash::{AHashMap, AHashSet}; +use parking_lot::RwLock; + +use crate::config::Normalize; +use crate::error::{ProxyError, Result}; +use crate::route::normalize_key; + +pub struct FileStore { + path: String, + normalize: Normalize, + valid_destinations: AHashSet, + map: RwLock, Box>>>, +} + +impl FileStore { + pub fn open( + path: String, + normalize: Normalize, + valid_destinations: AHashSet, + ) -> Result { + let map = Self::load(&path, normalize, &valid_destinations)?; + Ok(FileStore { + path, + normalize, + valid_destinations, + map: RwLock::new(Arc::new(map)), + }) + } + + fn load( + path: &str, + normalize: Normalize, + valid: &AHashSet, + ) -> Result, Box>> { + let data = std::fs::read_to_string(path) + .map_err(|e| ProxyError::config(format!("failed to read mapping file {path}: {e}")))?; + let mut map = AHashMap::new(); + for (lineno, line) in data.lines().enumerate() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + let mut fields = line.split_whitespace(); + let (Some(id), Some(dest)) = (fields.next(), fields.next()) else { + return Err(ProxyError::config(format!( + "mapping file {path}:{} is not ", + lineno + 1 + ))); + }; + if !valid.contains(dest) { + return Err(ProxyError::config(format!( + "mapping file {path}:{} references undeclared destination {dest:?}", + lineno + 1 + ))); + } + map.insert(normalize_key(id, normalize).into(), dest.into()); + } + Ok(map) + } + + fn snapshot(&self) -> Arc, Box>> { + self.map.read().clone() + } + + pub fn lookup(&self, key: &str) -> Result> { + Ok(self.snapshot().get(key).map(|v| v.to_string())) + } + + pub fn mapped_destinations(&self) -> Vec { + self.snapshot() + .values() + .map(|v| v.to_string()) + .collect::>() + .into_iter() + .collect() + } + + pub fn reload(&self) -> Result<()> { + let map = Self::load(&self.path, self.normalize, &self.valid_destinations)?; + *self.map.write() = Arc::new(map); + Ok(()) + } + + pub fn upsert(&self, key: &str, dest: &str) -> Result<()> { + let mut guard = self.map.write(); + let mut next = (**guard).clone(); + next.insert(key.into(), dest.into()); + let next = Arc::new(next); + Self::persist(&self.path, &next)?; + *guard = next; + Ok(()) + } + + pub fn remove(&self, key: &str) -> Result { + let mut guard = self.map.write(); + if !guard.contains_key(key) { + return Ok(false); + } + let mut next = (**guard).clone(); + next.remove(key); + let next = Arc::new(next); + Self::persist(&self.path, &next)?; + *guard = next; + Ok(true) + } + + fn persist(path: &str, map: &AHashMap, Box>) -> Result<()> { + let mut entries: Vec<(&str, &str)> = + map.iter().map(|(k, v)| (k.as_ref(), v.as_ref())).collect(); + entries.sort_unstable(); + let mut out = String::new(); + for (id, dest) in entries { + out.push_str(id); + out.push('\t'); + out.push_str(dest); + out.push('\n'); + } + let tmp = format!("{path}.tmp"); + std::fs::write(&tmp, out.as_bytes()) + .map_err(|e| ProxyError::config(format!("failed to write mapping file {tmp}: {e}")))?; + std::fs::rename(&tmp, path).map_err(|e| { + ProxyError::config(format!("failed to replace mapping file {path}: {e}")) + })?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid() -> AHashSet { + ["legacy", "secondary"] + .into_iter() + .map(String::from) + .collect() + } + + fn store_with(contents: &str) -> (FileStore, tempfile::NamedTempFile) { + use std::io::Write; + let mut file = tempfile::NamedTempFile::new().unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + let path = file.path().display().to_string(); + let store = FileStore::open(path, Normalize::Lowercase, valid()).unwrap(); + (store, file) + } + + #[test] + fn upsert_persists_and_reloads() { + let (store, file) = store_with("user@example.com\tlegacy\n"); + store.upsert("alice@example.com", "secondary").unwrap(); + assert_eq!( + store.lookup("alice@example.com").unwrap(), + Some("secondary".to_string()) + ); + + let on_disk = std::fs::read_to_string(file.path()).unwrap(); + assert!(on_disk.contains("alice@example.com\tsecondary")); + assert!(on_disk.contains("user@example.com\tlegacy")); + + let reloaded = FileStore::open( + file.path().display().to_string(), + Normalize::Lowercase, + valid(), + ) + .unwrap(); + assert_eq!( + reloaded.lookup("alice@example.com").unwrap(), + Some("secondary".to_string()) + ); + } + + #[test] + fn upsert_replaces_existing() { + let (store, _file) = store_with("user@example.com\tlegacy\n"); + store.upsert("user@example.com", "secondary").unwrap(); + assert_eq!( + store.lookup("user@example.com").unwrap(), + Some("secondary".to_string()) + ); + } + + #[test] + fn remove_reports_existence_and_persists() { + let (store, file) = store_with("user@example.com\tlegacy\n"); + assert!(store.remove("user@example.com").unwrap()); + assert!(!store.remove("user@example.com").unwrap()); + assert_eq!(store.lookup("user@example.com").unwrap(), None); + let on_disk = std::fs::read_to_string(file.path()).unwrap(); + assert!(!on_disk.contains("user@example.com")); + } +} diff --git a/src/route/store/mod.rs b/src/route/store/mod.rs new file mode 100644 index 0000000..8eff50a --- /dev/null +++ b/src/route/store/mod.rs @@ -0,0 +1,68 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +pub mod file; +pub mod redis; +pub mod sql; + +use crate::error::Result; + +use file::FileStore; +use redis::RedisStore; +use sql::SqlStore; + +pub enum MappingStore { + File(FileStore), + Redis(RedisStore), + Sql(SqlStore), +} + +impl MappingStore { + pub async fn lookup(&self, key: &str) -> Result> { + match self { + MappingStore::File(s) => s.lookup(key), + MappingStore::Redis(s) => s.lookup(key).await, + MappingStore::Sql(s) => s.lookup(key).await, + } + } + + pub fn writable(&self) -> bool { + match self { + MappingStore::File(_) | MappingStore::Redis(_) => true, + MappingStore::Sql(s) => s.writable(), + } + } + + pub async fn upsert(&self, key: &str, dest: &str) -> Result<()> { + match self { + MappingStore::File(s) => s.upsert(key, dest), + MappingStore::Redis(s) => s.upsert(key, dest).await, + MappingStore::Sql(s) => s.upsert(key, dest).await, + } + } + + pub async fn remove(&self, key: &str) -> Result { + match self { + MappingStore::File(s) => s.remove(key), + MappingStore::Redis(s) => s.remove(key).await, + MappingStore::Sql(s) => s.remove(key).await, + } + } + + pub async fn reload(&self) -> Result<()> { + match self { + MappingStore::File(s) => s.reload(), + MappingStore::Redis(_) | MappingStore::Sql(_) => Ok(()), + } + } + + pub fn mapped_destinations(&self) -> Vec { + match self { + MappingStore::File(s) => s.mapped_destinations(), + MappingStore::Redis(_) | MappingStore::Sql(_) => Vec::new(), + } + } +} diff --git a/src/route/store/redis.rs b/src/route/store/redis.rs new file mode 100644 index 0000000..349a6c2 --- /dev/null +++ b/src/route/store/redis.rs @@ -0,0 +1,77 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::{AtomicUsize, Ordering}; + +use redis::aio::ConnectionManager; + +use crate::config::RedisMappingConfig; +use crate::error::{ProxyError, Result}; + +pub struct RedisStore { + key_prefix: String, + pool: Vec, + next: AtomicUsize, +} + +impl RedisStore { + pub async fn connect(cfg: &RedisMappingConfig) -> Result { + let client = redis::Client::open(cfg.url.as_str()) + .map_err(|e| ProxyError::config(format!("invalid redis url: {e}")))?; + let size = cfg.pool_size.max(1); + let mut pool = Vec::with_capacity(size); + for _ in 0..size { + let conn = ConnectionManager::new(client.clone()) + .await + .map_err(|e| ProxyError::backend(format!("redis connect: {e}")))?; + pool.push(conn); + } + Ok(RedisStore { + key_prefix: cfg.key_prefix.clone(), + pool, + next: AtomicUsize::new(0), + }) + } + + fn conn(&self) -> ConnectionManager { + let idx = self.next.fetch_add(1, Ordering::Relaxed) % self.pool.len(); + self.pool[idx].clone() + } + + pub async fn lookup(&self, key: &str) -> Result> { + let mut conn = self.conn(); + let full_key = format!("{}{}", self.key_prefix, key); + let value: Option = redis::cmd("GET") + .arg(full_key) + .query_async(&mut conn) + .await + .map_err(|e| ProxyError::backend(format!("redis GET: {e}")))?; + Ok(value.filter(|v| !v.is_empty())) + } + + pub async fn upsert(&self, key: &str, dest: &str) -> Result<()> { + let mut conn = self.conn(); + let full_key = format!("{}{}", self.key_prefix, key); + let _: () = redis::cmd("SET") + .arg(full_key) + .arg(dest) + .query_async(&mut conn) + .await + .map_err(|e| ProxyError::backend(format!("redis SET: {e}")))?; + Ok(()) + } + + pub async fn remove(&self, key: &str) -> Result { + let mut conn = self.conn(); + let full_key = format!("{}{}", self.key_prefix, key); + let removed: i64 = redis::cmd("DEL") + .arg(full_key) + .query_async(&mut conn) + .await + .map_err(|e| ProxyError::backend(format!("redis DEL: {e}")))?; + Ok(removed > 0) + } +} diff --git a/src/route/store/sql.rs b/src/route/store/sql.rs new file mode 100644 index 0000000..3e95866 --- /dev/null +++ b/src/route/store/sql.rs @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use sqlx::AnyPool; +use sqlx::any::{AnyPoolOptions, install_default_drivers}; + +use crate::config::SqlMappingConfig; +use crate::error::{ProxyError, Result}; + +pub struct SqlStore { + pool: AnyPool, + query: String, + upsert_query: Option, + delete_query: Option, +} + +impl SqlStore { + pub async fn connect(cfg: &SqlMappingConfig) -> Result { + install_default_drivers(); + let pool = AnyPoolOptions::new() + .max_connections(cfg.pool_size.max(1) as u32) + .connect(&cfg.url) + .await + .map_err(|e| ProxyError::backend(format!("sql connect: {e}")))?; + Ok(SqlStore { + pool, + query: cfg.query.clone(), + upsert_query: cfg.upsert_query.clone(), + delete_query: cfg.delete_query.clone(), + }) + } + + pub async fn lookup(&self, key: &str) -> Result> { + let dest: Option = sqlx::query_scalar(sqlx::AssertSqlSafe(self.query.clone())) + .bind(key) + .fetch_optional(&self.pool) + .await + .map_err(|e| ProxyError::backend(format!("sql query: {e}")))?; + Ok(dest.filter(|v| !v.is_empty())) + } + + pub fn writable(&self) -> bool { + self.upsert_query.is_some() && self.delete_query.is_some() + } + + pub async fn upsert(&self, key: &str, dest: &str) -> Result<()> { + let query = self.upsert_query.as_ref().ok_or_else(|| { + ProxyError::config("sql mapping store has no upsert_query configured") + })?; + sqlx::query(sqlx::AssertSqlSafe(query.clone())) + .bind(key) + .bind(dest) + .execute(&self.pool) + .await + .map_err(|e| ProxyError::backend(format!("sql upsert: {e}")))?; + Ok(()) + } + + pub async fn remove(&self, key: &str) -> Result { + let query = self.delete_query.as_ref().ok_or_else(|| { + ProxyError::config("sql mapping store has no delete_query configured") + })?; + let result = sqlx::query(sqlx::AssertSqlSafe(query.clone())) + .bind(key) + .execute(&self.pool) + .await + .map_err(|e| ProxyError::backend(format!("sql delete: {e}")))?; + Ok(result.rows_affected() > 0) + } +} diff --git a/src/sasl.rs b/src/sasl.rs new file mode 100644 index 0000000..d157764 --- /dev/null +++ b/src/sasl.rs @@ -0,0 +1,216 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Credential { + Plain { + authcid: String, + passwd: String, + }, + OAuth { + username: Option, + token: String, + }, +} + +impl Credential { + pub fn decode_sasl_challenge_plain(challenge: &[u8]) -> Option { + let mut username = Vec::new(); + let mut secret = Vec::new(); + let mut arg_num = 0; + for &ch in challenge { + if ch != 0 { + if arg_num == 1 { + username.push(ch); + } else if arg_num == 2 { + secret.push(ch); + } + } else { + arg_num += 1; + } + } + + match (String::from_utf8(username), String::from_utf8(secret)) { + (Ok(username), Ok(secret)) if !username.is_empty() && !secret.is_empty() => { + Some(Credential::Plain { + authcid: username, + passwd: secret, + }) + } + _ => None, + } + } + + pub fn decode_sasl_challenge_oauth(challenge: &[u8]) -> Option { + extract_oauth_bearer(challenge) + .map(|(token, username)| Credential::OAuth { username, token }) + } +} + +pub fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option)> { + let mut start_pos = 0; + let eof = bytes.len().saturating_sub(1); + let mut iter = bytes.iter().enumerate(); + let mut a = None; + + while let Some((pos, ch)) = iter.next() { + if *ch == b',' + && bytes + .get(pos + 1..pos + 3) + .is_some_and(|s| s.eq_ignore_ascii_case(b"a=")) + { + let from_pos = pos + 3; + let mut to_pos = from_pos; + for (pos, ch) in iter.by_ref() { + if *ch == b',' || *ch == 1 { + to_pos = pos; + break; + } + } + + if to_pos > from_pos { + a = bytes + .get(from_pos..to_pos) + .and_then(|s| std::str::from_utf8(s).ok()) + .filter(|v| v.contains('@')); + } + } else { + let is_separator = *ch == 1; + if is_separator || pos == eof { + if bytes + .get(start_pos..start_pos + 12) + .is_some_and(|s| s.eq_ignore_ascii_case(b"auth=Bearer ")) + { + return bytes + .get(start_pos + 12..if is_separator { pos } else { bytes.len() }) + .and_then(|s| std::str::from_utf8(s).ok()) + .map(|token| (token.to_string(), a.map(|s| s.to_string()))); + } + + start_pos = pos + 1; + } + } + } + + None +} + +pub fn decode_sasl_ir(b64: &[u8]) -> Option> { + mail_parser::decoders::base64::base64_decode(b64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plain_extracts_authcid() { + let challenge = b"\0test\0secret"; + let cred = Credential::decode_sasl_challenge_plain(challenge).unwrap(); + assert_eq!( + cred, + Credential::Plain { + authcid: "test".into(), + passwd: "secret".into() + } + ); + } + + #[test] + fn plain_rejects_empty() { + assert!(Credential::decode_sasl_challenge_plain(b"\0\0").is_none()); + assert!(Credential::decode_sasl_challenge_plain(b"\0user\0").is_none()); + } + + #[test] + fn test_extract_oauth_bearer() { + assert_eq!( + extract_oauth_bearer(b"auth=Bearer validtoken"), + Some(("validtoken".to_string(), None)) + ); + assert_eq!(extract_oauth_bearer(b"auth=Invalid validtoken"), None); + assert_eq!(extract_oauth_bearer(b"auth=Bearer"), None); + assert_eq!(extract_oauth_bearer(b""), None); + assert_eq!( + extract_oauth_bearer(b"auth=Bearer token1\x01auth=Bearer token2"), + Some(("token1".to_string(), None)) + ); + assert_eq!( + extract_oauth_bearer(b"auth=Bearer token with spaces"), + Some(("token with spaces".to_string(), None)) + ); + let input = "n,a=user@example.com,\x01host=server.example.com\x01port=143\x01auth=Bearer vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==\x01\x01"; + assert_eq!( + extract_oauth_bearer(input.as_bytes()), + Some(( + "vF9dft4qmTc2Nvb3RlckBhbHRhdmlzdGEuY29tCg==".to_string(), + Some("user@example.com".to_string()) + )) + ); + } + + #[test] + fn lenient_ir_decode() { + let out = decode_sasl_ir(b"dGVzdAB0ZXN0AHRlc3Q=").unwrap(); + assert_eq!(out, b"test\0test\0test"); + } + + #[test] + fn plain_with_authzid_takes_authcid() { + let challenge = b"admin\0alice\0secret"; + let cred = Credential::decode_sasl_challenge_plain(challenge).unwrap(); + assert_eq!( + cred, + Credential::Plain { + authcid: "alice".into(), + passwd: "secret".into() + } + ); + } + + #[test] + fn oauth_wrapper_returns_credential() { + let input = "n,a=u@x,\x01auth=Bearer tok\x01\x01"; + let cred = Credential::decode_sasl_challenge_oauth(input.as_bytes()).unwrap(); + assert_eq!( + cred, + Credential::OAuth { + username: Some("u@x".into()), + token: "tok".into() + } + ); + } + + #[test] + fn xoauth2_frame_extracts_token() { + let input = "user=u@x\x01auth=Bearer xtok\x01\x01"; + assert_eq!( + extract_oauth_bearer(input.as_bytes()), + Some(("xtok".to_string(), None)) + ); + } + + #[test] + fn oauth_a_field_requires_at_sign() { + let input = "n,a=plainuser,\x01auth=Bearer tok\x01\x01"; + assert_eq!( + extract_oauth_bearer(input.as_bytes()), + Some(("tok".to_string(), None)) + ); + } + + #[test] + fn lenient_ir_tolerates_whitespace() { + let with_ws = decode_sasl_ir(b"dGVz dAB0\nZXN0AHRlc3Q=").unwrap(); + assert_eq!(with_ws, b"test\0test\0test"); + } + + #[test] + fn lenient_ir_decodes_full_quads_without_padding() { + let out = decode_sasl_ir(b"AHRlc3QAc2VjcmV0").unwrap(); + assert_eq!(out, b"\0test\0secret"); + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..9e411af --- /dev/null +++ b/src/server.rs @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use arc_swap::ArcSwap; +use rustls::crypto::CryptoProvider; +use tokio_rustls::TlsAcceptor; + +use crate::config::Config; +use crate::error::Result; +use crate::net::acceptor::Listener; +use crate::net::tls; +use crate::proto::common::{ConnRegistry, Ctx, Metrics}; +use crate::route::Router; + +pub async fn run(config: Arc, config_path: Arc) -> Result<()> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let provider = tls::provider(); + + if let Some(admin) = &config.admin { + crate::admin::load_token(admin)?; + } + + let conns = Arc::new(ConnRegistry::default()); + let metrics = Arc::new(Metrics::default()); + let ctx = build_ctx(config.clone(), &provider, conns, metrics).await?; + let shared = Arc::new(ArcSwap::from_pointee(ctx)); + + spawn_metrics_logger(shared.clone()); + + let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false); + let mut tasks = Vec::new(); + + for (id, listener_cfg) in &config.listener { + let listener = Arc::new(Listener::new( + id.as_str().into(), + Arc::new(listener_cfg.clone()), + )); + let shared = shared.clone(); + let shutdown_rx = shutdown_rx.clone(); + tasks.push(tokio::spawn(async move { + listener.run(shared, shutdown_rx).await; + })); + } + + if config.admin.is_some() { + let shared = shared.clone(); + let config = config.clone(); + let config_path = config_path.clone(); + tasks.push(tokio::spawn(async move { + if let Err(e) = crate::admin::run(shared, config, Some(config_path)).await { + tracing::error!(error = %e, "admin listener exited"); + } + })); + } + + if tasks.is_empty() { + return Err(crate::error::ProxyError::config("no listeners configured")); + } + + tracing::info!("proxy started"); + shutdown_signal().await; + let grace = config.server.shutdown_grace; + tracing::info!(grace = ?grace, "shutdown signal received; draining"); + let _ = shutdown_tx.send(true); + drain(&shared, grace).await; + Ok(()) +} + +pub(crate) async fn build_ctx( + config: Arc, + provider: &Arc, + conns: Arc, + metrics: Arc, +) -> Result { + let server_config = tls::build_server_config(&config, provider.clone())?; + let tls_acceptor = Some(TlsAcceptor::from(server_config)); + + let valid_destinations: Vec = config.destination.keys().cloned().collect(); + let router = Arc::new(Router::build(&config, valid_destinations).await?); + validate_mapped_destinations(&config, &router)?; + let http_router = Arc::new(crate::http::router::HttpRouter::build( + &config.http, + config.oauth.jwt_username_claim.clone(), + )?); + let dests = Ctx::build_dest_runtimes(&config, provider)?; + let dest_health = Ctx::build_dest_health(&config); + let self_binds = Ctx::build_self_binds(&config); + + for dest in config.destination.values() { + if dest.tls_allow_invalid_certs { + tracing::warn!( + host = %dest.host, + "tls_allow_invalid_certs is enabled: the proxy->backend leg is UNAUTHENTICATED and forwards real credentials" + ); + } + } + + let greetings = crate::proto::common::build_greetings(&config); + Ok(Ctx { + config, + router, + http_router, + tls_acceptor, + dests, + dest_health, + self_binds, + conns, + metrics, + greetings, + }) +} + +fn validate_mapped_destinations(config: &Config, router: &Router) -> Result<()> { + use crate::config::{MappingSource, Protocol}; + if matches!( + config.mapping.source, + MappingSource::Redis | MappingSource::Sql + ) { + tracing::info!( + "dynamic mapping store: per-destination protocol support is validated at connection time, not at boot" + ); + } + let routed: Vec = config + .listener + .values() + .map(|l| l.protocol) + .filter(|p| !p.is_passthrough() && *p != Protocol::Http) + .collect(); + for dest_id in router.mapped_destinations() { + let Some(dest) = config.destination.get(&dest_id) else { + continue; + }; + for proto in &routed { + if !dest.protocol.contains_key(proto) { + return Err(crate::error::ProxyError::config(format!( + "mapped destination {dest_id:?} does not declare protocol {} required by a listener", + proto.as_str() + ))); + } + } + } + Ok(()) +} + +async fn drain(shared: &Arc>, grace: std::time::Duration) { + use std::sync::atomic::Ordering::Relaxed; + let deadline = tokio::time::sleep(grace); + tokio::pin!(deadline); + let mut tick = tokio::time::interval(std::time::Duration::from_millis(100)); + loop { + tokio::select! { + _ = &mut deadline => { + let active = shared.load().metrics.active_connections.load(Relaxed); + if active > 0 { + tracing::warn!(active, "grace elapsed; forcing shutdown with live connections"); + } + return; + } + _ = tick.tick() => { + if shared.load().metrics.active_connections.load(Relaxed) <= 0 { + tracing::info!("all connections drained"); + return; + } + } + } + } +} + +fn spawn_metrics_logger(shared: Arc>) { + use std::sync::atomic::Ordering::Relaxed; + use std::time::Duration; + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(60)); + interval.tick().await; + loop { + interval.tick().await; + let ctx = shared.load(); + let hits = ctx.router.stats.hits.load(Relaxed); + let misses = ctx.router.stats.misses.load(Relaxed); + let total = hits + misses; + let hit_rate = if total > 0 { + (hits as f64 / total as f64) * 100.0 + } else { + 0.0 + }; + let active = ctx.metrics.active_connections.load(Relaxed); + tracing::info!( + cache_hits = hits, + cache_misses = misses, + hit_rate_pct = hit_rate, + active_connections = active, + "metrics" + ); + } + }); +} + +async fn shutdown_signal() { + #[cfg(unix)] + { + use tokio::signal::unix::{SignalKind, signal}; + let mut term = match signal(SignalKind::terminate()) { + Ok(s) => s, + Err(_) => { + let _ = tokio::signal::ctrl_c().await; + return; + } + }; + tokio::select! { + _ = tokio::signal::ctrl_c() => {}, + _ = term.recv() => {}, + } + } + #[cfg(not(unix))] + { + let _ = tokio::signal::ctrl_c().await; + } +} diff --git a/src/tests/admin.rs b/src/tests/admin.rs new file mode 100644 index 0000000..9f294d7 --- /dev/null +++ b/src/tests/admin.rs @@ -0,0 +1,643 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::Ordering; + +use tokio::net::TcpListener; + +use super::harness::*; + +const TOKEN: &str = "0123456789abcdef0123456789abcdef0123"; + +fn config(mapping_path: &str, lockout_threshold: u32) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "mail.legacy.example.com" +[destination.legacy.protocol.imap] +port = 993 +tls = "implicit" + +[admin] +bind = "127.0.0.1:0" +tls = "implicit" +bearer_token = "{TOKEN}" +min_token_len = 32 +lockout_threshold = {lockout_threshold} +lockout_duration = "5m" +"# + ) +} + +async fn start_admin( + toml: &str, +) -> ( + std::net::SocketAddr, + std::sync::Arc, +) { + let ctx = make_ctx(toml).await; + let listener = TcpListener::bind(unused_addr()).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let ctx2 = ctx.clone(); + let config = ctx.config.clone(); + tokio::spawn(async move { + let shared = std::sync::Arc::new(arc_swap::ArcSwap::new(ctx2)); + let _ = crate::admin::serve(shared, config, None, listener).await; + }); + (addr, ctx) +} + +#[tokio::test] +async fn admin_auth_and_routing() { + let mapping = mapping_file("user@example.com\tlegacy\n"); + let toml = config(&mapping.path().display().to_string(), 100); + let (addr, ctx) = start_admin(&toml).await; + + let (status, _) = admin_request(addr, "POST", "/cache/invalidate", Some(TOKEN)).await; + assert_eq!(status, 200); + + let (status, _) = admin_request(addr, "POST", "/cache/invalidate", None).await; + assert_eq!(status, 401); + + let (status, _) = admin_request( + addr, + "POST", + "/cache/invalidate", + Some("wrong-but-long-enough-token-aaaaaaaa"), + ) + .await; + assert_eq!(status, 401); + + let (status, _) = admin_request(addr, "GET", "/cache/invalidate", Some(TOKEN)).await; + assert_eq!(status, 405); + + let (status, _) = admin_request(addr, "POST", "/nope", Some(TOKEN)).await; + assert_eq!(status, 404); + + let (status, _) = admin_request(addr, "POST", "/mappings/reload", Some(TOKEN)).await; + assert_eq!(status, 200); + + let _ = ctx; +} + +#[tokio::test] +async fn admin_cache_invalidate_clears_entry() { + let mapping = mapping_file("user@example.com\tlegacy\n"); + let toml = config(&mapping.path().display().to_string(), 100); + let (addr, ctx) = start_admin(&toml).await; + + ctx.router.resolve(Some("user@example.com")).await; + let misses_before = ctx.router.stats.misses.load(Ordering::Relaxed); + ctx.router.resolve(Some("user@example.com")).await; + assert_eq!( + ctx.router.stats.misses.load(Ordering::Relaxed), + misses_before + ); + + let (status, _) = admin_request( + addr, + "POST", + "/cache/invalidate?identifier=user@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + + ctx.router.resolve(Some("user@example.com")).await; + assert_eq!( + ctx.router.stats.misses.load(Ordering::Relaxed), + misses_before + 1 + ); +} + +#[tokio::test] +async fn admin_lockout_after_repeated_failures() { + let mapping = mapping_file(""); + let toml = config(&mapping.path().display().to_string(), 3); + let (addr, _ctx) = start_admin(&toml).await; + + let bad = "this-is-a-wrong-token-but-long-enough-xx"; + for _ in 0..3 { + let (status, _) = admin_request(addr, "POST", "/cache/invalidate", Some(bad)).await; + assert_eq!(status, 401); + } + let (status, _) = admin_request(addr, "POST", "/cache/invalidate", Some(TOKEN)).await; + assert_eq!(status, 429); +} + +fn kick_config(backend_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 = {backend_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" +"# + ) +} + +#[tokio::test] +async fn admin_kick_disconnects_live_session() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + 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"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(b"+OK logged in\r\n").await.unwrap(); + loop { + match sock.read(&mut scratch).await { + Ok(0) | Err(_) => break, + Ok(_) => {} + } + } + }) + .await; + + let mapping = mapping_file("user@example.com\tlegacy\n"); + let toml = kick_config(backend_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 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; + + let mut seen = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut scratch)) + .await + .expect("login reply timed out") + .unwrap(); + assert!(n > 0, "client closed before login"); + seen.extend_from_slice(&scratch[..n]); + if find(&seen, b"+OK logged in").is_some() { + break; + } + } + + let (status, body) = admin_request( + admin_addr, + "POST", + "/connections/kick?identifier=user@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!(body.contains("kicked 1"), "kick body: {body}"); + + let n = tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut scratch)) + .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; + let _ = handler.await; +} + +#[tokio::test] +async fn admin_delay_holds_new_login() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + 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"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(b"+OK logged in\r\n").await.unwrap(); + let _ = sock.read(&mut scratch).await; + }) + .await; + + let mapping = mapping_file("user@example.com\tlegacy\n"); + let toml = kick_config(backend_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=2", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + + let (status, _) = admin_request( + admin_addr, + "POST", + "/connections/delay?identifier=user@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 400); + let (status, _) = admin_request( + admin_addr, + "POST", + "/connections/delay?identifier=user@example.com&seconds=abc", + Some(TOKEN), + ) + .await; + assert_eq!(status, 400); + + 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}"); + + let started = std::time::Instant::now(); + send(&mut client, b"USER user@example.com\r\nPASS secret\r\n").await; + + let mut seen = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = tokio::time::timeout( + std::time::Duration::from_secs(10), + client.read(&mut scratch), + ) + .await + .expect("delayed login reply timed out") + .unwrap(); + assert!(n > 0, "client closed before login"); + seen.extend_from_slice(&scratch[..n]); + if find(&seen, b"+OK logged in").is_some() { + break; + } + } + assert!( + started.elapsed() >= std::time::Duration::from_millis(1500), + "login should be held for ~2s, but completed in {:?}", + started.elapsed() + ); + + drop(client); + let _ = backend.await; + let _ = handler.await; +} + +#[tokio::test] +async fn admin_kick_unknown_identifier_reports_zero() { + let mapping = mapping_file("user@example.com\tlegacy\n"); + let toml = config(&mapping.path().display().to_string(), 100); + let (addr, _ctx) = start_admin(&toml).await; + + let (status, body) = admin_request( + addr, + "POST", + "/connections/kick?identifier=nobody@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!(body.contains("kicked 0"), "body: {body}"); + + let (status, _) = admin_request(addr, "POST", "/connections/kick", Some(TOKEN)).await; + assert_eq!(status, 400); +} + +#[tokio::test] +async fn admin_rejects_plaintext_connection() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let mapping = mapping_file(""); + let toml = config(&mapping.path().display().to_string(), 100); + let (addr, _ctx) = start_admin(&toml).await; + + let mut tcp = tokio::net::TcpStream::connect(addr).await.unwrap(); + let req = format!( + "POST /cache/invalidate HTTP/1.1\r\nHost: admin\r\nAuthorization: Bearer {TOKEN}\r\nConnection: close\r\n\r\n" + ); + let _ = tcp.write_all(req.as_bytes()).await; + let _ = tcp.flush().await; + + let mut buf = Vec::new(); + let _ = + tokio::time::timeout(std::time::Duration::from_secs(3), tcp.read_to_end(&mut buf)).await; + assert!( + find(&buf, b"200").is_none(), + "a plaintext admin request must not be served, got {:?}", + String::from_utf8_lossy(&buf) + ); +} + +fn two_dest_config(mapping_path: &str, default_dest: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[routing] +default_destination = "{default_dest}" + +[destination.legacy] +host = "mail.legacy.example.com" +[destination.legacy.protocol.imap] +port = 993 +tls = "implicit" + +[destination.secondary] +host = "mail.secondary.example.com" +[destination.secondary.protocol.imap] +port = 993 +tls = "implicit" + +[admin] +bind = "127.0.0.1:0" +tls = "implicit" +bearer_token = "{TOKEN}" +min_token_len = 32 +lockout_threshold = 100 +lockout_duration = "5m" +"# + ) +} + +#[tokio::test] +async fn admin_mapping_crud_and_reads() { + let mapping = mapping_file(""); + let toml = two_dest_config(&mapping.path().display().to_string(), "legacy"); + let (addr, ctx) = start_admin(&toml).await; + + let (status, _) = admin_request(addr, "GET", "/healthz", None).await; + assert_eq!(status, 200); + let (status, _) = admin_request(addr, "GET", "/healthz", Some(TOKEN)).await; + assert_eq!(status, 200); + + let (status, body) = admin_request(addr, "GET", "/stats", Some(TOKEN)).await; + assert_eq!(status, 200); + assert!(body.contains("cache_hits"), "body: {body}"); + + let (status, body) = admin_request(addr, "GET", "/destinations", Some(TOKEN)).await; + assert_eq!(status, 200); + assert!( + body.contains("legacy") && body.contains("secondary"), + "body: {body}" + ); + + let (status, _) = admin_request( + addr, + "PUT", + "/mappings?identifier=alice@example.com&destination=secondary", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert_eq!( + &*ctx.router.resolve(Some("alice@example.com")).await, + "secondary" + ); + let on_disk = std::fs::read_to_string(mapping.path()).unwrap(); + assert!( + on_disk.contains("alice@example.com\tsecondary"), + "{on_disk}" + ); + + let (status, body) = admin_request( + addr, + "GET", + "/mappings?identifier=alice@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!( + body.contains("\"destination\":\"secondary\""), + "body: {body}" + ); + assert!(body.contains("\"routed_to_default\":false"), "body: {body}"); + + let (status, _) = admin_request( + addr, + "PUT", + "/mappings?identifier=bob@example.com&destination=ghost", + Some(TOKEN), + ) + .await; + assert_eq!(status, 400); + + let (status, body) = admin_request( + addr, + "DELETE", + "/mappings?identifier=alice@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!(body.contains("removed"), "body: {body}"); + let (status, body) = admin_request( + addr, + "DELETE", + "/mappings?identifier=alice@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!(body.contains("absent"), "body: {body}"); + assert_eq!( + &*ctx.router.resolve(Some("alice@example.com")).await, + "legacy" + ); + + let (status, _) = admin_request( + addr, + "POST", + "/destinations/reset?destination=legacy", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + let (status, _) = admin_request( + addr, + "POST", + "/destinations/reset?destination=ghost", + Some(TOKEN), + ) + .await; + assert_eq!(status, 404); +} + +#[tokio::test] +async fn admin_config_reload_swaps_routing() { + let mapping = mapping_file(""); + let mapping_path = mapping.path().display().to_string(); + let toml_v1 = two_dest_config(&mapping_path, "legacy"); + + let cfg_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(cfg_file.path(), toml_v1.as_bytes()).unwrap(); + let cfg_path: std::sync::Arc = std::sync::Arc::from(cfg_file.path().display().to_string()); + + let ctx = make_ctx(&toml_v1).await; + let listener = TcpListener::bind(unused_addr()).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let admin_config = ctx.config.clone(); + let shared = std::sync::Arc::new(arc_swap::ArcSwap::new(ctx.clone())); + let serve_shared = shared.clone(); + tokio::spawn(async move { + let _ = crate::admin::serve(serve_shared, admin_config, Some(cfg_path), listener).await; + }); + + let (status, body) = admin_request( + addr, + "GET", + "/mappings?identifier=x@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!(body.contains("\"destination\":\"legacy\""), "body: {body}"); + + let toml_v2 = two_dest_config(&mapping_path, "secondary"); + std::fs::write(cfg_file.path(), toml_v2.as_bytes()).unwrap(); + let (status, body) = admin_request(addr, "POST", "/config/reload", Some(TOKEN)).await; + assert_eq!(status, 200, "body: {body}"); + + let (status, body) = admin_request( + addr, + "GET", + "/mappings?identifier=x@example.com", + Some(TOKEN), + ) + .await; + assert_eq!(status, 200); + assert!( + body.contains("\"destination\":\"secondary\""), + "body: {body}" + ); + assert_eq!( + &*shared.load().router.resolve(Some("y@example.com")).await, + "secondary" + ); +} + +#[tokio::test] +async fn admin_config_reload_rejects_invalid() { + let mapping = mapping_file(""); + let mapping_path = mapping.path().display().to_string(); + let toml_v1 = two_dest_config(&mapping_path, "legacy"); + + let cfg_file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write(cfg_file.path(), toml_v1.as_bytes()).unwrap(); + let cfg_path: std::sync::Arc = std::sync::Arc::from(cfg_file.path().display().to_string()); + + let ctx = make_ctx(&toml_v1).await; + let listener = TcpListener::bind(unused_addr()).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let admin_config = ctx.config.clone(); + let shared = std::sync::Arc::new(arc_swap::ArcSwap::new(ctx.clone())); + let serve_shared = shared.clone(); + tokio::spawn(async move { + let _ = crate::admin::serve(serve_shared, admin_config, Some(cfg_path), listener).await; + }); + + std::fs::write( + cfg_file.path(), + b"[routing]\ndefault_destination = \"nope\"\n", + ) + .unwrap(); + let (status, _) = admin_request(addr, "POST", "/config/reload", Some(TOKEN)).await; + assert_eq!(status, 400); + assert_eq!( + &*shared.load().router.resolve(Some("y@example.com")).await, + "legacy" + ); +} diff --git a/src/tests/config_validation.rs b/src/tests/config_validation.rs new file mode 100644 index 0000000..7c294a0 --- /dev/null +++ b/src/tests/config_validation.rs @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use crate::config::Config; + +use super::harness::mapping_file; + +fn mapping_block(path: &str) -> String { + format!( + r#" +[mapping] +source = "file" +[mapping.file] +path = "{path}" +"# + ) +} + +#[test] +fn rejects_unknown_default_destination() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "ghost" + +[destination.real] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.real.protocol.imap] +port = 143 +tls = "plain" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_plaintext_credential_destination_without_override() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_ip_host_tls_without_server_name() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +[destination.legacy.protocol.imap] +port = 993 +tls = "implicit" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_smtp_listener_without_passthrough_destination() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[listener.smtp] +protocol = "smtp" +bind = ["127.0.0.1:25"] +tls = "plain" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_listener_protocol_not_in_default_destination() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:110"] +tls = "plain" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +fn passthrough_base(path: &str, extra: &str) -> String { + format!( + r#" +{} +[routing] +default_destination = "legacy" +smtp_passthrough_destination = "relay" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[destination.relay] +host = "127.0.0.1" +proxy_protocol = false +[destination.relay.protocol.imap] +port = 143 +tls = "plain" + +{extra} +"#, + mapping_block(path) + ) +} + +#[test] +fn rejects_passthrough_destination_missing_smtp() { + let m = mapping_file(""); + let toml = passthrough_base( + &m.path().display().to_string(), + r#" +[listener.smtp] +protocol = "smtp" +bind = ["127.0.0.1:25"] +tls = "plain" +"#, + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_starttls_passthrough_listener() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" +smtp_passthrough_destination = "relay" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[destination.relay] +host = "127.0.0.1" +proxy_protocol = false +[destination.relay.protocol.smtp] +port = 25 +tls = "plain" + +[listener.smtp] +protocol = "smtp" +bind = ["127.0.0.1:25"] +tls = "starttls" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +fn admin_base(path: &str, admin: &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.imap] +port = 143 +tls = "plain" + +{admin} +"#, + mapping_block(path) + ) +} + +#[test] +fn rejects_admin_plaintext_tls() { + let m = mapping_file(""); + let toml = admin_base( + &m.path().display().to_string(), + r#" +[admin] +bind = "127.0.0.1:9443" +tls = "plain" +bearer_token = "0123456789abcdef0123456789abcdef" +"#, + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_admin_without_token_source() { + let m = mapping_file(""); + let toml = admin_base( + &m.path().display().to_string(), + r#" +[admin] +bind = "127.0.0.1:9443" +tls = "implicit" +"#, + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_http_route_extract_body_without_regex() { + let m = mapping_file(""); + let toml = admin_base( + &m.path().display().to_string(), + r#" +[[http.route]] +match = "/auth" +extract = { from = "body" } +"#, + ); + assert!(Config::parse_and_validate(&toml).is_err()); +} + +#[test] +fn rejects_mapping_source_redis_without_section() { + let toml = r#" +[mapping] +source = "redis" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" +"#; + assert!(Config::parse_and_validate(toml).is_err()); +} + +#[test] +fn accepts_passthrough_with_smtp_declared() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" +smtp_passthrough_destination = "relay" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[destination.relay] +host = "127.0.0.1" +proxy_protocol = false +[destination.relay.protocol.smtp] +port = 25 +tls = "plain" + +[listener.smtp] +protocol = "smtp" +bind = ["127.0.0.1:25"] +tls = "plain" +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_ok()); +} + +#[test] +fn accepts_minimal_valid_config() { + let m = mapping_file(""); + let toml = format!( + r#" +{} +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[listener.imap] +protocol = "imap" +bind = ["127.0.0.1:143"] +tls = "plain" + +[capabilities.imap] +allow_plain_auth_without_tls = true +"#, + mapping_block(&m.path().display().to_string()) + ); + assert!(Config::parse_and_validate(&toml).is_ok()); +} diff --git a/src/tests/containers/clients.rs b/src/tests/containers/clients.rs new file mode 100644 index 0000000..77cf518 --- /dev/null +++ b/src/tests/containers/clients.rs @@ -0,0 +1,179 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpStream; + +use crate::tests::harness::tls_connect_client; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Tls { + Implicit, + Starttls, +} + +fn plain_ir(login: &str, password: &str) -> String { + B64.encode(format!("\0{login}\0{password}")) +} + +async fn read_reply(stream: &mut S) -> String { + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).await.unwrap_or(0); + String::from_utf8_lossy(&buf[..n]).into_owned() +} + +async fn read_until(stream: &mut S, needle: &str) -> String { + let mut acc = String::new(); + let mut buf = [0u8; 4096]; + for _ in 0..64 { + let n = match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + acc.push_str(&String::from_utf8_lossy(&buf[..n])); + if acc.contains(needle) { + break; + } + } + acc +} + +pub async fn imap_auth(host: &str, port: u16, tls: Tls, login: &str, password: &str) -> String { + let cmd = format!("a1 AUTHENTICATE PLAIN {}\r\n", plain_ir(login, password)); + match tls { + Tls::Implicit => { + let tcp = TcpStream::connect((host, port)).await.unwrap(); + let mut s = tls_connect_client(tcp).await; + let _ = read_reply(&mut s).await; + s.write_all(cmd.as_bytes()).await.unwrap(); + read_until(&mut s, "a1 ").await + } + Tls::Starttls => { + let mut tcp = TcpStream::connect((host, port)).await.unwrap(); + let _ = read_reply(&mut tcp).await; + tcp.write_all(b"a0 STARTTLS\r\n").await.unwrap(); + let _ = read_until(&mut tcp, "a0 ").await; + let mut s = tls_connect_client(tcp).await; + s.write_all(cmd.as_bytes()).await.unwrap(); + read_until(&mut s, "a1 ").await + } + } +} + +pub async fn pop3_auth(host: &str, port: u16, tls: Tls, login: &str, password: &str) -> String { + let cmd = format!("AUTH PLAIN {}\r\n", plain_ir(login, password)); + match tls { + Tls::Implicit => { + let tcp = TcpStream::connect((host, port)).await.unwrap(); + let mut s = tls_connect_client(tcp).await; + let _ = read_reply(&mut s).await; + s.write_all(cmd.as_bytes()).await.unwrap(); + read_reply(&mut s).await + } + Tls::Starttls => { + let mut tcp = TcpStream::connect((host, port)).await.unwrap(); + let _ = read_reply(&mut tcp).await; + tcp.write_all(b"STLS\r\n").await.unwrap(); + let _ = read_reply(&mut tcp).await; + let mut s = tls_connect_client(tcp).await; + s.write_all(cmd.as_bytes()).await.unwrap(); + read_reply(&mut s).await + } + } +} + +pub async fn managesieve_auth( + host: &str, + port: u16, + tls: Tls, + login: &str, + password: &str, +) -> String { + let cmd = format!( + "AUTHENTICATE \"PLAIN\" \"{}\"\r\n", + plain_ir(login, password) + ); + match tls { + Tls::Implicit => { + let tcp = TcpStream::connect((host, port)).await.unwrap(); + let mut s = tls_connect_client(tcp).await; + let _ = read_until(&mut s, "OK").await; + s.write_all(cmd.as_bytes()).await.unwrap(); + read_reply(&mut s).await + } + Tls::Starttls => { + let mut tcp = TcpStream::connect((host, port)).await.unwrap(); + let _ = read_until(&mut tcp, "OK").await; + tcp.write_all(b"STARTTLS\r\n").await.unwrap(); + let _ = read_reply(&mut tcp).await; + let mut s = tls_connect_client(tcp).await; + let _ = read_until(&mut s, "OK").await; + s.write_all(cmd.as_bytes()).await.unwrap(); + read_reply(&mut s).await + } + } +} + +async fn submission_auth( + stream: &mut S, + login: &str, + password: &str, +) -> String { + stream.write_all(b"EHLO proxy.test\r\n").await.unwrap(); + let _ = read_until(stream, "250 ").await; + let ir = B64.encode(format!("\0{login}\0{password}")); + stream + .write_all(format!("AUTH PLAIN {ir}\r\n").as_bytes()) + .await + .unwrap(); + read_reply(stream).await +} + +pub async fn submission_send( + host: &str, + port: u16, + tls: Tls, + login: &str, + password: &str, +) -> String { + match tls { + Tls::Implicit => { + let tcp = TcpStream::connect((host, port)).await.unwrap(); + let mut s = tls_connect_client(tcp).await; + let _ = read_reply(&mut s).await; + submission_auth(&mut s, login, password).await + } + Tls::Starttls => { + let mut tcp = TcpStream::connect((host, port)).await.unwrap(); + let _ = read_reply(&mut tcp).await; + tcp.write_all(b"EHLO proxy.test\r\n").await.unwrap(); + let _ = read_until(&mut tcp, "250 ").await; + tcp.write_all(b"STARTTLS\r\n").await.unwrap(); + let _ = read_reply(&mut tcp).await; + let mut s = tls_connect_client(tcp).await; + submission_auth(&mut s, login, password).await + } + } +} + +pub async fn smtp_passthrough_send(host: &str, port: u16, from: &str, to: &str) -> String { + let mut s = TcpStream::connect((host, port)).await.unwrap(); + let _ = read_reply(&mut s).await; + s.write_all(b"EHLO proxy.test\r\n").await.unwrap(); + let _ = read_until(&mut s, "250 ").await; + s.write_all(format!("MAIL FROM:<{from}>\r\n").as_bytes()) + .await + .unwrap(); + let _ = read_reply(&mut s).await; + s.write_all(format!("RCPT TO:<{to}>\r\n").as_bytes()) + .await + .unwrap(); + let rcpt = read_reply(&mut s).await; + s.write_all(b"QUIT\r\n").await.unwrap(); + rcpt +} diff --git a/src/tests/containers/http.rs b/src/tests/containers/http.rs new file mode 100644 index 0000000..9dcdfa8 --- /dev/null +++ b/src/tests/containers/http.rs @@ -0,0 +1,375 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::Arc; + +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tokio::net::TcpStream; + +pub struct Response { + pub status: u16, + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +impl Response { + pub fn text(&self) -> String { + String::from_utf8_lossy(&self.body).into_owned() + } + + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(name)) + .map(|(_, v)| v.as_str()) + } + + pub fn json(&self) -> serde_json::Value { + serde_json::from_slice(&self.body) + .unwrap_or_else(|e| panic!("response body is not JSON ({e}): {}", self.text())) + } +} + +pub fn basic_auth(user: &str, pass: &str) -> String { + use base64::Engine; + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}")) + ) +} + +pub async fn upgrade( + host: &str, + port: u16, + tls: bool, + path: &str, + headers: &[(&str, &str)], +) -> Result { + async fn head_only( + mut stream: S, + host: &str, + path: &str, + headers: &[(&str, &str)], + ) -> Result { + let mut req = format!("GET {path} HTTP/1.1\r\nHost: {host}\r\n"); + for (name, value) in headers { + req.push_str(name); + req.push_str(": "); + req.push_str(value); + req.push_str("\r\n"); + } + req.push_str("\r\n"); + stream + .write_all(req.as_bytes()) + .await + .map_err(|e| format!("write: {e}"))?; + stream.flush().await.map_err(|e| format!("flush: {e}"))?; + + let mut raw = Vec::new(); + let mut byte = [0u8; 1]; + loop { + let n = stream + .read(&mut byte) + .await + .map_err(|e| format!("read: {e}"))?; + if n == 0 { + break; + } + raw.push(byte[0]); + if raw.ends_with(b"\r\n\r\n") { + break; + } + } + Ok(parse_head(&raw)) + } + + let tcp = TcpStream::connect((host, port)) + .await + .map_err(|e| format!("connect {host}:{port}: {e}"))?; + let _ = tcp.set_nodelay(true); + let deadline = std::time::Duration::from_secs(20); + let fut = async { + if tls { + head_only(tls_client(tcp, host).await?, host, path, headers).await + } else { + head_only(tcp, host, path, headers).await + } + }; + tokio::time::timeout(deadline, fut) + .await + .unwrap_or_else(|_| Err(format!("upgrade request to {host}:{port} timed out"))) +} + +fn parse_head(raw: &[u8]) -> Response { + let head_end = raw + .windows(4) + .position(|w| w == b"\r\n\r\n") + .unwrap_or(raw.len()); + let head_text = String::from_utf8_lossy(&raw[..head_end]); + let mut lines = head_text.lines(); + let status = lines + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse().ok()) + .unwrap_or(0); + let headers = lines + .filter_map(|line| { + line.split_once(':') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + Response { + status, + headers, + body: Vec::new(), + } +} + +pub async fn request( + host: &str, + port: u16, + tls: bool, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: Option<&[u8]>, +) -> Response { + try_request(host, port, tls, method, path, headers, body) + .await + .unwrap_or_else(|e| panic!("{e}")) +} + +pub async fn try_request( + host: &str, + port: u16, + tls: bool, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: Option<&[u8]>, +) -> Result { + let deadline = std::time::Duration::from_secs(20); + tokio::time::timeout(deadline, async { + let tcp = TcpStream::connect((host, port)) + .await + .map_err(|e| format!("connect {host}:{port}: {e}"))?; + let _ = tcp.set_nodelay(true); + if tls { + let stream = tls_client(tcp, host).await?; + exchange(stream, host, method, path, headers, body).await + } else { + exchange(tcp, host, method, path, headers, body).await + } + }) + .await + .unwrap_or_else(|_| { + Err(format!( + "http request to {host}:{port} timed out after {deadline:?}" + )) + }) +} + +async fn exchange( + mut stream: S, + host: &str, + method: &str, + path: &str, + headers: &[(&str, &str)], + body: Option<&[u8]>, +) -> Result { + let mut req = format!("{method} {path} HTTP/1.1\r\nHost: {host}\r\nConnection: close\r\n"); + for (name, value) in headers { + req.push_str(name); + req.push_str(": "); + req.push_str(value); + req.push_str("\r\n"); + } + if let Some(body) = body { + req.push_str(&format!("Content-Length: {}\r\n", body.len())); + } + req.push_str("\r\n"); + + stream + .write_all(req.as_bytes()) + .await + .map_err(|e| format!("write: {e}"))?; + if let Some(body) = body { + stream + .write_all(body) + .await + .map_err(|e| format!("write body: {e}"))?; + } + stream.flush().await.map_err(|e| format!("flush: {e}"))?; + + let mut raw = Vec::new(); + stream + .read_to_end(&mut raw) + .await + .map_err(|e| format!("read: {e}"))?; + + let Some(split) = raw.windows(4).position(|w| w == b"\r\n\r\n") else { + return Err(format!( + "no header terminator in response: {:?}", + String::from_utf8_lossy(&raw) + )); + }; + let head = &raw[..split]; + let body_raw = raw[split + 4..].to_vec(); + + let head_text = String::from_utf8_lossy(head); + let mut lines = head_text.lines(); + let status = lines + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|code| code.parse().ok()) + .unwrap_or(0); + let headers: Vec<(String, String)> = lines + .filter_map(|line| { + line.split_once(':') + .map(|(k, v)| (k.trim().to_string(), v.trim().to_string())) + }) + .collect(); + let chunked = headers.iter().any(|(k, v)| { + k.eq_ignore_ascii_case("transfer-encoding") && v.to_ascii_lowercase().contains("chunked") + }); + + let body = if chunked { + dechunk(&body_raw) + } else { + body_raw + }; + Ok(Response { + status, + headers, + body, + }) +} + +pub async fn get_follow( + host: &str, + port: u16, + tls: bool, + path: &str, + headers: &[(&str, &str)], +) -> Response { + let mut current = path.to_string(); + for _ in 0..5 { + let resp = request(host, port, tls, "GET", ¤t, headers, None).await; + if matches!(resp.status, 301 | 302 | 307 | 308) + && let Some(location) = resp.header("location") + { + current = location_path(location); + continue; + } + return resp; + } + request(host, port, tls, "GET", ¤t, headers, None).await +} + +fn location_path(location: &str) -> String { + if let Some(rest) = location.split_once("://") { + match rest.1.split_once('/') { + Some((_authority, path)) => format!("/{path}"), + None => "/".to_string(), + } + } else { + location.to_string() + } +} + +fn dechunk(input: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut rest = input; + while let Some(eol) = rest.windows(2).position(|w| w == b"\r\n") { + let size_line = &rest[..eol]; + let size_str = String::from_utf8_lossy(size_line); + let size = + usize::from_str_radix(size_str.trim().split(';').next().unwrap_or("0").trim(), 16) + .unwrap_or(0); + rest = &rest[eol + 2..]; + if size == 0 { + break; + } + if rest.len() < size { + out.extend_from_slice(rest); + break; + } + out.extend_from_slice(&rest[..size]); + rest = &rest[size..]; + if rest.starts_with(b"\r\n") { + rest = &rest[2..]; + } + } + out +} + +async fn tls_client( + tcp: TcpStream, + sni: &str, +) -> Result, String> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyCert)) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + let server_name = rustls::pki_types::ServerName::try_from(sni.to_string()) + .unwrap_or_else(|_| rustls::pki_types::ServerName::try_from("localhost").unwrap()); + connector + .connect(server_name, tcp) + .await + .map_err(|e| format!("TLS handshake: {e}")) +} + +#[derive(Debug)] +struct AcceptAnyCert; + +impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + use rustls::SignatureScheme::*; + vec![ + RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512, + ECDSA_NISTP256_SHA256, + ECDSA_NISTP384_SHA384, + ECDSA_NISTP521_SHA512, + RSA_PSS_SHA256, + RSA_PSS_SHA384, + RSA_PSS_SHA512, + ED25519, + ] + } +} diff --git a/src/tests/containers/jmap.rs b/src/tests/containers/jmap.rs new file mode 100644 index 0000000..70e46c8 --- /dev/null +++ b/src/tests/containers/jmap.rs @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use futures_util::StreamExt; +use jmap_client::client::{Client, Credentials}; +use jmap_client::client_ws::WebSocketMessage; +use jmap_client::event_source::PushNotification; +use jmap_client::mailbox::Role; +use tokio::time::timeout; + +use super::stalwart::{Edition, Stalwart}; +use super::{Proxy, pick_free_port}; +use crate::tests::harness::mapping_file; + +fn proxy_config(proxy_port: u16, new_https: u16, old_https: u16, map_path: &str) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "new" + +[destination.new] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +proxy_protocol = false +forwarded = true +[destination.new.protocol.http] +port = {new_https} +tls = "implicit" + +[destination.old] +host = "127.0.0.1" +tls_server_name = "old.test" +tls_allow_invalid_certs = true +proxy_protocol = false +forwarded = true +[destination.old.protocol.http] +port = {old_https} +tls = "implicit" + +[[http.route]] +match = "/**" +extract = {{ from = "authorization" }} +fallback = "default" + +[listener.http] +protocol = "http" +bind = ["0.0.0.0:{proxy_port}"] +tls = "plain" +"# + ) +} + +async fn connect_via_proxy(proxy_port: u16, login: &str, password: &str) -> Client { + Client::new() + .credentials(Credentials::basic(login, password)) + .accept_invalid_certs(true) + .follow_redirects(["127.0.0.1"]) + .connect(&format!("http://127.0.0.1:{proxy_port}")) + .await + .expect("jmap-client session via proxy") +} + +async fn exercise_websocket(client: &Client, who: &str) { + let mut ws = client + .connect_ws() + .await + .unwrap_or_else(|e| panic!("[{who}] connect_ws: {e:?}")); + + let mut request = client.build(); + request.get_mailbox(); + let request_id = request.send_ws().await.expect("send_ws"); + + let message = timeout(Duration::from_secs(10), ws.next()) + .await + .unwrap_or_else(|_| panic!("[{who}] WebSocket response timed out")) + .unwrap_or_else(|| panic!("[{who}] WebSocket stream ended")) + .unwrap_or_else(|e| panic!("[{who}] WebSocket error: {e:?}")); + match message { + WebSocketMessage::Response(response) => assert_eq!( + response.request_id(), + Some(request_id.as_str()), + "[{who}] the WebSocket response must echo our request id" + ), + other => panic!("[{who}] expected a method response over WebSocket, got {other:?}"), + } +} + +async fn exercise_event_source(client: &Client, who: &str) { + let mut events = client + .event_source(None::>, false, Some(1), None) + .await + .expect("event_source"); + + let mailbox_id = client + .mailbox_create("EventSource Test", None::, Role::None) + .await + .expect("mailbox_create") + .take_id(); + + let account_id = client.default_account_id().to_string(); + let mut saw_change = false; + for _ in 0..20 { + match timeout(Duration::from_secs(5), events.next()).await { + Ok(Some(Ok(PushNotification::StateChange(changes)))) => { + if changes.changes(&account_id).is_some() { + saw_change = true; + break; + } + } + Ok(Some(Ok(_))) => {} + _ => break, + } + } + + client.mailbox_destroy(&mailbox_id, true).await.ok(); + assert!( + saw_change, + "[{who}] EventSource must deliver a state change for account {account_id}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn jmap_client_ws_and_sse_through_proxy() { + let proxy_port = pick_free_port(); + let public_url = format!("http://127.0.0.1:{proxy_port}"); + + let new = Stalwart::start_fronted( + Edition::New, + "new.test", + &[("ivan", "IvanPass#2026")], + &public_url, + ) + .await; + let old = Stalwart::start_fronted( + Edition::Old, + "old.test", + &[("judy", "JudyPass#2026")], + &public_url, + ) + .await; + + let map = mapping_file("ivan@new.test\tnew\njudy\told\n"); + let toml = proxy_config( + proxy_port, + new.https, + old.https, + map.path().to_str().unwrap(), + ); + let proxy = Proxy::launch(&toml).await; + assert_eq!(proxy.port("http"), proxy_port); + + let new_client = connect_via_proxy(proxy_port, "ivan@new.test", "IvanPass#2026").await; + exercise_websocket(&new_client, "new").await; + exercise_event_source(&new_client, "new").await; + + let old_client = connect_via_proxy(proxy_port, "judy", "JudyPass#2026").await; + exercise_websocket(&old_client, "old").await; + exercise_event_source(&old_client, "old").await; +} diff --git a/src/tests/containers/legacy.rs b/src/tests/containers/legacy.rs new file mode 100644 index 0000000..1c9ef01 --- /dev/null +++ b/src/tests/containers/legacy.rs @@ -0,0 +1,335 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers::runners::{AsyncBuilder, AsyncRunner}; +use testcontainers::{ContainerAsync, GenericBuildableImage, GenericImage, ImageExt}; +use tokio::net::TcpStream; + +pub const MASTER_USER: &str = "admin"; +pub const MASTER_PASSWORD: &str = "MasterPass#2026"; + +const DOCKERFILE: &str = r#"FROM debian:bookworm-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN echo "postfix postfix/main_mailer_type select Internet Site" | debconf-set-selections \ + && echo "postfix postfix/mailname string legacy.test" | debconf-set-selections \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + postfix postfix-pcre \ + dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd \ + dovecot-managesieved dovecot-sieve \ + openssl ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY dovecot.conf /etc/dovecot/dovecot.conf +COPY users /etc/dovecot/users +COPY master-users /etc/dovecot/master-users +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +EXPOSE 143 993 110 995 587 465 4190 25 +ENTRYPOINT ["/entrypoint.sh"] +"#; + +const DOVECOT_CONF: &str = r#"protocols = imap pop3 sieve lmtp +listen = * + +ssl = yes +ssl_cert = /dev/null 2>&1 + chmod 600 /etc/dovecot/ssl/dovecot.key +fi + +getent group vmail >/dev/null 2>&1 || groupadd -g 5000 vmail +getent passwd vmail >/dev/null 2>&1 || useradd -u 5000 -g 5000 -d /var/vmail -s /usr/sbin/nologin vmail +mkdir -p /var/vmail/alice@legacy.test /var/vmail/bob@legacy.test +chown -R vmail:vmail /var/vmail + +postconf -e "maillog_file=/dev/stdout" +postconf -e "myhostname=legacy.test" +postconf -e "mydestination=" +postconf -e "smtpd_sasl_type=dovecot" +postconf -e "smtpd_sasl_path=private/auth" +postconf -e "smtpd_sasl_auth_enable=yes" +postconf -e "broken_sasl_auth_clients=yes" +postconf -e "smtpd_tls_cert_file=/etc/dovecot/ssl/dovecot.crt" +postconf -e "smtpd_tls_key_file=/etc/dovecot/ssl/dovecot.key" +postconf -e "smtpd_tls_security_level=may" +postconf -e "smtpd_authorized_xclient_hosts=0.0.0.0/0" +postconf -e "virtual_mailbox_domains=legacy.test" +postconf -e "virtual_mailbox_base=/var/vmail" +postconf -e "virtual_transport=lmtp:unix:private/dovecot-lmtp" +postconf -e "virtual_mailbox_maps=inline:{alice@legacy.test=alice@legacy.test/,bob@legacy.test=bob@legacy.test/}" +postconf -e "virtual_minimum_uid=5000" +postconf -e "virtual_uid_maps=static:5000" +postconf -e "virtual_gid_maps=static:5000" + +postconf -M submission/inet="submission inet n - y - - smtpd" +postconf -P "submission/inet/syslog_name=postfix/submission" +postconf -P "submission/inet/smtpd_tls_security_level=may" +postconf -P "submission/inet/smtpd_sasl_auth_enable=yes" +postconf -P "submission/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" +postconf -P "submission/inet/smtpd_relay_restrictions=permit_sasl_authenticated,reject" +postconf -P "submission/inet/smtpd_recipient_restrictions=permit_sasl_authenticated,reject" + +postconf -M smtps/inet="smtps inet n - y - - smtpd" +postconf -P "smtps/inet/syslog_name=postfix/smtps" +postconf -P "smtps/inet/smtpd_tls_wrappermode=yes" +postconf -P "smtps/inet/smtpd_sasl_auth_enable=yes" +postconf -P "smtps/inet/smtpd_client_restrictions=permit_sasl_authenticated,reject" +postconf -P "smtps/inet/smtpd_relay_restrictions=permit_sasl_authenticated,reject" +postconf -P "smtps/inet/smtpd_recipient_restrictions=permit_sasl_authenticated,reject" + +chown root:dovecot /etc/dovecot/users /etc/dovecot/master-users +chmod 640 /etc/dovecot/users /etc/dovecot/master-users + +dovecot +postfix check || true +postfix start + +echo "LEGACY-MAIL READY" + +exec sleep infinity +"#; + +pub struct Legacy { + _container: ContainerAsync, + pub host: String, + pub imap: u16, + pub imaps: u16, + pub pop3: u16, + pub pop3s: u16, + pub submission: u16, + pub smtps: u16, + pub sieve: u16, +} + +impl Legacy { + pub async fn start() -> Legacy { + let image: GenericImage = GenericBuildableImage::new("proxy-legacymail", "test") + .with_dockerfile_string(DOCKERFILE.to_string()) + .with_data(DOVECOT_CONF.as_bytes().to_vec(), "dovecot.conf") + .with_data(USERS.as_bytes().to_vec(), "users") + .with_data(MASTER_USERS.as_bytes().to_vec(), "master-users") + .with_data(ENTRYPOINT.as_bytes().to_vec(), "entrypoint.sh") + .build_image() + .await + .expect("build legacy image"); + + let container = image + .with_exposed_port(143u16.tcp()) + .with_exposed_port(993u16.tcp()) + .with_exposed_port(110u16.tcp()) + .with_exposed_port(995u16.tcp()) + .with_exposed_port(587u16.tcp()) + .with_exposed_port(465u16.tcp()) + .with_exposed_port(4190u16.tcp()) + .with_exposed_port(25u16.tcp()) + .with_wait_for(WaitFor::message_on_stdout("LEGACY-MAIL READY")) + .with_startup_timeout(Duration::from_secs(240)) + .start() + .await + .expect("start legacy container"); + + let host = container.get_host().await.expect("legacy host").to_string(); + let map = |p: u16| { + let container = &container; + async move { + container + .get_host_port_ipv4(p.tcp()) + .await + .unwrap_or_else(|e| panic!("legacy port {p}: {e}")) + } + }; + let legacy = Legacy { + imap: map(143).await, + imaps: map(993).await, + pop3: map(110).await, + pop3s: map(995).await, + submission: map(587).await, + smtps: map(465).await, + sieve: map(4190).await, + host, + _container: container, + }; + wait_port(&legacy.host, legacy.imap).await; + wait_port(&legacy.host, legacy.submission).await; + wait_port(&legacy.host, legacy.sieve).await; + legacy + } +} + +async fn wait_port(host: &str, port: u16) { + for _ in 0..200 { + if TcpStream::connect((host, port)).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(50)).await; + } + panic!("legacy backend port {host}:{port} never came up"); +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + #[ignore = "requires Docker"] + async fn legacy_boots_and_serves_all_protocols() { + let legacy = Legacy::start().await; + + let mut imap = TcpStream::connect((legacy.host.as_str(), legacy.imap)) + .await + .unwrap(); + let mut buf = [0u8; 256]; + let n = imap.read(&mut buf).await.unwrap(); + assert!( + buf[..n].windows(4).any(|w| w == b"* OK"), + "imap greeting: {:?}", + String::from_utf8_lossy(&buf[..n]) + ); + imap.write_all(b"a1 LOGIN alice@legacy.test AlicePass#2026\r\n") + .await + .unwrap(); + let n = imap.read(&mut buf).await.unwrap(); + assert!( + buf[..n].windows(5).any(|w| w == b"a1 OK"), + "imap login: {:?}", + String::from_utf8_lossy(&buf[..n]) + ); + + let mut sieve = TcpStream::connect((legacy.host.as_str(), legacy.sieve)) + .await + .unwrap(); + let mut caps = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = sieve.read(&mut scratch).await.unwrap(); + if n == 0 { + break; + } + caps.extend_from_slice(&scratch[..n]); + if caps.windows(4).any(|w| w == b"OK\r\n") { + break; + } + } + assert!( + caps.windows(7).any(|w| w == b"XCLIENT"), + "managesieve advertises XCLIENT: {:?}", + String::from_utf8_lossy(&caps) + ); + } +} diff --git a/src/tests/containers/limits.rs b/src/tests/containers/limits.rs new file mode 100644 index 0000000..93ceb96 --- /dev/null +++ b/src/tests/containers/limits.rs @@ -0,0 +1,191 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::{Duration, Instant}; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; +use tokio::time::timeout; + +use super::{Proxy, pick_free_port}; +use crate::tests::harness::{dummy_backend, mapping_file}; + +async fn idle_pop3_backend() -> (u16, tokio::task::JoinHandle<()>) { + dummy_backend(|mut sock| async move { + sock.write_all(b"+OK dummy ready\r\n").await.unwrap(); + let mut buf = [0u8; 256]; + let _ = sock.read(&mut buf).await; + }) + .await +} + +fn pop3_config(listen_port: u16, backend_port: u16, map_path: &str, knobs: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[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" + +[capabilities.pop3] +allow_plain_auth_without_tls = true + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:{listen_port}"] +tls = "plain" +{knobs} +"# + ) +} + +async fn read_some(stream: &mut TcpStream) -> Vec { + let mut buf = [0u8; 512]; + match stream.read(&mut buf).await { + Ok(n) => buf[..n].to_vec(), + Err(_) => Vec::new(), + } +} + +#[tokio::test] +async fn max_connections_drops_over_limit_connections() { + let (backend_port, backend) = idle_pop3_backend().await; + let map = mapping_file("user@example.com\tlegacy\n"); + let listen = pick_free_port(); + let toml = pop3_config( + listen, + backend_port, + map.path().to_str().unwrap(), + "max_connections = 2", + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("pop3"); + + let mut held = Vec::new(); + for _ in 0..2 { + let mut c = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let greeting = read_some(&mut c).await; + assert!( + greeting.starts_with(b"+OK"), + "within-limit connection should be greeted, got {:?}", + String::from_utf8_lossy(&greeting) + ); + held.push(c); + } + + let mut over = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let greeting = timeout(Duration::from_secs(2), read_some(&mut over)) + .await + .expect("over-limit connection should be closed, not left hanging"); + assert!( + greeting.is_empty(), + "over-limit connection must be dropped with no greeting, got {:?}", + String::from_utf8_lossy(&greeting) + ); + + drop(held); + drop(proxy); + backend.abort(); +} + +#[tokio::test] +async fn preauth_timeout_closes_idle_connection() { + let (backend_port, backend) = idle_pop3_backend().await; + let map = mapping_file("user@example.com\tlegacy\n"); + let listen = pick_free_port(); + let toml = pop3_config( + listen, + backend_port, + map.path().to_str().unwrap(), + "preauth_timeout = \"1s\"", + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("pop3"); + + let mut c = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let greeting = read_some(&mut c).await; + assert!(greeting.starts_with(b"+OK"), "expected greeting"); + + let started = Instant::now(); + let tail = timeout(Duration::from_secs(5), read_some(&mut c)) + .await + .expect("idle connection must be closed by preauth_timeout"); + assert!( + tail.is_empty(), + "connection should close on timeout, got {:?}", + String::from_utf8_lossy(&tail) + ); + assert!( + started.elapsed() >= Duration::from_millis(900), + "should not close before the 1s timeout elapses" + ); + + drop(proxy); + backend.abort(); +} + +#[tokio::test] +async fn max_auth_attempts_disconnects_after_limit() { + let (backend_port, backend) = idle_pop3_backend().await; + let map = mapping_file("user@example.com\tlegacy\n"); + let listen = pick_free_port(); + let toml = pop3_config( + listen, + backend_port, + map.path().to_str().unwrap(), + "max_auth_attempts = 2", + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("pop3"); + + let mut c = TcpStream::connect(("127.0.0.1", port)).await.unwrap(); + let greeting = read_some(&mut c).await; + assert!(greeting.starts_with(b"+OK"), "expected greeting"); + + c.write_all(b"AUTH FROBNICATE\r\n").await.unwrap(); + let first = read_some(&mut c).await; + assert!( + first.starts_with(b"-ERR"), + "first bad attempt should be rejected but not disconnected, got {:?}", + String::from_utf8_lossy(&first) + ); + + c.write_all(b"AUTH FROBNICATE\r\n").await.unwrap(); + let second = timeout(Duration::from_secs(2), read_to_eof(&mut c)) + .await + .expect("second bad attempt must reach the limit and disconnect"); + assert!( + !second.windows(4).any(|w| w == b"+OK "), + "connection must be closed after hitting max_auth_attempts" + ); + + drop(proxy); + backend.abort(); +} + +async fn read_to_eof(stream: &mut TcpStream) -> Vec { + let mut acc = Vec::new(); + let mut buf = [0u8; 512]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => acc.extend_from_slice(&buf[..n]), + } + } + acc +} diff --git a/src/tests/containers/master_user_and_load.rs b/src/tests/containers/master_user_and_load.rs new file mode 100644 index 0000000..b0e628a --- /dev/null +++ b/src/tests/containers/master_user_and_load.rs @@ -0,0 +1,166 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use super::clients::{self, Tls}; +use super::legacy::{Legacy, MASTER_PASSWORD, MASTER_USER}; +use super::{Proxy, pick_free_port}; +use crate::tests::harness::mapping_file; + +fn legacy_imap_config( + listen: u16, + legacy_port: u16, + map_path: &str, + tls: &str, + extra: &str, +) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "legacy" +master_user_separators = ["%"] + +[destination.legacy] +host = "127.0.0.1" +tls_server_name = "legacy.test" +tls_allow_invalid_certs = true +forwarding = "xclient" +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = {legacy_port} +tls = "{tls}" + +[capabilities.imap] +allow_plain_auth_without_tls = true + +[listener.l] +protocol = "imap" +bind = ["0.0.0.0:{listen}"] +tls = "{tls}" +{extra} +"# + ) +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn master_user_login_routes_on_account_and_authenticates() { + let legacy = Legacy::start().await; + + let map = mapping_file("alice@legacy.test\tlegacy\n"); + let listen = pick_free_port(); + let toml = legacy_imap_config( + listen, + legacy.imaps, + map.path().to_str().unwrap(), + "implicit", + "", + ); + let proxy = Proxy::launch(&toml).await; + + let master_login = format!("alice@legacy.test{}{}", "%", MASTER_USER); + let reply = clients::imap_auth( + "127.0.0.1", + proxy.port("l"), + Tls::Implicit, + &master_login, + MASTER_PASSWORD, + ) + .await; + assert!( + reply.contains("a1 OK"), + "master login {master_login:?} should authenticate as alice via Dovecot master passdb: {reply:?}" + ); +} + +async fn read_until_tag(s: &mut TcpStream, tag: &str) -> String { + let mut buf = [0u8; 1024]; + let mut acc = String::new(); + for _ in 0..16 { + match s.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => acc.push_str(&String::from_utf8_lossy(&buf[..n])), + } + if acc.contains(tag) { + break; + } + } + acc +} + +async fn plain_imap_auth(port: u16, login: &str, password: &str) -> bool { + let Ok(mut s) = TcpStream::connect(("127.0.0.1", port)).await else { + return false; + }; + let mut buf = [0u8; 1024]; + if s.read(&mut buf).await.unwrap_or(0) == 0 { + return false; + } + let ir = B64.encode(format!("\0{login}\0{password}")); + if s.write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes()) + .await + .is_err() + { + return false; + } + if !read_until_tag(&mut s, "a1 ").await.contains("a1 OK") { + return false; + } + if s.write_all(b"a2 SELECT INBOX\r\n").await.is_err() { + return false; + } + read_until_tag(&mut s, "a2 ").await.contains("a2 OK") +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn concurrent_sessions_stay_stable() { + let legacy = Legacy::start().await; + + let map = mapping_file("alice@legacy.test\tlegacy\n"); + let listen = pick_free_port(); + let toml = legacy_imap_config( + listen, + legacy.imap, + map.path().to_str().unwrap(), + "plain", + "max_connections = 4096", + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("l"); + + const SESSIONS: usize = 150; + let mut handles = Vec::with_capacity(SESSIONS); + for _ in 0..SESSIONS { + handles.push(tokio::spawn(async move { + plain_imap_auth(port, "alice@legacy.test", "AlicePass#2026").await + })); + } + + let mut ok = 0usize; + for h in handles { + if h.await.unwrap_or(false) { + ok += 1; + } + } + assert_eq!( + ok, SESSIONS, + "all {SESSIONS} concurrent sessions should authenticate and bridge ({ok} succeeded)" + ); +} diff --git a/src/tests/containers/mod.rs b/src/tests/containers/mod.rs new file mode 100644 index 0000000..d694326 --- /dev/null +++ b/src/tests/containers/mod.rs @@ -0,0 +1,195 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::collections::HashMap; +use std::net::{SocketAddr, TcpListener as StdTcpListener}; +use std::sync::Arc; +use std::time::Duration; + +use tokio::net::TcpStream; +use tokio::sync::watch; +use tokio::task::JoinHandle; + +use crate::net::acceptor::Listener; +use crate::proto::common::Ctx; + +use super::harness::make_ctx; + +pub mod clients; +pub mod http; +pub mod jmap; +pub mod legacy; +pub mod limits; +pub mod master_user_and_load; +pub mod oauth; +pub mod scenario1; +pub mod scenario2; +pub mod stalwart; +pub mod toolbox; +pub mod toolbox_http; +pub mod toolbox_matrix; + +pub const HOST_GATEWAY: &str = "host.docker.internal"; + +pub fn pick_free_port() -> u16 { + let l = StdTcpListener::bind("127.0.0.1:0").unwrap(); + l.local_addr().unwrap().port() +} + +pub struct Proxy { + shutdown: watch::Sender, + handles: Vec>, + listeners: HashMap, + admin: Option, + _ctx: Arc, +} + +impl Proxy { + pub async fn launch(toml: &str) -> Proxy { + let ctx = make_ctx(toml).await; + let config = ctx.config.clone(); + let shared = Arc::new(arc_swap::ArcSwap::new(ctx.clone())); + let (shutdown, rx) = watch::channel(false); + let mut handles = Vec::new(); + let mut listeners = HashMap::new(); + + for (id, listener_cfg) in &config.listener { + let bind = *listener_cfg + .bind + .first() + .expect("listener has at least one bind address"); + listeners.insert(id.clone(), bind); + let listener = Arc::new(Listener::new( + id.as_str().into(), + Arc::new(listener_cfg.clone()), + )); + let shared = shared.clone(); + let rx = rx.clone(); + handles.push(tokio::spawn(async move { + listener.run(shared, rx).await; + })); + } + + let mut admin = None; + if let Some(admin_cfg) = &config.admin { + admin = Some(admin_cfg.bind); + let shared = shared.clone(); + let config = config.clone(); + handles.push(tokio::spawn(async move { + let _ = crate::admin::run(shared, config, None).await; + })); + } + + let proxy = Proxy { + shutdown, + handles, + listeners, + admin, + _ctx: ctx, + }; + for addr in proxy.listeners.values() { + wait_listening(*addr).await; + } + if let Some(addr) = proxy.admin { + wait_listening(addr).await; + } + proxy + } + + pub fn port(&self, listener_id: &str) -> u16 { + self.listeners + .get(listener_id) + .unwrap_or_else(|| panic!("unknown listener {listener_id:?}")) + .port() + } +} + +impl Drop for Proxy { + fn drop(&mut self) { + let _ = self.shutdown.send(true); + for handle in &self.handles { + handle.abort(); + } + } +} + +async fn wait_listening(addr: SocketAddr) { + let target = if addr.ip().is_unspecified() { + SocketAddr::new("127.0.0.1".parse().unwrap(), addr.port()) + } else { + addr + }; + for _ in 0..200 { + if TcpStream::connect(target).await.is_ok() { + return; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + panic!("proxy listener {addr} never came up"); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::harness::{dummy_backend, mapping_file}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn launcher_binds_and_bridges_pop3() { + let (backend_port, backend) = dummy_backend(|mut sock| async move { + sock.write_all(b"+OK dummy ready\r\n").await.unwrap(); + let mut buf = [0u8; 256]; + let _ = sock.read(&mut buf).await; + }) + .await; + + let map = mapping_file("user@example.com\tlegacy\n"); + let listen_port = pick_free_port(); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[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" + +[capabilities.pop3] +allow_plain_auth_without_tls = true + +[listener.pop3] +protocol = "pop3" +bind = ["0.0.0.0:{listen_port}"] +tls = "plain" +"#, + map.path().display() + ); + + let proxy = Proxy::launch(&toml).await; + let mut client = TcpStream::connect(("127.0.0.1", proxy.port("pop3"))) + .await + .unwrap(); + let mut greeting = [0u8; 64]; + let n = client.read(&mut greeting).await.unwrap(); + assert!( + greeting[..n].starts_with(b"+OK"), + "expected POP3 greeting, got {:?}", + String::from_utf8_lossy(&greeting[..n]) + ); + drop(proxy); + backend.abort(); + } +} diff --git a/src/tests/containers/oauth.rs b/src/tests/containers/oauth.rs new file mode 100644 index 0000000..f90bd86 --- /dev/null +++ b/src/tests/containers/oauth.rs @@ -0,0 +1,359 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::Engine; +use base64::engine::general_purpose::STANDARD as B64; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use super::stalwart::{Edition, Stalwart}; +use super::toolbox::Toolbox; +use super::{HOST_GATEWAY, Proxy, pick_free_port}; +use crate::tests::harness::{mapping_file, tls_connect_client}; + +const DOMAIN: &str = "new.test"; + +#[derive(Clone, Copy, Debug)] +enum Mech { + OAuthBearer, + XOauth2, +} + +impl Mech { + fn name(self) -> &'static str { + match self { + Mech::OAuthBearer => "OAUTHBEARER", + Mech::XOauth2 => "XOAUTH2", + } + } + + fn ir(self, email: &str, token: &str) -> String { + let frame = match self { + Mech::OAuthBearer => format!("n,a={email},\x01auth=Bearer {token}\x01\x01"), + Mech::XOauth2 => format!("user={email}\x01auth=Bearer {token}\x01\x01"), + }; + B64.encode(frame.as_bytes()) + } +} + +async fn read_until(stream: &mut S, needle: &str) -> String { + let mut acc = String::new(); + let mut buf = [0u8; 4096]; + for _ in 0..64 { + let n = match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => n, + }; + acc.push_str(&String::from_utf8_lossy(&buf[..n])); + if acc.contains(needle) { + break; + } + } + acc +} + +async fn read_one(stream: &mut S) -> String { + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).await.unwrap_or(0); + String::from_utf8_lossy(&buf[..n]).into_owned() +} + +fn config(proto: &str, tls: &str, listen: u16, backend: u16, map_path: &str) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "new" + +[destination.new] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +forwarding = "proxy" +proxy_protocol = true +[destination.new.protocol.{proto}] +port = {backend} +tls = "{tls}" + +[listener.l] +protocol = "{proto}" +bind = ["0.0.0.0:{listen}"] +tls = "{tls}" +"# + ) +} + +async fn imap_oauth(port: u16, mech: Mech, email: &str, token: &str) -> Result<(), String> { + let tcp = TcpStream::connect(("127.0.0.1", port)).await.map_err(de)?; + let mut s = tls_connect_client(tcp).await; + let _ = read_until(&mut s, "OK").await; + let cmd = format!( + "a1 AUTHENTICATE {} {}\r\n", + mech.name(), + mech.ir(email, token) + ); + s.write_all(cmd.as_bytes()).await.map_err(de)?; + let auth = read_until(&mut s, "a1 ").await; + if !auth.contains("a1 OK") { + return Err(format!("auth: {auth:?}")); + } + s.write_all(b"a2 SELECT INBOX\r\n").await.map_err(de)?; + let action = read_until(&mut s, "a2 ").await; + if action.contains("a2 OK") { + Ok(()) + } else { + Err(format!("SELECT: {action:?}")) + } +} + +async fn pop3_oauth(port: u16, mech: Mech, email: &str, token: &str) -> Result<(), String> { + let tcp = TcpStream::connect(("127.0.0.1", port)).await.map_err(de)?; + let mut s = tls_connect_client(tcp).await; + let _ = read_one(&mut s).await; + let cmd = format!("AUTH {} {}\r\n", mech.name(), mech.ir(email, token)); + s.write_all(cmd.as_bytes()).await.map_err(de)?; + let auth = read_until(&mut s, "\r\n").await; + if !auth.trim_start().starts_with("+OK") { + return Err(format!("auth: {auth:?}")); + } + s.write_all(b"STAT\r\n").await.map_err(de)?; + let action = read_until(&mut s, "\r\n").await; + if action.trim_start().starts_with("+OK") { + Ok(()) + } else { + Err(format!("STAT: {action:?}")) + } +} + +async fn submission_oauth(port: u16, mech: Mech, email: &str, token: &str) -> Result<(), String> { + let tcp = TcpStream::connect(("127.0.0.1", port)).await.map_err(de)?; + let mut s = tls_connect_client(tcp).await; + let _ = read_one(&mut s).await; + s.write_all(b"EHLO proxy.test\r\n").await.map_err(de)?; + let _ = read_until(&mut s, "250 ").await; + let cmd = format!("AUTH {} {}\r\n", mech.name(), mech.ir(email, token)); + s.write_all(cmd.as_bytes()).await.map_err(de)?; + let auth = read_until(&mut s, "\r\n").await; + if !auth.contains("235") { + return Err(format!("auth: {auth:?}")); + } + s.write_all(format!("MAIL FROM:<{email}>\r\n").as_bytes()) + .await + .map_err(de)?; + let from = read_until(&mut s, "\r\n").await; + if !from.contains("250") { + return Err(format!("MAIL FROM: {from:?}")); + } + s.write_all(format!("RCPT TO:<{email}>\r\n").as_bytes()) + .await + .map_err(de)?; + let rcpt = read_until(&mut s, "\r\n").await; + if rcpt.contains("250") { + Ok(()) + } else { + Err(format!("RCPT TO: {rcpt:?}")) + } +} + +async fn managesieve_oauth(port: u16, mech: Mech, email: &str, token: &str) -> Result<(), String> { + let mut tcp = TcpStream::connect(("127.0.0.1", port)).await.map_err(de)?; + let _ = read_until(&mut tcp, "OK").await; + tcp.write_all(b"STARTTLS\r\n").await.map_err(de)?; + let _ = read_until(&mut tcp, "OK").await; + let mut s = tls_connect_client(tcp).await; + let _ = read_until(&mut s, "OK").await; + let cmd = format!( + "AUTHENTICATE \"{}\" \"{}\"\r\n", + mech.name(), + mech.ir(email, token) + ); + s.write_all(cmd.as_bytes()).await.map_err(de)?; + let auth = read_until(&mut s, "\r\n").await; + if !auth.trim_start().starts_with("OK") { + return Err(format!("auth: {auth:?}")); + } + s.write_all(b"LISTSCRIPTS\r\n").await.map_err(de)?; + let action = read_until(&mut s, "OK").await; + if action.contains("OK") { + Ok(()) + } else { + Err(format!("LISTSCRIPTS: {action:?}")) + } +} + +fn de(e: E) -> String { + format!("{e:?}") +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn oauth_mail_mechanisms_through_proxy() { + let new = Stalwart::start_proxied(Edition::New, DOMAIN, &[("ivan", "IvanPass#2026")]).await; + let email = new.accounts[0].login.clone(); + let token = new.accounts[0].token.clone(); + assert!(!token.is_empty(), "harness must mint a real access token"); + + let map = mapping_file(&format!("{email}\tnew\n")); + let map_path = map.path().to_str().unwrap(); + + let mechs = [Mech::OAuthBearer, Mech::XOauth2]; + let mut failures = Vec::new(); + + for mech in mechs { + let listen = pick_free_port(); + let toml = config("imap", "implicit", listen, new.imaps, map_path); + let proxy = Proxy::launch(&toml).await; + if let Err(e) = imap_oauth(proxy.port("l"), mech, &email, &token).await { + failures.push(format!("imap/{}: {e}", mech.name())); + } + + let listen = pick_free_port(); + let toml = config("pop3", "implicit", listen, new.pop3s, map_path); + let proxy = Proxy::launch(&toml).await; + if let Err(e) = pop3_oauth(proxy.port("l"), mech, &email, &token).await { + failures.push(format!("pop3/{}: {e}", mech.name())); + } + + let listen = pick_free_port(); + let toml = config("submission", "implicit", listen, new.smtps, map_path); + let proxy = Proxy::launch(&toml).await; + if let Err(e) = submission_oauth(proxy.port("l"), mech, &email, &token).await { + failures.push(format!("submission/{}: {e}", mech.name())); + } + + let listen = pick_free_port(); + let toml = config("managesieve", "starttls", listen, new.sieve, map_path); + let proxy = Proxy::launch(&toml).await; + if let Err(e) = managesieve_oauth(proxy.port("l"), mech, &email, &token).await { + failures.push(format!("managesieve/{}: {e}", mech.name())); + } + } + + assert!( + failures.is_empty(), + "{} OAuth mail mechanism/protocol combinations failed:\n{}", + failures.len(), + failures.join("\n") + ); +} + +fn toolbox_config(map_path: &str, imap_listen: u16, sub_listen: u16, new: &Stalwart) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "new" + +[destination.new] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +forwarding = "proxy" +proxy_protocol = true +[destination.new.protocol.imap] +port = {imaps} +tls = "implicit" +[destination.new.protocol.submission] +port = {smtps} +tls = "implicit" + +[listener.imap] +protocol = "imap" +bind = ["0.0.0.0:{imap_listen}"] +tls = "starttls" + +[listener.submission] +protocol = "submission" +bind = ["0.0.0.0:{sub_listen}"] +tls = "starttls" +"#, + imaps = new.imaps, + smtps = new.smtps, + ) +} + +struct OauthClient { + name: &'static str, + script: String, + expect: &'static str, +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn oauth_mail_real_clients_through_proxy() { + let new = Stalwart::start_proxied(Edition::New, DOMAIN, &[("ivan", "IvanPass#2026")]).await; + let email = new.accounts[0].login.clone(); + let token = new.accounts[0].token.clone(); + assert!(!token.is_empty(), "harness must mint a real access token"); + + let map = mapping_file(&format!("{email}\tnew\n")); + let (imap_listen, sub_listen) = (pick_free_port(), pick_free_port()); + let toml = toolbox_config(map.path().to_str().unwrap(), imap_listen, sub_listen, &new); + let proxy = Proxy::launch(&toml).await; + let imap = proxy.port("imap"); + let sub = proxy.port("submission"); + + let toolbox = Toolbox::start().await; + let gw = HOST_GATEWAY; + + let clients = vec![ + OauthClient { + name: "curl/imap-oauthbearer", + script: format!( + "curl -sS -k --ssl-reqd --url 'imap://{gw}:{imap}/' \ + --login-options 'AUTH=OAUTHBEARER' --oauth2-bearer '{token}' \ + --user '{email}:' --request 'LIST \"\" \"*\"'" + ), + expect: "INBOX", + }, + OauthClient { + name: "msmtp/submission-oauthbearer", + script: format!( + "printf 'Subject: m6\\r\\n\\r\\nhi\\r\\n' | \ + msmtp --host={gw} --port={sub} --tls=on --tls-starttls=on --tls-certcheck=off \ + --auth=oauthbearer --user='{email}' --passwordeval=\"printf '%s' '{token}'\" \ + --from='{email}' --debug '{email}'" + ), + expect: "235", + }, + ]; + + let mut failures = Vec::new(); + for c in &clients { + let out = toolbox.sh(&c.script).await; + let combined = out.combined(); + if !combined.contains(c.expect) { + failures.push(format!( + "[{}] exit={} expect={:?}\n--- output ---\n{}\n--------------", + c.name, out.code, c.expect, combined + )); + } + } + + assert!( + failures.is_empty(), + "{} of {} OAuth mail clients failed:\n\n{}", + failures.len(), + clients.len(), + failures.join("\n\n") + ); +} diff --git a/src/tests/containers/scenario1.rs b/src/tests/containers/scenario1.rs new file mode 100644 index 0000000..0c2779e --- /dev/null +++ b/src/tests/containers/scenario1.rs @@ -0,0 +1,242 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::clients::{self, Tls}; +use super::legacy::Legacy; +use super::stalwart::{Edition, Stalwart}; +use super::{Proxy, pick_free_port}; +use crate::tests::harness::mapping_file; + +pub(crate) fn tls_str(mode: Tls) -> &'static str { + match mode { + Tls::Implicit => "implicit", + Tls::Starttls => "starttls", + } +} + +fn legacy_endpoint(l: &Legacy, proto: &str, mode: Tls) -> u16 { + match (proto, mode) { + ("imap", Tls::Implicit) => l.imaps, + ("imap", _) => l.imap, + ("pop3", Tls::Implicit) => l.pop3s, + ("pop3", _) => l.pop3, + ("submission", Tls::Implicit) => l.smtps, + ("submission", _) => l.submission, + ("managesieve", _) => l.sieve, + _ => panic!("bad endpoint {proto}/{mode:?}"), + } +} + +pub(crate) fn stalwart_endpoint(s: &Stalwart, proto: &str, mode: Tls) -> u16 { + match (proto, mode) { + ("imap", Tls::Implicit) => s.imaps, + ("imap", _) => s.imap, + ("pop3", Tls::Implicit) => s.pop3s, + ("pop3", _) => s.pop3, + ("submission", Tls::Implicit) => s.smtps, + ("submission", _) => s.submission, + ("managesieve", _) => s.sieve, + _ => panic!("bad endpoint {proto}/{mode:?}"), + } +} + +fn build_config( + proto: &str, + mode: Tls, + listen_port: u16, + legacy_port: u16, + stalwart_port: u16, + map_path: &str, +) -> String { + let tls = tls_str(mode); + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +tls_server_name = "legacy.test" +tls_allow_invalid_certs = true +forwarding = "xclient" +[destination.legacy.protocol.{proto}] +port = {legacy_port} +tls = "{tls}" + +[destination.stalwartnew] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +forwarding = "proxy" +proxy_protocol = true +[destination.stalwartnew.protocol.{proto}] +port = {stalwart_port} +tls = "{tls}" + +[listener.l] +protocol = "{proto}" +bind = ["0.0.0.0:{listen_port}"] +tls = "{tls}" +"# + ) +} + +pub(crate) async fn drive( + proto: &str, + mode: Tls, + port: u16, + login: &str, + password: &str, +) -> String { + match proto { + "imap" => clients::imap_auth("127.0.0.1", port, mode, login, password).await, + "pop3" => clients::pop3_auth("127.0.0.1", port, mode, login, password).await, + "managesieve" => clients::managesieve_auth("127.0.0.1", port, mode, login, password).await, + "submission" => clients::submission_send("127.0.0.1", port, mode, login, password).await, + other => panic!("no driver for {other}"), + } +} + +pub(crate) fn assert_authenticated(proto: &str, who: &str, reply: &str) { + let ok = match proto { + "imap" => reply.contains("a1 OK"), + "pop3" => reply.trim_start().starts_with("+OK"), + "managesieve" => reply.trim_start().starts_with("OK"), + "submission" => reply.contains("235"), + _ => false, + }; + assert!(ok, "{proto} auth for {who} failed: {reply:?}"); +} + +pub(crate) fn assert_rejected(proto: &str, who: &str, reply: &str) { + let rejected = match proto { + "imap" => reply.contains("a1 NO") || reply.contains("a1 BAD"), + "pop3" => reply.trim_start().starts_with("-ERR"), + "managesieve" => reply.trim_start().starts_with("NO"), + "submission" => reply.contains("535") || reply.contains("454"), + _ => false, + }; + assert!( + rejected, + "{proto} auth for {who} should be rejected through the proxy: {reply:?}" + ); +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario1_routes_all_protocols_in_all_tls_modes() { + let legacy = Legacy::start().await; + let stalwart = Stalwart::start_proxied( + Edition::New, + "new.test", + &[("carol", "CarolPass#2026"), ("dave", "DavePass#2026")], + ) + .await; + + let legacy_user = ("alice@legacy.test", "AlicePass#2026"); + let stalwart_user = ("carol@new.test", "CarolPass#2026"); + let map = mapping_file(&format!( + "{}\tlegacy\n{}\tstalwartnew\n", + legacy_user.0, stalwart_user.0 + )); + + let matrix: &[(&str, &[Tls])] = &[ + ("imap", &[Tls::Starttls, Tls::Implicit]), + ("pop3", &[Tls::Starttls, Tls::Implicit]), + ("submission", &[Tls::Starttls, Tls::Implicit]), + ("managesieve", &[Tls::Starttls]), + ]; + + for (proto, modes) in matrix { + for &mode in *modes { + let lport = legacy_endpoint(&legacy, proto, mode); + let sport = stalwart_endpoint(&stalwart, proto, mode); + let listen = pick_free_port(); + let toml = build_config( + proto, + mode, + listen, + lport, + sport, + map.path().to_str().unwrap(), + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("l"); + + let to_legacy = drive(proto, mode, port, legacy_user.0, legacy_user.1).await; + assert_authenticated(proto, &format!("legacy {proto}/{mode:?}"), &to_legacy); + + let to_stalwart = drive(proto, mode, port, stalwart_user.0, stalwart_user.1).await; + assert_authenticated(proto, &format!("stalwart {proto}/{mode:?}"), &to_stalwart); + + let wrong = drive(proto, mode, port, legacy_user.0, "WrongPass#0000").await; + assert_rejected(proto, &format!("legacy bad-pass {proto}/{mode:?}"), &wrong); + } + } +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario1_smtp_passthrough_to_stalwart() { + let stalwart = + Stalwart::start_proxied(Edition::New, "new.test", &[("carol", "CarolPass#2026")]).await; + let map = mapping_file("placeholder@new.test\tstalwartnew\n"); + let listen = pick_free_port(); + let toml = format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[routing] +default_destination = "stalwartnew" +smtp_passthrough_destination = "stalwartnew" + +[destination.stalwartnew] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +proxy_protocol = true +forwarding = "proxy" +[destination.stalwartnew.protocol.smtp] +port = {} +tls = "plain" + +[listener.smtp] +protocol = "smtp" +bind = ["0.0.0.0:{listen}"] +tls = "plain" +"#, + map.path().to_str().unwrap(), + stalwart.smtp, + ); + let proxy = Proxy::launch(&toml).await; + let reply = clients::smtp_passthrough_send( + "127.0.0.1", + proxy.port("smtp"), + "sender@example.com", + "carol@new.test", + ) + .await; + assert!( + reply.contains("250") || reply.contains("251"), + "passthrough RCPT to a hosted Stalwart user should be accepted: {reply:?}" + ); +} diff --git a/src/tests/containers/scenario2.rs b/src/tests/containers/scenario2.rs new file mode 100644 index 0000000..fd9c89f --- /dev/null +++ b/src/tests/containers/scenario2.rs @@ -0,0 +1,366 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::clients::Tls; +use super::scenario1::{assert_authenticated, assert_rejected, drive, stalwart_endpoint, tls_str}; +use super::stalwart::{Edition, Stalwart}; +use super::{Proxy, http, pick_free_port}; +use crate::tests::harness::mapping_file; + +const OLD_DOMAIN: &str = "old.test"; +const NEW_DOMAIN: &str = "new.test"; + +fn mail_config( + proto: &str, + mode: Tls, + listen_port: u16, + old_port: u16, + new_port: u16, + map_path: &str, +) -> String { + let tls = tls_str(mode); + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "old" + +[destination.old] +host = "127.0.0.1" +tls_server_name = "old.test" +tls_allow_invalid_certs = true +forwarding = "proxy" +proxy_protocol = true +[destination.old.protocol.{proto}] +port = {old_port} +tls = "{tls}" + +[destination.new] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +forwarding = "proxy" +proxy_protocol = true +[destination.new.protocol.{proto}] +port = {new_port} +tls = "{tls}" + +[listener.l] +protocol = "{proto}" +bind = ["0.0.0.0:{listen_port}"] +tls = "{tls}" +"# + ) +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_routes_all_mail_protocols_in_all_tls_modes() { + let old = Stalwart::start_proxied( + Edition::Old, + OLD_DOMAIN, + &[("frank", "FrankPass#2026"), ("grace", "GracePass#2026")], + ) + .await; + let new = Stalwart::start_proxied( + Edition::New, + NEW_DOMAIN, + &[("heidi", "HeidiPass#2026"), ("ivan", "IvanPass#2026")], + ) + .await; + + let old_user = ("frank", "FrankPass#2026"); + let new_user = ("heidi@new.test", "HeidiPass#2026"); + let map = mapping_file(&format!("{}\told\n{}\tnew\n", old_user.0, new_user.0)); + + let matrix: &[(&str, &[Tls])] = &[ + ("imap", &[Tls::Starttls, Tls::Implicit]), + ("pop3", &[Tls::Starttls, Tls::Implicit]), + ("submission", &[Tls::Starttls, Tls::Implicit]), + ("managesieve", &[Tls::Starttls]), + ]; + + for (proto, modes) in matrix { + for &mode in *modes { + let old_port = stalwart_endpoint(&old, proto, mode); + let new_port = stalwart_endpoint(&new, proto, mode); + let listen = pick_free_port(); + let toml = mail_config( + proto, + mode, + listen, + old_port, + new_port, + map.path().to_str().unwrap(), + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("l"); + + let to_old = drive(proto, mode, port, old_user.0, old_user.1).await; + assert_authenticated(proto, &format!("old {proto}/{mode:?}"), &to_old); + + let to_new = drive(proto, mode, port, new_user.0, new_user.1).await; + assert_authenticated(proto, &format!("new {proto}/{mode:?}"), &to_new); + + let wrong = drive(proto, mode, port, old_user.0, "WrongPass#0000").await; + assert_rejected(proto, &format!("old bad-pass {proto}/{mode:?}"), &wrong); + } + } +} + +fn http_config( + listen_port: u16, + old_https: u16, + new_https: u16, + map_path: &str, + listener_tls: &str, +) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "old" + +[destination.old] +host = "127.0.0.1" +tls_server_name = "old.test" +tls_allow_invalid_certs = true +proxy_protocol = false +forwarded = true +[destination.old.protocol.http] +port = {old_https} +tls = "implicit" + +[destination.new] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +proxy_protocol = false +forwarded = true +[destination.new.protocol.http] +port = {new_https} +tls = "implicit" + +[[http.route]] +match = "/**" +extract = {{ from = "authorization" }} +fallback = "default" + +[listener.http] +protocol = "http" +bind = ["0.0.0.0:{listen_port}"] +tls = "{listener_tls}" +"# + ) +} + +async fn jmap_session_account(host: &str, port: u16, tls: bool, bearer: &str) -> String { + let resp = http::get_follow( + host, + port, + tls, + "/.well-known/jmap", + &[("Authorization", bearer)], + ) + .await; + resp.json()["primaryAccounts"] + .as_object() + .and_then(|m| m.values().next()) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() +} + +async fn run_bearer_routing(listener_tls: &str) { + let old = + Stalwart::start_proxied(Edition::Old, OLD_DOMAIN, &[("frank", "FrankPass#2026")]).await; + let new = + Stalwart::start_proxied(Edition::New, NEW_DOMAIN, &[("heidi", "HeidiPass#2026")]).await; + + let old_token = old.accounts[0].token.clone(); + let new_token = new.accounts[0].token.clone(); + + let map = mapping_file("heidi@new.test\tnew\nheidi\tnew\n"); + let listen = pick_free_port(); + let toml = http_config( + listen, + old.https, + new.https, + map.path().to_str().unwrap(), + listener_tls, + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("http"); + let via_tls = listener_tls != "plain"; + + let new_routed = + jmap_session_account("127.0.0.1", port, via_tls, &format!("Bearer {new_token}")).await; + let new_direct = + jmap_session_account("127.0.0.1", new.https, true, &format!("Bearer {new_token}")).await; + let old_direct = + jmap_session_account("127.0.0.1", old.https, true, &format!("Bearer {old_token}")).await; + assert!( + !new_direct.is_empty() && !old_direct.is_empty(), + "both systems must return a primary account directly (else the comparison is vacuous)" + ); + assert_ne!( + new_direct, old_direct, + "the two systems must have distinct primary accounts for routing to be observable" + ); + assert_eq!( + new_routed, new_direct, + "an sw1. token must route to the new system and return its session" + ); + + let old_routed = + jmap_session_account("127.0.0.1", port, via_tls, &format!("Bearer {old_token}")).await; + assert_eq!( + old_routed, old_direct, + "an opaque (non-sw1) token must fall back to the default destination (old system)" + ); +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_http_routes_by_bearer_token() { + run_bearer_routing("plain").await; +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_https_routes_by_bearer_token() { + run_bearer_routing("implicit").await; +} + +async fn run_basic_routing(listener_tls: &str) { + let old = + Stalwart::start_proxied(Edition::Old, OLD_DOMAIN, &[("frank", "FrankPass#2026")]).await; + let new = + Stalwart::start_proxied(Edition::New, NEW_DOMAIN, &[("heidi", "HeidiPass#2026")]).await; + + let map = mapping_file("heidi@new.test\tnew\nfrank\told\n"); + let listen = pick_free_port(); + let toml = http_config( + listen, + old.https, + new.https, + map.path().to_str().unwrap(), + listener_tls, + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("http"); + let via_tls = listener_tls != "plain"; + + let new_auth = http::basic_auth("heidi@new.test", "HeidiPass#2026"); + let old_auth = http::basic_auth("frank", "FrankPass#2026"); + let new_routed = jmap_session_account("127.0.0.1", port, via_tls, &new_auth).await; + let new_direct = jmap_session_account("127.0.0.1", new.https, true, &new_auth).await; + let old_direct = jmap_session_account("127.0.0.1", old.https, true, &old_auth).await; + assert!( + !new_direct.is_empty() && !old_direct.is_empty(), + "both systems must return a primary account directly (else the comparison is vacuous)" + ); + assert_ne!( + new_direct, old_direct, + "the two systems must have distinct primary accounts for routing to be observable" + ); + assert_eq!( + new_routed, new_direct, + "Basic auth for a new-system user must route to the new system" + ); + + let old_routed = jmap_session_account("127.0.0.1", port, via_tls, &old_auth).await; + assert_eq!( + old_routed, old_direct, + "Basic auth for an old-system user must route to the old system" + ); +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_http_routes_by_basic_auth() { + run_basic_routing("plain").await; +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_https_routes_by_basic_auth() { + run_basic_routing("implicit").await; +} + +async fn run_websocket_passthrough(listener_tls: &str) { + let new = + Stalwart::start_proxied(Edition::New, NEW_DOMAIN, &[("heidi", "HeidiPass#2026")]).await; + let token = new.accounts[0].token.clone(); + let map = mapping_file("heidi@new.test\tnew\nheidi\tnew\n"); + let listen = pick_free_port(); + let toml = http_config( + listen, + new.https, + new.https, + map.path().to_str().unwrap(), + listener_tls, + ); + let proxy = Proxy::launch(&toml).await; + let port = proxy.port("http"); + let via_tls = listener_tls != "plain"; + + let bearer = format!("Bearer {token}"); + let headers = [ + ("Authorization", bearer.as_str()), + ("Connection", "upgrade"), + ("Upgrade", "websocket"), + ("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("Sec-WebSocket-Version", "13"), + ("Sec-WebSocket-Protocol", "jmap"), + ]; + + let direct = http::upgrade("127.0.0.1", new.https, true, "/jmap/ws", &headers) + .await + .expect("direct WS upgrade"); + assert_eq!( + direct.status, 101, + "backend should accept the WS upgrade directly" + ); + + let proxied = http::upgrade("127.0.0.1", port, via_tls, "/jmap/ws", &headers) + .await + .expect("proxied WS upgrade"); + assert_eq!(proxied.status, 101, "proxy must relay the WS upgrade"); + assert_eq!( + proxied.header("sec-websocket-accept"), + direct.header("sec-websocket-accept"), + "the backend's Sec-WebSocket-Accept must be relayed unchanged" + ); +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_http_websocket_upgrade_passes_through() { + run_websocket_passthrough("plain").await; +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn scenario2_https_websocket_upgrade_passes_through() { + run_websocket_passthrough("implicit").await; +} diff --git a/src/tests/containers/stalwart.rs b/src/tests/containers/stalwart.rs new file mode 100644 index 0000000..5be29bd --- /dev/null +++ b/src/tests/containers/stalwart.rs @@ -0,0 +1,898 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use serde_json::{Value, json}; +use testcontainers::core::{IntoContainerPort, WaitFor}; +use testcontainers::runners::AsyncRunner; +use testcontainers::{ContainerAsync, GenericImage, ImageExt}; + +use super::http::{self, basic_auth}; +use super::pick_free_port; + +pub const NEW_IMAGE: &str = "stalwartlabs/stalwart"; +pub const NEW_TAG: &str = "latest"; +pub const OLD_IMAGE: &str = "stalwartlabs/stalwart"; +pub const OLD_TAG: &str = "v0.15.5"; + +const CONFIG_JSON: &str = r#"{"@type":"RocksDb","blobSize":16834,"bufferSize":134217728,"path":"/var/lib/stalwart","poolWorkers":null}"#; + +const ADMIN_USER: &str = "admin"; +const ADMIN_PASSWORD: &str = "admin"; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum Edition { + New, + Old, +} + +#[derive(Clone, Debug)] +pub struct Account { + pub login: String, + pub password: String, + pub token: String, +} + +pub struct Stalwart { + _container: ContainerAsync, + pub host: String, + pub https: u16, + pub imap: u16, + pub imaps: u16, + pub pop3: u16, + pub pop3s: u16, + pub submission: u16, + pub smtps: u16, + pub sieve: u16, + pub smtp: u16, + pub accounts: Vec, +} + +impl Stalwart { + pub async fn start(edition: Edition, domain: &str, users: &[(&str, &str)]) -> Stalwart { + Self::start_inner(edition, domain, users, false, None).await + } + + pub async fn start_proxied(edition: Edition, domain: &str, users: &[(&str, &str)]) -> Stalwart { + Self::start_inner(edition, domain, users, true, None).await + } + + pub async fn start_fronted( + edition: Edition, + domain: &str, + users: &[(&str, &str)], + public_url: &str, + ) -> Stalwart { + Self::start_inner(edition, domain, users, false, Some(public_url.to_string())).await + } + + async fn start_inner( + edition: Edition, + domain: &str, + users: &[(&str, &str)], + proxy_trust: bool, + public_url: Option, + ) -> Stalwart { + match edition { + Edition::New => Self::start_new(domain, users, proxy_trust, public_url).await, + Edition::Old => Self::start_old(domain, users, proxy_trust, public_url).await, + } + } + + async fn start_new( + domain: &str, + users: &[(&str, &str)], + proxy_trust: bool, + public_url: Option, + ) -> Stalwart { + let ports = Ports::pick(); + let container = GenericImage::new(NEW_IMAGE, NEW_TAG) + .with_wait_for(WaitFor::seconds(3)) + .with_env_var( + "STALWART_PUBLIC_URL", + public_url + .clone() + .unwrap_or_else(|| format!("https://127.0.0.1:{}", ports.https)), + ) + .with_env_var( + "STALWART_RECOVERY_ADMIN", + format!("{ADMIN_USER}:{ADMIN_PASSWORD}"), + ) + .with_copy_to("/etc/stalwart/config.json", CONFIG_JSON.as_bytes().to_vec()) + .with_mapped_port(ports.https, 443u16.tcp()) + .with_mapped_port(ports.http, 8080u16.tcp()) + .with_mapped_port(ports.imap, 143u16.tcp()) + .with_mapped_port(ports.imaps, 993u16.tcp()) + .with_mapped_port(ports.pop3, 110u16.tcp()) + .with_mapped_port(ports.pop3s, 995u16.tcp()) + .with_mapped_port(ports.submission, 587u16.tcp()) + .with_mapped_port(ports.smtps, 465u16.tcp()) + .with_mapped_port(ports.sieve, 4190u16.tcp()) + .with_mapped_port(ports.smtp, 25u16.tcp()) + .with_startup_timeout(Duration::from_secs(180)) + .start() + .await + .expect("start new stalwart"); + + let host = container.get_host().await.expect("host").to_string(); + wait_jmap_ready(&host, ports.https).await; + + let admin = basic_auth(ADMIN_USER, ADMIN_PASSWORD); + let (api_path, admin_account) = jmap_session(&host, ports.https, &admin).await; + let domain_id = create_domain_new( + &host, + ports.https, + &api_path, + &admin, + &admin_account, + domain, + ) + .await; + let mut accounts = Vec::new(); + for (name, password) in users { + create_account_new( + &host, + ports.https, + &api_path, + &admin, + &admin_account, + &domain_id, + (name, password), + ) + .await; + accounts.push(Account { + login: format!("{name}@{domain}"), + password: password.to_string(), + token: String::new(), + }); + } + invalidate_caches_new(&host, ports.https, &api_path, &admin, &admin_account).await; + configure_listeners( + &host, + ports.https, + &api_path, + &admin, + &admin_account, + proxy_trust, + ) + .await; + + container + .stop_with_timeout(Some(10)) + .await + .expect("stop for restart"); + container.start().await.expect("restart"); + wait_jmap_ready(&host, ports.https).await; + + for account in &mut accounts { + account.token = + mint_token_new(&host, ports.https, &account.login, &account.password).await; + } + + Self::assemble(container, host, ports, accounts) + } + + async fn start_old( + domain: &str, + users: &[(&str, &str)], + proxy_trust: bool, + public_url: Option, + ) -> Stalwart { + let ports = Ports::pick(); + let container = GenericImage::new(OLD_IMAGE, OLD_TAG) + .with_wait_for(WaitFor::seconds(3)) + .with_mapped_port(ports.https, 443u16.tcp()) + .with_mapped_port(ports.http, 8080u16.tcp()) + .with_mapped_port(ports.imap, 143u16.tcp()) + .with_mapped_port(ports.imaps, 993u16.tcp()) + .with_mapped_port(ports.pop3, 110u16.tcp()) + .with_mapped_port(ports.pop3s, 995u16.tcp()) + .with_mapped_port(ports.submission, 587u16.tcp()) + .with_mapped_port(ports.smtps, 465u16.tcp()) + .with_mapped_port(ports.sieve, 4190u16.tcp()) + .with_mapped_port(ports.smtp, 25u16.tcp()) + .with_startup_timeout(Duration::from_secs(180)) + .start() + .await + .expect("start old stalwart"); + + let host = container.get_host().await.expect("host").to_string(); + let http = ports.http; + let admin_password = scrape_admin_password(&container).await; + wait_old_api_ready(&host, http).await; + + if proxy_trust || public_url.is_some() { + let admin = format!( + "Bearer {}", + mint_token_old(&host, http, ADMIN_USER, &admin_password).await + ); + if proxy_trust { + set_old_proxy_trust(&host, http, &admin).await; + } + if let Some(url) = &public_url { + set_old_setting(&host, http, &admin, "http.url", &format!("'{url}'")).await; + } + container + .stop_with_timeout(Some(10)) + .await + .expect("stop for restart"); + container.start().await.expect("restart"); + wait_old_api_ready(&host, http).await; + } + + let admin = format!( + "Bearer {}", + mint_token_old(&host, http, ADMIN_USER, &admin_password).await + ); + create_principal_old(&host, http, &admin, json!({"type":"domain","name":domain})).await; + let mut accounts = Vec::new(); + for (name, password) in users { + let secret = pwhash::sha512_crypt::hash(password).expect("sha512-crypt"); + create_principal_old( + &host, + http, + &admin, + json!({ + "type":"individual","name":name,"secrets":[secret], + "emails":[format!("{name}@{domain}")],"roles":["user"] + }), + ) + .await; + accounts.push(Account { + login: name.to_string(), + password: password.to_string(), + token: String::new(), + }); + } + for account in &mut accounts { + account.token = mint_token_old(&host, http, &account.login, &account.password).await; + } + + Self::assemble(container, host, ports, accounts) + } + + fn assemble( + container: ContainerAsync, + host: String, + ports: Ports, + accounts: Vec, + ) -> Stalwart { + Stalwart { + imap: ports.imap, + imaps: ports.imaps, + pop3: ports.pop3, + pop3s: ports.pop3s, + submission: ports.submission, + smtps: ports.smtps, + sieve: ports.sieve, + smtp: ports.smtp, + https: ports.https, + host, + accounts, + _container: container, + } + } +} + +struct Ports { + https: u16, + http: u16, + imap: u16, + imaps: u16, + pop3: u16, + pop3s: u16, + submission: u16, + smtps: u16, + sieve: u16, + smtp: u16, +} + +impl Ports { + fn pick() -> Ports { + Ports { + https: pick_free_port(), + http: pick_free_port(), + imap: pick_free_port(), + imaps: pick_free_port(), + pop3: pick_free_port(), + pop3s: pick_free_port(), + submission: pick_free_port(), + smtps: pick_free_port(), + sieve: pick_free_port(), + smtp: pick_free_port(), + } + } +} + +async fn wait_jmap_ready(host: &str, https: u16) { + let admin = basic_auth(ADMIN_USER, ADMIN_PASSWORD); + for _ in 0..240 { + if let Ok(resp) = http::try_request( + host, + https, + true, + "GET", + "/.well-known/jmap", + &[("Authorization", &admin)], + None, + ) + .await + && resp.status != 0 + { + return; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + panic!("stalwart JMAP never became ready on {host}:{https}"); +} + +async fn jmap_session(host: &str, https: u16, admin: &str) -> (String, String) { + let resp = http::get_follow( + host, + https, + true, + "/.well-known/jmap", + &[("Authorization", admin)], + ) + .await; + let session = resp.json(); + let api_url = session["apiUrl"].as_str().expect("apiUrl").to_string(); + let api_path = path_of(&api_url); + let admin_account = session["primaryAccounts"] + .as_object() + .and_then(|m| m.values().next()) + .and_then(|v| v.as_str()) + .expect("admin primary account") + .to_string(); + (api_path, admin_account) +} + +async fn jmap_call(host: &str, https: u16, api_path: &str, admin: &str, calls: Value) -> Value { + let envelope = json!({ + "using":["urn:ietf:params:jmap:core","urn:stalwart:jmap"], + "methodCalls": calls, + }); + let body = serde_json::to_vec(&envelope).unwrap(); + let resp = http::request( + host, + https, + true, + "POST", + api_path, + &[ + ("Authorization", admin), + ("Content-Type", "application/json"), + ], + Some(&body), + ) + .await; + assert_eq!(resp.status, 200, "jmap call failed: {}", resp.text()); + resp.json() +} + +async fn create_domain_new( + host: &str, + https: u16, + api_path: &str, + admin: &str, + account: &str, + domain: &str, +) -> String { + let resp = jmap_call( + host, + https, + api_path, + admin, + json!([["x:Domain/set", {"accountId":account,"create":{"v":{"name":domain}}}, "c0"]]), + ) + .await; + resp["methodResponses"][0][1]["created"]["v"]["id"] + .as_str() + .unwrap_or_else(|| panic!("domain not created: {resp}")) + .to_string() +} + +async fn create_account_new( + host: &str, + https: u16, + api_path: &str, + admin: &str, + account: &str, + domain_id: &str, + cred: (&str, &str), +) { + let (name, password) = cred; + let create = json!({ + "@type":"User","name":name,"domainId":domain_id, + "credentials":{"0":{"@type":"Password","secret":password}}, + "encryptionAtRest":{"@type":"Disabled"}, + "permissions":{"@type":"Inherit"}, + "roles":{"@type":"User"},"locale":"en_US" + }); + let resp = jmap_call( + host, + https, + api_path, + admin, + json!([["x:Account/set", {"accountId":account,"create":{"a":create}}, "c0"]]), + ) + .await; + assert!( + resp["methodResponses"][0][1]["created"]["a"].is_object(), + "account {name} not created: {resp}" + ); +} + +async fn invalidate_caches_new(host: &str, https: u16, api_path: &str, admin: &str, account: &str) { + jmap_call( + host, + https, + api_path, + admin, + json!([["x:Action/set", {"accountId":account,"create":{"c":{"@type":"InvalidateCaches"}}}, "c0"]]), + ) + .await; +} + +fn trust_map() -> serde_json::Map { + let mut trust = serde_json::Map::new(); + for net in [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ] { + trust.insert(net.to_string(), Value::Bool(true)); + } + trust +} + +async fn configure_listeners( + host: &str, + https: u16, + api_path: &str, + admin: &str, + account: &str, + proxy_trust: bool, +) { + for (name, proto, port) in [ + ("imap-plain", "imap", 143), + ("pop3-plain", "pop3", 110), + ("submission-plain", "smtp", 587), + ] { + let mut bind = serde_json::Map::new(); + bind.insert(format!("0.0.0.0:{port}"), Value::Bool(true)); + let mut listener = serde_json::Map::new(); + listener.insert("name".into(), json!(name)); + listener.insert("bind".into(), Value::Object(bind)); + listener.insert("protocol".into(), json!(proto)); + listener.insert("tlsImplicit".into(), json!(false)); + listener.insert("useTls".into(), json!(true)); + if proxy_trust { + listener.insert( + "overrideProxyTrustedNetworks".into(), + Value::Object(trust_map()), + ); + } + let resp = jmap_call( + host, + https, + api_path, + admin, + json!([[ + "x:NetworkListener/set", + {"accountId":account,"create":{"l":Value::Object(listener)}}, + "c0" + ]]), + ) + .await; + assert!( + resp["methodResponses"][0][1]["created"]["l"].is_object(), + "listener {name} not created: {resp}" + ); + } + if proxy_trust { + trust_existing_mail_listeners(host, https, api_path, admin, account).await; + } +} + +async fn trust_existing_mail_listeners( + host: &str, + https: u16, + api_path: &str, + admin: &str, + account: &str, +) { + let query = jmap_call( + host, + https, + api_path, + admin, + json!([["x:NetworkListener/query", {"accountId":account}, "c0"]]), + ) + .await; + let ids: Vec = query["methodResponses"][0][1]["ids"] + .as_array() + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + let got = jmap_call( + host, + https, + api_path, + admin, + json!([["x:NetworkListener/get", {"accountId":account,"ids":ids}, "c0"]]), + ) + .await; + let listeners = got["methodResponses"][0][1]["list"] + .as_array() + .cloned() + .unwrap_or_default(); + for listener in listeners { + let id = listener["id"].as_str().unwrap_or_default(); + let protocol = listener["protocol"].as_str().unwrap_or_default(); + if id.is_empty() || protocol == "http" { + continue; + } + let mut update = serde_json::Map::new(); + update.insert( + id.to_string(), + json!({"overrideProxyTrustedNetworks": trust_map()}), + ); + jmap_call( + host, + https, + api_path, + admin, + json!([["x:NetworkListener/set", {"accountId":account,"update":update}, "c0"]]), + ) + .await; + } +} + +async fn mint_token_new(host: &str, https: u16, login: &str, password: &str) -> String { + let login_body = serde_json::to_vec(&json!({ + "type":"authCode","accountName":login,"accountSecret":password, + "clientId":"stalwart-webui","redirectUri":"https://localhost/oauth/callback" + })) + .unwrap(); + let resp = http::request( + host, + https, + true, + "POST", + "/api/auth", + &[("Content-Type", "application/json")], + Some(&login_body), + ) + .await; + assert_eq!( + resp.status, + 200, + "new login failed for {login}: {}", + resp.text() + ); + let code = resp.json()["client_code"] + .as_str() + .expect("client_code") + .to_string(); + let form = format!( + "grant_type=authorization_code&code={code}&client_id=stalwart-webui&redirect_uri=https://localhost/oauth/callback" + ); + let resp = http::request( + host, + https, + true, + "POST", + "/auth/token", + &[("Content-Type", "application/x-www-form-urlencoded")], + Some(form.as_bytes()), + ) + .await; + assert_eq!( + resp.status, + 200, + "new token exchange failed: {}", + resp.text() + ); + resp.json()["access_token"] + .as_str() + .expect("access_token") + .to_string() +} + +async fn mint_token_old(host: &str, http_port: u16, login: &str, password: &str) -> String { + let code_body = serde_json::to_vec(&json!({ + "type":"code","client_id":"webadmin","redirect_uri":"stalwart://auth","nonce":"proxytests0" + })) + .unwrap(); + let resp = http::request( + host, + http_port, + false, + "POST", + "/api/oauth", + &[ + ("Authorization", &basic_auth(login, password)), + ("Content-Type", "application/json"), + ], + Some(&code_body), + ) + .await; + assert_eq!( + resp.status, + 200, + "old oauth code failed for {login}: {}", + resp.text() + ); + let code = resp.json()["data"]["code"] + .as_str() + .expect("code") + .to_string(); + let form = format!( + "grant_type=authorization_code&client_id=webadmin&code={code}&redirect_uri=stalwart://auth" + ); + let resp = http::request( + host, + http_port, + false, + "POST", + "/auth/token", + &[("Content-Type", "application/x-www-form-urlencoded")], + Some(form.as_bytes()), + ) + .await; + assert_eq!( + resp.status, + 200, + "old token exchange failed: {}", + resp.text() + ); + resp.json()["access_token"] + .as_str() + .expect("access_token") + .to_string() +} + +async fn wait_old_api_ready(host: &str, http_port: u16) { + for _ in 0..240 { + if let Ok(resp) = http::try_request(host, http_port, false, "GET", "/", &[], None).await + && resp.status != 0 + { + return; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + panic!("old stalwart API never became ready on {host}:{http_port}"); +} + +async fn set_old_proxy_trust(host: &str, http_port: u16, admin: &str) { + let mail_listeners = [ + "smtp", + "submission", + "submissions", + "imap", + "imaptls", + "pop3", + "pop3s", + "sieve", + ]; + let nets = [ + "127.0.0.0/8", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + ]; + let mut values: Vec = Vec::new(); + for id in mail_listeners { + values.push(json!([ + format!("server.listener.{id}.proxy.override"), + "true" + ])); + for (i, net) in nets.iter().enumerate() { + values.push(json!([ + format!("server.listener.{id}.proxy.trusted-networks.{i}"), + net + ])); + } + } + let body = serde_json::to_vec(&json!([{ + "type":"insert", + "values": values, + "assert_empty": false + }])) + .unwrap(); + let resp = http::request( + host, + http_port, + false, + "POST", + "/api/settings", + &[ + ("Authorization", admin), + ("Content-Type", "application/json"), + ], + Some(&body), + ) + .await; + assert_eq!( + resp.status, + 200, + "old settings update failed: {}", + resp.text() + ); + http::request( + host, + http_port, + false, + "GET", + "/api/reload", + &[("Authorization", admin)], + None, + ) + .await; +} + +async fn set_old_setting(host: &str, http_port: u16, admin: &str, key: &str, value: &str) { + let body = serde_json::to_vec(&json!([{ + "type":"insert", + "values": [[key, value]], + "assert_empty": false + }])) + .unwrap(); + let resp = http::request( + host, + http_port, + false, + "POST", + "/api/settings", + &[ + ("Authorization", admin), + ("Content-Type", "application/json"), + ], + Some(&body), + ) + .await; + assert_eq!( + resp.status, + 200, + "old setting {key} update failed: {}", + resp.text() + ); +} + +async fn create_principal_old(host: &str, http_port: u16, admin: &str, principal: Value) { + let body = serde_json::to_vec(&principal).unwrap(); + let resp = http::request( + host, + http_port, + false, + "POST", + "/api/principal", + &[ + ("Authorization", admin), + ("Content-Type", "application/json"), + ], + Some(&body), + ) + .await; + assert_eq!( + resp.status, + 200, + "old principal create failed: {}", + resp.text() + ); +} + +async fn scrape_admin_password(container: &ContainerAsync) -> String { + for _ in 0..120 { + for raw in [ + container.stdout_to_vec().await.unwrap_or_default(), + container.stderr_to_vec().await.unwrap_or_default(), + ] { + let text = String::from_utf8_lossy(&raw); + if let Some(pw) = parse_admin_password(&text) { + return pw; + } + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + panic!("old stalwart never printed an administrator password"); +} + +fn parse_admin_password(logs: &str) -> Option { + let marker = "with password '"; + let start = logs.find(marker)? + marker.len(); + let rest = &logs[start..]; + let end = rest.find('\'')?; + Some(rest[..end].to_string()) +} + +fn path_of(url: &str) -> String { + match url.split_once("://") { + Some((_scheme, rest)) => match rest.split_once('/') { + Some((_authority, path)) => format!("/{path}"), + None => "/".to_string(), + }, + None => url.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::Engine; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + async fn imaps_auth(host: &str, port: u16, login: &str, password: &str) -> String { + let tcp = tokio::net::TcpStream::connect((host, port)).await.unwrap(); + let mut stream = crate::tests::harness::tls_connect_client(tcp).await; + let mut buf = [0u8; 512]; + let _ = stream.read(&mut buf).await.unwrap(); + let ir = base64::engine::general_purpose::STANDARD.encode(format!("\0{login}\0{password}")); + stream + .write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes()) + .await + .unwrap(); + let n = stream.read(&mut buf).await.unwrap(); + String::from_utf8_lossy(&buf[..n]).into_owned() + } + + #[tokio::test] + #[ignore = "requires Docker"] + async fn new_stalwart_seeds_accounts_tokens_and_plaintext_ports() { + let sw = Stalwart::start(Edition::New, "new.test", &[("carol", "CarolPass#2026")]).await; + assert_eq!(sw.accounts.len(), 1); + assert!(!sw.accounts[0].token.is_empty(), "token minted"); + assert!( + sw.accounts[0].token.starts_with("sw1."), + "0.16.8 must mint sw1. access tokens (got {:?}); HTTP Bearer routing depends on this", + sw.accounts[0].token + ); + + let reply = imaps_auth( + &sw.host, + sw.imaps, + &sw.accounts[0].login, + &sw.accounts[0].password, + ) + .await; + assert!(reply.contains("a1 OK"), "IMAPS auth (email login): {reply}"); + + let mut plain = tokio::net::TcpStream::connect((sw.host.as_str(), sw.imap)) + .await + .unwrap(); + let mut buf = [0u8; 256]; + let n = plain.read(&mut buf).await.unwrap(); + assert!( + buf[..n].windows(4).any(|w| w == b"* OK"), + "plaintext IMAP 143 enabled: {:?}", + String::from_utf8_lossy(&buf[..n]) + ); + } + + #[tokio::test] + #[ignore = "requires Docker"] + async fn old_stalwart_seeds_accounts_and_tokens() { + let sw = Stalwart::start(Edition::Old, "old.test", &[("dave", "DavePass#2026")]).await; + assert_eq!(sw.accounts.len(), 1); + assert!(!sw.accounts[0].token.is_empty(), "token minted"); + assert_eq!(sw.accounts[0].login, "dave", "old login is the username"); + + let reply = imaps_auth( + &sw.host, + sw.imaps, + &sw.accounts[0].login, + &sw.accounts[0].password, + ) + .await; + assert!(reply.contains("a1 OK"), "IMAPS auth (name login): {reply}"); + } +} diff --git a/src/tests/containers/toolbox.rs b/src/tests/containers/toolbox.rs new file mode 100644 index 0000000..7b01695 --- /dev/null +++ b/src/tests/containers/toolbox.rs @@ -0,0 +1,95 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::time::Duration; + +use testcontainers::core::{ExecCommand, Host, WaitFor}; +use testcontainers::runners::{AsyncBuilder, AsyncRunner}; +use testcontainers::{ContainerAsync, GenericBuildableImage, GenericImage, ImageExt}; +use tokio::io::AsyncReadExt; + +use super::HOST_GATEWAY; + +const DOCKERFILE: &str = r#"FROM debian:trixie-slim +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl openssl swaks msmtp isync getmail6 fetchmail \ + sieve-connect dovecot-core cyrus-clients neomutt vdirsyncer \ + python3 python3-venv \ + && rm -rf /var/lib/apt/lists/* +RUN python3 -m venv /opt/venv && /opt/venv/bin/pip install --no-cache-dir jmapc sievelib +RUN set -eu; arch="$(uname -m)"; case "$arch" in \ + aarch64|arm64) asset="websocat.aarch64-unknown-linux-musl";; \ + x86_64|amd64) asset="websocat.x86_64-unknown-linux-musl";; \ + *) echo "unsupported arch $arch" >&2; exit 1;; esac; \ + curl -fsSL -o /usr/local/bin/websocat \ + "https://github.com/vi/websocat/releases/latest/download/$asset"; \ + chmod +x /usr/local/bin/websocat +ENV PATH=/opt/venv/bin:/usr/lib/cyrus/bin:$PATH +CMD ["sleep", "infinity"] +"#; + +pub struct Toolbox { + container: ContainerAsync, +} + +pub struct Exec { + pub code: i64, + pub stdout: String, + pub stderr: String, +} + +impl Exec { + pub fn ok(&self) -> bool { + self.code == 0 + } + + pub fn combined(&self) -> String { + format!("{}{}", self.stdout, self.stderr) + } +} + +impl Toolbox { + pub async fn start() -> Toolbox { + let image: GenericImage = GenericBuildableImage::new("proxy-toolbox", "test") + .with_dockerfile_string(DOCKERFILE.to_string()) + .build_image() + .await + .expect("build toolbox image"); + + let container = image + .with_wait_for(WaitFor::seconds(1)) + .with_startup_timeout(Duration::from_secs(300)) + .with_host(HOST_GATEWAY, Host::HostGateway) + .start() + .await + .expect("start toolbox container"); + + Toolbox { container } + } + + pub async fn run(&self, argv: &[&str]) -> Exec { + let mut result = self + .container + .exec(ExecCommand::new(argv.iter().map(|s| s.to_string()))) + .await + .expect("toolbox exec"); + let mut stdout = Vec::new(); + let mut stderr = Vec::new(); + let _ = result.stdout().read_to_end(&mut stdout).await; + let _ = result.stderr().read_to_end(&mut stderr).await; + let code = result.exit_code().await.ok().flatten().unwrap_or(-1); + Exec { + code, + stdout: String::from_utf8_lossy(&stdout).into_owned(), + stderr: String::from_utf8_lossy(&stderr).into_owned(), + } + } + + pub async fn sh(&self, script: &str) -> Exec { + self.run(&["sh", "-c", script]).await + } +} diff --git a/src/tests/containers/toolbox_http.rs b/src/tests/containers/toolbox_http.rs new file mode 100644 index 0000000..bfcf363 --- /dev/null +++ b/src/tests/containers/toolbox_http.rs @@ -0,0 +1,225 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::stalwart::{Edition, Stalwart}; +use super::toolbox::Toolbox; +use super::{HOST_GATEWAY, Proxy, pick_free_port}; +use crate::tests::harness::mapping_file; + +const USER: &str = "ivan@new.test"; +const PASS: &str = "IvanPass#2026"; + +fn proxy_config(proxy_port: u16, new_https: u16, map_path: &str) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "new" + +[destination.new] +host = "127.0.0.1" +tls_server_name = "new.test" +tls_allow_invalid_certs = true +proxy_protocol = false +forwarded = true +[destination.new.protocol.http] +port = {new_https} +tls = "implicit" + +[[http.route]] +match = "/**" +extract = {{ from = "authorization" }} +fallback = "default" + +[listener.http] +protocol = "http" +bind = ["0.0.0.0:{proxy_port}"] +tls = "plain" +"# + ) +} + +const JMAPC_SCRIPT: &str = r#" +import sys, functools +import requests +from jmapc import Client +from jmapc.session import Session +from jmapc.methods import MailboxGet + +class HttpClient(Client): + @functools.cached_property + def jmap_session(self): + r = self.requests_session.get( + f"http://{self._host}/.well-known/jmap", timeout=30 + ) + r.raise_for_status() + return Session.from_dict(r.json()) + +host, user, password = sys.argv[1], sys.argv[2], sys.argv[3] +c = HttpClient.create_with_password(host=host, user=user, password=password) +session = c.jmap_session +account = c.account_id +resp = c.request(MailboxGet(ids=None), single_response=True) +api = session.api_url +assert host in api, f"apiUrl must point at the proxy, got {api}" +print(f"JMAPC_OK account={account} mailboxes={len(resp.data)} api={api}") +"#; + +struct Client { + name: &'static str, + script: String, + expect: String, +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn toolbox_http_clients_through_proxy() { + let proxy_port = pick_free_port(); + let public_url = format!("http://{HOST_GATEWAY}:{proxy_port}"); + let new = + Stalwart::start_fronted(Edition::New, "new.test", &[("ivan", PASS)], &public_url).await; + + let map = mapping_file(&format!("{USER}\tnew\n")); + let toml = proxy_config(proxy_port, new.https, map.path().to_str().unwrap()); + let proxy = Proxy::launch(&toml).await; + assert_eq!(proxy.port("http"), proxy_port); + + let toolbox = Toolbox::start().await; + let gw = format!("{HOST_GATEWAY}:{proxy_port}"); + let account = USER.replace('@', "%40"); + + let clients = vec![ + Client { + name: "curl/jmap-session", + script: format!( + "curl -sS -L --url 'http://{gw}/.well-known/jmap' --user '{USER}:{PASS}'" + ), + expect: format!("\"apiUrl\":\"http://{gw}/"), + }, + Client { + name: "jmapc/jmap", + script: format!("python3 -c '{JMAPC_SCRIPT}' '{gw}' '{USER}' '{PASS}'"), + expect: "JMAPC_OK".to_string(), + }, + Client { + name: "curl/dav-propfind", + script: format!( + "curl -sS -i -X PROPFIND --url 'http://{gw}/dav/cal/{account}/' \ + --user '{USER}:{PASS}' -H 'Depth: 0' -H 'Content-Type: application/xml' \ + --data ''" + ), + expect: "207".to_string(), + }, + Client { + name: "curl/carddav-propfind", + script: format!( + "curl -sS -i -X PROPFIND --url 'http://{gw}/dav/card/{account}/' \ + --user '{USER}:{PASS}' -H 'Depth: 0' -H 'Content-Type: application/xml' \ + --data ''" + ), + expect: "207".to_string(), + }, + Client { + name: "curl/caldav-report", + script: format!( + "curl -sS -i -X REPORT --url 'http://{gw}/dav/cal/{account}/default/' \ + --user '{USER}:{PASS}' -H 'Depth: 1' -H 'Content-Type: application/xml' \ + --data ''" + ), + expect: "207".to_string(), + }, + Client { + name: "curl/sse-stream", + script: format!( + "curl -sS -i --max-time 3 \ + --url 'http://{gw}/jmap/eventsource/?types=*&closeafter=no&ping=1' \ + --user '{USER}:{PASS}' || true" + ), + expect: "text/event-stream".to_string(), + }, + Client { + name: "vdirsyncer/dav", + script: format!( + "mkdir -p /tmp/vd/status /tmp/vd/cal; \ + cat > /tmp/vd.conf < + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::legacy::Legacy; +use super::toolbox::Toolbox; +use super::{Proxy, pick_free_port}; +use crate::tests::harness::mapping_file; + +const USER: &str = "alice@legacy.test"; +const PASS: &str = "AlicePass#2026"; +const GW: &str = "host.docker.internal"; + +fn proxy_config( + map_path: &str, + imap_listen: u16, + pop3_listen: u16, + sub_listen: u16, + sieve_listen: u16, + legacy: &Legacy, +) -> String { + format!( + r#" +[server] +hostname = "proxy.test" + +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{map_path}" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +tls_server_name = "legacy.test" +tls_allow_invalid_certs = true +forwarding = "xclient" +[destination.legacy.protocol.imap] +port = {imap} +tls = "starttls" +[destination.legacy.protocol.pop3] +port = {pop3} +tls = "starttls" +[destination.legacy.protocol.submission] +port = {sub} +tls = "starttls" +[destination.legacy.protocol.managesieve] +port = {sieve} +tls = "starttls" + +[listener.imap] +protocol = "imap" +bind = ["0.0.0.0:{imap_listen}"] +tls = "starttls" + +[listener.pop3] +protocol = "pop3" +bind = ["0.0.0.0:{pop3_listen}"] +tls = "starttls" + +[listener.submission] +protocol = "submission" +bind = ["0.0.0.0:{sub_listen}"] +tls = "starttls" + +[listener.managesieve] +protocol = "managesieve" +bind = ["0.0.0.0:{sieve_listen}"] +tls = "starttls" +"#, + imap = legacy.imap, + pop3 = legacy.pop3, + sub = legacy.submission, + sieve = legacy.sieve, + ) +} + +struct Client { + name: &'static str, + script: String, + expect: &'static str, +} + +#[tokio::test] +#[ignore = "requires Docker"] +async fn toolbox_mail_clients_through_proxy_legacy() { + let legacy = Legacy::start().await; + let toolbox = Toolbox::start().await; + + let map = mapping_file(&format!("{USER}\tlegacy\n")); + let (ip, pp, sp, mp) = ( + pick_free_port(), + pick_free_port(), + pick_free_port(), + pick_free_port(), + ); + let toml = proxy_config(map.path().to_str().unwrap(), ip, pp, sp, mp, &legacy); + let proxy = Proxy::launch(&toml).await; + let (imap, pop3, sub, sieve) = ( + proxy.port("imap"), + proxy.port("pop3"), + proxy.port("submission"), + proxy.port("managesieve"), + ); + + let clients = vec![ + Client { + name: "curl/imap", + script: format!( + "curl -sS -k --ssl-reqd --url 'imap://{GW}:{imap}/' --user '{USER}:{PASS}' --request 'LIST \"\" \"*\"'" + ), + expect: "INBOX", + }, + Client { + name: "imtest/imap", + script: format!( + "printf 'C01 SELECT INBOX\\r\\nC02 LOGOUT\\r\\n' | \ + imtest -t '' -m PLAIN -a '{USER}' -w '{PASS}' -p {imap} {GW}" + ), + expect: "Authenticated", + }, + Client { + name: "curl/pop3", + script: format!( + "curl -sS -v -k --ssl-reqd --url 'pop3://{GW}:{pop3}/' --user '{USER}:{PASS}' --request 'STAT'" + ), + expect: "+OK", + }, + Client { + name: "pop3test/pop3", + script: format!( + "printf 'STAT\\r\\nQUIT\\r\\n' | \ + pop3test -t '' -m PLAIN -a '{USER}' -w '{PASS}' -p {pop3} {GW}" + ), + expect: "Authenticated", + }, + Client { + name: "swaks/submission", + script: format!( + "swaks --server {GW}:{sub} --tls --auth PLAIN --au '{USER}' --ap '{PASS}' \ + --from '{USER}' --to 'bob@legacy.test' --h-Subject m6 --body hi" + ), + expect: "250", + }, + Client { + name: "msmtp/submission", + script: format!( + "printf 'Subject: m6\\r\\n\\r\\nhi\\r\\n' | \ + msmtp --host={GW} --port={sub} --tls=on --tls-starttls=on --tls-certcheck=off \ + --auth=plain --user='{USER}' --passwordeval=\"printf '%s' '{PASS}'\" \ + --from='{USER}' --debug 'bob@legacy.test'" + ), + expect: "235", + }, + Client { + name: "smtptest/submission", + script: format!( + "printf 'QUIT\\r\\n' | \ + smtptest -t '' -m PLAIN -a '{USER}' -w '{PASS}' -p {sub} {GW}" + ), + expect: "Authenticated", + }, + Client { + name: "sieve-connect/managesieve", + script: format!( + "printf 'keep;\\n' > /tmp/m6.sieve; \ + printf '%s' '{PASS}' | sieve-connect --server {GW} --port {sieve} \ + --user '{USER}' --passwordfd 0 --notlsverify \ + --upload --localsieve /tmp/m6.sieve --remotesieve m6test; \ + printf '%s' '{PASS}' | sieve-connect --server {GW} --port {sieve} \ + --user '{USER}' --passwordfd 0 --notlsverify --list" + ), + expect: "m6test", + }, + Client { + name: "sivtest/managesieve", + script: format!( + "printf 'LISTSCRIPTS\\r\\nLOGOUT\\r\\n' | \ + sivtest -t '' -m PLAIN -a '{USER}' -w '{PASS}' -p {sieve} {GW}" + ), + expect: "Authenticated", + }, + ]; + + let mut failures = Vec::new(); + for c in &clients { + let out = toolbox.sh(&c.script).await; + let combined = out.combined(); + let ok = if c.expect.is_empty() { + out.ok() + } else { + combined.contains(c.expect) + }; + if !ok { + failures.push(format!( + "[{}] exit={} expect={:?}\n--- output ---\n{}\n--------------", + c.name, out.code, c.expect, combined + )); + } + } + + assert!( + failures.is_empty(), + "{} of {} toolbox clients failed:\n\n{}", + failures.len(), + clients.len(), + failures.join("\n\n") + ); +} diff --git a/src/tests/forwarding.rs b/src/tests/forwarding.rs new file mode 100644 index 0000000..ffc928f --- /dev/null +++ b/src/tests/forwarding.rs @@ -0,0 +1,290 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +use super::harness::*; + +async fn read_crlf(sock: &mut TcpStream, buf: &mut Vec) -> String { + let mut scratch = [0u8; 1024]; + loop { + if let Some(pos) = find(buf, b"\r\n") { + let line = String::from_utf8_lossy(&buf[..pos]).into_owned(); + buf.drain(..pos + 2); + return line; + } + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return String::from_utf8_lossy(buf).into_owned(); + } + buf.extend_from_slice(&scratch[..n]); + } +} + +fn pop3_config(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 = "xclient" +allow_plaintext_auth = true +[destination.legacy.protocol.pop3] +port = {port} +tls = "plain" + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +#[tokio::test] +async fn pop3_xclient_is_sent_when_advertised() { + let (port, backend) = dummy_backend(|mut sock| async move { + sock.write_all(b"+OK ready [XCLIENT]\r\n").await.unwrap(); + let mut buf = Vec::new(); + let xclient = read_crlf(&mut sock, &mut buf).await; + assert!(xclient.starts_with("XCLIENT "), "got: {xclient}"); + assert!(xclient.contains("ADDR=203.0.113.7"), "addr: {xclient}"); + assert!(xclient.contains("PORT=40000"), "port: {xclient}"); + assert!(xclient.contains("TTL=6"), "ttl decremented: {xclient}"); + assert!( + xclient.contains("CLIENT-TRANSPORT=insecure"), + "transport: {xclient}" + ); + sock.write_all(b"+OK\r\n").await.unwrap(); + let auth = read_crlf(&mut sock, &mut buf).await; + assert!(auth.starts_with("AUTH PLAIN "), "got: {auth}"); + sock.write_all(b"+OK logged in\r\n").await.unwrap(); + }) + .await; + + let mapping = mapping_file(""); + let toml = pop3_config(port, &mapping.path().display().to_string()); + 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.7:40000".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 secret\r\n").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"+OK logged in").is_some(), + "expected login, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn pop3_master_login_routes_on_account() { + let (port, backend) = dummy_backend(|mut sock| async move { + sock.write_all(b"+OK ready\r\n").await.unwrap(); + let mut buf = Vec::new(); + let auth = read_crlf(&mut sock, &mut buf).await; + assert!(auth.starts_with("AUTH PLAIN "), "got: {auth}"); + let b64 = auth.split_whitespace().nth(2).unwrap(); + let raw = BASE64.decode(b64).unwrap(); + assert!( + find(&raw, b"user@example.com%admin").is_some(), + "credential replayed verbatim: {:?}", + String::from_utf8_lossy(&raw) + ); + sock.write_all(b"+OK logged in\r\n").await.unwrap(); + }) + .await; + + let mapping = mapping_file("user@example.com\tlegacy\n"); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[capabilities.pop3] +allow_plain_auth_without_tls = true + +[routing] +default_destination = "blackhole" +master_user_separators = ["%"] + +[destination.legacy] +host = "127.0.0.1" +forwarding = "none" +allow_plaintext_auth = true +[destination.legacy.protocol.pop3] +port = {port} +tls = "plain" + +[destination.blackhole] +host = "127.0.0.1" +forwarding = "none" +allow_plaintext_auth = true +[destination.blackhole.protocol.pop3] +port = 1 +tls = "plain" + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:0"] +tls = "plain" +"#, + mapping.path().display(), + ); + let ctx = make_ctx(&toml).await; + let 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.7:40000".parse().unwrap(), + "127.0.0.1:110".parse().unwrap(), + ) + .await; + }); + + let _ = read_line(&mut client).await; + send( + &mut client, + b"USER user@example.com%admin\r\nPASS secret\r\n", + ) + .await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"+OK logged in").is_some(), + "master login should route to legacy, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +fn imap_config(port: u16, mapping_path: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[capabilities.imap] +allow_plain_auth_without_tls = true + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +forwarding = "xclient" +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = {port} +tls = "plain" + +[listener.imap] +protocol = "imap" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +#[tokio::test] +async fn imap_id_is_sent_when_advertised() { + let (port, backend) = dummy_backend(|mut sock| async move { + sock.write_all(b"* OK [CAPABILITY IMAP4rev1 SASL-IR AUTH=PLAIN ID] ready\r\n") + .await + .unwrap(); + let mut buf = Vec::new(); + let id = read_crlf(&mut sock, &mut buf).await; + assert!(id.starts_with("F1 ID ("), "got: {id}"); + assert!( + id.contains("\"x-originating-ip\" \"203.0.113.8\""), + "id fields: {id}" + ); + assert!(id.contains("\"x-proxy-ttl\" \"6\""), "ttl: {id}"); + sock.write_all(b"* ID NIL\r\nF1 OK ID completed\r\n") + .await + .unwrap(); + let auth = read_crlf(&mut sock, &mut buf).await; + assert!(auth.contains("AUTHENTICATE PLAIN "), "got: {auth}"); + sock.write_all(b"a1 OK logged in\r\n").await.unwrap(); + }) + .await; + + let mapping = mapping_file(""); + let toml = imap_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.8:40000".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let _ = read_line(&mut client).await; + let ir = BASE64.encode(b"\0user@example.com\0secret"); + send( + &mut client, + format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes(), + ) + .await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"a1 OK logged in").is_some(), + "expected login reply, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} diff --git a/src/tests/harness.rs b/src/tests/harness.rs new file mode 100644 index 0000000..113bb18 --- /dev/null +++ b/src/tests/harness.rs @@ -0,0 +1,237 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; +use std::net::SocketAddr; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::task::JoinHandle; +use tokio_rustls::TlsAcceptor; + +use crate::config::Config; +use crate::net::tls; +use crate::proto::common::Ctx; +use crate::route::Router; + +pub fn unused_addr() -> SocketAddr { + "127.0.0.1:0".parse().unwrap() +} + +pub async fn dummy_backend(script: F) -> (u16, JoinHandle<()>) +where + F: FnOnce(TcpStream) -> Fut + Send + 'static, + Fut: Future + Send + 'static, +{ + let listener = TcpListener::bind(unused_addr()).await.unwrap(); + let port = listener.local_addr().unwrap().port(); + let handle = tokio::spawn(async move { + if let Ok((socket, _)) = listener.accept().await { + let _ = socket.set_nodelay(true); + script(socket).await; + } + }); + (port, handle) +} + +pub async fn make_ctx(toml: &str) -> Arc { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let config = Arc::new(Config::parse_and_validate(toml).expect("config should validate")); + let provider = tls::provider(); + let server_config = tls::build_server_config(&config, provider.clone()).unwrap(); + let tls_acceptor = Some(TlsAcceptor::from(server_config)); + let valid: Vec = config.destination.keys().cloned().collect(); + let router = Arc::new(Router::build(&config, valid).await.unwrap()); + let http_router = Arc::new( + crate::http::router::HttpRouter::build( + &config.http, + config.oauth.jwt_username_claim.clone(), + ) + .unwrap(), + ); + let dests = Ctx::build_dest_runtimes(&config, &provider).unwrap(); + let dest_health = Ctx::build_dest_health(&config); + let self_binds = Ctx::build_self_binds(&config); + let greetings = crate::proto::common::build_greetings(&config); + Arc::new(Ctx { + config, + router, + http_router, + tls_acceptor, + dests, + dest_health, + self_binds, + conns: Arc::new(crate::proto::common::ConnRegistry::default()), + metrics: Arc::new(crate::proto::common::Metrics::default()), + greetings, + }) +} + +pub async fn tcp_pair() -> (TcpStream, TcpStream) { + let listener = TcpListener::bind(unused_addr()).await.unwrap(); + let addr = listener.local_addr().unwrap(); + let client = TcpStream::connect(addr).await.unwrap(); + let (server, _) = listener.accept().await.unwrap(); + let _ = client.set_nodelay(true); + let _ = server.set_nodelay(true); + (client, server) +} + +pub async fn read_until(stream: &mut TcpStream, needle: &[u8]) -> Vec { + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + if find(&buf, needle).is_some() { + return buf; + } + let n = tokio::time::timeout(std::time::Duration::from_secs(5), stream.read(&mut scratch)) + .await + .expect("read timed out") + .unwrap(); + if n == 0 { + return buf; + } + buf.extend_from_slice(&scratch[..n]); + } +} + +pub async fn read_line(stream: &mut TcpStream) -> String { + let buf = read_until(stream, b"\r\n").await; + let end = find(&buf, b"\r\n").unwrap_or(buf.len()); + String::from_utf8_lossy(&buf[..end]).into_owned() +} + +pub async fn read_to_eof(stream: &mut TcpStream) -> Vec { + let mut buf = Vec::new(); + let mut scratch = [0u8; 4096]; + loop { + match tokio::time::timeout(std::time::Duration::from_secs(3), stream.read(&mut scratch)) + .await + { + Ok(Ok(0)) => return buf, + Ok(Ok(n)) => buf.extend_from_slice(&scratch[..n]), + Ok(Err(_)) | Err(_) => return buf, + } + } +} + +pub async fn send(stream: &mut TcpStream, bytes: &[u8]) { + stream.write_all(bytes).await.unwrap(); + stream.flush().await.unwrap(); +} + +pub fn find(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() || haystack.len() < needle.len() { + return None; + } + haystack.windows(needle.len()).position(|w| w == needle) +} + +pub fn mapping_file(contents: &str) -> tempfile::NamedTempFile { + use std::io::Write; + let mut f = tempfile::NamedTempFile::new().unwrap(); + f.write_all(contents.as_bytes()).unwrap(); + f.flush().unwrap(); + f +} + +#[derive(Debug)] +struct AcceptAnyCert; + +impl rustls::client::danger::ServerCertVerifier for AcceptAnyCert { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + use rustls::SignatureScheme::*; + vec![ + RSA_PKCS1_SHA256, + RSA_PKCS1_SHA384, + RSA_PKCS1_SHA512, + ECDSA_NISTP256_SHA256, + ECDSA_NISTP384_SHA384, + ECDSA_NISTP521_SHA512, + RSA_PSS_SHA256, + RSA_PSS_SHA384, + RSA_PSS_SHA512, + ED25519, + ] + } +} + +pub async fn tls_connect_client(stream: TcpStream) -> tokio_rustls::client::TlsStream { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyCert)) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + let name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); + connector.connect(name, stream).await.unwrap() +} + +pub async fn admin_request( + addr: SocketAddr, + method: &str, + path: &str, + token: Option<&str>, +) -> (u16, String) { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let config = rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(AcceptAnyCert)) + .with_no_client_auth(); + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + let tcp = TcpStream::connect(addr).await.unwrap(); + let name = rustls::pki_types::ServerName::try_from("localhost").unwrap(); + let mut tls = connector.connect(name, tcp).await.unwrap(); + + let mut req = format!("{method} {path} HTTP/1.1\r\nHost: admin\r\nConnection: close\r\n"); + if let Some(t) = token { + req.push_str(&format!("Authorization: Bearer {t}\r\n")); + } + req.push_str("\r\n"); + tls.write_all(req.as_bytes()).await.unwrap(); + tls.flush().await.unwrap(); + + let mut buf = Vec::new(); + tls.read_to_end(&mut buf).await.unwrap(); + let text = String::from_utf8_lossy(&buf).into_owned(); + let status = text + .split_whitespace() + .nth(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(0); + let body = text.split("\r\n\r\n").nth(1).unwrap_or("").to_string(); + (status, body) +} diff --git a/src/tests/http_proxy.rs b/src/tests/http_proxy.rs new file mode 100644 index 0000000..763f3ca --- /dev/null +++ b/src/tests/http_proxy.rs @@ -0,0 +1,987 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + +use super::harness::*; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn split_host_port(hp: &str) -> (String, u16) { + let (host, port) = hp.rsplit_once(':').unwrap(); + (host.to_string(), port.parse().unwrap()) +} + +fn static_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 = "/**" +destination = "web" + +[listener.http] +protocol = "http" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +async fn read_req_head(sock: &mut tokio::net::TcpStream) { + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + if find(&buf, b"\r\n\r\n").is_some() { + return; + } + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + buf.extend_from_slice(&scratch[..n]); + } +} + +#[tokio::test] +async fn http_static_route_proxies_to_backend() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/inbox") + .with_status(200) + .with_body("hello") + .create_async() + .await; + let (host, port) = split_host_port(&server.host_with_port()); + + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +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 = "/**" +destination = "web" + +[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 ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::http::proxy::handle( + &ctx2, + &listener, + Box::new(srv), + "127.0.0.1:40030".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"GET /inbox HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"200").is_some(), + "expected 200, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"hello").is_some(), + "expected body hello, got {:?}", + String::from_utf8_lossy(&resp) + ); + + mock.assert_async().await; + handler.abort(); +} + +#[tokio::test] +async fn http_client_connection_close_closes_after_response() { + let (port, backend) = dummy_backend(|mut sock| async move { + read_req_head(&mut sock).await; + sock.write_all( + b"HTTP/1.1 307 Temporary Redirect\r\nLocation: /x\r\nContent-Length: 0\r\n\r\n", + ) + .await + .unwrap(); + let mut scratch = [0u8; 256]; + let _ = sock.read(&mut scratch).await; + }) + .await; + + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[routing] +default_destination = "web" + +[destination.web] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.web.protocol.http] +port = {port} +tls = "plain" + +[[http.route]] +match = "/**" +destination = "web" + +[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 ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::http::proxy::handle( + &ctx2, + &listener, + Box::new(srv), + "127.0.0.1:40031".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"GET /a HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ) + .await; + let resp = tokio::time::timeout(std::time::Duration::from_secs(5), read_to_eof(&mut client)) + .await + .expect("proxy must close the client leg on Connection: close, not hang"); + assert!( + find(&resp, b"307").is_some(), + "expected 307, got {:?}", + String::from_utf8_lossy(&resp) + ); + + backend.abort(); + handler.abort(); +} + +#[tokio::test] +async fn http_authorization_extract_routes_to_backend() { + let mut server = mockito::Server::new_async().await; + let mock = server + .mock("GET", "/") + .with_status(200) + .with_body("ok") + .create_async() + .await; + let (host, port) = split_host_port(&server.host_with_port()); + + let mapping = mapping_file("user@example.com\tweb\n"); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +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" +"#, + mapping.path().display() + ); + 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:40031".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + let creds = BASE64.encode("user@example.com:pw"); + let req = format!( + "GET / HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\nConnection: close\r\n\r\n" + ); + send(&mut client, req.as_bytes()).await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"200").is_some(), + "expected 200, got {:?}", + String::from_utf8_lossy(&resp) + ); + + mock.assert_async().await; + handler.abort(); +} + +#[tokio::test] +async fn http_rejects_cl_te_smuggling() { + let mapping = mapping_file(""); + let toml = static_http_config("127.0.0.1", 1, &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:40040".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"POST / HTTP/1.1\r\nHost: x\r\nContent-Length: 5\r\nTransfer-Encoding: chunked\r\n\r\n", + ) + .await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"400").is_some(), + "CL+TE must be rejected with 400, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} + +#[tokio::test] +async fn http_rejects_http_1_0() { + let mapping = mapping_file(""); + let toml = static_http_config("127.0.0.1", 1, &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:40041".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send(&mut client, b"GET / HTTP/1.0\r\nHost: x\r\n\r\n").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"505").is_some(), + "HTTP/1.0 must be rejected with 505, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} + +#[tokio::test] +async fn http_no_body_304_with_content_length_keeps_connection_alive() { + async fn backend(mut sock: tokio::net::TcpStream) { + read_req_head(&mut sock).await; + sock.write_all(b"HTTP/1.1 304 Not Modified\r\nContent-Length: 100\r\nETag: \"x\"\r\n\r\n") + .await + .unwrap(); + 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 (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40042".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send(&mut client, b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n").await; + let first = read_until(&mut client, b"\r\n\r\n").await; + assert!( + find(&first, b"304").is_some(), + "expected 304, got {:?}", + String::from_utf8_lossy(&first) + ); + + send( + &mut client, + b"GET /b HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n", + ) + .await; + let second = read_to_eof(&mut client).await; + assert!( + find(&second, b"200").is_some() && find(&second, b"ok").is_some(), + "a 304 with a Content-Length must not consume a phantom body; second request hung. got {:?}", + String::from_utf8_lossy(&second) + ); + backend.abort(); + handler.abort(); +} + +#[tokio::test] +async fn http_misdirected_request_returns_421() { + async fn backend_a(mut sock: tokio::net::TcpStream) { + read_req_head(&mut sock).await; + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 1\r\n\r\na") + .await + .unwrap(); + let mut scratch = [0u8; 64]; + let _ = sock.read(&mut scratch).await; + } + let (port_a, backend_a) = dummy_backend(backend_a).await; + let host_a = "127.0.0.1".to_string(); + + let server_b = mockito::Server::new_async().await; + let (host_b, port_b) = split_host_port(&server_b.host_with_port()); + + let mapping = mapping_file("alice@x\tweb_a\nbob@x\tweb_b\n"); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[routing] +default_destination = "web_a" + +[destination.web_a] +host = "{host_a}" +proxy_protocol = false +allow_plaintext_auth = true +[destination.web_a.protocol.http] +port = {port_a} +tls = "plain" + +[destination.web_b] +host = "{host_b}" +proxy_protocol = false +allow_plaintext_auth = true +[destination.web_b.protocol.http] +port = {port_b} +tls = "plain" + +[[http.route]] +match = "/**" +extract = {{ from = "authorization" }} + +[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 ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::http::proxy::handle( + &ctx2, + &listener, + Box::new(srv), + "127.0.0.1:40043".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + let creds_a = BASE64.encode("alice@x:pw"); + send( + &mut client, + format!("GET / HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds_a}\r\n\r\n").as_bytes(), + ) + .await; + let first = read_until(&mut client, b"\r\n\r\n").await; + assert!( + find(&first, b"200").is_some(), + "first request should reach web_a, got {:?}", + String::from_utf8_lossy(&first) + ); + + let creds_b = BASE64.encode("bob@x:pw"); + send( + &mut client, + format!("GET / HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds_b}\r\n\r\n").as_bytes(), + ) + .await; + let second = read_to_eof(&mut client).await; + assert!( + find(&second, b"421").is_some(), + "a request routing to a different backend must get 421, got {:?}", + String::from_utf8_lossy(&second) + ); + + backend_a.abort(); + handler.abort(); +} + +async fn read_until_backend(sock: &mut tokio::net::TcpStream, needle: &[u8]) -> Vec { + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + if find(&buf, needle).is_some() { + return buf; + } + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return buf; + } + buf.extend_from_slice(&scratch[..n]); + } +} + +#[tokio::test] +async fn http_websocket_upgrade_is_relayed_and_bridged() { + 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 scratch = [0u8; 64]; + let n = sock.read(&mut scratch).await.unwrap(); + assert_eq!(&scratch[..n], b"PING"); + sock.write_all(b"PONG").await.unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40060".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await; + + let upgrade = read_until(&mut client, b"\r\n\r\n").await; + assert!( + find(&upgrade, b"101").is_some() + && find(&upgrade, b"s3pPLMBiTxaQ9kYGzzhZRbK+xOo=").is_some(), + "expected verbatim 101 with the backend's accept, got {:?}", + String::from_utf8_lossy(&upgrade) + ); + + send(&mut client, b"PING").await; + let echo = read_until(&mut client, b"PONG").await; + assert!( + find(&echo, b"PONG").is_some(), + "post-upgrade bytes must bridge raw, got {:?}", + String::from_utf8_lossy(&echo) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn http_websocket_declined_relayed_as_framed_response_not_bridged() { + async fn backend(mut sock: tokio::net::TcpStream) { + read_req_head(&mut sock).await; + sock.write_all( + b"HTTP/1.1 403 Forbidden\r\nContent-Length: 7\r\nConnection: close\r\n\r\ndenied!LEAK", + ) + .await + .unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40062".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"GET /ws HTTP/1.1\r\nHost: x\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\r\n", + ) + .await; + + let resp = tokio::time::timeout(std::time::Duration::from_secs(5), read_to_eof(&mut client)) + .await + .expect("a declined upgrade must be relayed and the client leg closed, not bridged"); + assert!( + find(&resp, b"403").is_some() && find(&resp, b"denied!").is_some(), + "expected the 403 body relayed, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"LEAK").is_none(), + "framed relay must stop at Content-Length; raw bridge leaked trailing bytes: {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn http_backend_connection_close_closes_client_leg() { + 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\nConnection: close\r\n\r\nhi") + .await + .unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40063".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send(&mut client, b"GET /a HTTP/1.1\r\nHost: x\r\n\r\n").await; + let resp = tokio::time::timeout(std::time::Duration::from_secs(5), read_to_eof(&mut client)) + .await + .expect("proxy must close the client leg when the backend signals Connection: close"); + assert!( + find(&resp, b"200").is_some() && find(&resp, b"hi").is_some(), + "expected the 200 body, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"Connection: close").is_some(), + "the backend's Connection: close must be propagated to the client, got {:?}", + String::from_utf8_lossy(&resp) + ); + + backend.abort(); + handler.abort(); +} + +#[tokio::test] +async fn http_sse_streams_until_close() { + 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-Type: text/event-stream\r\nCache-Control: no-cache\r\n\r\n") + .await + .unwrap(); + sock.write_all(b"data: hello\n\n").await.unwrap(); + sock.write_all(b"data: world\n\n").await.unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40061".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send(&mut client, b"GET /events HTTP/1.1\r\nHost: x\r\n\r\n").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"text/event-stream").is_some() + && find(&resp, b"data: hello").is_some() + && find(&resp, b"data: world").is_some(), + "SSE events must stream through until close, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn http_100_continue_with_early_hints() { + async fn backend(mut sock: tokio::net::TcpStream) { + read_req_head(&mut sock).await; + sock.write_all(b"HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n") + .await + .unwrap(); + sock.write_all(b"HTTP/1.1 100 Continue\r\n\r\n") + .await + .unwrap(); + let body = read_until_backend(&mut sock, b"hello").await; + assert!(find(&body, b"hello").is_some()); + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40062".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"POST /upload HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\nContent-Length: 5\r\nConnection: close\r\n\r\n", + ) + .await; + let interim = read_until(&mut client, b"100 Continue").await; + assert!( + find(&interim, b"103").is_some(), + "the 103 Early Hints interim must be relayed, got {:?}", + String::from_utf8_lossy(&interim) + ); + + send(&mut client, b"hello").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"200").is_some() && find(&resp, b"ok").is_some(), + "after 100 Continue the body must flow and the final response return, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn http_100_continue_final_response_forces_close_no_smuggling() { + async fn backend(mut sock: tokio::net::TcpStream) { + read_req_head(&mut sock).await; + sock.write_all(b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + let mut scratch = [0u8; 256]; + let extra = sock.read(&mut scratch).await.unwrap_or(0); + assert_eq!( + extra, 0, + "proxy must not forward a second request on the same backend leg" + ); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40064".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"POST /upload HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\nContent-Length: 40\r\n\r\nGET /evil HTTP/1.1\r\nHost: x\r\n\r\n", + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"413").is_some(), + "expected the backend's 413 final response, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"Connection: close").is_some(), + "the proxy must force Connection: close on an unhonored 100-continue, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn http_100_continue_body_route_proxy_answers_continue() { + async fn backend(mut sock: tokio::net::TcpStream) { + let req = read_until_backend(&mut sock, b"corp.com").await; + assert!( + find(&req, b"Expect").is_none() && find(&req, b"100-continue").is_none(), + "proxy must strip Expect when it answers 100-continue itself, got {:?}", + String::from_utf8_lossy(&req) + ); + assert!( + find(&req, b"username=joe").is_some(), + "the routing body must reach the backend, got {:?}", + String::from_utf8_lossy(&req) + ); + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[routing] +default_destination = "web" + +[destination.web] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.web.protocol.http] +port = {port} +tls = "plain" + +[[http.route]] +match = "/auth/**" +extract = {{ from = "body", regex = "username=([^&]+)" }} + +[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 ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::http::proxy::handle( + &ctx2, + &listener, + Box::new(srv), + "127.0.0.1:40066".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + let body = b"username=joe@corp.com"; + let req = format!( + "POST /auth/token HTTP/1.1\r\nHost: x\r\nExpect: 100-continue\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + send(&mut client, req.as_bytes()).await; + + let interim = read_until(&mut client, b"100 Continue").await; + assert!( + find(&interim, b"100").is_some(), + "the proxy must answer 100 Continue itself before the body is sent, got {:?}", + String::from_utf8_lossy(&interim) + ); + + send(&mut client, body).await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"200").is_some() && find(&resp, b"ok").is_some(), + "expected 200 ok after body-based routing, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn http_chunked_request_and_response_round_trip() { + async fn backend(mut sock: tokio::net::TcpStream) { + let _ = read_until_backend(&mut sock, b"0\r\n\r\n").await; + sock.write_all( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nabc\r\n0\r\n\r\n", + ) + .await + .unwrap(); + } + + let (port, backend) = dummy_backend(backend).await; + let mapping = mapping_file(""); + let toml = static_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:40063".parse().unwrap(), + "127.0.0.1:80".parse().unwrap(), + ) + .await; + }); + + send( + &mut client, + b"POST /up HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n5\r\nhello\r\n0\r\n\r\n", + ) + .await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"200").is_some() && find(&resp, b"abc").is_some(), + "chunked request and response must round-trip, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} diff --git a/src/tests/imap.rs b/src/tests/imap.rs new file mode 100644 index 0000000..848158a --- /dev/null +++ b/src/tests/imap.rs @@ -0,0 +1,448 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::harness::*; + +fn config(port: u16, mapping_path: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[capabilities.imap] +allow_plain_auth_without_tls = true + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = {port} +tls = "plain" + +[destination.primary] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.primary.protocol.imap] +port = {port} +tls = "plain" + +[listener.imap] +protocol = "imap" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +async fn backend_expect_auth(sock: tokio::net::TcpStream, want_user: &'static str) { + backend_expect_mech(sock, "PLAIN", want_user).await +} + +async fn backend_expect_mech( + mut sock: tokio::net::TcpStream, + mech: &'static str, + want_user: &'static str, +) { + sock.write_all(b"* OK [CAPABILITY IMAP4rev2] backend ready\r\n") + .await + .unwrap(); + + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + buf.extend_from_slice(&scratch[..n]); + if find(&buf, b"\r\n").is_some() { + break; + } + } + let line_end = find(&buf, b"\r\n").unwrap(); + let line = String::from_utf8_lossy(&buf[..line_end]).into_owned(); + let tag = line.split(' ').next().unwrap().to_string(); + assert!( + line.contains(&format!("AUTHENTICATE {mech}")), + "got: {line}" + ); + let b64 = line.split_whitespace().nth(3).unwrap(); + let raw = BASE64.decode(b64).unwrap(); + assert!( + find(&raw, want_user.as_bytes()).is_some(), + "decoded IR {:?} should contain {want_user}", + String::from_utf8_lossy(&raw) + ); + sock.write_all(format!("{tag} OK [CAPABILITY IMAP4rev2] authenticated\r\n").as_bytes()) + .await + .unwrap(); + + let mut rest = buf[line_end + 2..].to_vec(); + loop { + if find(&rest, b"NOOP").is_some() { + break; + } + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + rest.extend_from_slice(&scratch[..n]); + } + sock.write_all(b"a2 OK NOOP done\r\n").await.unwrap(); +} + +#[tokio::test] +async fn imap_login_routes_replays_and_bridges() { + let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40000".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let greeting = read_line(&mut client).await; + assert!( + greeting.starts_with("* OK [CAPABILITY"), + "greeting: {greeting}" + ); + + send( + &mut client, + b"a1 LOGIN user@example.com secret\r\na2 NOOP\r\n", + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"a1 OK").is_some(), + "expected a1 OK, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"a2 OK").is_some(), + "expected bridged a2 OK, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn imap_authenticate_plain_with_ir() { + let (port, backend) = + dummy_backend(|sock| backend_expect_auth(sock, "alice@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40001".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + let ir = BASE64.encode(b"\0alice@example.com\0secret"); + send( + &mut client, + format!("a1 AUTHENTICATE PLAIN {ir}\r\na2 NOOP\r\n").as_bytes(), + ) + .await; + + let resp = read_until(&mut client, b"a1 OK").await; + assert!(find(&resp, b"a1 OK").is_some()); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn imap_authenticate_oauthbearer_extracts_identifier() { + let (port, backend) = + dummy_backend(|sock| backend_expect_mech(sock, "OAUTHBEARER", "carol@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40003".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + let frame = "n,a=carol@example.com,\x01auth=Bearer token-xyz\x01\x01"; + let ir = BASE64.encode(frame.as_bytes()); + send( + &mut client, + format!("a1 AUTHENTICATE OAUTHBEARER {ir}\r\na2 NOOP\r\n").as_bytes(), + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!(find(&resp, b"a1 OK").is_some()); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn imap_backend_unavailable_returns_no() { + let mapping = mapping_file(""); + let toml = config(1, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40004".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + send(&mut client, b"a1 LOGIN user@example.com secret\r\n").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"a1 NO [UNAVAILABLE]").is_some(), + "expected backend-unavailable NO, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} + +#[tokio::test] +async fn imap_login_rejected_without_tls_when_plain_disabled() { + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[capabilities.imap] +allow_plain_auth_without_tls = false + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +tls_server_name = "backend.example.com" +[destination.legacy.protocol.imap] +port = 1 +tls = "implicit" + +[listener.imap] +protocol = "imap" +bind = ["127.0.0.1:0"] +tls = "starttls" +"#, + mapping.path().display() + ); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40002".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let greeting = read_line(&mut client).await; + assert!(greeting.contains("LOGINDISABLED"), "greeting: {greeting}"); + assert!(greeting.contains("STARTTLS"), "greeting: {greeting}"); + + send(&mut client, b"a1 LOGIN user@example.com secret\r\n").await; + let resp = read_until(&mut client, b"a1 NO").await; + assert!( + find(&resp, b"PRIVACYREQUIRED").is_some(), + "expected PRIVACYREQUIRED, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} + +#[tokio::test] +async fn imap_malformed_credentials_rejected_without_closing() { + let mapping = mapping_file(""); + let toml = config(1, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40050".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + let ir = BASE64.encode(b"nonulhere"); + send( + &mut client, + format!("a1 AUTHENTICATE PLAIN {ir}\r\na2 CAPABILITY\r\n").as_bytes(), + ) + .await; + + let resp = read_until(&mut client, b"a2 OK").await; + let s = String::from_utf8_lossy(&resp); + assert!( + s.contains("a1 NO"), + "malformed credentials must get a tagged NO, got {s}" + ); + assert!( + s.contains("a2 OK"), + "connection must stay usable after a rejected AUTH, got {s}" + ); + handler.abort(); +} + +#[tokio::test] +async fn imap_starttls_discards_buffered_plaintext() { + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 1 +tls = "plain" + +[listener.imap] +protocol = "imap" +bind = ["127.0.0.1:0"] +tls = "starttls" +"#, + mapping.path().display() + ); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["imap"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::imap::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40070".parse().unwrap(), + "127.0.0.1:143".parse().unwrap(), + ) + .await; + }); + + let greeting = read_line(&mut client).await; + assert!( + greeting.contains("STARTTLS"), + "starttls listener must advertise STARTTLS, got {greeting}" + ); + + send(&mut client, b"a STARTTLS\r\nz NOOP\r\n").await; + let _ = read_until(&mut client, b"a OK").await; + + let mut tls = tls_connect_client(client).await; + tls.write_all(b"b CAPABILITY\r\n").await.unwrap(); + tls.flush().await.unwrap(); + + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + if find(&buf, b"b OK").is_some() { + break; + } + let n = tokio::time::timeout(std::time::Duration::from_secs(5), tls.read(&mut scratch)) + .await + .expect("read timed out") + .unwrap(); + if n == 0 { + break; + } + buf.extend_from_slice(&scratch[..n]); + } + + assert!( + find(&buf, b"b OK").is_some(), + "CAPABILITY over TLS should succeed, got {:?}", + String::from_utf8_lossy(&buf) + ); + assert!( + find(&buf, b"z OK").is_none() && find(&buf, b"z BAD").is_none(), + "injected pre-TLS command must never be processed, got {:?}", + String::from_utf8_lossy(&buf) + ); + handler.abort(); +} diff --git a/src/tests/managesieve.rs b/src/tests/managesieve.rs new file mode 100644 index 0000000..ab5c322 --- /dev/null +++ b/src/tests/managesieve.rs @@ -0,0 +1,308 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::harness::*; + +fn config(port: u16, mapping_path: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[capabilities.managesieve] +allow_plain_auth_without_tls = true + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.managesieve] +port = {port} +tls = "plain" + +[listener.managesieve] +protocol = "managesieve" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +async fn backend_expect_auth(mut sock: tokio::net::TcpStream, want_user: &'static str) { + sock.write_all(b"\"IMPLEMENTATION\" \"backend\"\r\n") + .await + .unwrap(); + sock.write_all(b"\"SASL\" \"PLAIN\"\r\n").await.unwrap(); + sock.write_all(b"OK \"ready\"\r\n").await.unwrap(); + + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + buf.extend_from_slice(&scratch[..n]); + if find(&buf, b"\r\n").is_some() { + break; + } + } + let line_end = find(&buf, b"\r\n").unwrap(); + let line = String::from_utf8_lossy(&buf[..line_end]).into_owned(); + assert!(line.starts_with("AUTHENTICATE \"PLAIN\" \""), "got: {line}"); + let b64 = line + .rsplit('"') + .nth(1) + .expect("base64 token between quotes"); + let raw = BASE64.decode(b64).unwrap(); + assert!( + find(&raw, want_user.as_bytes()).is_some(), + "decoded IR {:?} should contain {want_user}", + String::from_utf8_lossy(&raw) + ); + sock.write_all(b"OK \"authenticated\"\r\n").await.unwrap(); +} + +#[tokio::test] +async fn managesieve_authenticate_plain_with_ir() { + let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["managesieve"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::managesieve::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40010".parse().unwrap(), + "127.0.0.1:4190".parse().unwrap(), + ) + .await; + }); + + let greeting = read_until(&mut client, b"OK ").await; + let greeting_str = String::from_utf8_lossy(&greeting); + assert!( + greeting_str.contains("\"SASL\""), + "greeting should advertise SASL, got {greeting_str}" + ); + assert!( + greeting_str.contains("\"SIEVE\""), + "greeting should advertise SIEVE, got {greeting_str}" + ); + + let ir = BASE64.encode(b"\0user@example.com\0secret"); + send( + &mut client, + format!("AUTHENTICATE \"PLAIN\" \"{ir}\"\r\n").as_bytes(), + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"OK \"authenticated\"").is_some(), + "expected OK authenticated, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn managesieve_noop_echoes_tag() { + let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["managesieve"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::managesieve::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40013".parse().unwrap(), + "127.0.0.1:4190".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_until(&mut client, b"OK ").await; + send(&mut client, b"NOOP \"STARTTLS-RESYNC-CAPA\"\r\n").await; + let resp = read_until(&mut client, b"Done").await; + let resp = String::from_utf8_lossy(&resp); + assert!( + resp.contains("(TAG {20}\r\nSTARTTLS-RESYNC-CAPA)"), + "NOOP with a tag must echo it (RFC 5804 2.13), got {resp:?}" + ); + + send(&mut client, b"LOGOUT\r\n").await; + backend.abort(); + handler.abort(); +} + +#[tokio::test] +async fn managesieve_authenticate_plain_nonsync_literal() { + let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["managesieve"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::managesieve::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40012".parse().unwrap(), + "127.0.0.1:4190".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_until(&mut client, b"OK ").await; + + let ir = BASE64.encode(b"\0user@example.com\0secret"); + send( + &mut client, + format!("AUTHENTICATE \"PLAIN\" {{{}+}}\r\n", ir.len()).as_bytes(), + ) + .await; + send(&mut client, format!("{ir}\r\n").as_bytes()).await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"OK \"authenticated\"").is_some(), + "non-sync literal auth should succeed, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn managesieve_authenticate_continuation_when_ir_omitted() { + let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "dan@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["managesieve"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::managesieve::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40011".parse().unwrap(), + "127.0.0.1:4190".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_until(&mut client, b"OK ").await; + send(&mut client, b"AUTHENTICATE \"PLAIN\"\r\n").await; + let cont = read_until(&mut client, b"\r\n").await; + assert!( + find(&cont, b"{0}").is_some(), + "expected {{0}} continuation, got {:?}", + String::from_utf8_lossy(&cont) + ); + + let ir = BASE64.encode(b"\0dan@example.com\0secret"); + send(&mut client, format!("{ir}\r\n").as_bytes()).await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"OK \"authenticated\"").is_some(), + "continuation auth should succeed, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn managesieve_backend_unavailable_reports_no() { + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[capabilities.managesieve] +allow_plain_auth_without_tls = true + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.managesieve] +port = 1 +tls = "plain" + +[listener.managesieve] +protocol = "managesieve" +bind = ["127.0.0.1:0"] +tls = "plain" +"#, + mapping.path().display() + ); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["managesieve"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::managesieve::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40012".parse().unwrap(), + "127.0.0.1:4190".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_until(&mut client, b"OK ").await; + let ir = BASE64.encode(b"\0user@example.com\0secret"); + send( + &mut client, + format!("AUTHENTICATE \"PLAIN\" \"{ir}\"\r\n").as_bytes(), + ) + .await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"NO \"backend unavailable\"").is_some(), + "expected backend unavailable NO, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs new file mode 100644 index 0000000..0b1b114 --- /dev/null +++ b/src/tests/mod.rs @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +mod admin; +mod config_validation; +mod containers; +mod forwarding; +mod harness; +mod http_proxy; +mod imap; +mod managesieve; +mod pop3; +mod proxy_protocol; +mod redis_store; +mod routing; +mod smtp; +mod sql_store; diff --git a/src/tests/pop3.rs b/src/tests/pop3.rs new file mode 100644 index 0000000..ae6c06b --- /dev/null +++ b/src/tests/pop3.rs @@ -0,0 +1,398 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::harness::*; + +fn config(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" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.pop3] +port = {port} +tls = "plain" + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +async fn backend_expect_auth(mut sock: tokio::net::TcpStream, want_user: &'static str) { + sock.write_all(b"+OK backend ready\r\n").await.unwrap(); + + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + buf.extend_from_slice(&scratch[..n]); + if find(&buf, b"\r\n").is_some() { + break; + } + } + let line_end = find(&buf, b"\r\n").unwrap(); + let line = String::from_utf8_lossy(&buf[..line_end]).into_owned(); + assert!(line.starts_with("AUTH PLAIN "), "got: {line}"); + let b64 = line.split_whitespace().nth(2).unwrap(); + let raw = BASE64.decode(b64).unwrap(); + assert!( + find(&raw, want_user.as_bytes()).is_some(), + "decoded IR {:?} should contain {want_user}", + String::from_utf8_lossy(&raw) + ); + sock.write_all(b"+OK logged in\r\n").await.unwrap(); + + let mut rest = buf[line_end + 2..].to_vec(); + loop { + if find(&rest, b"STAT").is_some() { + break; + } + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + rest.extend_from_slice(&scratch[..n]); + } + sock.write_all(b"+OK 0 0\r\n").await.unwrap(); +} + +#[tokio::test] +async fn pop3_user_pass_routes_replays_and_bridges() { + let (port, backend) = dummy_backend(|sock| backend_expect_auth(sock, "user@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["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), + "127.0.0.1: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\nSTAT\r\n", + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"+OK logged in").is_some(), + "expected +OK logged in, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"+OK 0 0").is_some(), + "expected bridged STAT reply, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn pop3_auth_plain_with_ir() { + let (port, backend) = + dummy_backend(|sock| backend_expect_auth(sock, "alice@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["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), + "127.0.0.1:40001".parse().unwrap(), + "127.0.0.1:110".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + let ir = BASE64.encode(b"\0alice@example.com\0secret"); + send( + &mut client, + format!("AUTH PLAIN {ir}\r\nSTAT\r\n").as_bytes(), + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"+OK logged in").is_some(), + "expected +OK logged in, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; + handler.abort(); +} + +fn count(haystack: &[u8], needle: &[u8]) -> usize { + if needle.is_empty() { + return 0; + } + haystack + .windows(needle.len()) + .filter(|w| *w == needle) + .count() +} + +async fn spawn_pop3(ctx: &std::sync::Arc) -> tokio::net::TcpStream { + let listener = ctx.config.listener["pop3"].clone(); + let (client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + tokio::spawn(async move { + let _ = crate::proto::pop3::handle( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40010".parse().unwrap(), + "127.0.0.1:110".parse().unwrap(), + ) + .await; + }); + client +} + +#[tokio::test] +async fn pop3_bad_inline_ir_does_not_loop() { + let mapping = mapping_file(""); + let toml = config(1, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let mut client = spawn_pop3(&ctx).await; + + let _greeting = read_line(&mut client).await; + let ir = BASE64.encode(b"nonulhere"); + send( + &mut client, + format!("AUTH PLAIN {ir}\r\nQUIT\r\n").as_bytes(), + ) + .await; + + let resp = read_to_eof(&mut client).await; + assert_eq!( + count(&resp, b"-ERR"), + 1, + "one bad AUTH must produce exactly one -ERR, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"+OK Bye").is_some(), + "the pipelined QUIT after a bad AUTH must still be processed, got {:?}", + String::from_utf8_lossy(&resp) + ); +} + +#[tokio::test] +async fn pop3_bare_pass_is_rejected() { + let mapping = mapping_file(""); + let toml = config(1, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let mut client = spawn_pop3(&ctx).await; + + let _greeting = read_line(&mut client).await; + send(&mut client, b"PASS secret\r\n").await; + let resp = read_until(&mut client, b"\r\n").await; + assert!( + find(&resp, b"-ERR").is_some(), + "bare PASS must be rejected, got {:?}", + String::from_utf8_lossy(&resp) + ); +} + +#[tokio::test] +async fn pop3_auth_continuation_when_ir_omitted() { + let (port, backend) = + dummy_backend(|sock| backend_expect_auth(sock, "carol@example.com")).await; + let mapping = mapping_file(""); + let toml = config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let mut client = spawn_pop3(&ctx).await; + + let _greeting = read_line(&mut client).await; + send(&mut client, b"AUTH PLAIN\r\n").await; + let cont = read_until(&mut client, b"\r\n").await; + assert!( + cont.starts_with(b"+ "), + "expected continuation prompt, got {:?}", + String::from_utf8_lossy(&cont) + ); + + let ir = BASE64.encode(b"\0carol@example.com\0secret"); + send(&mut client, format!("{ir}\r\nSTAT\r\n").as_bytes()).await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"+OK logged in").is_some(), + "continuation auth should succeed, got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; +} + +#[tokio::test] +async fn pop3_aborts_when_backend_does_not_advertise_stls() { + async fn backend_no_stls(mut sock: tokio::net::TcpStream) { + sock.write_all(b"+OK backend ready\r\n").await.unwrap(); + let mut scratch = [0u8; 1024]; + let _ = sock.read(&mut scratch).await; + sock.write_all(b"+OK capa\r\nTOP\r\nUIDL\r\n.\r\n") + .await + .unwrap(); + let _ = sock.read(&mut scratch).await; + } + + let (port, backend) = dummy_backend(backend_no_stls).await; + let mapping = mapping_file(""); + 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" +tls_server_name = "backend.example.com" +proxy_protocol = false +[destination.legacy.protocol.pop3] +port = {port} +tls = "starttls" + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:0"] +tls = "plain" +"#, + mapping.path().display() + ); + let ctx = make_ctx(&toml).await; + let mut client = spawn_pop3(&ctx).await; + + let _greeting = read_line(&mut client).await; + send(&mut client, b"USER user@example.com\r\nPASS secret\r\n").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"-ERR backend unavailable").is_some(), + "credentials must not egress to a backend that won't do STLS; got {:?}", + String::from_utf8_lossy(&resp) + ); + let _ = backend.await; +} + +#[tokio::test] +async fn pop3_user_rejected_without_tls_when_plain_disabled() { + let mapping = mapping_file(""); + let toml = format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{}" + +[capabilities.pop3] +allow_plain_auth_without_tls = false + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +tls_server_name = "backend.example.com" +[destination.legacy.protocol.pop3] +port = 1 +tls = "implicit" + +[listener.pop3] +protocol = "pop3" +bind = ["127.0.0.1:0"] +tls = "plain" +"#, + mapping.path().display() + ); + let ctx = make_ctx(&toml).await; + let 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), + "127.0.0.1:40002".parse().unwrap(), + "127.0.0.1:110".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + + send(&mut client, b"CAPA\r\n").await; + let capa = read_until(&mut client, b".\r\n").await; + let capa_str = String::from_utf8_lossy(&capa); + assert!( + !capa_str.contains("USER\r\n"), + "CAPA should omit USER, got {capa_str}" + ); + assert!( + !capa_str.to_uppercase().contains("PLAIN"), + "CAPA should omit PLAIN, got {capa_str}" + ); + + send(&mut client, b"USER user@example.com\r\n").await; + let resp = read_until(&mut client, b"-ERR").await; + assert!( + find(&resp, b"-ERR").is_some(), + "expected -ERR, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} diff --git a/src/tests/proxy_protocol.rs b/src/tests/proxy_protocol.rs new file mode 100644 index 0000000..d1333f7 --- /dev/null +++ b/src/tests/proxy_protocol.rs @@ -0,0 +1,108 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::config::ProxyProtocolMode; +use crate::net::cidr::Cidr; +use crate::net::proxy_protocol::resolve_inbound; + +use super::harness::*; + +async fn read_payload(stream: &mut crate::net::BoxedStream) -> Vec { + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + match tokio::time::timeout(std::time::Duration::from_secs(3), stream.read(&mut scratch)) + .await + { + Ok(Ok(0)) => return buf, + Ok(Ok(n)) => { + buf.extend_from_slice(&scratch[..n]); + if !buf.is_empty() { + return buf; + } + } + Ok(Err(_)) | Err(_) => return buf, + } + } +} + +#[tokio::test] +async fn v1_header_from_trusted_peer_rewrites_peer_and_preserves_payload() { + let (mut client, server) = tcp_pair().await; + let tcp_peer = client.local_addr().unwrap(); + let tcp_local = server.local_addr().unwrap(); + + client + .write_all(b"PROXY TCP4 1.2.3.4 5.6.7.8 1111 2222\r\nPAYLOAD") + .await + .unwrap(); + client.flush().await.unwrap(); + + let any: Cidr = "0.0.0.0/0".parse().unwrap(); + let mut res = resolve_inbound( + server, + tcp_peer, + tcp_local, + ProxyProtocolMode::Optional, + &[any], + ) + .await + .unwrap(); + + assert_eq!(res.peer, "1.2.3.4:1111".parse().unwrap()); + + let payload = read_payload(&mut res.stream).await; + assert_eq!(&payload, b"PAYLOAD"); +} + +#[tokio::test] +async fn optional_untrusted_peer_leaves_peer_unchanged() { + let (mut client, server) = tcp_pair().await; + let tcp_peer = client.local_addr().unwrap(); + let tcp_local = server.local_addr().unwrap(); + + client.write_all(b"PAYLOAD").await.unwrap(); + client.flush().await.unwrap(); + + let mut res = resolve_inbound( + server, + tcp_peer, + tcp_local, + ProxyProtocolMode::Optional, + &[], + ) + .await + .unwrap(); + + assert_eq!(res.peer, tcp_peer); + + let payload = read_payload(&mut res.stream).await; + assert_eq!(&payload, b"PAYLOAD"); +} + +#[tokio::test] +async fn required_trusted_peer_without_header_errors() { + let (mut client, server) = tcp_pair().await; + let tcp_peer = client.local_addr().unwrap(); + let tcp_local = server.local_addr().unwrap(); + + client.write_all(b"PAYLOAD").await.unwrap(); + client.flush().await.unwrap(); + + let any: Cidr = "0.0.0.0/0".parse().unwrap(); + let res = resolve_inbound( + server, + tcp_peer, + tcp_local, + ProxyProtocolMode::Required, + &[any], + ) + .await; + + assert!(res.is_err()); +} diff --git a/src/tests/redis_store.rs b/src/tests/redis_store.rs new file mode 100644 index 0000000..2ba43d5 --- /dev/null +++ b/src/tests/redis_store.rs @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::redis::{REDIS_PORT, Redis}; + +use crate::config::RedisMappingConfig; +use crate::route::store::redis::RedisStore; + +#[tokio::test] +async fn redis_store_resolves_and_misses() { + let node = Redis::default().start().await.unwrap(); + let port = node.get_host_port_ipv4(REDIS_PORT).await.unwrap(); + let url = format!("redis://127.0.0.1:{port}"); + + let client = redis::Client::open(url.as_str()).unwrap(); + let mut conn = loop { + match client.get_multiplexed_async_connection().await { + Ok(c) => break c, + Err(_) => tokio::time::sleep(std::time::Duration::from_millis(250)).await, + } + }; + let _: () = redis::cmd("SET") + .arg("route:user@example.com") + .arg("primary") + .query_async(&mut conn) + .await + .unwrap(); + let _: () = redis::cmd("SET") + .arg("route:empty@example.com") + .arg("") + .query_async(&mut conn) + .await + .unwrap(); + + let cfg = RedisMappingConfig { + url, + key_prefix: "route:".to_string(), + pool_size: 3, + }; + let store = RedisStore::connect(&cfg).await.unwrap(); + + assert_eq!( + store.lookup("user@example.com").await.unwrap(), + Some("primary".to_string()) + ); + assert_eq!(store.lookup("nobody@example.com").await.unwrap(), None); + assert_eq!(store.lookup("empty@example.com").await.unwrap(), None); + + store + .upsert("alice@example.com", "secondary") + .await + .unwrap(); + assert_eq!( + store.lookup("alice@example.com").await.unwrap(), + Some("secondary".to_string()) + ); + store.upsert("alice@example.com", "tertiary").await.unwrap(); + assert_eq!( + store.lookup("alice@example.com").await.unwrap(), + Some("tertiary".to_string()) + ); + assert!(store.remove("alice@example.com").await.unwrap()); + assert!(!store.remove("alice@example.com").await.unwrap()); + assert_eq!(store.lookup("alice@example.com").await.unwrap(), None); +} diff --git a/src/tests/routing.rs b/src/tests/routing.rs new file mode 100644 index 0000000..153e1bf --- /dev/null +++ b/src/tests/routing.rs @@ -0,0 +1,214 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::sync::atomic::Ordering; + +use crate::config::Config; +use crate::route::Router; + +use super::harness::*; + +fn config(mapping_path: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[destination.primary] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.primary.protocol.imap] +port = 143 +tls = "plain" +"# + ) +} + +async fn build_router(toml: &str) -> Router { + let config = Config::parse_and_validate(toml).expect("config should validate"); + let valid: Vec = config.destination.keys().cloned().collect(); + Router::build(&config, valid).await.unwrap() +} + +#[tokio::test] +async fn router_resolves_normalizes_and_caches() { + let mapping = mapping_file("user@example.com\tprimary\n"); + let toml = config(&mapping.path().display().to_string()); + let router = build_router(&toml).await; + + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + assert_eq!(&*router.resolve(Some("USER@EXAMPLE.COM")).await, "primary"); + assert_eq!(&*router.resolve(Some("nobody@x")).await, "legacy"); + assert_eq!(&*router.resolve(None).await, "legacy"); + + router.invalidate("user@example.com").await; + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); +} + +#[tokio::test] +async fn router_cache_hits_and_misses() { + let mapping = mapping_file("user@example.com\tprimary\n"); + let toml = config(&mapping.path().display().to_string()); + let router = build_router(&toml).await; + + let misses_before = router.stats.misses.load(Ordering::Relaxed); + let hits_before = router.stats.hits.load(Ordering::Relaxed); + + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + let misses_after_first = router.stats.misses.load(Ordering::Relaxed); + assert_eq!(misses_after_first, misses_before + 1); + + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + let hits_after = router.stats.hits.load(Ordering::Relaxed); + assert_eq!(hits_after, hits_before + 1); + assert_eq!( + router.stats.misses.load(Ordering::Relaxed), + misses_after_first + ); + + assert_eq!(&*router.resolve(Some("nobody@x")).await, "legacy"); + assert_eq!(&*router.resolve(Some("nobody@x")).await, "legacy"); + assert_eq!( + router.stats.hits.load(Ordering::Relaxed), + hits_after + 1, + "second negative lookup should be a cache hit" + ); +} + +fn config_with_ttls(mapping_path: &str, positive: &str, negative: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +positive_ttl = "{positive}" +negative_ttl = "{negative}" +[mapping.file] +path = "{mapping_path}" + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = 143 +tls = "plain" + +[destination.primary] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.primary.protocol.imap] +port = 143 +tls = "plain" +"# + ) +} + +#[tokio::test] +async fn positive_entry_expires_and_is_looked_up_again() { + let mapping = mapping_file("user@example.com\tprimary\n"); + let toml = config_with_ttls(&mapping.path().display().to_string(), "60ms", "60ms"); + let router = build_router(&toml).await; + + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + let misses_after_first = router.stats.misses.load(Ordering::Relaxed); + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + assert_eq!( + router.stats.misses.load(Ordering::Relaxed), + misses_after_first, + "second lookup within TTL is a hit" + ); + + tokio::time::sleep(std::time::Duration::from_millis(120)).await; + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + assert!( + router.stats.misses.load(Ordering::Relaxed) > misses_after_first, + "lookup after TTL expiry must miss the cache and re-hit the store" + ); +} + +#[tokio::test] +async fn invalidate_all_clears_every_entry() { + let mapping = mapping_file("user@example.com\tprimary\n"); + let toml = config(&mapping.path().display().to_string()); + let router = build_router(&toml).await; + + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + let misses = router.stats.misses.load(Ordering::Relaxed); + router.invalidate_all(); + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + assert!( + router.stats.misses.load(Ordering::Relaxed) > misses, + "after invalidate_all the entry must be re-fetched" + ); +} + +#[tokio::test] +async fn reload_picks_up_edited_mapping() { + let mapping = mapping_file("user@example.com\tprimary\n"); + let toml = config(&mapping.path().display().to_string()); + let router = build_router(&toml).await; + + assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary"); + std::fs::write(mapping.path(), "user@example.com\tlegacy\n").unwrap(); + router.reload().await.unwrap(); + router.invalidate("user@example.com").await; + assert_eq!(&*router.resolve(Some("user@example.com")).await, "legacy"); +} + +#[tokio::test] +async fn cache_respects_max_entries() { + let mut lines = String::new(); + for i in 0..40 { + lines.push_str(&format!("user{i}@example.com\tprimary\n")); + } + let mapping = mapping_file(&lines); + let toml = config(&mapping.path().display().to_string()).replace( + r#"normalize = "lowercase""#, + "normalize = \"lowercase\"\ncache_max_entries = 10", + ); + let router = build_router(&toml).await; + + for i in 0..40 { + router.resolve(Some(&format!("user{i}@example.com"))).await; + } + + let count = router.cache_entry_count().await; + assert!( + count <= 10, + "cache must not exceed cache_max_entries, got {count}" + ); +} + +#[tokio::test] +async fn normalize_none_preserves_case() { + let mapping = mapping_file("User@Example.com\tprimary\n"); + let toml = config(&mapping.path().display().to_string()) + .replace(r#"normalize = "lowercase""#, r#"normalize = "none""#); + let router = build_router(&toml).await; + + assert_eq!(&*router.resolve(Some("User@Example.com")).await, "primary"); + assert_eq!(&*router.resolve(Some("user@example.com")).await, "legacy"); +} diff --git a/src/tests/smtp.rs b/src/tests/smtp.rs new file mode 100644 index 0000000..8bd2112 --- /dev/null +++ b/src/tests/smtp.rs @@ -0,0 +1,317 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use super::harness::*; + +fn submission_config(port: u16, mapping_path: &str, plain: bool) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[capabilities.submission] +allow_plain_auth_without_tls = {plain} + +[routing] +default_destination = "legacy" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.submission] +port = {port} +tls = "plain" + +[listener.submission] +protocol = "submission" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +async fn submission_backend(mut sock: tokio::net::TcpStream, want_user: &'static str) { + sock.write_all(b"220 mock ESMTP\r\n").await.unwrap(); + + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + + async fn read_line_into( + sock: &mut tokio::net::TcpStream, + buf: &mut Vec, + scratch: &mut [u8; 1024], + ) -> Option { + loop { + if let Some(pos) = find(buf, b"\r\n") { + let line = String::from_utf8_lossy(&buf[..pos]).into_owned(); + buf.drain(..pos + 2); + return Some(line); + } + let n = sock.read(scratch).await.ok()?; + if n == 0 { + return None; + } + buf.extend_from_slice(&scratch[..n]); + } + } + + let ehlo = read_line_into(&mut sock, &mut buf, &mut scratch) + .await + .unwrap(); + assert!( + ehlo.to_uppercase().starts_with("EHLO"), + "expected EHLO from proxy, got {ehlo}" + ); + sock.write_all(b"250-mock\r\n250 AUTH PLAIN\r\n") + .await + .unwrap(); + + let auth = read_line_into(&mut sock, &mut buf, &mut scratch) + .await + .unwrap(); + assert!( + auth.starts_with("AUTH PLAIN "), + "expected AUTH PLAIN, got {auth}" + ); + let b64 = auth.split_whitespace().nth(2).unwrap(); + let raw = BASE64.decode(b64).unwrap(); + assert!( + find(&raw, want_user.as_bytes()).is_some(), + "decoded IR {:?} should contain {want_user}", + String::from_utf8_lossy(&raw) + ); + sock.write_all(b"235 2.7.0 Authentication successful\r\n") + .await + .unwrap(); +} + +#[tokio::test] +async fn submission_ehlo_auth_routes_and_bridges() { + let (port, backend) = dummy_backend(|sock| submission_backend(sock, "user@example.com")).await; + let mapping = mapping_file(""); + let toml = submission_config(port, &mapping.path().display().to_string(), true); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["submission"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::smtp::handle_submission( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40020".parse().unwrap(), + "127.0.0.1:587".parse().unwrap(), + ) + .await; + }); + + let greeting = read_line(&mut client).await; + assert!(greeting.starts_with("220"), "greeting: {greeting}"); + + send(&mut client, b"EHLO test\r\n").await; + let ehlo_resp = read_until(&mut client, b"250 ").await; + let ehlo_str = String::from_utf8_lossy(&ehlo_resp); + assert!( + ehlo_str.contains("250-"), + "expected multiline EHLO, got {ehlo_str}" + ); + assert!( + ehlo_str.to_uppercase().contains("AUTH"), + "expected AUTH advertised, got {ehlo_str}" + ); + + let ir = BASE64.encode(b"\0user@example.com\0secret"); + send(&mut client, format!("AUTH PLAIN {ir}\r\n").as_bytes()).await; + + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"235").is_some(), + "expected 235, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} + +#[tokio::test] +async fn submission_auth_rejected_without_tls_when_plain_disabled() { + let mapping = mapping_file(""); + let toml = submission_config(1, &mapping.path().display().to_string(), false); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["submission"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::smtp::handle_submission( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40021".parse().unwrap(), + "127.0.0.1:587".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + send(&mut client, b"EHLO test\r\n").await; + let _ehlo = read_until(&mut client, b"250 ").await; + + let ir = BASE64.encode(b"\0user@example.com\0secret"); + send(&mut client, format!("AUTH PLAIN {ir}\r\n").as_bytes()).await; + let resp = read_until(&mut client, b"\r\n").await; + let resp_str = String::from_utf8_lossy(&resp); + assert!( + resp_str.starts_with("535") || resp_str.starts_with("538"), + "expected 535/538 refusal, got {resp_str}" + ); + handler.abort(); +} + +#[tokio::test] +async fn submission_bad_inline_ir_does_not_loop() { + let mapping = mapping_file(""); + let toml = submission_config(1, &mapping.path().display().to_string(), true); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["submission"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::smtp::handle_submission( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40022".parse().unwrap(), + "127.0.0.1:587".parse().unwrap(), + ) + .await; + }); + + let _greeting = read_line(&mut client).await; + send(&mut client, b"EHLO test\r\n").await; + let _ehlo = read_until(&mut client, b"250 ").await; + + let ir = BASE64.encode(b"nonulhere"); + send( + &mut client, + format!("AUTH PLAIN {ir}\r\nNOOP\r\n").as_bytes(), + ) + .await; + + let resp = read_to_eof(&mut client).await; + let n535 = resp.windows(3).filter(|w| *w == b"535").count(); + assert_eq!( + n535, + 1, + "one bad AUTH must produce exactly one 535, got {:?}", + String::from_utf8_lossy(&resp) + ); + assert!( + find(&resp, b"250 2.0.0 OK").is_some(), + "the pipelined NOOP after a bad AUTH must still be processed, got {:?}", + String::from_utf8_lossy(&resp) + ); + handler.abort(); +} + +fn passthrough_config(port: u16, mapping_path: &str) -> String { + format!( + r#" +[mapping] +source = "file" +normalize = "lowercase" +[mapping.file] +path = "{mapping_path}" + +[routing] +default_destination = "legacy" +smtp_passthrough_destination = "relay" + +[destination.legacy] +host = "127.0.0.1" +proxy_protocol = false +allow_plaintext_auth = true +[destination.legacy.protocol.imap] +port = {port} +tls = "plain" + +[destination.relay] +host = "127.0.0.1" +proxy_protocol = false +[destination.relay.protocol.smtp] +port = {port} +tls = "plain" + +[listener.smtp] +protocol = "smtp" +bind = ["127.0.0.1:0"] +tls = "plain" +"# + ) +} + +async fn passthrough_backend(mut sock: tokio::net::TcpStream) { + sock.write_all(b"220 relay\r\n").await.unwrap(); + let mut buf = Vec::new(); + let mut scratch = [0u8; 1024]; + loop { + let n = sock.read(&mut scratch).await.unwrap(); + if n == 0 { + return; + } + buf.extend_from_slice(&scratch[..n]); + if find(&buf, b"\r\n").is_some() { + break; + } + } + sock.write_all(b"250 x\r\n").await.unwrap(); +} + +#[tokio::test] +async fn smtp_passthrough_bridges_raw() { + let (port, backend) = dummy_backend(passthrough_backend).await; + let mapping = mapping_file(""); + let toml = passthrough_config(port, &mapping.path().display().to_string()); + let ctx = make_ctx(&toml).await; + let listener = ctx.config.listener["smtp"].clone(); + + let (mut client, server) = tcp_pair().await; + let ctx2 = ctx.clone(); + let handler = tokio::spawn(async move { + let _ = crate::proto::smtp::handle_passthrough( + &ctx2, + &listener, + Box::new(server), + "127.0.0.1:40022".parse().unwrap(), + "127.0.0.1:25".parse().unwrap(), + ) + .await; + }); + + let greeting = read_line(&mut client).await; + assert!(greeting.starts_with("220 relay"), "greeting: {greeting}"); + + send(&mut client, b"EHLO x\r\n").await; + let resp = read_to_eof(&mut client).await; + assert!( + find(&resp, b"250 x").is_some(), + "expected 250 x, got {:?}", + String::from_utf8_lossy(&resp) + ); + + let _ = backend.await; + handler.abort(); +} diff --git a/src/tests/sql_store.rs b/src/tests/sql_store.rs new file mode 100644 index 0000000..c922385 --- /dev/null +++ b/src/tests/sql_store.rs @@ -0,0 +1,92 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::postgres::Postgres; + +use crate::config::SqlMappingConfig; +use crate::route::store::sql::SqlStore; + +#[tokio::test] +async fn sql_store_resolves_and_misses() { + let node = Postgres::default().start().await.unwrap(); + let port = node.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + sqlx::any::install_default_drivers(); + let pool = sqlx::AnyPool::connect(&url).await.unwrap(); + sqlx::query("CREATE TABLE routes (identifier TEXT PRIMARY KEY, destination TEXT NOT NULL)") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO routes (identifier, destination) VALUES ($1, $2)") + .bind("user@example.com") + .bind("primary") + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let cfg = SqlMappingConfig { + url: url.clone(), + query: "SELECT destination FROM routes WHERE identifier = $1".to_string(), + pool_size: 3, + upsert_query: None, + delete_query: None, + }; + let store = SqlStore::connect(&cfg).await.unwrap(); + + assert_eq!( + store.lookup("user@example.com").await.unwrap(), + Some("primary".to_string()) + ); + assert_eq!(store.lookup("nobody@example.com").await.unwrap(), None); + assert!(!store.writable()); +} + +#[tokio::test] +async fn sql_store_upsert_and_remove() { + let node = Postgres::default().start().await.unwrap(); + let port = node.get_host_port_ipv4(5432).await.unwrap(); + let url = format!("postgres://postgres:postgres@127.0.0.1:{port}/postgres"); + + sqlx::any::install_default_drivers(); + let pool = sqlx::AnyPool::connect(&url).await.unwrap(); + sqlx::query("CREATE TABLE routes (identifier TEXT PRIMARY KEY, destination TEXT NOT NULL)") + .execute(&pool) + .await + .unwrap(); + pool.close().await; + + let cfg = SqlMappingConfig { + url, + query: "SELECT destination FROM routes WHERE identifier = $1".to_string(), + pool_size: 3, + upsert_query: Some( + "INSERT INTO routes (identifier, destination) VALUES ($1, $2) ON CONFLICT (identifier) DO UPDATE SET destination = $2" + .to_string(), + ), + delete_query: Some("DELETE FROM routes WHERE identifier = $1".to_string()), + }; + let store = SqlStore::connect(&cfg).await.unwrap(); + assert!(store.writable()); + + store.upsert("user@example.com", "primary").await.unwrap(); + assert_eq!( + store.lookup("user@example.com").await.unwrap(), + Some("primary".to_string()) + ); + + store.upsert("user@example.com", "secondary").await.unwrap(); + assert_eq!( + store.lookup("user@example.com").await.unwrap(), + Some("secondary".to_string()) + ); + + assert!(store.remove("user@example.com").await.unwrap()); + assert!(!store.remove("user@example.com").await.unwrap()); + assert_eq!(store.lookup("user@example.com").await.unwrap(), None); +} diff --git a/src/token.rs b/src/token.rs new file mode 100644 index 0000000..6c9117e --- /dev/null +++ b/src/token.rs @@ -0,0 +1,168 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + +use crate::sasl::Credential; + +pub fn bearer_identifier(token: &str, jwt_username_claim: &str) -> Option { + if let Some(rest) = token.strip_prefix("sw1.") { + return match rest.split_once('.') { + Some((_body, footer)) => { + let decoded = URL_SAFE_NO_PAD.decode(footer).ok()?; + String::from_utf8(decoded).ok() + } + None => None, + }; + } + + let claims_seg = token.split('.').nth(1)?; + let decoded = URL_SAFE_NO_PAD.decode(claims_seg).ok()?; + let claims: serde_json::Value = serde_json::from_slice(&decoded).ok()?; + + for name in [ + jwt_username_claim, + "email", + "preferred_username", + "upn", + "unique_name", + "sub", + ] { + if let Some(v) = claims.get(name).and_then(|v| v.as_str()) + && v.contains('@') + { + return Some(v.to_string()); + } + } + None +} + +pub fn valid_routing_identifier(id: &str) -> bool { + !id.bytes() + .any(|b| b < 0x20 || b == 0x7f || b == b' ' || b == b'"') +} + +pub fn identifier_from_credential(cred: &Credential, jwt_username_claim: &str) -> Option { + match cred { + Credential::Plain { authcid, .. } => Some(authcid.clone()), + Credential::OAuth { + username: Some(u), .. + } => Some(u.clone()), + Credential::OAuth { + username: None, + token, + } => bearer_identifier(token, jwt_username_claim), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; + + #[test] + fn sw1_footer() { + let footer = URL_SAFE_NO_PAD.encode("user@example.com"); + let token = format!("sw1.bodyblob.{footer}"); + assert_eq!( + bearer_identifier(&token, "email"), + Some("user@example.com".to_string()) + ); + } + + #[test] + fn sw1_no_footer() { + assert_eq!(bearer_identifier("sw1.bodyonly", "email"), None); + } + + #[test] + fn jwt_email_claim() { + let claims = URL_SAFE_NO_PAD.encode(r#"{"email":"alice@example.com","sub":"x"}"#); + let token = format!("hdr.{claims}.sig"); + assert_eq!( + bearer_identifier(&token, "email"), + Some("alice@example.com".to_string()) + ); + } + + #[test] + fn jwt_configured_claim_first() { + let claims = URL_SAFE_NO_PAD.encode(r#"{"login_email":"bob@corp.com","sub":"nope"}"#); + let token = format!("hdr.{claims}.sig"); + assert_eq!( + bearer_identifier(&token, "login_email"), + Some("bob@corp.com".to_string()) + ); + } + + #[test] + fn jwt_requires_at_sign() { + let claims = URL_SAFE_NO_PAD.encode(r#"{"sub":"not-an-email"}"#); + let token = format!("hdr.{claims}.sig"); + assert_eq!(bearer_identifier(&token, "email"), None); + } + + #[test] + fn garbage_token_is_none() { + assert_eq!(bearer_identifier("not-a-jwt", "email"), None); + } + + #[test] + fn jwt_precedence_falls_through_to_later_claims() { + let claims = URL_SAFE_NO_PAD.encode(r#"{"upn":"carol@corp.com","sub":"x"}"#); + let token = format!("hdr.{claims}.sig"); + assert_eq!( + bearer_identifier(&token, "email"), + Some("carol@corp.com".to_string()) + ); + } + + #[test] + fn jwt_configured_claim_without_at_falls_through() { + let claims = URL_SAFE_NO_PAD.encode(r#"{"login":"noat","email":"dan@corp.com"}"#); + let token = format!("hdr.{claims}.sig"); + assert_eq!( + bearer_identifier(&token, "login"), + Some("dan@corp.com".to_string()) + ); + } + + #[test] + fn sw1_invalid_footer_is_none() { + assert_eq!(bearer_identifier("sw1.body.!!!notbase64!!!", "email"), None); + } + + #[test] + fn identifier_from_credential_all_arms() { + let plain = Credential::Plain { + authcid: "alice".into(), + passwd: "p".into(), + }; + assert_eq!( + identifier_from_credential(&plain, "email"), + Some("alice".to_string()) + ); + + let oauth_named = Credential::OAuth { + username: Some("u@x".into()), + token: "tok".into(), + }; + assert_eq!( + identifier_from_credential(&oauth_named, "email"), + Some("u@x".to_string()) + ); + + let footer = URL_SAFE_NO_PAD.encode("eve@example.com"); + let oauth_tok = Credential::OAuth { + username: None, + token: format!("sw1.body.{footer}"), + }; + assert_eq!( + identifier_from_credential(&oauth_tok, "email"), + Some("eve@example.com".to_string()) + ); + } +}