4 Commits
Author SHA1 Message Date
Maurus Decimus dc612a6d42 v1.0.5 2026-10-25 19:04:03 +01:00
Maurus Decimus b923651c89 Minor fixes 2026-10-24 19:36:18 +02:00
Maurus Decimus 24c53fc1e7 v1.0.5 2026-10-17 11:42:32 +02:00
Maurus Decimus 65e71079ec Replace base64 with encodify 2026-09-28 10:58:55 +02:00
40 changed files with 1435 additions and 440 deletions
+29 -10
View File
@@ -115,31 +115,40 @@ jobs:
matrix:
include:
- target: x86_64-unknown-linux-gnu
runner: ubuntu-latest
platform: linux/amd64
suffix: ""
- target: x86_64-unknown-linux-musl
runner: ubuntu-latest
platform: linux/amd64
suffix: "-alpine"
- target: aarch64-unknown-linux-gnu
runner: ubuntu-24.04-arm
platform: linux/arm64
suffix: ""
- target: aarch64-unknown-linux-musl
runner: ubuntu-24.04-arm
platform: linux/arm64
suffix: "-alpine"
- target: armv7-unknown-linux-gnueabihf
runner: ubuntu-latest
platform: linux/arm/v7
suffix: ""
- target: armv7-unknown-linux-musleabihf
runner: ubuntu-latest
platform: linux/arm/v7
suffix: "-alpine"
- target: arm-unknown-linux-gnueabihf
runner: ubuntu-latest
platform: linux/arm/v6
suffix: ""
- target: arm-unknown-linux-musleabihf
runner: ubuntu-latest
platform: linux/arm/v6
suffix: "-alpine"
name: Build / ${{matrix.target}}
runs-on: ubuntu-latest
runs-on: ${{matrix.runner}}
timeout-minutes: 180
steps:
- name: Checkout
uses: actions/checkout@v7
@@ -155,23 +164,31 @@ jobs:
- name: Add swap (heavy ARM targets)
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
run: |
mnt_avail=$(df --output=avail -k /mnt | tail -1)
if [ "$mnt_avail" -lt 18874368 ]; then
echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup"
swap_dir=""
for dir in /mnt /; do
if [ -d "$dir" ] && [ "$(df --output=avail -k "$dir" | tail -1)" -ge 18874368 ]; then
swap_dir="$dir"
break
fi
done
if [ -z "$swap_dir" ]; then
echo "Insufficient space for swap on /mnt or /, aborting swap setup"
df -h
exit 1
fi
sudo fallocate -l 16G /mnt/swapfile
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
sudo fallocate -l 16G "${swap_dir%/}/swapfile"
sudo chmod 600 "${swap_dir%/}/swapfile"
sudo mkswap "${swap_dir%/}/swapfile"
sudo swapon "${swap_dir%/}/swapfile"
sudo sysctl vm.swappiness=80
free -h
swapon --show
- name: Set up QEMU
if: startsWith(matrix.platform, 'linux/arm/')
uses: docker/setup-qemu-action@v4
with:
platforms: "arm64,arm"
platforms: "arm"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
@@ -190,6 +207,7 @@ jobs:
password: ${{github.token}}
- name: Log In to DockerHub
if: github.event_name == 'push' || inputs.Docker
uses: docker/login-action@v4
with:
username: ${{secrets.DOCKERHUB_USERNAME}}
@@ -207,7 +225,7 @@ jobs:
path: |
var-cache-apt
var-lib-apt
key: apt-cache-${{ hashFiles('Dockerfile.build') }}
key: apt-cache-${{ runner.arch }}-${{ hashFiles('Dockerfile.build') }}
- name: Cache Cargo
uses: actions/cache@v6.1.0
@@ -244,6 +262,7 @@ jobs:
type=ref,event=branch,prefix=branch-
type=edge,branch=main
type=semver,pattern=v{{major}}.{{minor}}
type=semver,pattern=v{{major}}
- name: Build Artifact
id: bake
+1
View File
@@ -3,3 +3,4 @@
*.md
!CHANGELOG.md
!README.md
__pycache__/
+17
View File
@@ -2,6 +2,23 @@
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
## [1.0.5] - 2026-10-25
### Added
### Changed
- Replaced `base64` with `encodify`.
- An HTTP route's `fallback` is now honoured: unmatched or unmapped requests on that route go to the fallback destination instead of `routing.default_destination`. A `fallback` naming an undeclared destination, or one that does not declare the `http` protocol, is rejected at startup.
- A login held by `POST /connections/delay` now resolves its destination when the hold ends instead of before it.
- `PUT /mappings`, `DELETE /mappings` and `POST /cache/invalidate?identifier=` end the login delay of that identifier; `POST /mappings/reload` and `POST /cache/invalidate` without an identifier end every login delay. Held logins proceed at once to the newly mapped destination.
- Calling `POST /connections/delay` again for an identifier also moves the hold deadline of logins already waiting (still capped at 120 seconds per login).
### Fixed
- The sample configuration written by `install.sh` and `resources/config.sample.toml` named an undeclared HTTP route fallback (`default`) and failed startup validation.
- `resources/config.stalwart-upgrade.toml` now documents the WebUI OAuth client identifiers (`https://<host>/admin/oauth-client.json`, `https://<host>/account/oauth-client.json`) and pins `/admin`, `/account`, `/api/auth/**` to the new deployment.
- The `POST /config/reload` response no longer claims that cache capacity and TTLs require a restart; a reload rebuilds the router and its cache.
- `POST /connections/delay` with a `seconds` value too large to represent now returns `400` instead of panicking.
## [1.0.4] - 2026-09-10
### Added
Generated
+204 -211
View File
File diff suppressed because it is too large Load Diff
+3 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "proxy"
version = "1.0.4"
version = "1.0.5"
edition = "2024"
license = "AGPL-3.0-only OR LicenseRef-SEL"
description = "Multi-protocol e-mail proxy"
@@ -22,10 +22,9 @@ tokio-rustls = { version = "0.26", default-features = false, features = ["aws_lc
rustls-pemfile = "2.2"
rustls-pki-types = "1.15"
aws-lc-rs = "1.17"
proxy-header = { version = "0.1.2", features = ["tokio"] }
proxy-header = { version = "0.1", features = ["tokio"] }
smtp-proto = "0.2.2"
mail-parser = "0.11"
base64 = "0.23"
encodify = "1.0"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
+25 -13
View File
@@ -24,19 +24,22 @@ RUN \
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 git jq xz-utils make libclang-19-dev
# Zig is pinned to 0.13.0. Zig 0.14+ changed how its default target ABI is
# resolved for `x86_64-linux-gnu`-style triples, and on this dependency tree
# (aws-lc-sys, libsqlite3-sys, rust-std 1.87+) that surfaces as ld.lld
# undefined references to glibc 2.33+ symbols. Zig 0.13 produces binaries with
# a max glibc ref of ~2.28 which stays compatible with Debian 11 / RHEL 8.
RUN \
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=0.17.0 && \
case "$(uname -m)" in \
x86_64) ZIG_SHA256=1cbe9df9f27e6b78d14ccbca43b6703a404ef79ef1c463de901d7f088d4e2026 ;; \
aarch64) ZIG_SHA256=9e8d11661d4ae3bd57702a3832781e23ad151dde5798e16a5ccd503f65234ff8 ;; \
*) exit 1 ;; \
esac && \
curl --retry 5 -fsSLo /tmp/zig.tar.xz "https://ziglang.org/download/${ZIG_VERSION}/zig-$(uname -m)-linux-${ZIG_VERSION}.tar.xz" && \
echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - && \
tar -J -x -C /usr/local -f /tmp/zig.tar.xz && \
rm /tmp/zig.tar.xz && \
ln -s "/usr/local/zig-$(uname -m)-linux-${ZIG_VERSION}/zig" /usr/local/bin/zig && \
zig version
# 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. Versions are pinned so that an
# Install cargo-chef & sccache. Versions are pinned so that an
# upstream release without prebuilt binaries for this platform cannot silently
# turn this into a from-source build, and the `compile` strategy is disabled
# because that fallback needs an OpenSSL toolchain this image does not carry:
@@ -44,11 +47,12 @@ RUN curl --retry 5 -fL --proto '=https' --tlsv1.2 -sSf https://raw.githubusercon
RUN \
for attempt in 1 2 3; do \
cargo binstall --no-confirm --disable-strategies compile \
cargo-chef@0.1.78 sccache@0.17.0 cargo-zigbuild@0.23.2 && break; \
cargo-chef@0.1.78 sccache@0.17.0 && break; \
echo "cargo binstall attempt ${attempt} failed, retrying"; \
sleep $((attempt * 15)); \
done && \
command -v cargo-chef && command -v sccache && command -v cargo-zigbuild
command -v cargo-chef && command -v sccache
RUN cargo install --locked --git https://github.com/rust-cross/cargo-zigbuild --rev d9f7fd32fbac13cc26d23847835f7f23a5ed3a25 cargo-zigbuild
# *****************
# Planner
@@ -67,6 +71,7 @@ WORKDIR /app
COPY --from=planner /app/recipe.json recipe.json
ARG TARGET
ARG BUILD_ENV
ARG GLIBC_VERSION=2.28
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install toolchain and specify some env variables
RUN \
@@ -74,6 +79,13 @@ RUN \
rustup target add ${TARGET} && \
mkdir -p artifact && \
touch /env-cargo && \
if [[ "${TARGET}" == *-gnu* ]]; then \
echo "export BUILD_TARGET=${TARGET}.${GLIBC_VERSION}" >> /env-cargo; \
mkdir -p target && \
ln -s "${TARGET}" "target/${TARGET}.${GLIBC_VERSION}"; \
else \
echo "export BUILD_TARGET=${TARGET}" >> /env-cargo; \
fi && \
if [ ! -z "${BUILD_ENV}" ]; then \
echo "export ${BUILD_ENV}" >> /env-cargo; \
echo "Setting up ${BUILD_ENV}"; \
@@ -85,7 +97,7 @@ RUN \
--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
cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${BUILD_TARGET} -p proxy
# Copy the source code
COPY . .
ENV RUSTC_WRAPPER="sccache" \
@@ -97,7 +109,7 @@ RUN \
--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 && \
cargo zigbuild --release --target ${BUILD_TARGET} -p proxy && \
mv /app/target/${TARGET}/release/proxy /app/artifact/proxy
# *****************
+10 -10
View File
@@ -266,8 +266,8 @@ tls = "implicit"
port = 995
tls = "implicit"
[destination.stalwart.protocol.submission]
port = 587
tls = "starttls"
port = 465
tls = "implicit"
[destination.stalwart.protocol.managesieve]
port = 4190
tls = "starttls"
@@ -277,37 +277,37 @@ tls = "implicit"
[listener.imap]
protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"]
bind = ["[::]:993"]
tls = "implicit"
[listener.imap-starttls]
protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"]
bind = ["[::]:143"]
tls = "starttls"
[listener.pop3]
protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"]
bind = ["[::]:995"]
tls = "implicit"
[listener.submission]
protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"]
bind = ["[::]:587"]
tls = "starttls"
[listener.submissions]
protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"]
bind = ["[::]:465"]
tls = "implicit"
[listener.managesieve]
protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"]
bind = ["[::]:4190"]
tls = "starttls"
[listener.https]
protocol = "http"
bind = ["0.0.0.0:443", "[::]:443"]
bind = ["[::]:443"]
tls = "implicit"
forwarded = "off"
@@ -318,7 +318,7 @@ destination = "legacy"
[[http.route]]
match = "/**"
extract = { from = "authorization" }
fallback = "default"
fallback = "legacy"
[admin]
bind = "127.0.0.1:9443"
+17 -10
View File
@@ -112,8 +112,8 @@ tls = "implicit"
port = 995
tls = "implicit"
[destination.stalwart.protocol.submission]
port = 587
tls = "starttls"
port = 465
tls = "implicit"
[destination.stalwart.protocol.managesieve]
port = 4190
tls = "starttls"
@@ -129,38 +129,45 @@ tls = "plain"
# here. Once accounts are on Stalwart, JMAP and the web interface are served by
# the new server; an HTTP listener routing to "stalwart" can be added at that
# point if a single hostname for web access is wanted.
#
# Each listener binds [::] only: on Linux and macOS that socket also accepts
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
# use "0.0.0.0:<port>" instead.
# ---------------------------------------------------------------------------
[listener.imaps]
protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"]
bind = ["[::]:993"]
tls = "implicit"
[listener.imap]
protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"]
bind = ["[::]:143"]
tls = "starttls"
[listener.pop3s]
protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"]
bind = ["[::]:995"]
tls = "implicit"
[listener.pop3]
protocol = "pop3"
bind = ["0.0.0.0:110", "[::]:110"]
bind = ["[::]:110"]
tls = "starttls"
[listener.submission]
protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"]
bind = ["[::]:587"]
tls = "starttls"
[listener.submissions]
protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"]
bind = ["[::]:465"]
tls = "implicit"
[listener.managesieve]
protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"]
bind = ["[::]:4190"]
tls = "starttls"
[listener.smtp]
protocol = "smtp"
bind = ["0.0.0.0:25", "[::]:25"]
bind = ["[::]:25"]
tls = "plain"
# ---------------------------------------------------------------------------
+17 -10
View File
@@ -92,8 +92,8 @@ tls = "implicit"
port = 995
tls = "implicit"
[destination.stalwart.protocol.submission]
port = 587
tls = "starttls"
port = 465
tls = "implicit"
[destination.stalwart.protocol.managesieve]
port = 4190
tls = "starttls"
@@ -104,40 +104,47 @@ tls = "implicit"
# ---------------------------------------------------------------------------
# Listeners: one entry per protocol + TLS mode + bind set.
# tls = "implicit" | "starttls" | "plain".
#
# Each listener binds [::] only: on Linux and macOS that socket also accepts
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
# use "0.0.0.0:<port>" instead.
# ---------------------------------------------------------------------------
[listener.imap]
protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"]
bind = ["[::]:993"]
tls = "implicit"
[listener.imap-starttls]
protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"]
bind = ["[::]:143"]
tls = "starttls"
[listener.pop3]
protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"]
bind = ["[::]:995"]
tls = "implicit"
[listener.submission]
protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"]
bind = ["[::]:587"]
tls = "starttls"
[listener.submissions]
protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"]
bind = ["[::]:465"]
tls = "implicit"
[listener.managesieve]
protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"]
bind = ["[::]:4190"]
tls = "starttls"
[listener.https]
protocol = "http"
bind = ["0.0.0.0:443", "[::]:443"]
bind = ["[::]:443"]
tls = "implicit"
forwarded = "off"
@@ -152,7 +159,7 @@ destination = "legacy"
[[http.route]]
match = "/**"
extract = { from = "authorization" }
fallback = "default"
fallback = "legacy"
# ---------------------------------------------------------------------------
# Admin listener: the cache-integrity boundary. Bind to loopback or a dedicated
+35 -16
View File
@@ -100,8 +100,8 @@ tls = "implicit"
port = 995
tls = "implicit"
[destination.old.protocol.submission]
port = 587
tls = "starttls"
port = 465
tls = "implicit"
[destination.old.protocol.managesieve]
port = 4190
tls = "starttls"
@@ -124,8 +124,8 @@ tls = "implicit"
port = 995
tls = "implicit"
[destination.new.protocol.submission]
port = 587
tls = "starttls"
port = 465
tls = "implicit"
[destination.new.protocol.managesieve]
port = 4190
tls = "starttls"
@@ -165,12 +165,21 @@ destination = "old"
match = "/api/auth"
destination = "new"
[[http.route]]
match = "/api/auth/**"
destination = "new"
[[http.route]]
match = "/api/discover/**"
destination = "new"
[[http.route]]
match = "/admin"
destination = "new"
[[http.route]]
match = "/admin/**"
destination = "new"
[[http.route]]
match = "/account"
destination = "new"
[[http.route]]
match = "/account/**"
destination = "new"
[[http.route]]
@@ -181,53 +190,63 @@ match = "/device"
destination = "new"
# OAuth token exchange is shared; route it by the web client that requested it.
# Add matching mapping entries: webadmin old and stalwart-webui new
# Add matching mapping entries (replace the hostname with the public one):
# webadmin old
# https://mail.example.org/admin/oauth-client.json new
# https://mail.example.org/account/oauth-client.json new
[[http.route]]
match = "/auth/token"
extract = { from = "body", regex = "client_id=([^&]+)" }
fallback = "old"
# Everything else: route by the authenticated account, fall back to default.
# Everything else: route by the authenticated account, fall back to the old deployment.
[[http.route]]
match = "/**"
extract = { from = "authorization" }
fallback = "default"
fallback = "old"
# ---------------------------------------------------------------------------
# Listeners: the public ports clients connect to. These take over the ports the
# old server used to answer on directly.
#
# Each listener binds [::] only: on Linux and macOS that socket also accepts
# IPv4 clients, and on Linux a second 0.0.0.0 entry for the same port fails
# with "Address already in use". Where IPv6 sockets are IPv6-only (FreeBSD by
# default, or net.ipv6.bindv6only = 1 on Linux), add "0.0.0.0:<port>" to each
# bind list. On a host whose kernel has IPv6 disabled, [::] cannot be bound:
# use "0.0.0.0:<port>" instead.
# ---------------------------------------------------------------------------
[listener.imaps]
protocol = "imap"
bind = ["0.0.0.0:993", "[::]:993"]
bind = ["[::]:993"]
tls = "implicit"
[listener.imap]
protocol = "imap"
bind = ["0.0.0.0:143", "[::]:143"]
bind = ["[::]:143"]
tls = "starttls"
[listener.pop3s]
protocol = "pop3"
bind = ["0.0.0.0:995", "[::]:995"]
bind = ["[::]:995"]
tls = "implicit"
[listener.pop3]
protocol = "pop3"
bind = ["0.0.0.0:110", "[::]:110"]
bind = ["[::]:110"]
tls = "starttls"
[listener.submission]
protocol = "submission"
bind = ["0.0.0.0:587", "[::]:587"]
bind = ["[::]:587"]
tls = "starttls"
[listener.submissions]
protocol = "submission"
bind = ["0.0.0.0:465", "[::]:465"]
bind = ["[::]:465"]
tls = "implicit"
[listener.managesieve]
protocol = "managesieve"
bind = ["0.0.0.0:4190", "[::]:4190"]
bind = ["[::]:4190"]
tls = "starttls"
[listener.https]
protocol = "http"
bind = ["0.0.0.0:443", "[::]:443"]
bind = ["[::]:443"]
tls = "implicit"
# Strip any Forwarded / X-Forwarded-For headers arriving from clients rather
# than trusting them; the proxy sets its own based on the real peer.
@@ -236,7 +255,7 @@ forwarded = "off"
# a pass-through listener cannot offer STARTTLS.
[listener.smtp]
protocol = "smtp"
bind = ["0.0.0.0:25", "[::]:25"]
bind = ["[::]:25"]
tls = "plain"
# ---------------------------------------------------------------------------
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env python3
import argparse
import base64
import json
import os
import ssl
import sys
import urllib.error
import urllib.parse
import urllib.request
REQUEST_TIMEOUT = 60
DEFAULT_PAGE_SIZE = 500
MASKED = "****"
USING = ["urn:ietf:params:jmap:core", "urn:stalwart:jmap"]
COPIED_FIELDS = ("description", "quotas", "locale", "timeZone")
class ApiError(RuntimeError):
pass
class StalwartV016Client:
def __init__(self, url, token=None, username=None, password=None, insecure=False):
self.base = url.rstrip("/")
if token:
self.auth = "Bearer " + token
elif username is not None:
raw = f"{username}:{password or ''}".encode()
self.auth = "Basic " + base64.b64encode(raw).decode()
else:
raise ValueError("either a token or a username must be provided")
self.ctx = None
if insecure:
self.ctx = ssl.create_default_context()
self.ctx.check_hostname = False
self.ctx.verify_mode = ssl.CERT_NONE
self.api_url = None
self.page_size = DEFAULT_PAGE_SIZE
def request(self, url, body=None):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(url, data=data, method="POST" if data else "GET")
req.add_header("Authorization", self.auth)
req.add_header("Accept", "application/json")
if data:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT, context=self.ctx) as resp:
return json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise ApiError(f"{exc.code} {exc.reason} for {url}: {detail}") from exc
except urllib.error.URLError as exc:
raise ApiError(f"cannot reach {url}: {exc.reason}") from exc
def connect(self):
session = self.request(self.base + "/.well-known/jmap")
api_url = session.get("apiUrl")
if not api_url:
raise ApiError("the JMAP session does not advertise an apiUrl")
self.api_url = self.base + (urllib.parse.urlsplit(api_url).path or "/jmap")
core = (session.get("capabilities") or {}).get("urn:ietf:params:jmap:core") or {}
max_get = core.get("maxObjectsInGet")
if isinstance(max_get, int) and max_get > 0:
self.page_size = min(DEFAULT_PAGE_SIZE, max_get)
def call(self, method, args):
resp = self.request(self.api_url, {"using": USING, "methodCalls": [[method, args, "c0"]]})
responses = resp.get("methodResponses") or []
if len(responses) != 1:
raise ApiError(f"{method}: unexpected response {resp}")
name, result, _ = responses[0]
if name == "error":
raise ApiError(f"{method}: {result.get('type')}: {result.get('description', '')}")
return result
def query_ids(self, obj):
ids = []
args = {"limit": self.page_size}
while True:
batch = self.call(f"x:{obj}/query", args).get("ids") or []
ids.extend(batch)
if len(batch) < self.page_size:
return ids
args = {"limit": self.page_size, "anchor": batch[-1], "anchorOffset": 1}
def get_all(self, obj):
objects = []
ids = self.query_ids(obj)
for start in range(0, len(ids), self.page_size):
result = self.call(f"x:{obj}/get", {"ids": ids[start : start + self.page_size]})
objects.extend(result.get("list") or [])
return objects
def entries(value):
if isinstance(value, list):
return value
if isinstance(value, dict):
return [value[k] for k in sorted(value, key=lambda k: int(k) if k.isdigit() else k)]
return []
def as_list_map(items):
return {str(i): item for i, item in enumerate(items)}
def domain_ref(domain):
return "domain_" + "".join(c if c.isalnum() else "_" for c in domain)
class PlanBuilder:
def __init__(self, domains, existing, warn):
self.domains = domains
self.existing = existing
self.warn = warn
def domain_target(self, domain_id, address, used):
name = self.domains.get(domain_id)
if name is None:
raise ApiError(f"{address}: references unknown domain id {domain_id}")
used.add(name)
return self.existing.get(name) or ("#" + domain_ref(name))
def credentials(self, account, address):
creds = []
skipped = 0
for cred in entries(account.get("credentials")):
if cred.get("@type") != "Password":
skipped += 1
continue
secret = cred.get("secret")
if not secret or secret == MASKED:
raise ApiError(
f"{address}: the source returned a masked password; restart it with "
"STALWART_INCLUDE_SECRETS=1 in its environment"
)
out = {"@type": "Password", "secret": secret}
otp = cred.get("otpAuth")
if otp and otp != MASKED:
out["otpUrl"] = otp
for key in ("expiresAt", "allowedIps"):
if cred.get(key):
out[key] = cred[key]
creds.append(out)
if skipped:
self.warn(f"{address}: {skipped} app password(s) or API key(s) not migrated")
return creds
def account(self, account, address, used):
kind = account.get("@type")
out = {
"@type": kind,
"name": account["name"],
"domainId": self.domain_target(account["domainId"], address, used),
}
for key in COPIED_FIELDS:
if account.get(key) not in (None, {}, []):
out[key] = account[key]
if kind == "User":
creds = self.credentials(account, address)
if creds:
out["credentials"] = as_list_map(creds)
else:
self.warn(f"{address}: no password credential, the account is created without one")
roles = account.get("roles") or {}
if roles.get("@type") == "Custom":
fallback = "User" if kind == "User" else "Default"
self.warn(f"{address}: custom roles not migrated, assigned the {fallback} roles")
out["roles"] = {"@type": fallback}
elif roles:
out["roles"] = roles
permissions = account.get("permissions") or {}
if permissions.get("@type") in ("Merge", "Replace"):
self.warn(f"{address}: account-level permissions copied, check them on the target")
out["permissions"] = permissions
if kind == "User":
encryption = account.get("encryptionAtRest") or {}
if encryption.get("@type") not in (None, "Disabled"):
self.warn(f"{address}: encryption at rest not migrated, the public key must be added again")
out["encryptionAtRest"] = {"@type": "Disabled"}
if account.get("memberGroupIds"):
self.warn(f"{address}: group memberships not migrated")
if account.get("externalId"):
self.warn(f"{address}: external directory identifier not migrated")
if account.get("memberTenantId"):
self.warn(f"{address}: tenant membership not migrated")
aliases = []
for alias in entries(account.get("aliases")):
entry = {
"name": alias["name"],
"domainId": self.domain_target(alias["domainId"], address, used),
"enable": alias.get("enabled", True),
}
if alias.get("description"):
entry["description"] = alias["description"]
aliases.append(entry)
if aliases:
out["aliases"] = as_list_map(aliases)
return out
def plan_lines(accounts, builder):
used = set()
value = {}
for address, account in accounts:
value["acct_" + str(account["id"])] = builder.account(account, address, used)
ops = []
missing = sorted(d for d in used if d not in builder.existing)
if missing:
ops.append(
{
"@type": "upsert",
"object": "Domain",
"matchOn": ["name"],
"value": {domain_ref(d): {"name": d} for d in missing},
}
)
ops.append({"@type": "create", "object": "Account", "value": value})
return [json.dumps(op) for op in ops]
def write_lines(path, lines):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as out:
for line in lines:
out.write(line + "\n")
def main():
parser = argparse.ArgumentParser(
description="Build Stalwart CLI plans that recreate the accounts of a Stalwart "
"v0.16.27 server, with their password hashes, on a Stalwart v1 server.",
)
parser.add_argument("addresses", nargs="*", help="export only these accounts (default: all)")
parser.add_argument("--url", required=True, help="base URL of the v0.16 server")
parser.add_argument("--token", help="admin bearer token")
parser.add_argument("--user", help="admin username (HTTP basic auth)")
parser.add_argument("--password", help="admin password (default: $SOURCE_ADMIN_PASSWORD)")
parser.add_argument("--insecure", action="store_true", help="skip TLS verification")
parser.add_argument("--include-groups", action="store_true", help="also export groups")
parser.add_argument(
"--existing-domain",
action="append",
default=[],
metavar="NAME=ID",
help="reference a target domain by id instead of matching it by name",
)
target = parser.add_mutually_exclusive_group()
target.add_argument("--output", help="write one plan to this file (default: stdout)")
target.add_argument("--output-dir", help="write one plan per account into this directory")
args = parser.parse_args()
if not args.token and args.user is None:
parser.error("provide --token or --user")
password = args.password if args.password is not None else os.environ.get("SOURCE_ADMIN_PASSWORD")
existing = {}
for entry in args.existing_domain:
name, sep, value = entry.partition("=")
if not sep:
parser.error(f"--existing-domain expects NAME=ID, got {entry!r}")
existing[name.strip().lower()] = value.strip()
client = StalwartV016Client(
args.url, token=args.token, username=args.user, password=password, insecure=args.insecure
)
client.connect()
domains = {d["id"]: d["name"].lower() for d in client.get_all("Domain")}
kinds = {"User", "Group"} if args.include_groups else {"User"}
accounts = {}
for account in client.get_all("Account"):
if account.get("@type") not in kinds:
continue
domain = domains.get(account.get("domainId"))
if domain:
accounts[f"{account['name']}@{domain}".lower()] = account
if args.addresses:
wanted = [a.lower() for a in args.addresses]
unknown = [a for a in wanted if a not in accounts]
if unknown:
sys.stderr.write("unknown account(s): " + ", ".join(unknown) + "\n")
return 1
selected = [(a, accounts[a]) for a in wanted]
else:
selected = sorted(accounts.items())
if not selected:
sys.stderr.write("no accounts to export\n")
return 1
warnings = []
builder = PlanBuilder(domains, existing, warnings.append)
if args.output_dir:
os.makedirs(args.output_dir, mode=0o700, exist_ok=True)
for address, account in selected:
write_lines(
os.path.join(args.output_dir, address + ".ndjson"),
plan_lines([(address, account)], builder),
)
else:
lines = plan_lines(selected, builder)
if args.output:
write_lines(args.output, lines)
else:
for line in lines:
sys.stdout.write(line + "\n")
for warning in warnings:
sys.stderr.write("warning: " + warning + "\n")
sys.stderr.write(f"exported {len(selected)} account(s)\n")
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except ApiError as exc:
sys.stderr.write(f"error: {exc}\n")
sys.exit(1)
+13 -6
View File
@@ -303,6 +303,7 @@ async fn handle(
} else {
match ctx.router.upsert(&id, &dest).await {
Ok(()) => {
ctx.conns.release_delay(&ctx.router.normalize(&id));
tracing::info!(identifier = %crate::observe::redact(&id), %dest, "admin upserted mapping");
write_response(&mut stream, 200, "OK", "ok\n").await?;
}
@@ -343,6 +344,7 @@ async fn handle(
} else {
match ctx.router.remove(&id).await {
Ok(existed) => {
ctx.conns.release_delay(&ctx.router.normalize(&id));
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?;
@@ -379,7 +381,7 @@ async fn handle(
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",
"note": "listener binds/protocols and admin bind/token require a restart to change",
})
.to_string();
write_json(&mut stream, 200, &body).await?;
@@ -400,10 +402,12 @@ async fn handle(
match request.query_param("identifier") {
Some(id) => {
ctx.router.invalidate(&id).await;
ctx.conns.release_delay(&ctx.router.normalize(&id));
tracing::info!(identifier = %crate::observe::redact(&id), "admin invalidated entry");
}
None => {
ctx.router.invalidate_all();
ctx.conns.release_all_delays();
tracing::info!("admin invalidated entire cache");
}
}
@@ -411,6 +415,7 @@ async fn handle(
}
("POST", "/mappings/reload") => match ctx.router.reload().await {
Ok(()) => {
ctx.conns.release_all_delays();
tracing::info!("admin reloaded mappings");
write_response(&mut stream, 200, "OK", "reloaded\n").await?;
}
@@ -435,15 +440,17 @@ async fn handle(
request.query_param("identifier"),
request.query_param("seconds"),
) {
(Some(id), Some(secs)) => match secs.parse::<u64>() {
Ok(seconds) => {
let until =
std::time::Instant::now() + std::time::Duration::from_secs(seconds);
(Some(id), Some(secs)) => match secs.parse::<u64>().ok().and_then(|seconds| {
std::time::Instant::now()
.checked_add(std::time::Duration::from_secs(seconds))
.map(|until| (seconds, until))
}) {
Some((seconds, until)) => {
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(_) => {
None => {
write_response(&mut stream, 400, "Bad Request", "invalid seconds\n")
.await?;
}
+8
View File
@@ -920,6 +920,14 @@ impl Config {
)));
}
}
if let Some(fallback) = &route.fallback {
let dest = self.destination(fallback)?;
if !dest.protocol.contains_key(&Protocol::Http) {
return Err(ProxyError::config(format!(
"http route fallback {fallback:?} does not declare the http protocol"
)));
}
}
if let Some(extract) = &route.extract {
if extract.from == ExtractFrom::Body && extract.regex.is_none() {
return Err(ProxyError::config(
+45 -29
View File
@@ -5,6 +5,7 @@
*/
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -521,11 +522,7 @@ async fn serve(
let mut first_head = first_head;
sanitize_inbound_forwarded(&mut first_head, listener);
let Prepared {
body: buffered_body,
dest_id,
identifier,
} = match prepare(
let prepared = match prepare(
ctx,
router,
stream,
@@ -544,9 +541,11 @@ async fn serve(
};
let mut account = Account::new(cancel);
if account.pin(ctx, identifier.as_deref()) {
crate::proto::common::hold_login(ctx, identifier.as_deref()).await;
if account.pin(ctx, prepared.identifier.as_deref()) {
crate::proto::common::hold_login(ctx, prepared.identifier.as_deref()).await;
}
let dest_id = prepared.destination(ctx).await;
let buffered_body = prepared.body;
let (dest, ep, tls_cfg) = match ctx.endpoint(&dest_id, Protocol::Http) {
Ok(t) => t,
@@ -648,11 +647,7 @@ async fn serve(
let mut next = next;
sanitize_inbound_forwarded(&mut next, listener);
let Prepared {
body: next_body,
dest_id: next_dest,
identifier: next_identifier,
} = match prepare(
let prepared = match prepare(
ctx,
router,
stream,
@@ -670,7 +665,14 @@ async fn serve(
}
};
if next_dest != dest_id || !account.accepts(ctx, next_identifier.as_deref()) {
let misdirected = !account.accepts(ctx, prepared.identifier.as_deref()) || {
if account.pin(ctx, prepared.identifier.as_deref()) {
crate::proto::common::hold_login(ctx, prepared.identifier.as_deref()).await;
}
prepared.destination(ctx).await != dest_id
};
let next_body = prepared.body;
if misdirected {
if let Some(body) = &next_body {
drain_buffered(stream, &mut reader, &next, body.len(), idle.body)
.await
@@ -679,9 +681,6 @@ async fn serve(
let _ = write_client(stream, &error_response(421, "Misdirected Request")).await;
return Ok(Disposition::Close);
}
if account.pin(ctx, next_identifier.as_deref()) {
crate::proto::common::hold_login(ctx, next_identifier.as_deref()).await;
}
current_head = next;
current_body = next_body;
@@ -970,12 +969,34 @@ async fn drain_buffered(
Ok(())
}
enum Target {
Pinned(String),
Account { fallback: Option<Arc<str>> },
}
struct Prepared {
body: Option<Vec<u8>>,
dest_id: String,
target: Target,
identifier: Option<String>,
}
impl Prepared {
async fn destination(&self, ctx: &Ctx) -> String {
match &self.target {
Target::Pinned(dest_id) => dest_id.clone(),
Target::Account {
fallback: Some(fallback),
} => ctx
.resolve_or(self.identifier.as_deref(), fallback)
.await
.to_string(),
Target::Account { fallback: None } => {
ctx.resolve(self.identifier.as_deref()).await.to_string()
}
}
}
}
async fn prepare(
ctx: &Ctx,
router: &HttpRouter,
@@ -1000,25 +1021,20 @@ async fn prepare(
None
};
let (dest_id, identifier) = match router.route_at(idx, head, body.as_deref()) {
RouteOutcome::Pin { dest_id } => (dest_id, None),
let (target, identifier) = match router.route_at(idx, head, body.as_deref()) {
RouteOutcome::Pin { dest_id } => (Target::Pinned(dest_id), None),
RouteOutcome::Identifier {
identifier,
fallback,
} => {
let identifier = ctx.routing_identifier(identifier);
let dest_id = if identifier.is_none() && fallback.is_none() {
ctx.resolve(None).await.to_string()
} else {
ctx.resolve(identifier.as_deref()).await.to_string()
};
(dest_id, identifier)
}
} => (
Target::Account { fallback },
ctx.routing_identifier(identifier),
),
};
Ok(Some(Prepared {
body,
dest_id,
target,
identifier,
}))
}
+9 -9
View File
@@ -4,9 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use std::sync::Arc;
use std::time::Duration;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex;
@@ -21,7 +22,7 @@ pub enum RouteOutcome {
},
Identifier {
identifier: Option<String>,
fallback: Option<String>,
fallback: Option<Arc<str>>,
},
}
@@ -35,7 +36,7 @@ struct CompiledExtract {
struct CompiledRoute {
destination: Option<String>,
extract: Option<CompiledExtract>,
fallback: Option<String>,
fallback: Option<Arc<str>>,
needs_body: bool,
}
@@ -144,8 +145,7 @@ impl HttpRouter {
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 text = BASE64.decode_to_string(rest.trim()).ok()?;
let login = match text.split_once(':') {
Some((login, _)) => login,
None => text.as_str(),
@@ -186,7 +186,7 @@ fn compile_route(route: &HttpRoute) -> Result<CompiledRoute> {
Ok(CompiledRoute {
destination: route.destination.clone(),
extract,
fallback: route.fallback.clone(),
fallback: route.fallback.as_deref().map(Arc::from),
needs_body,
})
}
@@ -320,7 +320,7 @@ destination = "legacy"
[[route]]
match = "/**"
extract = { from = "authorization" }
fallback = "default"
fallback = "legacy"
"#,
);
let head = parse(b"GET /static HTTP/1.1\r\nHost: x\r\n\r\n");
@@ -331,7 +331,7 @@ fallback = "default"
let head = parse(b"GET /dyn HTTP/1.1\r\nHost: x\r\n\r\n");
match route(&router, &head, None) {
RouteOutcome::Identifier { fallback, .. } => {
assert_eq!(fallback.as_deref(), Some("default"));
assert_eq!(fallback.as_deref(), Some("legacy"));
}
_ => panic!("expected identifier"),
}
@@ -359,7 +359,7 @@ extract = { from = "authorization" }
#[test]
fn bearer_uses_bearer_identifier() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use encodify::base64::URL_SAFE_NO_PAD;
let router = router_from_toml(
r#"
[[route]]
+149 -29
View File
@@ -23,7 +23,7 @@ use crate::error::{ProxyError, Result};
use crate::imap_receiver::Mechanism;
use crate::net::BoxedStream;
use crate::net::tls;
use crate::sasl::Credential;
use crate::sasl::{Credential, SASL_RESPONSE};
pub struct Handoff {
pub stream: BoxedStream,
@@ -133,13 +133,15 @@ pub fn sasl_cancelled(response: &[u8]) -> bool {
}
pub fn login_challenge(username_b64: &[u8], password_b64: &[u8]) -> Option<Vec<u8>> {
let username = crate::sasl::decode_sasl_ir(username_b64)?;
let password = crate::sasl::decode_sasl_ir(password_b64)?;
let mut raw = Vec::with_capacity(username.len() + password.len() + 2);
let mut raw = Vec::with_capacity(
SASL_RESPONSE.decoded_len_estimate(username_b64.len())
+ SASL_RESPONSE.decoded_len_estimate(password_b64.len())
+ 2,
);
raw.push(0);
raw.extend_from_slice(&username);
SASL_RESPONSE.decode_append(username_b64, &mut raw).ok()?;
raw.push(0);
raw.extend_from_slice(&password);
SASL_RESPONSE.decode_append(password_b64, &mut raw).ok()?;
Some(raw)
}
@@ -293,16 +295,22 @@ pub struct HealthSnapshot {
}
const REGISTRY_SHARDS: usize = 32;
const MAX_LOGIN_HOLD: Duration = Duration::from_secs(120);
struct LiveConn {
id: u64,
cancel: tokio::sync::watch::Sender<bool>,
}
struct LoginDelay {
until: Instant,
changed: tokio::sync::watch::Sender<()>,
}
#[derive(Default)]
struct Shard {
live: HashMap<Arc<str>, Vec<LiveConn>>,
delays: HashMap<Arc<str>, Instant>,
delays: HashMap<Arc<str>, LoginDelay>,
}
#[derive(Clone)]
@@ -396,21 +404,64 @@ impl ConnRegistry {
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);
shard.delays.retain(|_, delay| delay.until > now);
match shard.delays.get_mut(identifier) {
Some(delay) => {
delay.until = until;
delay.changed.send_replace(());
}
None => {
shard.delays.insert(
Arc::from(identifier),
LoginDelay {
until,
changed: tokio::sync::watch::Sender::new(()),
},
);
}
}
}
pub fn delay_remaining(&self, identifier: &str) -> Option<Duration> {
fn delay(&self, identifier: &str) -> Option<(Instant, tokio::sync::watch::Receiver<()>)> {
let mut shard = self.shards[self.shard_idx(identifier)].lock();
if let Some(&until) = shard.delays.get(identifier) {
let now = Instant::now();
if now < until {
return Some(until - now);
}
shard.delays.remove(identifier);
let delay = shard.delays.get(identifier)?;
if Instant::now() < delay.until {
return Some((delay.until, delay.changed.subscribe()));
}
shard.delays.remove(identifier);
None
}
pub fn release_delay(&self, identifier: &str) {
self.shards[self.shard_idx(identifier)]
.lock()
.delays
.remove(identifier);
}
pub fn release_all_delays(&self) {
for shard in self.shards.iter() {
shard.lock().delays.clear();
}
}
pub async fn hold_login(&self, identifier: &str) {
let deadline = Instant::now() + MAX_LOGIN_HOLD;
while let Some((until, mut changed)) = self.delay(identifier) {
let wake = until.min(deadline);
if Instant::now() >= wake {
break;
}
tracing::debug!(
hold_ms = wake.saturating_duration_since(Instant::now()).as_millis() as u64,
"holding login (delay_until)"
);
tokio::select! {
_ = tokio::time::sleep_until(wake.into()) => {}
_ = changed.changed() => {}
}
}
}
}
impl ConnGuard {
@@ -543,6 +594,10 @@ impl Ctx {
self.router.resolve(identifier).await
}
pub async fn resolve_or(&self, identifier: Option<&str>, default: &Arc<str>) -> Arc<str> {
self.router.resolve_or(identifier, default).await
}
pub fn register_conn(&self, identifier: &str) -> ConnGuard {
self.conns.register(&self.router.normalize(identifier))
}
@@ -655,15 +710,8 @@ where
}
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;
if let Some(id) = identifier {
ctx.conns.hold_login(&ctx.router.normalize(id)).await;
}
}
@@ -1114,7 +1162,7 @@ mod tests {
#[test]
fn login_challenge_builds_a_plain_authentication_frame() {
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
let user = BASE64.encode("user@example.com");
let pass = BASE64.encode("secret");
assert_eq!(
@@ -1365,14 +1413,86 @@ mod tests {
assert_eq!(reg.kick("user@example.com"), 0);
}
#[tokio::test]
async fn conn_registry_release_ends_hold() {
// releasing a delay wakes a held login immediately
let reg = ConnRegistry::default();
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(60));
let held = reg.clone();
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
tokio::time::sleep(Duration::from_millis(50)).await;
assert!(!hold.is_finished());
reg.release_delay("user@example.com");
tokio::time::timeout(Duration::from_secs(2), hold)
.await
.expect("release did not end the hold")
.unwrap();
assert!(reg.delay("user@example.com").is_none());
}
#[tokio::test]
async fn conn_registry_release_all_ends_holds() {
// releasing every delay wakes all held logins
let reg = ConnRegistry::default();
let until = Instant::now() + Duration::from_secs(60);
reg.set_delay("a@example.com", until);
reg.set_delay("b@example.com", until);
let holds = ["a@example.com", "b@example.com"].map(|id| {
let held = reg.clone();
tokio::spawn(async move { held.hold_login(id).await })
});
tokio::time::sleep(Duration::from_millis(50)).await;
reg.release_all_delays();
for hold in holds {
tokio::time::timeout(Duration::from_secs(2), hold)
.await
.expect("release did not end the hold")
.unwrap();
}
}
#[tokio::test]
async fn conn_registry_shortened_delay_ends_hold() {
// shortening a delay wakes a held login at the new deadline
let reg = ConnRegistry::default();
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(60));
let held = reg.clone();
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
tokio::time::sleep(Duration::from_millis(50)).await;
reg.set_delay("user@example.com", Instant::now());
tokio::time::timeout(Duration::from_secs(2), hold)
.await
.expect("shortened delay did not end the hold")
.unwrap();
}
#[tokio::test]
async fn conn_registry_extended_delay_keeps_hold() {
// extending a delay keeps a held login waiting past the original deadline
let reg = ConnRegistry::default();
reg.set_delay(
"user@example.com",
Instant::now() + Duration::from_millis(100),
);
let held = reg.clone();
let started = Instant::now();
let hold = tokio::spawn(async move { held.hold_login("user@example.com").await });
reg.set_delay(
"user@example.com",
Instant::now() + Duration::from_millis(400),
);
hold.await.unwrap();
assert!(started.elapsed() >= Duration::from_millis(350));
}
#[test]
fn conn_registry_delay_window() {
let reg = ConnRegistry::default();
assert!(reg.delay_remaining("user@example.com").is_none());
assert!(reg.delay("user@example.com").is_none());
reg.set_delay("user@example.com", Instant::now() + Duration::from_secs(30));
assert!(reg.delay_remaining("user@example.com").is_some());
assert!(reg.delay("user@example.com").is_some());
reg.set_delay("user@example.com", Instant::now());
assert!(reg.delay_remaining("user@example.com").is_none());
assert!(reg.delay("user@example.com").is_none());
}
#[test]
+10 -9
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::AsyncReadExt;
use tokio::time::timeout;
@@ -19,7 +19,7 @@ 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::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024;
@@ -82,24 +82,25 @@ pub async fn handle(
};
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 dest_id = ctx.resolve(identifier.as_deref()).await;
tracing::debug!(destination = %dest_id, "imap routing decision");
let client_tls = client.is_tls();
let fwd =
forward::ForwardInfo::new(peer, local, client_tls, None, ctx.config.server.proxy_ttl);
let mech_name = mechanism_name(&mech);
let response = BASE64.encode(&raw_challenge);
let mut response = String::with_capacity(BASE64.encoded_len(raw_challenge.len()) + 2);
BASE64.encode_append(&raw_challenge, &mut response);
response.push_str("\r\n");
let auth = common::AuthFrame::inline(
format!("{tag} AUTHENTICATE {mech_name} {response}\r\n").into_bytes(),
format!("{tag} AUTHENTICATE {mech_name} {response}").into_bytes(),
)
.with_deferred(
format!("{tag} AUTHENTICATE {mech_name}\r\n").into_bytes(),
format!("{response}\r\n").into_bytes(),
response.into_bytes(),
);
let unavailable = format!("{tag} NO [UNAVAILABLE] backend unavailable\r\n");
@@ -404,7 +405,7 @@ impl Auth<'_> {
read_crlf_line(stream, seed, MAX_PREAUTH).await?
};
let Some(raw) = decode_sasl_ir(&raw_b64) else {
let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
return self
.reject(
stream,
+8 -12
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::AsyncReadExt;
use tokio::time::timeout;
@@ -20,7 +20,7 @@ use crate::proto::common::{
plain_auth_allowed, write,
};
use crate::proto::forward;
use crate::sasl::decode_sasl_ir;
use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024;
@@ -70,9 +70,9 @@ pub async fn handle(
} = 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 dest_id = ctx.resolve(identifier.as_deref()).await;
let fwd = forward::ForwardInfo::new(
peer,
local,
@@ -81,14 +81,10 @@ pub async fn handle(
ctx.config.server.proxy_ttl,
);
let auth = common::AuthFrame::inline(
format!(
"AUTHENTICATE \"{}\" \"{}\"\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
)
.into_bytes(),
);
let mut frame = format!("AUTHENTICATE \"{}\" \"", mechanism_name(&mech));
BASE64.encode_append(&raw_challenge, &mut frame);
frame.push_str("\"\r\n");
let auth = common::AuthFrame::inline(frame.into_bytes());
let resume = common::bridge_authenticated(
ctx,
@@ -333,7 +329,7 @@ async fn handle_authenticate(
read_sasl_response(stream, seed).await?
};
let Some(raw) = decode_sasl_ir(&raw_b64) else {
let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
return fail(
stream,
"Invalid base64",
+8 -12
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::time::timeout;
use crate::config::{Forwarding, ListenerConfig, Pop3Capabilities, Protocol, TlsMode};
@@ -19,7 +19,7 @@ use crate::proto::common::{
plain_auth_allowed, write,
};
use crate::proto::forward;
use crate::sasl::decode_sasl_ir;
use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024;
@@ -60,9 +60,9 @@ pub async fn handle(
} = 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 dest_id = ctx.resolve(identifier.as_deref()).await;
let fwd = forward::ForwardInfo::new(
peer,
local,
@@ -71,14 +71,10 @@ pub async fn handle(
ctx.config.server.proxy_ttl,
);
let auth = common::AuthFrame::inline(
format!(
"AUTH {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
)
.into_bytes(),
);
let mut frame = format!("AUTH {} ", mechanism_name(&mech));
BASE64.encode_append(&raw_challenge, &mut frame);
frame.push_str("\r\n");
let auth = common::AuthFrame::inline(frame.into_bytes());
let resume = common::bridge_authenticated(
ctx,
@@ -338,7 +334,7 @@ async fn preauth(
line
};
let Some(raw) = decode_sasl_ir(&raw_b64) else {
let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
write(&mut stream, b"-ERR invalid base64\r\n").await?;
if bump(auth_failures, listener.max_auth_attempts) {
return Ok(None);
+8 -12
View File
@@ -6,7 +6,7 @@
use std::net::SocketAddr;
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use smtp_proto::{
AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2, Error as SmtpError, Request,
};
@@ -22,7 +22,7 @@ use crate::proto::common::{
plain_auth_allowed, write,
};
use crate::proto::forward;
use crate::sasl::decode_sasl_ir;
use crate::sasl::SASL_RESPONSE;
use crate::token::identifier_from_credential;
const MAX_PREAUTH: usize = 64 * 1024;
@@ -92,9 +92,9 @@ pub async fn handle_submission(
} = 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 dest_id = ctx.resolve(identifier.as_deref()).await;
let fwd = forward::ForwardInfo::new(
peer,
local,
@@ -103,14 +103,10 @@ pub async fn handle_submission(
ctx.config.server.proxy_ttl,
);
let auth = common::AuthFrame::inline(
format!(
"AUTH {} {}\r\n",
mechanism_name(&mech),
BASE64.encode(&raw_challenge)
)
.into_bytes(),
);
let mut frame = format!("AUTH {} ", mechanism_name(&mech));
BASE64.encode_append(&raw_challenge, &mut frame);
frame.push_str("\r\n");
let auth = common::AuthFrame::inline(frame.into_bytes());
let resume = common::bridge_authenticated(
ctx,
@@ -370,7 +366,7 @@ async fn handle_auth(
line
};
let Some(raw) = decode_sasl_ir(&raw_b64) else {
let Ok(raw) = SASL_RESPONSE.decode(&raw_b64) else {
return fail(
stream,
buf,
+7 -3
View File
@@ -164,17 +164,21 @@ impl Router {
}
pub async fn resolve(&self, identifier: Option<&str>) -> Arc<str> {
self.resolve_or(identifier, &self.default_destination).await
}
pub async fn resolve_or(&self, identifier: Option<&str>, default: &Arc<str>) -> Arc<str> {
let key: Cow<'_, str> = match identifier {
Some(id) => match self.normalize {
Normalize::None => Cow::Borrowed(id),
Normalize::Lowercase => Cow::Owned(id.to_lowercase()),
},
None => return self.default_destination.clone(),
None => return default.clone(),
};
if let Some(hit) = self.cache.get(key.as_ref()).await {
self.stats.hits.fetch_add(1, Ordering::Relaxed);
return hit.dest(&self.default_destination);
return hit.dest(default);
}
self.stats.misses.fetch_add(1, Ordering::Relaxed);
@@ -215,7 +219,7 @@ impl Router {
.try_get_with(key.into_boxed_str(), init)
.await
.unwrap_or(Route::Transient);
route.dest(&self.default_destination)
route.dest(default)
}
pub fn normalize(&self, identifier: &str) -> String {
+7 -7
View File
@@ -4,6 +4,10 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use encodify::base64::{self, Base64};
pub const SASL_RESPONSE: Base64 = base64::LENIENT;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Credential {
Plain {
@@ -98,10 +102,6 @@ pub fn extract_oauth_bearer(bytes: &[u8]) -> Option<(String, Option<String>)> {
None
}
pub fn decode_sasl_ir(b64: &[u8]) -> Option<Vec<u8>> {
mail_parser::decoders::base64::base64_decode(b64)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -154,7 +154,7 @@ mod tests {
#[test]
fn lenient_ir_decode() {
let out = decode_sasl_ir(b"dGVzdAB0ZXN0AHRlc3Q=").unwrap();
let out = SASL_RESPONSE.decode(b"dGVzdAB0ZXN0AHRlc3Q=").unwrap();
assert_eq!(out, b"test\0test\0test");
}
@@ -204,13 +204,13 @@ mod tests {
#[test]
fn lenient_ir_tolerates_whitespace() {
let with_ws = decode_sasl_ir(b"dGVz dAB0\nZXN0AHRlc3Q=").unwrap();
let with_ws = SASL_RESPONSE.decode(b"dGVz dAB0\nZXN0AHRlc3Q=").unwrap();
assert_eq!(with_ws, b"test\0test\0test");
}
#[test]
fn lenient_ir_decodes_full_quads_without_padding() {
let out = decode_sasl_ir(b"AHRlc3QAc2VjcmV0").unwrap();
let out = SASL_RESPONSE.decode(b"AHRlc3QAc2VjcmV0").unwrap();
assert_eq!(out, b"\0test\0secret");
}
}
+169
View File
@@ -324,6 +324,14 @@ async fn admin_delay_holds_new_login() {
)
.await;
assert_eq!(status, 400);
let (status, _) = admin_request(
admin_addr,
"POST",
"/connections/delay?identifier=user@example.com&seconds=18446744073709551615",
Some(TOKEN),
)
.await;
assert_eq!(status, 400);
let listener = ctx.config.listener["pop3"].clone();
let (mut client, server) = tcp_pair().await;
@@ -645,3 +653,164 @@ async fn admin_config_reload_rejects_invalid() {
"legacy"
);
}
fn switch_config(old_port: u16, new_port: u16, mapping_path: &str) -> String {
format!(
r#"
[mapping]
source = "file"
normalize = "lowercase"
[mapping.file]
path = "{mapping_path}"
[capabilities.pop3]
allow_plain_auth_without_tls = true
[routing]
default_destination = "legacy"
[destination.legacy]
host = "127.0.0.1"
forwarding = "none"
allow_plaintext_auth = true
[destination.legacy.protocol.pop3]
port = {old_port}
tls = "plain"
[destination.next]
host = "127.0.0.1"
forwarding = "none"
allow_plaintext_auth = true
[destination.next.protocol.pop3]
port = {new_port}
tls = "plain"
[listener.pop3]
protocol = "pop3"
bind = ["127.0.0.1:0"]
tls = "plain"
[admin]
bind = "127.0.0.1:0"
tls = "implicit"
bearer_token = "{TOKEN}"
min_token_len = 32
lockout_threshold = 100
lockout_duration = "5m"
"#
)
}
async fn pop3_named_backend(name: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
dummy_backend(move |mut sock| async move {
sock.write_all(b"+OK ready\r\n").await.unwrap();
let mut buf = Vec::new();
let mut scratch = [0u8; 1024];
while find(&buf, b"AUTH PLAIN").is_none() || find(&buf, b"\r\n").is_none() {
let n = sock.read(&mut scratch).await.unwrap();
if n == 0 {
return;
}
buf.extend_from_slice(&scratch[..n]);
}
sock.write_all(format!("+OK logged in at {name}\r\n").as_bytes())
.await
.unwrap();
let _ = sock.read(&mut scratch).await;
})
.await
}
async fn held_login_follows_mapping_change(method: &str, path: &str, mapping_update: Option<&str>) {
use tokio::io::AsyncReadExt;
let (old_port, old_backend) = pop3_named_backend("legacy").await;
let (new_port, new_backend) = pop3_named_backend("next").await;
let mapping = mapping_file("user@example.com\tlegacy\n");
let toml = switch_config(old_port, new_port, &mapping.path().display().to_string());
let ctx = make_ctx(&toml).await;
let admin_listener = TcpListener::bind(unused_addr()).await.unwrap();
let admin_addr = admin_listener.local_addr().unwrap();
let admin_ctx = ctx.clone();
let admin_config = ctx.config.clone();
tokio::spawn(async move {
let shared = std::sync::Arc::new(arc_swap::ArcSwap::new(admin_ctx));
let _ = crate::admin::serve(shared, admin_config, None, admin_listener).await;
});
let (status, _) = admin_request(
admin_addr,
"POST",
"/connections/delay?identifier=user@example.com&seconds=60",
Some(TOKEN),
)
.await;
assert_eq!(status, 200);
let listener = ctx.config.listener["pop3"].clone();
let (mut client, server) = tcp_pair().await;
let ctx2 = ctx.clone();
let handler = tokio::spawn(async move {
let _ = crate::proto::pop3::handle(
&ctx2,
&listener,
Box::new(server),
"203.0.113.9:40000".parse().unwrap(),
"127.0.0.1:110".parse().unwrap(),
)
.await;
});
let greeting = read_line(&mut client).await;
assert!(greeting.starts_with("+OK"), "greeting: {greeting}");
send(&mut client, b"USER user@example.com\r\nPASS secret\r\n").await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
if let Some(contents) = mapping_update {
std::fs::write(mapping.path(), contents).unwrap();
}
let (status, _) = admin_request(admin_addr, method, path, Some(TOKEN)).await;
assert_eq!(status, 200);
let mut seen = Vec::new();
let mut scratch = [0u8; 1024];
while find(&seen, b"+OK logged in at").is_none() {
let n = tokio::time::timeout(std::time::Duration::from_secs(5), client.read(&mut scratch))
.await
.expect("held login was not released by the mapping change")
.unwrap();
assert!(n > 0, "client closed before login");
seen.extend_from_slice(&scratch[..n]);
}
assert!(
find(&seen, b"+OK logged in at next").is_some(),
"held login must reach the new destination, got {:?}",
String::from_utf8_lossy(&seen)
);
drop(client);
let _ = new_backend.await;
old_backend.abort();
let _ = handler.await;
}
#[tokio::test]
async fn admin_mapping_put_releases_held_login_to_new_destination() {
// a login held by /connections/delay goes to the destination set by PUT /mappings at once
held_login_follows_mapping_change(
"PUT",
"/mappings?identifier=user@example.com&destination=next",
None,
)
.await;
}
#[tokio::test]
async fn admin_mappings_reload_releases_held_login_to_new_destination() {
// a login held by /connections/delay goes to the destination loaded by /mappings/reload at once
held_login_follows_mapping_change("POST", "/mappings/reload", Some("user@example.com\tnext\n"))
.await;
}
+76
View File
@@ -501,3 +501,79 @@ sasl = ["NTLM"]
);
}
}
fn http_fallback_config(mapping_path: &str, fallback: &str) -> String {
format!(
r#"
{}
[routing]
default_destination = "legacy"
[destination.legacy]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.legacy.protocol.http]
port = 80
tls = "plain"
[destination.imaponly]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.imaponly.protocol.imap]
port = 143
tls = "plain"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "{fallback}"
"#,
mapping_block(mapping_path)
)
}
#[test]
fn http_route_fallback_must_name_known_http_destination() {
let m = mapping_file("");
let path = m.path().display().to_string();
assert!(Config::parse_and_validate(&http_fallback_config(&path, "legacy")).is_ok());
assert!(Config::parse_and_validate(&http_fallback_config(&path, "ghost")).is_err());
assert!(Config::parse_and_validate(&http_fallback_config(&path, "imaponly")).is_err());
}
fn install_script_config() -> &'static str {
let script = include_str!("../../install.sh");
let (_, rest) = script
.split_once("write_config_file() {\n cat > \"$1\" <<'EOF'\n")
.expect("install.sh config heredoc start");
let (config, _) = rest
.split_once("\nEOF\n")
.expect("install.sh config heredoc end");
config
}
#[test]
fn shipped_configs_validate() {
// every configuration shipped with the proxy passes boot validation
for (name, raw) in [
(
"config.sample.toml",
include_str!("../../resources/config.sample.toml"),
),
(
"config.stalwart-upgrade.toml",
include_str!("../../resources/config.stalwart-upgrade.toml"),
),
(
"config.dovecot-postfix.toml",
include_str!("../../resources/config.dovecot-postfix.toml"),
),
("install.sh", install_script_config()),
] {
if let Err(e) = Config::parse_and_validate(raw) {
panic!("{name} fails validation: {e}");
}
}
}
+1 -2
View File
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
+4 -5
View File
@@ -6,6 +6,7 @@
use std::sync::Arc;
use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use tokio::net::TcpStream;
@@ -34,11 +35,9 @@ impl Response {
}
pub fn basic_auth(user: &str, pass: &str) -> String {
use base64::Engine;
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD.encode(format!("{user}:{pass}"))
)
let mut header = String::from("Basic ");
B64.encode_append(format!("{user}:{pass}"), &mut header);
header
}
pub async fn upgrade(
+1 -1
View File
@@ -55,7 +55,7 @@ tls = "implicit"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "default"
fallback = "new"
[listener.http]
protocol = "http"
+1 -2
View File
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
+1 -2
View File
@@ -4,8 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
+1 -1
View File
@@ -163,7 +163,7 @@ tls = "implicit"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "default"
fallback = "old"
[listener.http]
protocol = "http"
+2 -2
View File
@@ -830,7 +830,7 @@ fn path_of(url: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use encodify::base64::STANDARD as B64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
async fn imaps_auth(host: &str, port: u16, login: &str, password: &str) -> String {
@@ -838,7 +838,7 @@ mod tests {
let mut stream = crate::tests::harness::tls_connect_client(tcp).await;
let mut buf = [0u8; 512];
let _ = stream.read(&mut buf).await.unwrap();
let ir = base64::engine::general_purpose::STANDARD.encode(format!("\0{login}\0{password}"));
let ir = B64.encode(format!("\0{login}\0{password}"));
stream
.write_all(format!("a1 AUTHENTICATE PLAIN {ir}\r\n").as_bytes())
.await
+1 -1
View File
@@ -40,7 +40,7 @@ tls = "implicit"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "default"
fallback = "new"
[listener.http]
protocol = "http"
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;
+187 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use super::harness::*;
@@ -516,6 +516,113 @@ tls = "plain"
handler.abort();
}
#[tokio::test]
async fn http_route_fallback_destination_used_without_mapping() {
async fn backend_default(mut sock: tokio::net::TcpStream) {
read_req_head(&mut sock).await;
sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 7\r\nConnection: close\r\n\r\ndefault")
.await
.unwrap();
}
async fn backend_fallback(mut sock: tokio::net::TcpStream) {
read_req_head(&mut sock).await;
sock.write_all(
b"HTTP/1.1 200 OK\r\nContent-Length: 8\r\nConnection: close\r\n\r\nfallback",
)
.await
.unwrap();
}
let mapping = mapping_file("alice@x\tweb_default\n");
let mut handles = Vec::new();
let cases = [
(None, "fallback"),
(Some("nobody@x"), "fallback"),
(Some("alice@x"), "default"),
];
for (user, expected) in cases {
let (port_d, bd) = dummy_backend(backend_default).await;
let (port_f, bf) = dummy_backend(backend_fallback).await;
handles.push(bd);
handles.push(bf);
let toml = format!(
r#"
[mapping]
source = "file"
normalize = "lowercase"
[mapping.file]
path = "{}"
[routing]
default_destination = "web_default"
[destination.web_default]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.web_default.protocol.http]
port = {port_d}
tls = "plain"
[destination.web_fallback]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.web_fallback.protocol.http]
port = {port_f}
tls = "plain"
[[http.route]]
match = "/**"
extract = {{ from = "authorization" }}
fallback = "web_fallback"
[listener.http]
protocol = "http"
bind = ["127.0.0.1:0"]
tls = "plain"
"#,
mapping.path().display()
);
let ctx = make_ctx(&toml).await;
let listener = ctx.config.listener["http"].clone();
let (mut client, srv) = tcp_pair().await;
let handler = tokio::spawn(async move {
let _ = crate::http::proxy::handle(
&ctx,
&listener,
Box::new(srv),
"127.0.0.1:40044".parse().unwrap(),
"127.0.0.1:80".parse().unwrap(),
)
.await;
});
let auth = user
.map(|u| {
format!(
"Authorization: Basic {}\r\n",
BASE64.encode(format!("{u}:pw"))
)
})
.unwrap_or_default();
send(
&mut client,
format!("GET / HTTP/1.1\r\nHost: x\r\n{auth}Connection: close\r\n\r\n").as_bytes(),
)
.await;
let resp = read_to_eof(&mut client).await;
assert!(
resp.ends_with(expected.as_bytes()),
"user {user:?} should reach {expected}, got {:?}",
String::from_utf8_lossy(&resp)
);
handles.push(handler);
}
for h in handles {
h.abort();
}
}
async fn read_until_backend(sock: &mut tokio::net::TcpStream, needle: &[u8]) -> Vec<u8> {
let mut buf = Vec::new();
let mut scratch = [0u8; 1024];
@@ -1346,3 +1453,82 @@ async fn http_login_delay_holds_an_authenticated_request() {
let _ = backend.await;
handler.abort();
}
#[tokio::test]
async fn http_held_login_follows_mapping_change() {
// an http request held by a login delay is sent to the destination mapped when the hold ends
async fn backend(name: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
dummy_backend(move |mut sock| async move {
read_req_head(&mut sock).await;
let _ = sock
.write_all(
format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{name}",
name.len()
)
.as_bytes(),
)
.await;
})
.await
}
let (old_port, old_backend) = backend("legacy").await;
let (new_port, new_backend) = backend("next").await;
let mapping = mapping_file("user@example.com\tweb\n");
let toml = format!(
r#"{}
[destination.next]
host = "127.0.0.1"
proxy_protocol = false
allow_plaintext_auth = true
[destination.next.protocol.http]
port = {new_port}
tls = "plain"
"#,
extract_http_config("127.0.0.1", old_port, &mapping.path().display().to_string())
);
let ctx = make_ctx(&toml).await;
let listener = ctx.config.listener["http"].clone();
let identifier = ctx.router.normalize("user@example.com");
ctx.conns.set_delay(
&identifier,
std::time::Instant::now() + std::time::Duration::from_secs(60),
);
let (mut client, srv) = tcp_pair().await;
let ctx2 = ctx.clone();
let handler = tokio::spawn(async move {
let _ = crate::http::proxy::handle(
&ctx2,
&listener,
Box::new(srv),
"127.0.0.1:40076".parse().unwrap(),
"127.0.0.1:80".parse().unwrap(),
)
.await;
});
let creds = BASE64.encode("user@example.com:pw");
send(
&mut client,
format!("GET /jmap HTTP/1.1\r\nHost: x\r\nAuthorization: Basic {creds}\r\nConnection: close\r\n\r\n").as_bytes(),
)
.await;
tokio::time::sleep(std::time::Duration::from_millis(300)).await;
ctx.router.upsert("user@example.com", "next").await.unwrap();
ctx.conns.release_delay(&identifier);
let resp = tokio::time::timeout(std::time::Duration::from_secs(5), read_to_eof(&mut client))
.await
.expect("held request was not released by the mapping change");
assert!(
find(&resp, b"\r\n\r\nnext").is_some(),
"held request must reach the new destination, got {:?}",
String::from_utf8_lossy(&resp)
);
let _ = new_backend.await;
old_backend.abort();
handler.abort();
}
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*;
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*;
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*;
+23
View File
@@ -63,6 +63,29 @@ async fn router_resolves_normalizes_and_caches() {
assert_eq!(&*router.resolve(Some("user@example.com")).await, "primary");
}
#[tokio::test]
async fn router_resolve_or_uses_given_fallback() {
let mapping = mapping_file("user@example.com\tprimary\n");
let toml = config(&mapping.path().display().to_string());
let router = build_router(&toml).await;
let fallback: std::sync::Arc<str> = "primary".into();
let legacy: std::sync::Arc<str> = "legacy".into();
assert_eq!(&*router.resolve_or(None, &fallback).await, "primary");
assert_eq!(
&*router.resolve_or(Some("nobody@x"), &fallback).await,
"primary"
);
assert_eq!(
&*router.resolve_or(Some("nobody@x"), &legacy).await,
"legacy"
);
assert_eq!(
&*router.resolve_or(Some("user@example.com"), &legacy).await,
"primary"
);
}
#[tokio::test]
async fn router_cache_hits_and_misses() {
let mapping = mapping_file("user@example.com\tprimary\n");
+1 -1
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
use encodify::base64::STANDARD as BASE64;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::harness::*;
+2 -6
View File
@@ -4,17 +4,14 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use encodify::base64::URL_SAFE_NO_PAD;
use crate::sasl::Credential;
pub fn bearer_identifier(token: &str, jwt_username_claim: &str) -> Option<String> {
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()
}
Some((_body, footer)) => URL_SAFE_NO_PAD.decode_to_string(footer).ok(),
None => None,
};
}
@@ -61,7 +58,6 @@ pub fn identifier_from_credential(cred: &Credential, jwt_username_claim: &str) -
#[cfg(test)]
mod tests {
use super::*;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
#[test]
fn sw1_footer() {