Compare commits
29
Commits
v2026.9.20
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
825f49671e | ||
|
|
29bcfecb80 | ||
|
|
e953c68e2e | ||
|
|
9379c1f151 | ||
|
|
30be928e14 | ||
|
|
7dfe4c8e70 | ||
|
|
6b1e5c67e3 | ||
|
|
04252000da | ||
|
|
1a48474957 | ||
|
|
3ce50abcaa | ||
|
|
0fb98a6f4c | ||
|
|
bc2ae32207 | ||
|
|
8ffdeea85d | ||
|
|
b73aa13fa3 | ||
|
|
3f689529c7 | ||
|
|
f95f10809a | ||
|
|
1b3ec64862 | ||
|
|
3b29ca3571 | ||
|
|
08f12fa158 | ||
|
|
f04dbc3417 | ||
|
|
c35b24b123 | ||
|
|
33c529fd8a | ||
|
|
fee6b74e79 | ||
|
|
3b68e27d3c | ||
|
|
fac33548d1 | ||
|
|
94be824147 | ||
|
|
b39694f03b | ||
|
|
e490f515a1 | ||
|
|
b8a9d5a9d9 |
@@ -0,0 +1,42 @@
|
|||||||
|
# CI on the self-hosted Gitea, ported from .gitlab-ci.yml during the move off
|
||||||
|
# GitLab (2026-09-22). Gitea reads .gitea/workflows and ignores .github/ once
|
||||||
|
# this directory exists; .github/workflows stays as it was for GitHub.
|
||||||
|
#
|
||||||
|
# Every job runs in an image pinned by digest (tag in the trailing comment),
|
||||||
|
# and the only action used is coffey-labs/actions/checkout pinned by SHA. The
|
||||||
|
# instance resolves short `uses:` against itself, never GitHub, so nothing
|
||||||
|
# unreviewed can be pulled in.
|
||||||
|
#
|
||||||
|
# Not ported, as on GitLab: publish.yml and release.yml still need doing.
|
||||||
|
name: ci
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: docker
|
||||||
|
container:
|
||||||
|
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
||||||
|
# Both kept inside the workspace (a per-job volume on the project disk),
|
||||||
|
# deliberately not on /tmp, which on this host is a tmpfs that a Rust
|
||||||
|
# build of this size has filled before. There is no cache between runs
|
||||||
|
# here -- the runner's cache server is off -- so every build is cold.
|
||||||
|
env:
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
steps:
|
||||||
|
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||||
|
- run: |
|
||||||
|
echo "CARGO_HOME=$GITHUB_WORKSPACE/.cargo" >> "$GITHUB_ENV"
|
||||||
|
echo "CARGO_TARGET_DIR=$GITHUB_WORKSPACE/target" >> "$GITHUB_ENV"
|
||||||
|
- run: apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
|
||||||
|
- run: cargo build -p inbuxa --locked
|
||||||
|
# --no-run: the workflow compiled every test target without running them,
|
||||||
|
# which catches a test that no longer builds without paying for the suite.
|
||||||
|
- run: cargo test --workspace --locked --no-run
|
||||||
@@ -37,7 +37,7 @@ jobs:
|
|||||||
# including one pushed by whoever compromises the account. Dependabot
|
# including one pushed by whoever compromises the account. Dependabot
|
||||||
# updates both halves together -- do not "simplify" a pin back to a tag.
|
# updates both halves together -- do not "simplify" a pin back to a tag.
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
- uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2.9.2
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
- name: System dependencies
|
- name: System dependencies
|
||||||
# foundationdb and the search backends are off by default, but the
|
# foundationdb and the search backends are off by default, but the
|
||||||
# default feature set still links against the system's C libraries.
|
# default feature set still links against the system's C libraries.
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Prune old image versions from GHCR.
|
||||||
|
#
|
||||||
|
# Releases are kept forever -- they carry no assets and their generated notes
|
||||||
|
# are this project's only changelog, so deleting one destroys history that
|
||||||
|
# cannot be reconstructed for nothing saved. Images are the opposite: a
|
||||||
|
# multi-arch build a week, and the by-digest push in publish.yml leaves two
|
||||||
|
# untagged per-architecture manifests behind each time on top of the tagged
|
||||||
|
# index. Those accumulate and nobody wants fifty of them.
|
||||||
|
#
|
||||||
|
# THE FOOTGUN: the obvious tool for this -- delete-package-versions with
|
||||||
|
# `delete-only-untagged-versions` -- will happily delete the per-architecture
|
||||||
|
# manifests that a multi-arch tag points *at*, because they are untagged by
|
||||||
|
# design. Nothing appears to break: the tag still exists, and pulls simply
|
||||||
|
# start failing for one architecture. This action understands manifest lists
|
||||||
|
# and will not orphan a retained index, and `validate` re-checks every
|
||||||
|
# multi-arch manifest against the registry afterwards.
|
||||||
|
#
|
||||||
|
# Separate from publish.yml, and dispatchable on its own, so `dry_run` can show
|
||||||
|
# exactly what would be deleted without rebuilding and re-pushing an image to
|
||||||
|
# find out.
|
||||||
|
name: Prune images
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: "List what would be deleted, delete nothing"
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prune:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
# The only third-party action here that is not published by GitHub or
|
||||||
|
# Docker, and the one with the most to lose: it is handed
|
||||||
|
# `packages: write` and its whole job is deletion, so a ref repointed at
|
||||||
|
# something else -- by a compromise or a mistake upstream -- is a bad
|
||||||
|
# day. It was pinned to a commit long before the rest of them were.
|
||||||
|
- uses: dataaxiom/ghcr-cleanup-action@d52806a0dc70b430571a37da1fde39733ffd640f # v1.2.2
|
||||||
|
with:
|
||||||
|
owner: inbuxa
|
||||||
|
package: inbuxa-server
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# Ten weekly releases is roughly a quarter of history, which is more
|
||||||
|
# than enough to roll back to and far less than the year's worth that
|
||||||
|
# would otherwise pile up. Older *releases* stay either way; this
|
||||||
|
# only removes the images.
|
||||||
|
keep-n-tagged: 10
|
||||||
|
# Belt and braces on top of the action's own manifest awareness:
|
||||||
|
# `latest` is never a candidate for deletion under any counting.
|
||||||
|
exclude-tags: latest
|
||||||
|
delete-untagged: true
|
||||||
|
# Sweeps the wreckage of a half-failed run: an index whose platform
|
||||||
|
# images did not all land, and referrers whose parent is gone.
|
||||||
|
delete-partial-images: true
|
||||||
|
delete-orphaned-images: true
|
||||||
|
# Checks every remaining multi-architecture manifest still resolves
|
||||||
|
# in the registry. This is the step that would catch the footgun
|
||||||
|
# above rather than leaving a reader to discover it on `docker pull`.
|
||||||
|
validate: true
|
||||||
|
dry-run: ${{ inputs.dry_run }}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
# Publish the container image to GHCR.
|
||||||
|
#
|
||||||
|
# The README and the docs site have told people to run
|
||||||
|
# `ghcr.io/inbuxa/inbuxa-server:latest` for a long time, and nothing ever
|
||||||
|
# pushed it: `docker pull` answered `denied`, because the package did not
|
||||||
|
# exist. This is the workflow that makes those instructions true. It is also
|
||||||
|
# the prerequisite for the self-hosted app catalogs -- TrueNAS and Unraid
|
||||||
|
# both install by pulling an image and neither builds from source.
|
||||||
|
#
|
||||||
|
# FIRST RUN: a package GHCR creates for the first time is **private**, even in
|
||||||
|
# a public repository, and an anonymous `docker pull` will still answer
|
||||||
|
# `denied`. Nothing in a workflow can change that -- the visibility is set once
|
||||||
|
# by hand under the package's settings, and until it is, this looks like it
|
||||||
|
# worked while the docs stay just as wrong as before. Check with a logged-out
|
||||||
|
# pull, not with one from a machine that has credentials.
|
||||||
|
#
|
||||||
|
# Two architectures, each built on its own native runner rather than under
|
||||||
|
# QEMU. Emulated arm64 has to run `npm ci` and the Vite build through
|
||||||
|
# instruction translation, which takes tens of minutes and occasionally runs
|
||||||
|
# out of memory; `ubuntu-24.04-arm` is free for public repositories and does
|
||||||
|
# the same work at native speed. The cost is the by-digest dance below: each
|
||||||
|
# runner pushes an untagged image, and a final job joins the two digests into
|
||||||
|
# one multi-arch tag.
|
||||||
|
name: Publish image
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
# Callable, so release.yml can build the release it just cut. This is not a
|
||||||
|
# stylistic choice: a release created with GITHUB_TOKEN does **not** raise a
|
||||||
|
# `release` event -- GitHub refuses to let a token trigger another workflow,
|
||||||
|
# to stop a workflow looping on its own output. A scheduled job that cut a
|
||||||
|
# release and expected this file to notice would silently never publish. The
|
||||||
|
# alternatives are a personal access token kept as a secret, or calling the
|
||||||
|
# workflow directly. This is the one that needs no credential.
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Tag, branch or SHA to build"
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
tag_latest:
|
||||||
|
description: "Also move :latest to this build"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
# Same reasoning as ci.yml's dispatch trigger: a run GitHub queues and then
|
||||||
|
# orphans can be neither rerun nor canceled, and this workflow otherwise
|
||||||
|
# only fires on a release -- which is not something to cut twice because a
|
||||||
|
# runner died. `ref` also allows publishing an image for a tag that predates
|
||||||
|
# this workflow, which is how the first one gets built.
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
description: "Tag, branch or SHA to build"
|
||||||
|
required: true
|
||||||
|
default: main
|
||||||
|
tag_latest:
|
||||||
|
description: "Also move :latest to this build"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Hardcoded rather than derived from github.repository, which would have to
|
||||||
|
# be lowercased to be a legal registry path. This is the string the docs name.
|
||||||
|
IMAGE: ghcr.io/inbuxa/inbuxa-server
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# The version is read once and handed to both builds, so the two
|
||||||
|
# architectures cannot disagree about what they are. It is read from the
|
||||||
|
# macro the binary itself compiles in, which the weekly release commits
|
||||||
|
# before this runs -- so the image is tagged with the version it reports.
|
||||||
|
version:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version: ${{ steps.v.outputs.version }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
- id: v
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
# Scoped to the macro body: branding.rs holds other string literals,
|
||||||
|
# and tagging an image from one of those would be worse than failing.
|
||||||
|
V="$(awk '/macro_rules! brand_version/,/^}/' crates/types/src/branding.rs \
|
||||||
|
| grep -om1 '"[0-9][^"]*"' | tr -d '"')"
|
||||||
|
[ -n "$V" ] || { echo "could not read brand_version! from branding.rs" >&2; exit 1; }
|
||||||
|
# A date version carries nothing a Docker tag objects to, so there is
|
||||||
|
# no second, sanitized form of it here.
|
||||||
|
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "version $V"
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: version
|
||||||
|
runs-on: ${{ matrix.runner }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- platform: linux/amd64
|
||||||
|
runner: ubuntu-latest
|
||||||
|
- platform: linux/arm64
|
||||||
|
runner: ubuntu-24.04-arm
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.ref || github.ref }}
|
||||||
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Build and push by digest
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: ${{ matrix.platform }}
|
||||||
|
# Attestations are off deliberately: they add manifests of their own
|
||||||
|
# to the index, and `imagetools create` below expects the two entries
|
||||||
|
# it pushed rather than four.
|
||||||
|
provenance: false
|
||||||
|
sbom: false
|
||||||
|
cache-from: type=gha,scope=${{ matrix.platform }}
|
||||||
|
cache-to: type=gha,mode=max,scope=${{ matrix.platform }}
|
||||||
|
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
|
||||||
|
- name: Save the digest
|
||||||
|
run: |
|
||||||
|
mkdir -p /tmp/digests
|
||||||
|
# The prefix is stripped here and put back in the merge job, so the
|
||||||
|
# filename is the bare hash. Leaving it on produces
|
||||||
|
# `image@sha256:sha256:...` when the reference is rebuilt.
|
||||||
|
digest="${{ steps.push.outputs.digest }}"
|
||||||
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
|
with:
|
||||||
|
# One artifact per platform; the merge job globs them back together.
|
||||||
|
name: digest-${{ strategy.job-index }}
|
||||||
|
path: /tmp/digests/*
|
||||||
|
retention-days: 1
|
||||||
|
if-no-files-found: error
|
||||||
|
|
||||||
|
# Joins the per-architecture digests into a single tagged manifest, so
|
||||||
|
# `docker pull ghcr.io/inbuxa/inbuxa-server:<tag>` resolves on both.
|
||||||
|
publish:
|
||||||
|
needs: [version, build]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
steps:
|
||||||
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
path: /tmp/digests
|
||||||
|
pattern: digest-*
|
||||||
|
merge-multiple: true
|
||||||
|
- uses: docker/setup-buildx-action@594f3bf4285d9ea8dc53c9a0c9c4092420091003 # v4.4.0
|
||||||
|
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
- name: Create the manifest
|
||||||
|
run: |
|
||||||
|
# Arrays rather than a string: the tags and the digest references
|
||||||
|
# have to reach docker as separate arguments, and building them by
|
||||||
|
# word-splitting an unquoted variable is the version of this that
|
||||||
|
# breaks the day a value contains a space.
|
||||||
|
tags=(-t "${IMAGE}:${{ needs.version.outputs.version }}")
|
||||||
|
# :latest follows real releases only. A prerelease that moved it
|
||||||
|
# would hand every `:latest` deployment an unfinished build, and a
|
||||||
|
# dispatch run has to ask for it on purpose.
|
||||||
|
if [ "${{ github.event_name }}" = "release" ] && [ "${{ github.event.release.prerelease }}" = "false" ]; then
|
||||||
|
tags+=(-t "${IMAGE}:latest")
|
||||||
|
elif [ "${{ inputs.tag_latest }}" = "true" ]; then
|
||||||
|
tags+=(-t "${IMAGE}:latest")
|
||||||
|
fi
|
||||||
|
refs=()
|
||||||
|
for f in /tmp/digests/*; do
|
||||||
|
refs+=("${IMAGE}@sha256:$(basename "$f")")
|
||||||
|
done
|
||||||
|
echo "tags: ${tags[*]}"
|
||||||
|
echo "refs: ${refs[*]}"
|
||||||
|
docker buildx imagetools create "${tags[@]}" "${refs[@]}"
|
||||||
|
- name: Show what landed
|
||||||
|
run: docker buildx imagetools inspect "${IMAGE}:${{ needs.version.outputs.version }}"
|
||||||
|
|
||||||
|
# Runs only after a successful publish, because that is the only moment the
|
||||||
|
# package grows. See cleanup.yml for why this is not the obvious one-liner.
|
||||||
|
prune:
|
||||||
|
needs: publish
|
||||||
|
permissions:
|
||||||
|
packages: write
|
||||||
|
uses: ./.github/workflows/cleanup.yml
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
# Cut a release once a week, but only if there is something in it.
|
||||||
|
#
|
||||||
|
# It does nothing on a quiet week. A release with no commits in it is worse
|
||||||
|
# than no release: it moves `:latest` to an identical build, spends a version
|
||||||
|
# number, and mails everybody watching the repository about nothing.
|
||||||
|
#
|
||||||
|
# INBUXA's version is a string in crates/types/src/branding.rs, deliberately
|
||||||
|
# not in Cargo.toml so that upstream's version bumps merge without conflicts.
|
||||||
|
# So this writes it: the bump is committed to main, and the tag names that
|
||||||
|
# commit. The tree a tag points at therefore reports the version the tag
|
||||||
|
# claims, which a tag placed beside an unbumped macro cannot promise.
|
||||||
|
name: Weekly release
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays, 10:07 UTC, and last of the three: INBUXA Admin and the webmail
|
||||||
|
# release ahead of the server they talk to. Staggered rather than
|
||||||
|
# simultaneous so three releases do not compete for runners, and so a bad
|
||||||
|
# Monday names one repository instead of three. GitHub runs scheduled jobs
|
||||||
|
# best-effort and can delay a run considerably, so the exact minute is not
|
||||||
|
# a promise; the odd minute keeps it off the crowded top of the hour.
|
||||||
|
#
|
||||||
|
# Note also that GitHub disables scheduled workflows in a repository with
|
||||||
|
# no activity for 60 days, which is worth checking for before assuming
|
||||||
|
# this file is broken.
|
||||||
|
- cron: "7 10 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dry_run:
|
||||||
|
description: "Work out what would be released, then stop"
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
|
||||||
|
# One at a time. Two overlapping runs would race to write the same version and
|
||||||
|
# create the same tag, and the loser fails noisily for a reason that has
|
||||||
|
# nothing to do with the code.
|
||||||
|
concurrency:
|
||||||
|
group: weekly-release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
outputs:
|
||||||
|
should_release: ${{ steps.decide.outputs.should_release }}
|
||||||
|
version: ${{ steps.decide.outputs.version }}
|
||||||
|
tag: ${{ steps.decide.outputs.tag }}
|
||||||
|
previous: ${{ steps.decide.outputs.previous }}
|
||||||
|
count: ${{ steps.decide.outputs.count }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: decide
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# The newest published release, or empty on a repository that has
|
||||||
|
# never had one -- in which case everything counts as new. Drafts are
|
||||||
|
# excluded: an unpublished draft is not a release anybody has, so
|
||||||
|
# counting from it would hide commits that have never shipped.
|
||||||
|
previous="$(gh release list --limit 1 --exclude-drafts --json tagName --jq '.[0].tagName // ""')"
|
||||||
|
# A tag named by a release is normally present after a full checkout,
|
||||||
|
# but a release can outlive its tag. Falling back to the whole
|
||||||
|
# history is the safe direction to be wrong in: it over-counts, which
|
||||||
|
# cuts a release that was due anyway, where under-counting would skip
|
||||||
|
# one that was.
|
||||||
|
if [ -n "$previous" ] && git rev-parse -q --verify "refs/tags/${previous}" >/dev/null; then
|
||||||
|
count="$(git rev-list --count "${previous}..HEAD")"
|
||||||
|
else
|
||||||
|
count="$(git rev-list --count HEAD)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# INBUXA's version is the date: YYYY.M.D, unpadded, as branding.rs
|
||||||
|
# documents. A second release on one day takes a `.N` suffix,
|
||||||
|
# counting from 2, which is why this asks the tags rather than
|
||||||
|
# assuming today is free.
|
||||||
|
today="$(date -u +%Y.%-m.%-d)"
|
||||||
|
version="$today"
|
||||||
|
n=2
|
||||||
|
while git rev-parse -q --verify "refs/tags/v${version}" >/dev/null; do
|
||||||
|
version="${today}.${n}"
|
||||||
|
n=$((n + 1))
|
||||||
|
done
|
||||||
|
|
||||||
|
should_release=true
|
||||||
|
reason=""
|
||||||
|
if [ "$count" -eq 0 ]; then
|
||||||
|
should_release=false
|
||||||
|
reason="no commits since ${previous}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "should_release=$should_release"
|
||||||
|
echo "version=$version"
|
||||||
|
echo "tag=v${version}"
|
||||||
|
echo "previous=$previous"
|
||||||
|
echo "count=$count"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Written to the run summary so a skipped week reads as a decision
|
||||||
|
# rather than as a workflow that quietly did nothing.
|
||||||
|
{
|
||||||
|
echo "### Weekly release"
|
||||||
|
echo
|
||||||
|
if [ "$should_release" = "true" ]; then
|
||||||
|
echo "Releasing **v${version}** — ${count} commit(s) since ${previous:-the beginning}."
|
||||||
|
else
|
||||||
|
echo "Nothing to release: ${reason}."
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
cut:
|
||||||
|
needs: check
|
||||||
|
if: needs.check.outputs.should_release == 'true' && !inputs.dry_run
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
outputs:
|
||||||
|
sha: ${{ steps.land.outputs.sha }}
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
|
ref: main
|
||||||
|
fetch-depth: 0
|
||||||
|
- id: bump
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.check.outputs.version }}
|
||||||
|
BRANCH: release/v${{ needs.check.outputs.version }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Scoped to the macro body rather than replacing the first quoted
|
||||||
|
# string in the file, and asserted to have matched exactly once.
|
||||||
|
# branding.rs holds other string literals, and a bump that silently
|
||||||
|
# edited one of those -- or none -- would ship a build whose version
|
||||||
|
# disagrees with its tag.
|
||||||
|
python3 - <<'PY'
|
||||||
|
import os, re
|
||||||
|
path = "crates/types/src/branding.rs"
|
||||||
|
src = open(path, encoding="utf-8").read()
|
||||||
|
pattern = re.compile(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")')
|
||||||
|
out, n = pattern.subn(lambda m: m.group(1) + os.environ["VERSION"] + m.group(2), src, count=1)
|
||||||
|
assert n == 1, f"brand_version! not found in {path}"
|
||||||
|
open(path, "w", encoding="utf-8").write(out)
|
||||||
|
PY
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add crates/types/src/branding.rs
|
||||||
|
git commit -m "Version ${VERSION}"
|
||||||
|
git push origin "HEAD:refs/heads/${BRANCH}"
|
||||||
|
|
||||||
|
# main is protected: it takes a pull request with a green build, and
|
||||||
|
# GITHUB_TOKEN is not among the bypass actors. So the bump lands the way
|
||||||
|
# every other change does. The alternative was to hand the release a
|
||||||
|
# credential that outranks the rule, which is a worse thing to own than
|
||||||
|
# a slower Monday.
|
||||||
|
- id: land
|
||||||
|
env:
|
||||||
|
VERSION: ${{ needs.check.outputs.version }}
|
||||||
|
BRANCH: release/v${{ needs.check.outputs.version }}
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
url="$(gh pr create --base main --head "${BRANCH}" \
|
||||||
|
--title "Version ${VERSION}" \
|
||||||
|
--body "Weekly release. Bumps \`brand_version!\` to ${VERSION} so the tag names a tree that reports the version the tag claims.")"
|
||||||
|
# The number, not the branch: the branch is deleted on merge, and a
|
||||||
|
# deleted branch no longer resolves to its pull request.
|
||||||
|
pr="${url##*/}"
|
||||||
|
echo "Opened #${pr}"
|
||||||
|
|
||||||
|
# The build is what the rule actually requires, and it is also the
|
||||||
|
# thing worth waiting for: a release cut from a tree that does not
|
||||||
|
# compile is the failure this whole arrangement exists to prevent.
|
||||||
|
# A full build of this tree is long, so the deadline is generous.
|
||||||
|
deadline=$(( SECONDS + 3600 ))
|
||||||
|
while :; do
|
||||||
|
state="$(gh pr view "${pr}" --json statusCheckRollup \
|
||||||
|
--jq '[.statusCheckRollup[]? | .conclusion // "PENDING"] | join(",")')"
|
||||||
|
case "${state}" in
|
||||||
|
*FAILURE*|*CANCELLED*|*TIMED_OUT*)
|
||||||
|
echo "::error::CI failed on ${BRANCH} (${state}); no release cut. PR #${pr} is left open."
|
||||||
|
exit 1 ;;
|
||||||
|
*SUCCESS*) break ;;
|
||||||
|
esac
|
||||||
|
if [ "${SECONDS}" -ge "${deadline}" ]; then
|
||||||
|
echo "::error::timed out waiting for CI on ${BRANCH}. PR #${pr} is left open."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 30
|
||||||
|
done
|
||||||
|
|
||||||
|
gh pr merge "${pr}" --rebase --delete-branch
|
||||||
|
|
||||||
|
# A rebase merge rewrites the commit, so the sha to tag is the one
|
||||||
|
# GitHub recorded for the merge, not the tip that was pushed. It can
|
||||||
|
# take a moment to appear.
|
||||||
|
sha=""
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
sha="$(gh pr view "${pr}" --json mergeCommit --jq '.mergeCommit.oid // ""')"
|
||||||
|
[ -n "${sha}" ] && break
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
if [ -z "${sha}" ]; then
|
||||||
|
echo "::error::#${pr} merged but GitHub reported no merge commit; nothing safe to tag."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "sha=${sha}" >> "$GITHUB_OUTPUT"
|
||||||
|
- env:
|
||||||
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
args=(--target "${{ steps.land.outputs.sha }}"
|
||||||
|
--title "INBUXA ${{ needs.check.outputs.version }}"
|
||||||
|
--generate-notes)
|
||||||
|
# Bound the notes to what is actually new. Without a start tag the
|
||||||
|
# generator reaches back to whatever it decides is previous, which on
|
||||||
|
# a repository carrying upstream's tag shapes is not always the last
|
||||||
|
# release.
|
||||||
|
if [ -n "${{ needs.check.outputs.previous }}" ]; then
|
||||||
|
args+=(--notes-start-tag "${{ needs.check.outputs.previous }}")
|
||||||
|
fi
|
||||||
|
gh release create "${{ needs.check.outputs.tag }}" "${args[@]}"
|
||||||
|
|
||||||
|
# Called rather than left to the `release` trigger on purpose: see the note
|
||||||
|
# at the top of publish.yml. A release created with GITHUB_TOKEN raises no
|
||||||
|
# event, so without this the tag would exist and no image would follow it.
|
||||||
|
publish:
|
||||||
|
needs: [check, cut]
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
uses: ./.github/workflows/publish.yml
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.cut.outputs.sha }}
|
||||||
|
tag_latest: true
|
||||||
@@ -9,6 +9,8 @@ run.sh
|
|||||||
!.gitignore
|
!.gitignore
|
||||||
!.gitattributes
|
!.gitattributes
|
||||||
!.github
|
!.github
|
||||||
|
!.gitlab-ci.yml
|
||||||
|
!.gitea
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
# The cutover rehearsal writes its fixture and state here.
|
# The cutover rehearsal writes its fixture and state here.
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# CI on the self-hosted GitLab, ported from .github/workflows/ci.yml when the
|
||||||
|
# GitHub account was suspended on 2026-09-20. The Actions file stays in the
|
||||||
|
# tree: it is the reference this was written from and works unchanged if the
|
||||||
|
# appeal succeeds.
|
||||||
|
#
|
||||||
|
# The image is pinned by digest, with its tag in the trailing comment. That
|
||||||
|
# replaces the SHA-pinned `uses:` in the workflow -- GitLab has no action
|
||||||
|
# allowlist, so the digest is the only thing fixing what actually runs.
|
||||||
|
#
|
||||||
|
# Not ported here:
|
||||||
|
# * cleanup.yml pruned GHCR with dataaxiom/ghcr-cleanup-action. GitLab has
|
||||||
|
# no equivalent action because it does not need one: the container
|
||||||
|
# registry has a cleanup policy on the project itself, which is where that
|
||||||
|
# job's settings now live.
|
||||||
|
# * publish.yml and release.yml still need doing; they are larger and are
|
||||||
|
# being handled separately.
|
||||||
|
|
||||||
|
stages: [build]
|
||||||
|
|
||||||
|
default:
|
||||||
|
interruptible: true
|
||||||
|
|
||||||
|
build:
|
||||||
|
stage: build
|
||||||
|
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
||||||
|
# This is a big workspace and a cold build is expensive, so the registry and
|
||||||
|
# the target directory are cached between runs. Both are kept inside the
|
||||||
|
# project directory because that is the only path the runner will cache --
|
||||||
|
# and deliberately not on /tmp, which on this host is a tmpfs that a Rust
|
||||||
|
# build of this size has filled before.
|
||||||
|
variables:
|
||||||
|
CARGO_HOME: "$CI_PROJECT_DIR/.cargo"
|
||||||
|
CARGO_TARGET_DIR: "$CI_PROJECT_DIR/target"
|
||||||
|
CARGO_INCREMENTAL: "0"
|
||||||
|
cache:
|
||||||
|
key:
|
||||||
|
files: [Cargo.lock]
|
||||||
|
paths:
|
||||||
|
- .cargo/registry/
|
||||||
|
- target/
|
||||||
|
before_script:
|
||||||
|
- apt-get update -qq && apt-get install -y -qq --no-install-recommends clang >/dev/null
|
||||||
|
script:
|
||||||
|
- cargo build -p inbuxa --locked
|
||||||
|
# --no-run: the workflow compiled every test target without running them,
|
||||||
|
# which catches a test that no longer builds without paying for the suite.
|
||||||
|
- cargo test --workspace --locked --no-run
|
||||||
|
rules:
|
||||||
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
@@ -11,6 +11,25 @@ and it should be worth that.
|
|||||||
|
|
||||||
Small fixes — a bug, a typo, a test — need no ceremony. Send them.
|
Small fixes — a bug, a typo, a test — need no ceremony. Send them.
|
||||||
|
|
||||||
|
## How a change lands
|
||||||
|
|
||||||
|
`main` is protected. It cannot be force-pushed or deleted, and a change
|
||||||
|
reaches it through a pull request whose `build` check has passed. No approving
|
||||||
|
review is required — this is a small project and a gate nobody can pass is not
|
||||||
|
a gate — but the build is not optional.
|
||||||
|
|
||||||
|
So the shape of a change is: a branch, a pull request, a green CI run, a merge.
|
||||||
|
Branches are deleted on merge. Repository administrators can bypass the rule,
|
||||||
|
which exists so the maintainer can correct the tree quickly, not so that the
|
||||||
|
ordinary path can be skipped; use it for an emergency, not for convenience.
|
||||||
|
|
||||||
|
Releases are cut weekly from `main` by `.github/workflows/release.yml`, on
|
||||||
|
Monday morning UTC, and nothing is released on a quiet week. That is the reason
|
||||||
|
the rule matters: whatever is on `main` when the run starts is what ships, so
|
||||||
|
`main` is expected to be releasable at all times rather than at the end of a
|
||||||
|
piece of work. A change that is not finished should be behind something that
|
||||||
|
defaults to off, or it should not be on `main` yet.
|
||||||
|
|
||||||
## What this repository is
|
## What this repository is
|
||||||
|
|
||||||
INBUXA is a fork of Stalwart, taken under the AGPL-3.0-only half of its dual
|
INBUXA is a fork of Stalwart, taken under the AGPL-3.0-only half of its dual
|
||||||
|
|||||||
Generated
+44
-52
@@ -597,12 +597,6 @@ version = "0.13.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "base64"
|
|
||||||
version = "0.21.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -1041,16 +1035,16 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "calcard"
|
name = "calcard"
|
||||||
version = "0.3.13"
|
version = "0.3.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "75b779382e675380a1ff8a4873acee5ba60158be7a00458fb98e6b85aad1f2ee"
|
checksum = "c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ahash",
|
"ahash",
|
||||||
"chrono",
|
"chrono",
|
||||||
"chrono-tz",
|
"chrono-tz",
|
||||||
"hashify",
|
"hashify",
|
||||||
"jmap-tools",
|
"jmap-tools",
|
||||||
"mail-builder 0.5.0",
|
"mail-builder 1.0.0",
|
||||||
"mail-parser",
|
"mail-parser",
|
||||||
"rkyv",
|
"rkyv",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1987,11 +1981,10 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "decancer"
|
name = "decancer"
|
||||||
version = "3.3.3"
|
version = "4.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
|
checksum = "453589e364ce786381e7bcbf0d659088ff7e4d3e77f948dd1cf861344acf7a86"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static",
|
|
||||||
"regex",
|
"regex",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2309,11 +2302,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ece"
|
name = "ece"
|
||||||
version = "2.3.1"
|
version = "2.4.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
|
checksum = "c2467bac73e5a36d75e16cab0fa8d40676f075db6afde7d78b35f033e1f66e37"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.21.7",
|
"base64 0.22.1",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"hex",
|
"hex",
|
||||||
"hkdf 0.12.4",
|
"hkdf 0.12.4",
|
||||||
@@ -2322,7 +2315,7 @@ dependencies = [
|
|||||||
"openssl",
|
"openssl",
|
||||||
"serde",
|
"serde",
|
||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"thiserror 1.0.69",
|
"thiserror 2.0.20",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3540,7 +3533,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.6.5",
|
"socket2 0.5.10",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -4401,9 +4394,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jsonwebtoken"
|
name = "jsonwebtoken"
|
||||||
version = "11.0.0"
|
version = "11.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65"
|
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
@@ -4616,9 +4609,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librocksdb-sys"
|
name = "librocksdb-sys"
|
||||||
version = "0.17.3+10.4.2"
|
version = "0.19.0+11.8.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9"
|
checksum = "4f45e86edad8e88efe97dbf384b4e48e1ff0f111eabf154c7b09d7a1e5fb573c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bindgen",
|
"bindgen",
|
||||||
"bzip2-sys",
|
"bzip2-sys",
|
||||||
@@ -4626,6 +4619,7 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"libz-sys",
|
"libz-sys",
|
||||||
"lz4-sys",
|
"lz4-sys",
|
||||||
|
"rustflags",
|
||||||
"zstd-sys",
|
"zstd-sys",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -5532,8 +5526,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry"
|
name = "opentelemetry"
|
||||||
version = "0.31.0"
|
version = "0.32.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-sink",
|
"futures-sink",
|
||||||
@@ -5545,8 +5539,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-http"
|
name = "opentelemetry-http"
|
||||||
version = "0.31.0"
|
version = "0.32.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -5557,10 +5551,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-otlp"
|
name = "opentelemetry-otlp"
|
||||||
version = "0.31.0"
|
version = "0.32.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"http 1.5.0",
|
"http 1.5.0",
|
||||||
|
"httpdate",
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry-http",
|
"opentelemetry-http",
|
||||||
"opentelemetry-proto",
|
"opentelemetry-proto",
|
||||||
@@ -5575,8 +5570,8 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-proto"
|
name = "opentelemetry-proto"
|
||||||
version = "0.31.0"
|
version = "0.32.0"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"opentelemetry",
|
"opentelemetry",
|
||||||
"opentelemetry_sdk",
|
"opentelemetry_sdk",
|
||||||
@@ -5587,13 +5582,13 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-semantic-conventions"
|
name = "opentelemetry-semantic-conventions"
|
||||||
version = "0.31.0"
|
version = "0.32.1"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry_sdk"
|
name = "opentelemetry_sdk"
|
||||||
version = "0.31.0"
|
version = "0.32.1"
|
||||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-executor",
|
"futures-executor",
|
||||||
@@ -6265,7 +6260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"itertools 0.14.0",
|
"itertools 0.13.0",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.119",
|
"syn 2.0.119",
|
||||||
@@ -6404,7 +6399,7 @@ dependencies = [
|
|||||||
"quinn-udp",
|
"quinn-udp",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2 0.6.5",
|
"socket2 0.5.10",
|
||||||
"thiserror 2.0.20",
|
"thiserror 2.0.20",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
@@ -6445,7 +6440,7 @@ dependencies = [
|
|||||||
"cfg_aliases",
|
"cfg_aliases",
|
||||||
"libc",
|
"libc",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.6.5",
|
"socket2 0.5.10",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
@@ -7070,9 +7065,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rocksdb"
|
name = "rocksdb"
|
||||||
version = "0.24.0"
|
version = "0.25.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f"
|
checksum = "d8d90add70d1d420ee487bce4a1449880a8d147451c6051b2ee5f8354553dcbf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"librocksdb-sys",
|
"librocksdb-sys",
|
||||||
@@ -7213,6 +7208,12 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustflags"
|
||||||
|
version = "0.1.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a39e0e9135d7a7208ee80aa4e3e4b88f0f5ad7be92153ed70686c38a03db2e63"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusticata-macros"
|
name = "rusticata-macros"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -7237,9 +7238,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.44"
|
version = "0.23.45"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aws-lc-rs",
|
"aws-lc-rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -7579,7 +7580,7 @@ dependencies = [
|
|||||||
"sha2 0.10.9",
|
"sha2 0.10.9",
|
||||||
"sha3 0.10.9",
|
"sha3 0.10.9",
|
||||||
"slh-dsa",
|
"slh-dsa",
|
||||||
"thiserror 2.0.20",
|
"thiserror 1.0.69",
|
||||||
"twofish",
|
"twofish",
|
||||||
"typenum",
|
"typenum",
|
||||||
"x25519-dalek",
|
"x25519-dalek",
|
||||||
@@ -8736,18 +8737,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tinyvec"
|
name = "tinyvec"
|
||||||
version = "1.13.2"
|
version = "1.13.3"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
|
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||||
dependencies = [
|
|
||||||
"tinyvec_macros",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tinyvec_macros"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tls-listener"
|
name = "tls-listener"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ sha2 = "0.11"
|
|||||||
md5 = "0.8.1"
|
md5 = "0.8.1"
|
||||||
whatlang = "0.18"
|
whatlang = "0.18"
|
||||||
idna = "1.1"
|
idna = "1.1"
|
||||||
decancer = "3.3.3"
|
decancer = "4.0.0"
|
||||||
unicode-security = "0.1.2"
|
unicode-security = "0.1.2"
|
||||||
infer = "0.22"
|
infer = "0.22"
|
||||||
bincode = { version = "2.0.1", features = ["serde"] }
|
bincode = { version = "2.0.1", features = ["serde"] }
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ impl Data {
|
|||||||
|
|
||||||
Data {
|
Data {
|
||||||
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
|
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
|
||||||
|
listener_control: Default::default(),
|
||||||
tls_certificates: ArcSwap::from_pointee(certificates),
|
tls_certificates: ArcSwap::from_pointee(certificates),
|
||||||
tls_self_signed_cert: build_self_signed_cert(
|
tls_self_signed_cert: build_self_signed_cert(
|
||||||
subject_names
|
subject_names
|
||||||
@@ -222,6 +223,7 @@ impl Default for Data {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
spam_classifier: Default::default(),
|
spam_classifier: Default::default(),
|
||||||
|
listener_control: Default::default(),
|
||||||
tls_certificates: Default::default(),
|
tls_certificates: Default::default(),
|
||||||
tls_self_signed_cert: Default::default(),
|
tls_self_signed_cert: Default::default(),
|
||||||
blocked_ips: Default::default(),
|
blocked_ips: Default::default(),
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ pub struct Data {
|
|||||||
pub blocked_ips: RwLock<BlockedIps>,
|
pub blocked_ips: RwLock<BlockedIps>,
|
||||||
pub lookup_stores: ArcSwap<AHashMap<Box<str>, InMemoryStore>>,
|
pub lookup_stores: ArcSwap<AHashMap<Box<str>, InMemoryStore>>,
|
||||||
|
|
||||||
|
// inbuxa: the running listeners and their shutdown switches, so one
|
||||||
|
// protocol's ports can close while the rest keep accepting (LP-2)
|
||||||
|
pub listener_control: crate::network::control::ListenerControl,
|
||||||
|
|
||||||
pub asn_geo_data: AsnGeoLookupData,
|
pub asn_geo_data: AsnGeoLookupData,
|
||||||
|
|
||||||
pub jmap_id_gen: SnowflakeIdGenerator,
|
pub jmap_id_gen: SnowflakeIdGenerator,
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Per-listener shutdown (legacy-protocols spec, LP-2).
|
||||||
|
//!
|
||||||
|
//! Upstream gives every listener a clone of one `watch` channel, so the only
|
||||||
|
//! shutdown signal that exists stops all of them at once — port 25 included.
|
||||||
|
//! That is enough for "stop the server" and no use at all for "close the IMAP
|
||||||
|
//! port and leave the rest running", which is what the legacy-protocols switch
|
||||||
|
//! needs.
|
||||||
|
//!
|
||||||
|
//! So each listener gets its own channel, and this registry holds the sending
|
||||||
|
//! ends, keyed by listener id. Firing one stops exactly one listener: the
|
||||||
|
//! accept loop in [`super::listen`] breaks and drops its `TcpListener`, which
|
||||||
|
//! closes the socket. Whole-server shutdown still works, by firing all of them
|
||||||
|
//! ([`ListenerControl::stop_all`]).
|
||||||
|
//!
|
||||||
|
//! What this does **not** do is touch the host's firewall, NAT port-forwards
|
||||||
|
//! or any proxy in front of the server (LP-20). Closing a listener means this
|
||||||
|
//! process stops answering; anything that still routes the port is the
|
||||||
|
//! operator's to reconcile, and is deliberately left alone.
|
||||||
|
|
||||||
|
use crate::config::server::{Listener, ServerProtocol};
|
||||||
|
use crate::network::TcpAcceptor;
|
||||||
|
use ahash::AHashMap;
|
||||||
|
use parking_lot::RwLock;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use tokio::sync::watch;
|
||||||
|
|
||||||
|
/// How a listener is spawned. Only `main` knows how to build the session
|
||||||
|
/// manager for a protocol, so it leaves this behind at startup and the policy
|
||||||
|
/// uses it to put a listener back without a restart (LP-5).
|
||||||
|
pub type SpawnListener = Box<dyn Fn(Listener, TcpAcceptor, watch::Receiver<bool>) + Send + Sync>;
|
||||||
|
|
||||||
|
/// A listener that is currently accepting, and the switch that stops it.
|
||||||
|
struct Running {
|
||||||
|
protocol: ServerProtocol,
|
||||||
|
ports: Vec<u16>,
|
||||||
|
shutdown_tx: watch::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a caller is told about a running listener.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ListenerInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub protocol: ServerProtocol,
|
||||||
|
pub ports: Vec<u16>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The registry of running listeners and their shutdown switches.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ListenerControl {
|
||||||
|
running: RwLock<AHashMap<String, Running>>,
|
||||||
|
spawner: OnceLock<SpawnListener>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ListenerControl {
|
||||||
|
/// Registers a listener about to be spawned, returning the receiver its
|
||||||
|
/// accept loop should select on.
|
||||||
|
pub fn register(
|
||||||
|
&self,
|
||||||
|
id: impl Into<String>,
|
||||||
|
protocol: ServerProtocol,
|
||||||
|
ports: Vec<u16>,
|
||||||
|
) -> watch::Receiver<bool> {
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
self.running.write().insert(
|
||||||
|
id.into(),
|
||||||
|
Running {
|
||||||
|
protocol,
|
||||||
|
ports,
|
||||||
|
shutdown_tx,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
shutdown_rx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remembers how to spawn a listener, once, at startup. Later calls are
|
||||||
|
/// ignored, so nothing can swap the spawner out from under a running
|
||||||
|
/// server.
|
||||||
|
pub fn set_spawner(&self, spawner: SpawnListener) {
|
||||||
|
let _ = self.spawner.set(spawner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a spawner has been left behind. Without one, a listener can be
|
||||||
|
/// stopped but not started, and the caller has to say so rather than
|
||||||
|
/// promise a port that will not open until a restart.
|
||||||
|
pub fn can_spawn(&self) -> bool {
|
||||||
|
self.spawner.get().is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a listener and registers it, so it can be stopped again.
|
||||||
|
/// Returns false when no spawner was left behind.
|
||||||
|
pub fn spawn(&self, listener: Listener, acceptor: TcpAcceptor) -> bool {
|
||||||
|
let Some(spawner) = self.spawner.get() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let ports = listener.listeners.iter().map(|l| l.addr.port()).collect();
|
||||||
|
let shutdown_rx = self.register(listener.id.clone(), listener.protocol, ports);
|
||||||
|
spawner(listener, acceptor, shutdown_rx);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops one listener by id. Returns what was stopped, or `None` when no
|
||||||
|
/// listener of that id is running.
|
||||||
|
pub fn stop(&self, id: &str) -> Option<ListenerInfo> {
|
||||||
|
let running = self.running.write().remove(id).map(|running| {
|
||||||
|
let _ = running.shutdown_tx.send(true);
|
||||||
|
ListenerInfo {
|
||||||
|
id: id.to_string(),
|
||||||
|
protocol: running.protocol,
|
||||||
|
ports: running.ports,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
running
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops every running listener whose protocol `is_legacy` accepts, except
|
||||||
|
/// those whose id is in `keep`. Returns what was stopped.
|
||||||
|
///
|
||||||
|
/// The caller decides what counts as legacy, because the inbound SMTP
|
||||||
|
/// listener shares its protocol with submission and must never be stopped
|
||||||
|
/// (LP-3); `keep` is how it is spared.
|
||||||
|
pub fn stop_matching(
|
||||||
|
&self,
|
||||||
|
is_legacy: impl Fn(ServerProtocol, &[u16]) -> bool,
|
||||||
|
keep: &[String],
|
||||||
|
) -> Vec<ListenerInfo> {
|
||||||
|
let ids: Vec<String> = {
|
||||||
|
let running = self.running.read();
|
||||||
|
running
|
||||||
|
.iter()
|
||||||
|
.filter(|(id, listener)| {
|
||||||
|
!keep.contains(id) && is_legacy(listener.protocol, &listener.ports)
|
||||||
|
})
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
ids.iter().filter_map(|id| self.stop(id)).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops everything. This is whole-server shutdown, and replaces the single
|
||||||
|
/// shared channel upstream fired.
|
||||||
|
pub fn stop_all(&self) {
|
||||||
|
for (_, running) in self.running.write().drain() {
|
||||||
|
let _ = running.shutdown_tx.send(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every listener currently accepting.
|
||||||
|
pub fn running(&self) -> Vec<ListenerInfo> {
|
||||||
|
let mut out: Vec<ListenerInfo> = self
|
||||||
|
.running
|
||||||
|
.read()
|
||||||
|
.iter()
|
||||||
|
.map(|(id, listener)| ListenerInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
protocol: listener.protocol,
|
||||||
|
ports: listener.ports.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
out.sort_by(|a, b| a.id.cmp(&b.id));
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a listener of this id is accepting.
|
||||||
|
pub fn is_running(&self, id: &str) -> bool {
|
||||||
|
self.running.read().contains_key(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn control() -> ListenerControl {
|
||||||
|
let control = ListenerControl::default();
|
||||||
|
control.register("smtp", ServerProtocol::Smtp, vec![25]);
|
||||||
|
control.register("submission", ServerProtocol::Smtp, vec![465]);
|
||||||
|
control.register("imap", ServerProtocol::Imap, vec![993]);
|
||||||
|
control.register("pop3", ServerProtocol::Pop3, vec![995]);
|
||||||
|
control.register("sieve", ServerProtocol::ManageSieve, vec![4190]);
|
||||||
|
control.register("https", ServerProtocol::Http, vec![443]);
|
||||||
|
control
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One listener stops and the others keep accepting (LP-2).
|
||||||
|
#[test]
|
||||||
|
fn stop_one_leaves_the_rest() {
|
||||||
|
let control = control();
|
||||||
|
|
||||||
|
let stopped = control.stop("imap").expect("imap was running");
|
||||||
|
assert_eq!(stopped.protocol, ServerProtocol::Imap);
|
||||||
|
assert_eq!(stopped.ports, vec![993]);
|
||||||
|
|
||||||
|
assert!(!control.is_running("imap"));
|
||||||
|
for still in ["smtp", "submission", "pop3", "sieve", "https"] {
|
||||||
|
assert!(control.is_running(still), "{still} should still accept");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stopping the same listener twice is not an error, and says so.
|
||||||
|
#[test]
|
||||||
|
fn stop_is_idempotent() {
|
||||||
|
let control = control();
|
||||||
|
assert!(control.stop("imap").is_some());
|
||||||
|
assert!(control.stop("imap").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The accept loop's receiver sees the stop.
|
||||||
|
#[test]
|
||||||
|
fn the_listener_is_told() {
|
||||||
|
let control = ListenerControl::default();
|
||||||
|
let rx = control.register("imap", ServerProtocol::Imap, vec![993]);
|
||||||
|
|
||||||
|
assert!(!*rx.borrow());
|
||||||
|
control.stop("imap");
|
||||||
|
assert!(*rx.borrow(), "the accept loop must see true and break");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legacy protocols stop; inbound SMTP and HTTPS do not (LP-1, LP-3).
|
||||||
|
#[test]
|
||||||
|
fn stop_matching_spares_inbound_and_http() {
|
||||||
|
let control = control();
|
||||||
|
let keep = vec!["smtp".to_string()];
|
||||||
|
|
||||||
|
let stopped = control.stop_matching(
|
||||||
|
|protocol, _ports| {
|
||||||
|
matches!(
|
||||||
|
protocol,
|
||||||
|
ServerProtocol::Imap
|
||||||
|
| ServerProtocol::Pop3
|
||||||
|
| ServerProtocol::ManageSieve
|
||||||
|
| ServerProtocol::Smtp
|
||||||
|
)
|
||||||
|
},
|
||||||
|
&keep,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stopped_ids: Vec<String> = stopped.into_iter().map(|l| l.id).collect();
|
||||||
|
stopped_ids.sort();
|
||||||
|
assert_eq!(stopped_ids, vec!["imap", "pop3", "sieve", "submission"]);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
control.is_running("smtp"),
|
||||||
|
"port 25 must never close (LP-3)"
|
||||||
|
);
|
||||||
|
assert!(control.is_running("https"), "JMAP must keep working");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without `closeSubmission`, submission stays open and only the mail-app
|
||||||
|
/// protocols close (LP-1).
|
||||||
|
#[test]
|
||||||
|
fn stop_matching_can_leave_submission_open() {
|
||||||
|
let control = control();
|
||||||
|
let keep = vec!["smtp".to_string(), "submission".to_string()];
|
||||||
|
|
||||||
|
let stopped = control.stop_matching(
|
||||||
|
|protocol, _ports| {
|
||||||
|
matches!(
|
||||||
|
protocol,
|
||||||
|
ServerProtocol::Imap | ServerProtocol::Pop3 | ServerProtocol::ManageSieve
|
||||||
|
)
|
||||||
|
},
|
||||||
|
&keep,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(stopped.len(), 3);
|
||||||
|
assert!(control.is_running("submission"));
|
||||||
|
assert!(control.is_running("smtp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whole-server shutdown still stops everything.
|
||||||
|
#[test]
|
||||||
|
fn stop_all_stops_everything() {
|
||||||
|
let control = control();
|
||||||
|
let rx = control.register("extra", ServerProtocol::Imap, vec![143]);
|
||||||
|
|
||||||
|
control.stop_all();
|
||||||
|
|
||||||
|
assert!(*rx.borrow());
|
||||||
|
assert!(control.running().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `running` reports what is accepting, in a stable order.
|
||||||
|
#[test]
|
||||||
|
fn running_lists_what_accepts() {
|
||||||
|
let control = control();
|
||||||
|
control.stop("pop3");
|
||||||
|
|
||||||
|
let ids: Vec<String> = control.running().into_iter().map(|l| l.id).collect();
|
||||||
|
assert_eq!(ids, vec!["https", "imap", "sieve", "smtp", "submission"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Turning the legacy-protocols switch, and making it true of the running
|
||||||
|
//! server (legacy-protocols spec, LP-1, LP-2 and LP-5).
|
||||||
|
//!
|
||||||
|
//! Two halves meet here. `inbuxa_features::security` decides what the policy
|
||||||
|
//! means and owns the listener **objects**; [`ListenerControl`] owns the
|
||||||
|
//! running **sockets**. Neither can do the job alone, and only `Server` has
|
||||||
|
//! both, so the join lives here.
|
||||||
|
//!
|
||||||
|
//! Order matters in both directions. Closing removes the object first and then
|
||||||
|
//! stops the socket: a socket stopped before its object is gone would come
|
||||||
|
//! back on the next restart. Opening puts the object back first and then
|
||||||
|
//! spawns, for the same reason in reverse.
|
||||||
|
//!
|
||||||
|
//! Sign-in is the second lock (LP-6): while the switch is off, a sign-in over
|
||||||
|
//! a legacy protocol is refused before any password is looked at, so a
|
||||||
|
//! listener that exists by mistake still lets nobody in.
|
||||||
|
//!
|
||||||
|
//! Nothing here touches the host's firewall, NAT port-forwards or any proxy
|
||||||
|
//! (LP-20). The server stops answering; what still routes the port is the
|
||||||
|
//! operator's to reconcile.
|
||||||
|
|
||||||
|
use crate::{Server, config::server::Listeners, network::TcpAcceptor};
|
||||||
|
use directory::Credentials;
|
||||||
|
use inbuxa_features::security::{
|
||||||
|
listeners,
|
||||||
|
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||||
|
};
|
||||||
|
use registry::types::{error::Error, id::ObjectId};
|
||||||
|
use store::registry::bootstrap::Bootstrap;
|
||||||
|
|
||||||
|
/// What turning the switch actually did.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct PolicyChange {
|
||||||
|
/// Listeners removed and stopped (LP-1).
|
||||||
|
pub closed: Vec<SavedListener>,
|
||||||
|
/// Listeners put back and started again (LP-5).
|
||||||
|
pub reopened: Vec<SavedListener>,
|
||||||
|
/// Listeners that could not be put back, with the reason. Each stays
|
||||||
|
/// saved for another try (LP-5).
|
||||||
|
pub failed: Vec<(SavedListener, String)>,
|
||||||
|
/// Properties the locks overruled (LP-21).
|
||||||
|
pub overruled: Vec<&'static str>,
|
||||||
|
/// Listeners whose object is right but whose socket needs a restart,
|
||||||
|
/// because no spawner was left behind. Empty on a normally booted server.
|
||||||
|
pub pending_restart: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PolicyChange {
|
||||||
|
/// Whether anything at all happened, for the caller deciding to emit
|
||||||
|
/// `security.legacy-protocols-changed` (LP-8).
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.closed.is_empty()
|
||||||
|
&& self.reopened.is_empty()
|
||||||
|
&& self.failed.is_empty()
|
||||||
|
&& self.overruled.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// The policy in force.
|
||||||
|
pub async fn protocol_policy(&self) -> trc::Result<ProtocolPolicy> {
|
||||||
|
protocol_policy::get(&self.core.storage.data).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns the switch, and makes it true of the running server.
|
||||||
|
///
|
||||||
|
/// `requested` is what the client asked for; the locks are applied to it
|
||||||
|
/// first (LP-21), so what gets stored is what the server allows, not what
|
||||||
|
/// was asked. Returns what actually happened, for the response and the
|
||||||
|
/// event.
|
||||||
|
pub async fn set_protocol_policy(
|
||||||
|
&self,
|
||||||
|
requested: ProtocolPolicy,
|
||||||
|
changed_by: Option<String>,
|
||||||
|
) -> trc::Result<PolicyChange> {
|
||||||
|
let mut policy = requested;
|
||||||
|
let mut change = PolicyChange {
|
||||||
|
overruled: policy.apply_locks(),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Carry forward what earlier changes saved: the client never sets
|
||||||
|
// this, and a /set that omitted it must not lose the listeners still
|
||||||
|
// waiting to come back.
|
||||||
|
let previous = self.protocol_policy().await?;
|
||||||
|
policy.saved_listeners = previous.saved_listeners;
|
||||||
|
policy.changed_at = Some(store::write::now() * 1000);
|
||||||
|
policy.changed_by = changed_by;
|
||||||
|
|
||||||
|
if policy.legacy_protocols.is_disabled() {
|
||||||
|
self.close_legacy_listeners(&mut policy, &mut change).await?;
|
||||||
|
} else {
|
||||||
|
self.reopen_legacy_listeners(&mut policy, &mut change)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol_policy::set(&self.core.storage.data, &policy).await?;
|
||||||
|
|
||||||
|
Ok(change)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the listener objects the policy closes, then stops their
|
||||||
|
/// sockets (LP-1, LP-2).
|
||||||
|
async fn close_legacy_listeners(
|
||||||
|
&self,
|
||||||
|
policy: &mut ProtocolPolicy,
|
||||||
|
change: &mut PolicyChange,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
let removed = listeners::close(self.registry(), policy).await?;
|
||||||
|
|
||||||
|
for saved in &removed {
|
||||||
|
// The runtime registry is keyed by the listener's name, which is
|
||||||
|
// what `close` returns as the saved listener's id.
|
||||||
|
self.inner.data.listener_control.stop(&saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
policy.saved_listeners.extend(removed.iter().cloned());
|
||||||
|
change.closed = removed;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts back every saved listener and starts it again (LP-5).
|
||||||
|
async fn reopen_legacy_listeners(
|
||||||
|
&self,
|
||||||
|
policy: &mut ProtocolPolicy,
|
||||||
|
change: &mut PolicyChange,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
if policy.saved_listeners.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = std::mem::take(&mut policy.saved_listeners);
|
||||||
|
let (restored, failed) = listeners::reopen(self.registry(), &saved).await?;
|
||||||
|
|
||||||
|
// A listener that could not be put back stays saved for another try.
|
||||||
|
policy.saved_listeners = failed.iter().map(|(listener, _)| listener.clone()).collect();
|
||||||
|
change.failed = failed;
|
||||||
|
|
||||||
|
if !restored.is_empty() {
|
||||||
|
change.pending_restart = self.spawn_restored_listeners(&restored).await?;
|
||||||
|
}
|
||||||
|
change.reopened = restored;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Binds and spawns the listeners just put back, so a port opens without a
|
||||||
|
/// restart. Returns the names that still need one.
|
||||||
|
async fn spawn_restored_listeners(&self, restored: &[SavedListener]) -> trc::Result<Vec<String>> {
|
||||||
|
let control = &self.inner.data.listener_control;
|
||||||
|
if !control.can_spawn() {
|
||||||
|
return Ok(restored.iter().map(|listener| listener.id.clone()).collect());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-parse from the registry rather than from the saved object: the
|
||||||
|
// socket has to be created and bound afresh, and the parser is what
|
||||||
|
// knows how. The objects are already back, so this sees them.
|
||||||
|
let mut bootstrap = Bootstrap::new(self.registry().clone()).await;
|
||||||
|
let mut parsed = Listeners::parse(&mut bootstrap).await;
|
||||||
|
parsed
|
||||||
|
.parse_tcp_acceptors(&mut bootstrap, self.inner.clone())
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Only the wanted listeners, so re-parsing does not bind a port some
|
||||||
|
// other listener already holds.
|
||||||
|
let wanted: Vec<&str> = restored.iter().map(|l| l.id.as_str()).collect();
|
||||||
|
parsed
|
||||||
|
.servers
|
||||||
|
.retain(|listener| wanted.contains(&listener.id.as_str()));
|
||||||
|
|
||||||
|
// Bind, but do not drop privileges again. A port below 1024 fails
|
||||||
|
// here once privileges are gone; that listener is reported as needing
|
||||||
|
// a restart rather than quietly left dead.
|
||||||
|
let errors_before = bootstrap.errors.len();
|
||||||
|
parsed.bind(&mut bootstrap);
|
||||||
|
let unbindable: Vec<ObjectId> = bootstrap.errors[errors_before..]
|
||||||
|
.iter()
|
||||||
|
.filter_map(|error| match error {
|
||||||
|
Error::Build { object_id, .. } => Some(*object_id),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
parsed
|
||||||
|
.servers
|
||||||
|
.retain(|listener| !unbindable.contains(&listener.registry_id));
|
||||||
|
|
||||||
|
let mut spawned = Vec::new();
|
||||||
|
|
||||||
|
let mut acceptors = std::mem::take(&mut parsed.tcp_acceptors);
|
||||||
|
for listener in parsed.servers {
|
||||||
|
if !wanted.contains(&listener.id.as_str()) || control.is_running(&listener.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let acceptor = acceptors
|
||||||
|
.remove(&listener.id)
|
||||||
|
.unwrap_or(TcpAcceptor::Plain);
|
||||||
|
let id = listener.id.clone();
|
||||||
|
if control.spawn(listener, acceptor) {
|
||||||
|
spawned.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(restored
|
||||||
|
.iter()
|
||||||
|
.map(|listener| listener.id.clone())
|
||||||
|
.filter(|id| !spawned.contains(id))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A protocol a mail app signs in over, which the switch refuses (LP-6).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum LegacyProtocol {
|
||||||
|
Imap,
|
||||||
|
Pop3,
|
||||||
|
ManageSieve,
|
||||||
|
/// SMTP AUTH, on any SMTP listener: only mail apps authenticate, so
|
||||||
|
/// inbound delivery is untouched (LP-3).
|
||||||
|
Submission,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegacyProtocol {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LegacyProtocol::Imap => "imap",
|
||||||
|
LegacyProtocol::Pop3 => "pop3",
|
||||||
|
LegacyProtocol::ManageSieve => "manageSieve",
|
||||||
|
LegacyProtocol::Submission => "submission",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the mail app is told, at server scope (LP-12, LP-6). Each
|
||||||
|
/// protocol's own framing — IMAP's `[ALERT]`, ManageSieve's quoting —
|
||||||
|
/// is added by its session; POP3 carries `[AUTH]` in the text, since its
|
||||||
|
/// errors have no separate code, and SMTP is the whole reply line.
|
||||||
|
pub fn refusal(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
LegacyProtocol::Imap => {
|
||||||
|
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
}
|
||||||
|
LegacyProtocol::Pop3 => {
|
||||||
|
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
}
|
||||||
|
LegacyProtocol::ManageSieve => "This server allows only INBUXA webmail and JMAP apps.",
|
||||||
|
LegacyProtocol::Submission => {
|
||||||
|
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The refusal as an error: `auth.legacy-protocol-refused`, not
|
||||||
|
/// `auth.failed`, so it never counts against the account or feeds the
|
||||||
|
/// auto-ban (LP-11). It names the protocol and the domain, never the
|
||||||
|
/// account; the session it is raised in adds the remote IP.
|
||||||
|
pub fn refused(&self, credentials: &Credentials) -> trc::Error {
|
||||||
|
trc::AuthEvent::LegacyProtocolRefused
|
||||||
|
.into_err()
|
||||||
|
.details(self.refusal())
|
||||||
|
.ctx(trc::Key::Source, self.as_str())
|
||||||
|
.ctx(trc::Key::Policy, "server")
|
||||||
|
.ctx_opt(trc::Key::Domain, domain_of(credentials))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The domain a sign-in is for, from the name it gives, if it gives one.
|
||||||
|
fn domain_of(credentials: &Credentials) -> Option<String> {
|
||||||
|
let username = match credentials {
|
||||||
|
Credentials::Basic { username, .. } => Some(username.as_str()),
|
||||||
|
Credentials::Bearer { username, .. } => username.as_deref(),
|
||||||
|
}?;
|
||||||
|
username
|
||||||
|
.rsplit_once('@')
|
||||||
|
.map(|(_, domain)| domain.trim().to_lowercase())
|
||||||
|
.filter(|domain| !domain.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Refuses a sign-in over a legacy protocol while the server-wide switch
|
||||||
|
/// is off (LP-6). Called before the credentials are checked, so the
|
||||||
|
/// answer is the same for a right password, a wrong one and an account
|
||||||
|
/// that doesn't exist (LP-11).
|
||||||
|
///
|
||||||
|
/// Read from the store on each sign-in rather than cached, so every node
|
||||||
|
/// of a cluster answers the same the moment the switch turns.
|
||||||
|
pub async fn refuse_legacy_sign_in(
|
||||||
|
&self,
|
||||||
|
protocol: LegacyProtocol,
|
||||||
|
credentials: &Credentials,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||||
|
Err(protocol.refused(credentials))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn basic(username: &str) -> Credentials {
|
||||||
|
Credentials::Basic {
|
||||||
|
username: username.to_string(),
|
||||||
|
secret: "wrong or right, it is never read".to_string(),
|
||||||
|
mfa_token: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn refusals_read_as_the_spec_writes_them() {
|
||||||
|
// LP-12, with "Your organization" read as "This server" (LP-6).
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::Imap.refusal(),
|
||||||
|
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
LegacyProtocol::Pop3
|
||||||
|
.refusal()
|
||||||
|
.starts_with("[AUTH] This server allows")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::ManageSieve.refusal(),
|
||||||
|
"This server allows only INBUXA webmail and JMAP apps."
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
LegacyProtocol::Submission.refusal(),
|
||||||
|
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_refusal_is_not_a_failed_sign_in() {
|
||||||
|
let err = LegacyProtocol::Imap.refused(&basic("[email protected]"));
|
||||||
|
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
|
||||||
|
assert!(!err.matches(trc::EventType::Auth(trc::AuthEvent::Failed)));
|
||||||
|
// The session stays open: the mail app is told, not thrown off.
|
||||||
|
assert!(!err.must_disconnect());
|
||||||
|
assert!(err.should_write_err());
|
||||||
|
assert_eq!(err.value_as_str(trc::Key::Domain), Some("example.org"));
|
||||||
|
assert_eq!(err.value_as_str(trc::Key::Source), Some("imap"));
|
||||||
|
assert_eq!(err.value_as_str(trc::Key::AccountName), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_domain_comes_from_the_name_given() {
|
||||||
|
assert_eq!(domain_of(&basic("[email protected]")), Some("b.test".to_string()));
|
||||||
|
assert_eq!(domain_of(&basic("no-domain")), None);
|
||||||
|
assert_eq!(domain_of(&basic("trailing@")), None);
|
||||||
|
let bearer = Credentials::Bearer {
|
||||||
|
username: None,
|
||||||
|
token: "t".to_string(),
|
||||||
|
};
|
||||||
|
assert_eq!(domain_of(&bearer), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ use tokio_rustls::server::TlsStream;
|
|||||||
use trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent};
|
use trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent};
|
||||||
use utils::UnwrapFailure;
|
use utils::UnwrapFailure;
|
||||||
|
|
||||||
|
use super::control::ListenerControl;
|
||||||
|
|
||||||
impl Listener {
|
impl Listener {
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
self,
|
self,
|
||||||
@@ -324,8 +326,14 @@ impl SocketOpts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Listeners {
|
impl Listeners {
|
||||||
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
|
/// Binds every socket, reporting each failure against its listener.
|
||||||
// Bind as root
|
///
|
||||||
|
/// Split out of [`Listeners::bind_and_drop_priv`] so a listener can be
|
||||||
|
/// bound again at runtime, when the legacy-protocols switch puts one back
|
||||||
|
/// (LP-5), without dropping privileges a second time. A port below 1024
|
||||||
|
/// will fail here once privileges are gone, which is one of the cases
|
||||||
|
/// LP-5 expects and reports rather than hides.
|
||||||
|
pub fn bind(&self, bp: &mut Bootstrap) {
|
||||||
for server in &self.servers {
|
for server in &self.servers {
|
||||||
for listener in &server.listeners {
|
for listener in &server.listeners {
|
||||||
if let Err(err) = listener.socket.bind(listener.addr) {
|
if let Err(err) = listener.socket.bind(listener.addr) {
|
||||||
@@ -336,6 +344,11 @@ impl Listeners {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
|
||||||
|
// Bind as root
|
||||||
|
self.bind(bp);
|
||||||
|
|
||||||
// Drop privileges
|
// Drop privileges
|
||||||
#[cfg(not(target_env = "msvc"))]
|
#[cfg(not(target_env = "msvc"))]
|
||||||
@@ -370,6 +383,38 @@ impl Listeners {
|
|||||||
}
|
}
|
||||||
(shutdown_tx, shutdown_rx)
|
(shutdown_tx, shutdown_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// As [`Listeners::spawn`], but each listener gets its own shutdown
|
||||||
|
/// channel, registered in `control` under the listener's id, so one can be
|
||||||
|
/// stopped without touching the others (legacy-protocols LP-2).
|
||||||
|
///
|
||||||
|
/// The returned sender no longer reaches the listeners: whole-server
|
||||||
|
/// shutdown must also call [`ListenerControl::stop_all`]. `control` has to
|
||||||
|
/// outlive the listeners, because it owns the sending ends — dropping it
|
||||||
|
/// would stop every listener at once.
|
||||||
|
pub fn spawn_with_control(
|
||||||
|
mut self,
|
||||||
|
control: &ListenerControl,
|
||||||
|
spawn: impl Fn(Listener, TcpAcceptor, watch::Receiver<bool>),
|
||||||
|
) -> (watch::Sender<bool>, watch::Receiver<bool>) {
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
for server in self.servers {
|
||||||
|
let acceptor = self
|
||||||
|
.tcp_acceptors
|
||||||
|
.remove(&server.id)
|
||||||
|
.unwrap_or(TcpAcceptor::Plain);
|
||||||
|
|
||||||
|
let ports = server
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.map(|listener| listener.addr.port())
|
||||||
|
.collect();
|
||||||
|
let listener_rx = control.register(server.id.clone(), server.protocol, ports);
|
||||||
|
|
||||||
|
spawn(server, acceptor, listener_rx);
|
||||||
|
}
|
||||||
|
(shutdown_tx, shutdown_rx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TcpListener {
|
impl TcpListener {
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ use utils::snowflake::SnowflakeIdGenerator;
|
|||||||
pub mod acme;
|
pub mod acme;
|
||||||
pub mod asn;
|
pub mod asn;
|
||||||
pub mod autoconfig;
|
pub mod autoconfig;
|
||||||
|
pub mod control;
|
||||||
pub mod dkim;
|
pub mod dkim;
|
||||||
pub mod dns;
|
pub mod dns;
|
||||||
|
pub mod legacy;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod listen;
|
pub mod listen;
|
||||||
pub mod mta;
|
pub mod mta;
|
||||||
|
|||||||
@@ -21,5 +21,6 @@
|
|||||||
pub mod ai;
|
pub mod ai;
|
||||||
pub mod branding;
|
pub mod branding;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
pub mod security;
|
||||||
pub mod tenancy;
|
pub mod tenancy;
|
||||||
pub mod undelete;
|
pub mod undelete;
|
||||||
|
|||||||
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Taking the legacy listeners away and putting them back (legacy-protocols
|
||||||
|
//! spec, LP-1 and LP-5).
|
||||||
|
//!
|
||||||
|
//! The switch removes the listener **objects**, not just their sockets. A
|
||||||
|
//! stopped socket comes back on the next restart, which would reopen every
|
||||||
|
//! port the operator just closed; a removed object does not. It also means a
|
||||||
|
//! server that boots with the switch on never spawns them in the first place,
|
||||||
|
//! with no boot-time special case.
|
||||||
|
//!
|
||||||
|
//! Each removed object is kept whole in the policy so LP-5 can put it back
|
||||||
|
//! exactly as it was. Closing the running socket is a separate step, and lives
|
||||||
|
//! in `common`, which owns the listener registry: this crate sits below it.
|
||||||
|
//!
|
||||||
|
//! None of this touches the host's firewall or any port-forward (LP-20).
|
||||||
|
|
||||||
|
use crate::security::protocol_policy::{ProtocolPolicy, SavedListener};
|
||||||
|
use registry::schema::{
|
||||||
|
enums::NetworkListenerProtocol,
|
||||||
|
prelude::Object,
|
||||||
|
structs::NetworkListener,
|
||||||
|
};
|
||||||
|
use store::{
|
||||||
|
RegistryStore,
|
||||||
|
registry::write::{RegistryWrite, RegistryWriteResult},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// How the registry schema spells a listener's protocol. These are the strings
|
||||||
|
/// [`ProtocolPolicy::closes`] matches on.
|
||||||
|
pub fn protocol_name(protocol: NetworkListenerProtocol) -> &'static str {
|
||||||
|
match protocol {
|
||||||
|
NetworkListenerProtocol::Smtp => "smtp",
|
||||||
|
NetworkListenerProtocol::Lmtp => "lmtp",
|
||||||
|
NetworkListenerProtocol::Http => "http",
|
||||||
|
NetworkListenerProtocol::Imap => "imap",
|
||||||
|
NetworkListenerProtocol::Pop3 => "pop3",
|
||||||
|
NetworkListenerProtocol::ManageSieve => "manageSieve",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every port a listener binds. A listener may bind several, and one of them
|
||||||
|
/// being 25 makes the whole listener inbound (LP-3).
|
||||||
|
pub fn ports(listener: &NetworkListener) -> Vec<u16> {
|
||||||
|
listener.bind.iter().map(|addr| addr.0.port()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the policy closes this listener.
|
||||||
|
pub fn closes(policy: &ProtocolPolicy, listener: &NetworkListener) -> bool {
|
||||||
|
policy.closes(protocol_name(listener.protocol), &ports(listener))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Saves a listener whole, ready to be put back (LP-5).
|
||||||
|
fn save(listener: &NetworkListener) -> trc::Result<SavedListener> {
|
||||||
|
Ok(SavedListener {
|
||||||
|
id: listener.name.clone(),
|
||||||
|
protocol: protocol_name(listener.protocol).to_string(),
|
||||||
|
ports: ports(listener),
|
||||||
|
object: serde_json::to_value(listener).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes every listener the policy closes, saving each one whole first
|
||||||
|
/// (LP-1). Returns what was removed, in the order the registry listed it.
|
||||||
|
///
|
||||||
|
/// The caller then stops the matching running sockets, by the `id` of each
|
||||||
|
/// returned listener — which is the listener's name, the same key the runtime
|
||||||
|
/// registry uses.
|
||||||
|
pub async fn close(
|
||||||
|
registry: &RegistryStore,
|
||||||
|
policy: &ProtocolPolicy,
|
||||||
|
) -> trc::Result<Vec<SavedListener>> {
|
||||||
|
let mut removed = Vec::new();
|
||||||
|
|
||||||
|
for listener in registry
|
||||||
|
.list::<NetworkListener>()
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
if !closes(policy, &listener.object) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = save(&listener.object)?;
|
||||||
|
match registry
|
||||||
|
.write(RegistryWrite::delete(listener.id))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
RegistryWriteResult::Success(_) | RegistryWriteResult::NotFound { .. } => {
|
||||||
|
removed.push(saved);
|
||||||
|
}
|
||||||
|
// Anything else means the registry declined the delete. Leave the
|
||||||
|
// listener alone and say nothing was removed, so the policy does
|
||||||
|
// not claim a port is closed while it is still accepting.
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(removed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts back every saved listener (LP-5).
|
||||||
|
///
|
||||||
|
/// Returns the ones restored and the ones that could not be, each with the
|
||||||
|
/// reason. A listener that cannot come back — its port taken in the meantime,
|
||||||
|
/// say — does not stop the others, and stays saved for another try.
|
||||||
|
pub async fn reopen(
|
||||||
|
registry: &RegistryStore,
|
||||||
|
saved: &[SavedListener],
|
||||||
|
) -> trc::Result<(Vec<SavedListener>, Vec<(SavedListener, String)>)> {
|
||||||
|
let mut restored = Vec::new();
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
|
||||||
|
for listener in saved {
|
||||||
|
let object: NetworkListener = match serde_json::from_value(listener.object.clone()) {
|
||||||
|
Ok(object) => object,
|
||||||
|
Err(err) => {
|
||||||
|
failed.push((listener.clone(), format!("saved listener unreadable: {err}")));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let object: Object = object.into();
|
||||||
|
match registry
|
||||||
|
.write(RegistryWrite::insert(&object))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
RegistryWriteResult::Success(_) => restored.push(listener.clone()),
|
||||||
|
other => failed.push((listener.clone(), format!("{other:?}"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((restored, failed))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listener objects that exist right now, as `(name, protocol, ports)`.
|
||||||
|
/// The confirmation (LP-16) lists exactly what will close, before anything
|
||||||
|
/// happens.
|
||||||
|
pub async fn would_close(
|
||||||
|
registry: &RegistryStore,
|
||||||
|
policy: &ProtocolPolicy,
|
||||||
|
) -> trc::Result<Vec<SavedListener>> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for listener in registry
|
||||||
|
.list::<NetworkListener>()
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
{
|
||||||
|
if closes(policy, &listener.object) {
|
||||||
|
out.push(save(&listener.object)?);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::security::protocol_policy::LegacyProtocols;
|
||||||
|
use registry::{schema::prelude::SocketAddr, types::map::Map};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn listener(name: &str, protocol: NetworkListenerProtocol, binds: &[&str]) -> NetworkListener {
|
||||||
|
NetworkListener {
|
||||||
|
name: name.to_string(),
|
||||||
|
protocol,
|
||||||
|
bind: Map::new(
|
||||||
|
binds
|
||||||
|
.iter()
|
||||||
|
.map(|addr| SocketAddr::from_str(addr).unwrap())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disabled() -> ProtocolPolicy {
|
||||||
|
ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The protocol names match what the policy matches on.
|
||||||
|
#[test]
|
||||||
|
fn protocol_names_are_the_schema_spelling() {
|
||||||
|
assert_eq!(protocol_name(NetworkListenerProtocol::Imap), "imap");
|
||||||
|
assert_eq!(protocol_name(NetworkListenerProtocol::Pop3), "pop3");
|
||||||
|
assert_eq!(
|
||||||
|
protocol_name(NetworkListenerProtocol::ManageSieve),
|
||||||
|
"manageSieve"
|
||||||
|
);
|
||||||
|
assert_eq!(protocol_name(NetworkListenerProtocol::Smtp), "smtp");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every bound port is seen, so a listener that also binds 25 is caught.
|
||||||
|
#[test]
|
||||||
|
fn every_bound_port_is_seen() {
|
||||||
|
let l = listener(
|
||||||
|
"mixed",
|
||||||
|
NetworkListenerProtocol::Smtp,
|
||||||
|
&["[::]:465", "0.0.0.0:25"],
|
||||||
|
);
|
||||||
|
let mut p = ports(&l);
|
||||||
|
p.sort();
|
||||||
|
assert_eq!(p, vec![25, 465]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mail-app listeners close; inbound and JMAP do not (LP-1, LP-3).
|
||||||
|
#[test]
|
||||||
|
fn the_right_listeners_close() {
|
||||||
|
let policy = disabled();
|
||||||
|
|
||||||
|
for (name, protocol, binds) in [
|
||||||
|
("imaps", NetworkListenerProtocol::Imap, &["[::]:993"][..]),
|
||||||
|
("pop3s", NetworkListenerProtocol::Pop3, &["[::]:995"][..]),
|
||||||
|
(
|
||||||
|
"sieve",
|
||||||
|
NetworkListenerProtocol::ManageSieve,
|
||||||
|
&["[::]:4190"][..],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
closes(&policy, &listener(name, protocol, binds)),
|
||||||
|
"{name} should close"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, protocol, binds) in [
|
||||||
|
("smtp", NetworkListenerProtocol::Smtp, &["[::]:25"][..]),
|
||||||
|
// Locked whole, so submission stays too (LP-21).
|
||||||
|
(
|
||||||
|
"submissions",
|
||||||
|
NetworkListenerProtocol::Smtp,
|
||||||
|
&["[::]:465"][..],
|
||||||
|
),
|
||||||
|
("https", NetworkListenerProtocol::Http, &["[::]:443"][..]),
|
||||||
|
("lmtp", NetworkListenerProtocol::Lmtp, &["[::]:11200"][..]),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
!closes(&policy, &listener(name, protocol, binds)),
|
||||||
|
"{name} must stay"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A submission listener that also binds 25 is inbound, and stays (LP-3).
|
||||||
|
/// Kept so LP-3 stays covered if the LP-21 lock is ever lifted.
|
||||||
|
#[test]
|
||||||
|
fn a_listener_that_also_binds_25_stays() {
|
||||||
|
let policy = disabled();
|
||||||
|
let l = listener(
|
||||||
|
"mixed",
|
||||||
|
NetworkListenerProtocol::Smtp,
|
||||||
|
&["[::]:465", "[::]:25"],
|
||||||
|
);
|
||||||
|
assert!(!closes(&policy, &l));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved listener keeps every field, including ones this code never
|
||||||
|
/// reads, and comes back as the same object (LP-5).
|
||||||
|
#[test]
|
||||||
|
fn a_saved_listener_round_trips() {
|
||||||
|
let mut original = listener("imaps", NetworkListenerProtocol::Imap, &["[::]:993"]);
|
||||||
|
original.socket_no_delay = true;
|
||||||
|
original.socket_backlog = Some(2048);
|
||||||
|
|
||||||
|
let saved = save(&original).unwrap();
|
||||||
|
assert_eq!(saved.id, "imaps");
|
||||||
|
assert_eq!(saved.protocol, "imap");
|
||||||
|
assert_eq!(saved.ports, vec![993]);
|
||||||
|
|
||||||
|
let back: NetworkListener = serde_json::from_value(saved.object).unwrap();
|
||||||
|
assert_eq!(back, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While the switch is off, nothing closes.
|
||||||
|
#[test]
|
||||||
|
fn enabled_closes_nothing() {
|
||||||
|
let policy = ProtocolPolicy::default();
|
||||||
|
assert!(!closes(
|
||||||
|
&policy,
|
||||||
|
&listener("imaps", NetworkListenerProtocol::Imap, &["[::]:993"])
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Security hardening INBUXA adds of its own.
|
||||||
|
//!
|
||||||
|
//! Unlike the rest of this crate, these are not rebuilds of anything upstream
|
||||||
|
//! ships. The legacy-protocols switch is INBUXA's own design, specified in
|
||||||
|
//! `legacy-protocols.md`.
|
||||||
|
|
||||||
|
pub mod listeners;
|
||||||
|
pub mod protocol_policy;
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch
|
||||||
|
//! (legacy-protocols spec, data model and LP-1 to LP-8). Stored as JSON under
|
||||||
|
//! `P` + `p` in the fork's subspace; unset fields read as the defaults.
|
||||||
|
//!
|
||||||
|
//! This module is the fact, not the act. It holds what the operator chose and
|
||||||
|
//! which listeners were taken away to honour it. Closing sockets belongs to
|
||||||
|
//! `common`, which owns the listener registry, and removing the listener
|
||||||
|
//! objects belongs to the caller that has the registry to hand: this crate
|
||||||
|
//! sits below both.
|
||||||
|
|
||||||
|
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||||
|
use store::{
|
||||||
|
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||||
|
write::{AnyClass, BatchBuilder, ValueClass},
|
||||||
|
};
|
||||||
|
use trc::AddContext;
|
||||||
|
|
||||||
|
/// Whether the legacy mail protocols may be used at all.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub enum LegacyProtocols {
|
||||||
|
/// IMAP, POP3, ManageSieve and SMTP submission work as configured.
|
||||||
|
#[default]
|
||||||
|
Enabled,
|
||||||
|
/// They are off: the ports are closed and sign-in over them is refused.
|
||||||
|
Disabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LegacyProtocols {
|
||||||
|
pub fn is_disabled(&self) -> bool {
|
||||||
|
matches!(self, LegacyProtocols::Disabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A listener taken away to honour the switch, kept whole so it can be put
|
||||||
|
/// back exactly as it was (LP-1, LP-5).
|
||||||
|
///
|
||||||
|
/// `object` is the listener's registry object verbatim. Keeping the whole
|
||||||
|
/// object rather than a few fields is what lets LP-5 promise "exactly the
|
||||||
|
/// saved listeners": a listener has proxy networks, TLS timeouts and socket
|
||||||
|
/// options that nobody should have to re-derive, and a field this code has
|
||||||
|
/// never heard of must survive the round trip too.
|
||||||
|
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct SavedListener {
|
||||||
|
/// The listener's name, as the operator knows it.
|
||||||
|
pub id: String,
|
||||||
|
/// `imap`, `pop3`, `manageSieve` or `smtp`, as the registry spells it.
|
||||||
|
pub protocol: String,
|
||||||
|
/// The ports it was accepting on, for the confirmation's list (LP-16).
|
||||||
|
pub ports: Vec<u16>,
|
||||||
|
/// The registry object, whole.
|
||||||
|
pub object: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The server-wide switch.
|
||||||
|
#[derive(Debug, Clone, PartialEq, SerdeSerialize, SerdeDeserialize)]
|
||||||
|
#[serde(rename_all = "camelCase", default)]
|
||||||
|
pub struct ProtocolPolicy {
|
||||||
|
/// The switch itself.
|
||||||
|
pub legacy_protocols: LegacyProtocols,
|
||||||
|
/// With `disabled`, also close SMTP submission (LP-3). The inbound
|
||||||
|
/// listener on port 25 is never closed, whatever this says.
|
||||||
|
pub close_submission: bool,
|
||||||
|
/// The listeners removed when the switch went off (LP-1), for LP-5.
|
||||||
|
pub saved_listeners: Vec<SavedListener>,
|
||||||
|
/// When the switch last changed, in milliseconds since the epoch.
|
||||||
|
pub changed_at: Option<u64>,
|
||||||
|
/// The account that last changed it.
|
||||||
|
pub changed_by: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ProtocolPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Enabled,
|
||||||
|
close_submission: true,
|
||||||
|
saved_listeners: Vec::new(),
|
||||||
|
changed_at: None,
|
||||||
|
changed_by: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The properties `inbuxa:ProtocolPolicy` has, as they appear over JMAP.
|
||||||
|
pub const PROPERTIES: &[&str] = &[
|
||||||
|
"legacyProtocols",
|
||||||
|
"closeSubmission",
|
||||||
|
"savedListeners",
|
||||||
|
"changedAt",
|
||||||
|
"changedBy",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The registry protocols the switch closes, as the schema spells them.
|
||||||
|
///
|
||||||
|
/// `smtp` is deliberately absent: an SMTP listener is submission or inbound
|
||||||
|
/// depending on its port, which only the caller can tell (LP-3).
|
||||||
|
pub const LEGACY_PROTOCOLS: &[&str] = &["imap", "pop3", "manageSieve"];
|
||||||
|
|
||||||
|
/// The port that always means inbound mail, and is never closed (LP-3).
|
||||||
|
pub const INBOUND_SMTP_PORT: u16 = 25;
|
||||||
|
|
||||||
|
/// Protocols the switch may never close, whatever is asked of it (LP-21).
|
||||||
|
///
|
||||||
|
/// `http` carries JMAP, so closing it would lock every account out of its mail
|
||||||
|
/// and the operator out of INBUXA Admin. `smtp` is locked whole — inbound and
|
||||||
|
/// submission alike — by John's decision of 2026-09-20; LP-3 already spared
|
||||||
|
/// inbound, and this extends it to 465 and 587. `lmtp` is internal and was
|
||||||
|
/// never a candidate.
|
||||||
|
///
|
||||||
|
/// Locking submission costs the feature nothing: the ports stay open and
|
||||||
|
/// sign-in over them is still refused (LP-6), which is the case acceptance
|
||||||
|
/// test 2 already describes.
|
||||||
|
///
|
||||||
|
/// The front ends read this list rather than carry their own copy, so
|
||||||
|
/// unlocking later is a server change and no admin release.
|
||||||
|
pub const LOCKED_PROTOCOLS: &[&str] = &["smtp", "lmtp", "http"];
|
||||||
|
|
||||||
|
/// Whether this protocol is locked open (LP-21).
|
||||||
|
pub fn is_locked(protocol: &str) -> bool {
|
||||||
|
LOCKED_PROTOCOLS
|
||||||
|
.iter()
|
||||||
|
.any(|locked| locked.eq_ignore_ascii_case(protocol))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolPolicy {
|
||||||
|
/// Whether a listener of this protocol and these ports is one the switch
|
||||||
|
/// closes. A listener bound to port 25 is inbound whatever its name, and
|
||||||
|
/// any other SMTP listener counts as submission (LP-3).
|
||||||
|
pub fn closes(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||||
|
if !self.legacy_protocols.is_disabled() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// The lock is checked first and answers for every caller, so no
|
||||||
|
// request phrasing can reach past it (LP-21).
|
||||||
|
if is_locked(protocol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if LEGACY_PROTOCOLS.contains(&protocol) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
protocol.eq_ignore_ascii_case("smtp")
|
||||||
|
&& self.close_submission
|
||||||
|
&& !ports.contains(&INBOUND_SMTP_PORT)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the locks to what a client asked for, returning what was
|
||||||
|
/// overruled so the response can say so (LP-21).
|
||||||
|
///
|
||||||
|
/// `closeSubmission` is recorded and ignored rather than refused: the
|
||||||
|
/// field is specified, and the lock is meant to be temporary.
|
||||||
|
pub fn apply_locks(&mut self) -> Vec<&'static str> {
|
||||||
|
let mut overruled = Vec::new();
|
||||||
|
if self.close_submission && is_locked("smtp") {
|
||||||
|
self.close_submission = false;
|
||||||
|
overruled.push("closeSubmission");
|
||||||
|
}
|
||||||
|
overruled
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether creating a listener of this protocol is refused right now
|
||||||
|
/// (LP-4), so a closed port cannot be quietly reopened.
|
||||||
|
pub fn refuses_new_listener(&self, protocol: &str, ports: &[u16]) -> bool {
|
||||||
|
self.closes(protocol, ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What's wrong with these values, naming the property.
|
||||||
|
pub fn check(&self) -> Result<(), (&'static str, String)> {
|
||||||
|
if self.saved_listeners.len() > 1024 {
|
||||||
|
return Err((
|
||||||
|
"savedListeners",
|
||||||
|
"must hold at most 1024 listeners".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for saved in &self.saved_listeners {
|
||||||
|
if saved.id.is_empty() {
|
||||||
|
return Err(("savedListeners", "a saved listener has no id".to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn key() -> ValueClass {
|
||||||
|
ValueClass::Any(AnyClass {
|
||||||
|
subspace: SUBSPACE_INBUXA,
|
||||||
|
key: b"Pp".to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Json(ProtocolPolicy);
|
||||||
|
|
||||||
|
impl Deserialize for Json {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
serde_json::from_slice(bytes).map(Json).map_err(|err| {
|
||||||
|
trc::StoreEvent::DataCorruption
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The policy in force.
|
||||||
|
pub async fn get(data: &Store) -> trc::Result<ProtocolPolicy> {
|
||||||
|
Ok(data
|
||||||
|
.get_value::<Json>(ValueKey::from(key()))
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.map(|Json(policy)| policy)
|
||||||
|
.unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stores a new policy.
|
||||||
|
pub async fn set(data: &Store, policy: &ProtocolPolicy) -> trc::Result<()> {
|
||||||
|
let bytes = serde_json::to_vec(policy).map_err(|err| {
|
||||||
|
trc::StoreEvent::UnexpectedError
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.reason(err)
|
||||||
|
})?;
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch.set(key(), bytes);
|
||||||
|
data.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.map(|_| ())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn disabled() -> ProtocolPolicy {
|
||||||
|
ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default is on, and every property survives the round trip.
|
||||||
|
#[test]
|
||||||
|
fn defaults_and_partial_json() {
|
||||||
|
let policy = ProtocolPolicy::default();
|
||||||
|
assert_eq!(policy.legacy_protocols, LegacyProtocols::Enabled);
|
||||||
|
assert!(policy.close_submission);
|
||||||
|
assert!(policy.check().is_ok());
|
||||||
|
|
||||||
|
let partial: ProtocolPolicy =
|
||||||
|
serde_json::from_str(r#"{"legacyProtocols": "disabled"}"#).unwrap();
|
||||||
|
assert!(partial.legacy_protocols.is_disabled());
|
||||||
|
assert!(
|
||||||
|
partial.close_submission,
|
||||||
|
"an unset closeSubmission reads as the default, true"
|
||||||
|
);
|
||||||
|
|
||||||
|
let json = serde_json::to_value(&policy).unwrap();
|
||||||
|
for property in PROPERTIES {
|
||||||
|
assert!(json.get(property).is_some(), "{property}");
|
||||||
|
}
|
||||||
|
assert_eq!(json["legacyProtocols"], "enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While the switch is on, nothing closes (LP-1).
|
||||||
|
#[test]
|
||||||
|
fn enabled_closes_nothing() {
|
||||||
|
let policy = ProtocolPolicy::default();
|
||||||
|
for (protocol, ports) in [
|
||||||
|
("imap", vec![993]),
|
||||||
|
("pop3", vec![995]),
|
||||||
|
("manageSieve", vec![4190]),
|
||||||
|
("smtp", vec![465]),
|
||||||
|
("smtp", vec![25]),
|
||||||
|
] {
|
||||||
|
assert!(!policy.closes(protocol, &ports), "{protocol} {ports:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The mail-app protocols close. Submission does not, while SMTP is
|
||||||
|
/// locked (LP-1, LP-21).
|
||||||
|
#[test]
|
||||||
|
fn disabled_closes_the_legacy_protocols() {
|
||||||
|
let policy = disabled();
|
||||||
|
assert!(policy.closes("imap", &[993]));
|
||||||
|
assert!(policy.closes("pop3", &[995]));
|
||||||
|
assert!(policy.closes("manageSieve", &[4190]));
|
||||||
|
assert!(!policy.closes("smtp", &[465]), "SMTP is locked (LP-21)");
|
||||||
|
assert!(!policy.closes("smtp", &[587]), "SMTP is locked (LP-21)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SMTP and JMAP cannot be closed, however the question is put (LP-21).
|
||||||
|
#[test]
|
||||||
|
fn smtp_and_jmap_are_locked() {
|
||||||
|
assert!(is_locked("smtp"));
|
||||||
|
assert!(is_locked("SMTP"), "the lock ignores case");
|
||||||
|
assert!(is_locked("http"));
|
||||||
|
assert!(is_locked("lmtp"));
|
||||||
|
assert!(!is_locked("imap"));
|
||||||
|
assert!(!is_locked("pop3"));
|
||||||
|
assert!(!is_locked("manageSieve"));
|
||||||
|
|
||||||
|
// Even asked for directly, with closeSubmission set by hand.
|
||||||
|
let forced = ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
close_submission: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for ports in [vec![465], vec![587], vec![25], vec![2525]] {
|
||||||
|
assert!(!forced.closes("smtp", &ports), "smtp {ports:?}");
|
||||||
|
}
|
||||||
|
assert!(!forced.closes("http", &[443]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A client asking to close submission is overruled, not refused, and the
|
||||||
|
/// overrule is reported (LP-21, acceptance test 18).
|
||||||
|
#[test]
|
||||||
|
fn close_submission_is_overruled_and_reported() {
|
||||||
|
let mut policy = ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
close_submission: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let overruled = policy.apply_locks();
|
||||||
|
assert_eq!(overruled, vec!["closeSubmission"]);
|
||||||
|
assert!(!policy.close_submission);
|
||||||
|
|
||||||
|
// Applying twice says nothing the second time.
|
||||||
|
assert!(policy.apply_locks().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Port 25 is inbound whatever the listener is called, and never closes
|
||||||
|
/// (LP-3). It is doubly safe now that SMTP is locked (LP-21), and this
|
||||||
|
/// test stands so LP-3 stays covered if the lock is ever lifted.
|
||||||
|
#[test]
|
||||||
|
fn port_25_is_never_closed() {
|
||||||
|
let policy = disabled();
|
||||||
|
assert!(!policy.closes("smtp", &[25]));
|
||||||
|
assert!(
|
||||||
|
!policy.closes("smtp", &[25, 465]),
|
||||||
|
"a listener that also binds 25 is inbound and stays"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// JMAP, DAV and internal delivery are never touched.
|
||||||
|
#[test]
|
||||||
|
fn http_and_lmtp_are_never_closed() {
|
||||||
|
let policy = disabled();
|
||||||
|
assert!(!policy.closes("http", &[443]));
|
||||||
|
assert!(!policy.closes("lmtp", &[11200]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Without `closeSubmission`, 465 and 587 stay open (acceptance test 2).
|
||||||
|
/// The lock makes this the only behaviour for now (LP-21).
|
||||||
|
#[test]
|
||||||
|
fn submission_stays_open_when_asked() {
|
||||||
|
let policy = ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
close_submission: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(!policy.closes("smtp", &[465]));
|
||||||
|
assert!(!policy.closes("smtp", &[587]));
|
||||||
|
assert!(
|
||||||
|
policy.closes("imap", &[993]),
|
||||||
|
"the mail-app protocols close regardless"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A new legacy listener is refused while the switch is on (LP-4), and an
|
||||||
|
/// inbound one is still allowed.
|
||||||
|
#[test]
|
||||||
|
fn new_legacy_listeners_are_refused() {
|
||||||
|
let policy = disabled();
|
||||||
|
assert!(policy.refuses_new_listener("imap", &[143]));
|
||||||
|
assert!(!policy.refuses_new_listener("smtp", &[25]));
|
||||||
|
assert!(!ProtocolPolicy::default().refuses_new_listener("imap", &[143]));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved listener keeps its whole registry object, so LP-5 can put back
|
||||||
|
/// fields this code never reads.
|
||||||
|
#[test]
|
||||||
|
fn saved_listeners_survive_the_round_trip() {
|
||||||
|
let policy = ProtocolPolicy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
saved_listeners: vec![SavedListener {
|
||||||
|
id: "imaps".to_string(),
|
||||||
|
protocol: "imap".to_string(),
|
||||||
|
ports: vec![993],
|
||||||
|
object: serde_json::json!({
|
||||||
|
"bind": ["[::]:993"],
|
||||||
|
"tls": {"implicit": true},
|
||||||
|
"somethingThisCodeHasNeverHeardOf": 7,
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert!(policy.check().is_ok());
|
||||||
|
|
||||||
|
let round_tripped: ProtocolPolicy =
|
||||||
|
serde_json::from_slice(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||||
|
assert_eq!(round_tripped, policy);
|
||||||
|
assert_eq!(
|
||||||
|
round_tripped.saved_listeners[0].object["somethingThisCodeHasNeverHeardOf"],
|
||||||
|
7
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved listener with no id is refused, naming the property.
|
||||||
|
#[test]
|
||||||
|
fn a_nameless_saved_listener_is_refused() {
|
||||||
|
let policy = ProtocolPolicy {
|
||||||
|
saved_listeners: vec![SavedListener {
|
||||||
|
id: String::new(),
|
||||||
|
protocol: "imap".to_string(),
|
||||||
|
ports: vec![993],
|
||||||
|
object: serde_json::Value::Null,
|
||||||
|
}],
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(policy.check().unwrap_err().0, "savedListeners");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,14 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::core::{Session, SessionData, State};
|
use crate::core::{Session, SessionData, State};
|
||||||
use common::{
|
use common::{
|
||||||
auth::AuthRequest,
|
auth::AuthRequest,
|
||||||
network::{SessionStream, limiter::LimiterResult},
|
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||||
};
|
};
|
||||||
use directory::Credentials;
|
use directory::Credentials;
|
||||||
use imap_proto::{
|
use imap_proto::{
|
||||||
@@ -67,6 +69,12 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> {
|
pub async fn authenticate(&mut self, credentials: Credentials, tag: String) -> trc::Result<()> {
|
||||||
|
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||||
|
self.server
|
||||||
|
.refuse_legacy_sign_in(LegacyProtocol::Imap, &credentials)
|
||||||
|
.await
|
||||||
|
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
let access_token = self
|
let access_token = self
|
||||||
.server
|
.server
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:ProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: the
|
||||||
|
//! server-wide legacy mail protocols switch (legacy-protocols spec). A
|
||||||
|
//! singleton, id `singleton`.
|
||||||
|
//!
|
||||||
|
//! Three of its properties are the server's to say, not the client's:
|
||||||
|
//! `savedListeners` (LP-1), `lockedProtocols` (LP-21) and `wouldClose`
|
||||||
|
//! (LP-16). A client that sets them is answered with `invalidProperties`.
|
||||||
|
|
||||||
|
use crate::object::{AnyId, JmapObject, JmapObjectId};
|
||||||
|
use jmap_tools::{Element, Key, Property};
|
||||||
|
use std::{borrow::Cow, str::FromStr};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ProtocolPolicy;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum ProtocolPolicyProperty {
|
||||||
|
Id,
|
||||||
|
/// The switch: `enabled` or `disabled`.
|
||||||
|
LegacyProtocols,
|
||||||
|
/// Whether submission closes with it. Forced false while SMTP is locked.
|
||||||
|
CloseSubmission,
|
||||||
|
/// Server-set: the listeners taken away, for LP-5.
|
||||||
|
SavedListeners,
|
||||||
|
ChangedAt,
|
||||||
|
ChangedBy,
|
||||||
|
/// Server-set: the protocols that cannot be closed, so the selector can
|
||||||
|
/// render them locked rather than carry its own list (LP-21).
|
||||||
|
LockedProtocols,
|
||||||
|
/// Server-set: exactly which listeners turning the switch would close,
|
||||||
|
/// by name and port, for the confirmation (LP-16).
|
||||||
|
WouldClose,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub enum ProtocolPolicyValue {
|
||||||
|
Id(Id),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Property for ProtocolPolicyProperty {
|
||||||
|
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||||
|
ProtocolPolicyProperty::parse(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
ProtocolPolicyProperty::Id => "id",
|
||||||
|
ProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||||
|
ProtocolPolicyProperty::CloseSubmission => "closeSubmission",
|
||||||
|
ProtocolPolicyProperty::SavedListeners => "savedListeners",
|
||||||
|
ProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||||
|
ProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||||
|
ProtocolPolicyProperty::LockedProtocols => "lockedProtocols",
|
||||||
|
ProtocolPolicyProperty::WouldClose => "wouldClose",
|
||||||
|
}
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolPolicyProperty {
|
||||||
|
fn parse(value: &str) -> Option<Self> {
|
||||||
|
hashify::tiny_map!(value.as_bytes(),
|
||||||
|
b"id" => ProtocolPolicyProperty::Id,
|
||||||
|
b"legacyProtocols" => ProtocolPolicyProperty::LegacyProtocols,
|
||||||
|
b"closeSubmission" => ProtocolPolicyProperty::CloseSubmission,
|
||||||
|
b"savedListeners" => ProtocolPolicyProperty::SavedListeners,
|
||||||
|
b"changedAt" => ProtocolPolicyProperty::ChangedAt,
|
||||||
|
b"changedBy" => ProtocolPolicyProperty::ChangedBy,
|
||||||
|
b"lockedProtocols" => ProtocolPolicyProperty::LockedProtocols,
|
||||||
|
b"wouldClose" => ProtocolPolicyProperty::WouldClose,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProtocolPolicyProperty {
|
||||||
|
/// Whether this property is the server's to say. A client that sets one
|
||||||
|
/// is answered with `invalidProperties`.
|
||||||
|
pub fn is_server_set(&self) -> bool {
|
||||||
|
matches!(
|
||||||
|
self,
|
||||||
|
ProtocolPolicyProperty::SavedListeners
|
||||||
|
| ProtocolPolicyProperty::ChangedAt
|
||||||
|
| ProtocolPolicyProperty::ChangedBy
|
||||||
|
| ProtocolPolicyProperty::LockedProtocols
|
||||||
|
| ProtocolPolicyProperty::WouldClose
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromStr for ProtocolPolicyProperty {
|
||||||
|
type Err = ();
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
ProtocolPolicyProperty::parse(s).ok_or(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Element for ProtocolPolicyValue {
|
||||||
|
type Property = ProtocolPolicyProperty;
|
||||||
|
|
||||||
|
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||||
|
match key {
|
||||||
|
Key::Property(ProtocolPolicyProperty::Id) => Id::from_str(value).ok().map(ProtocolPolicyValue::Id),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_cow(&self) -> Cow<'static, str> {
|
||||||
|
match self {
|
||||||
|
ProtocolPolicyValue::Id(id) => id.to_string().into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObject for ProtocolPolicy {
|
||||||
|
type Property = ProtocolPolicyProperty;
|
||||||
|
|
||||||
|
type Element = ProtocolPolicyValue;
|
||||||
|
|
||||||
|
type Id = Id;
|
||||||
|
|
||||||
|
type Filter = ();
|
||||||
|
|
||||||
|
type Comparator = ();
|
||||||
|
|
||||||
|
type GetArguments = ();
|
||||||
|
|
||||||
|
type SetArguments<'de> = ();
|
||||||
|
|
||||||
|
type QueryArguments = ();
|
||||||
|
|
||||||
|
type CopyArguments = ();
|
||||||
|
|
||||||
|
type ParseArguments = ();
|
||||||
|
|
||||||
|
const ID_PROPERTY: Self::Property = ProtocolPolicyProperty::Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Id> for ProtocolPolicyValue {
|
||||||
|
fn from(id: Id) -> Self {
|
||||||
|
ProtocolPolicyValue::Id(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for ProtocolPolicyValue {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
match self {
|
||||||
|
ProtocolPolicyValue::Id(id) => Some(*id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
match self {
|
||||||
|
ProtocolPolicyValue::Id(id) => Some(AnyId::Id(*id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, new_id: AnyId) -> bool {
|
||||||
|
if let AnyId::Id(id) = new_id {
|
||||||
|
*self = ProtocolPolicyValue::Id(id);
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JmapObjectId for ProtocolPolicyProperty {
|
||||||
|
fn as_id(&self) -> Option<Id> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_any_id(&self) -> Option<AnyId> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn as_id_ref(&self) -> Option<&str> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_set_id(&mut self, _: AnyId) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ pub mod email;
|
|||||||
pub mod email_submission;
|
pub mod email_submission;
|
||||||
pub mod fastmail_masked_email; // inbuxa: masked email
|
pub mod fastmail_masked_email; // inbuxa: masked email
|
||||||
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
pub mod inbuxa_ai_limits; // inbuxa: AI spam classification
|
||||||
|
pub mod inbuxa_protocol_policy; // inbuxa: legacy protocols off
|
||||||
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
||||||
pub mod file_node;
|
pub mod file_node;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ impl Response<'_> {
|
|||||||
GetResponseMethod::AiLimits(response) => {
|
GetResponseMethod::AiLimits(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
GetResponseMethod::ProtocolPolicy(response) => {
|
||||||
|
response.eval_jptr(path, &mut results)
|
||||||
|
}
|
||||||
GetResponseMethod::Principal(response) => {
|
GetResponseMethod::Principal(response) => {
|
||||||
response.eval_jptr(path, &mut results)
|
response.eval_jptr(path, &mut results)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ impl Response<'_> {
|
|||||||
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
GetRequestMethod::DeletedAccount(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
GetRequestMethod::AiLimits(request) => request.resolve_references(self)?,
|
||||||
|
GetRequestMethod::ProtocolPolicy(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
GetRequestMethod::Principal(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
GetRequestMethod::Quota(request) => request.resolve_references(self)?,
|
||||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||||
@@ -89,6 +90,9 @@ impl Response<'_> {
|
|||||||
SetRequestMethod::AiLimits(request) => {
|
SetRequestMethod::AiLimits(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
SetRequestMethod::ProtocolPolicy(request) => {
|
||||||
|
request.resolve_references(self, 1, false)?
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(request) => {
|
SetRequestMethod::AddressBook(request) => {
|
||||||
request.resolve_references(self, 1, false)?
|
request.resolve_references(self, 1, false)?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ pub enum MethodObject {
|
|||||||
DeletedAccount,
|
DeletedAccount,
|
||||||
// inbuxa: AI call limits
|
// inbuxa: AI call limits
|
||||||
AiLimits,
|
AiLimits,
|
||||||
|
ProtocolPolicy,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MethodObject {
|
impl MethodObject {
|
||||||
@@ -75,6 +76,7 @@ impl MethodObject {
|
|||||||
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
||||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||||
MethodObject::AiLimits => Capability::Inbuxa,
|
MethodObject::AiLimits => Capability::Inbuxa,
|
||||||
|
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +254,8 @@ impl MethodName {
|
|||||||
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
|
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
|
||||||
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
||||||
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
(MethodFunction::Set, MethodObject::AiLimits) => "inbuxa:AiLimits/set",
|
||||||
|
(MethodFunction::Get, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/get",
|
||||||
|
(MethodFunction::Set, MethodObject::ProtocolPolicy) => "inbuxa:ProtocolPolicy/set",
|
||||||
(method, MethodObject::Registry(obj)) => {
|
(method, MethodObject::Registry(obj)) => {
|
||||||
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
||||||
}
|
}
|
||||||
@@ -377,6 +381,8 @@ impl MethodName {
|
|||||||
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
|
"inbuxa:DeletedAccount/set" => (MethodObject::DeletedAccount, MethodFunction::Set),
|
||||||
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
"inbuxa:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
||||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||||
|
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||||
|
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||||
|
|
||||||
).or_else(|| {
|
).or_else(|| {
|
||||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||||
@@ -430,6 +436,7 @@ impl Display for MethodObject {
|
|||||||
MethodObject::MaskedEmail => "MaskedEmail",
|
MethodObject::MaskedEmail => "MaskedEmail",
|
||||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||||
|
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||||
MethodObject::Registry(obj) => {
|
MethodObject::Registry(obj) => {
|
||||||
f.write_str("x:")?;
|
f.write_str("x:")?;
|
||||||
return f.write_str(obj.as_str());
|
return f.write_str(obj.as_str());
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ pub enum GetRequestMethod {
|
|||||||
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
|
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -139,6 +140,7 @@ pub enum SetRequestMethod<'x> {
|
|||||||
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
|
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Get, MethodObject::ProtocolPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::ProtocolPolicy(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Get, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
@@ -334,6 +341,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
|||||||
return Err(de::Error::invalid_length(1, &self));
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
(MethodFunction::Set, MethodObject::ProtocolPolicy) => match seq.next_element() {
|
||||||
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::ProtocolPolicy(value)),
|
||||||
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
Ok(None) => {
|
||||||
|
return Err(de::Error::invalid_length(1, &self));
|
||||||
|
}
|
||||||
|
},
|
||||||
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
(MethodFunction::Set, MethodObject::VacationResponse) => match seq.next_element() {
|
||||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||||
Err(err) => RequestMethod::invalid(err),
|
Err(err) => RequestMethod::invalid(err),
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ pub enum GetResponseMethod {
|
|||||||
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
||||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||||
|
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -127,6 +128,7 @@ pub enum SetResponseMethod {
|
|||||||
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||||
|
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
@@ -287,6 +289,22 @@ impl<'x> From<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEm
|
|||||||
}
|
}
|
||||||
|
|
||||||
// inbuxa: AI call limits
|
// inbuxa: AI call limits
|
||||||
|
impl<'x> From<GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(value: GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>) -> Self {
|
||||||
|
ResponseMethod::Get(GetResponseMethod::ProtocolPolicy(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'x> From<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>
|
||||||
|
for ResponseMethod<'x>
|
||||||
|
{
|
||||||
|
fn from(value: SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>) -> Self {
|
||||||
|
ResponseMethod::Set(SetResponseMethod::ProtocolPolicy(Box::new(value)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
impl<'x> From<GetResponse<crate::object::inbuxa_ai_limits::AiLimits>> for ResponseMethod<'x> {
|
||||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ impl JmapAuthorization for AccessToken {
|
|||||||
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
|
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
|
||||||
// inbuxa: AI call limits, with the classifier's permissions
|
// inbuxa: AI call limits, with the classifier's permissions
|
||||||
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
GetRequestMethod::AiLimits(_) => Permission::SysSpamLlmGet,
|
||||||
|
// inbuxa: legacy protocols off. It takes listeners away and
|
||||||
|
// puts them back, so it takes the listener's permissions
|
||||||
|
GetRequestMethod::ProtocolPolicy(_) => Permission::SysNetworkListenerGet,
|
||||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||||
@@ -173,6 +176,14 @@ impl JmapAuthorization for AccessToken {
|
|||||||
Permission::SysSpamLlmUpdate,
|
Permission::SysSpamLlmUpdate,
|
||||||
Permission::SysSpamLlmUpdate,
|
Permission::SysSpamLlmUpdate,
|
||||||
),
|
),
|
||||||
|
// inbuxa: legacy protocols off, with the listener's
|
||||||
|
SetRequestMethod::ProtocolPolicy(s) => validate_set(
|
||||||
|
s,
|
||||||
|
self,
|
||||||
|
Permission::SysNetworkListenerUpdate,
|
||||||
|
Permission::SysNetworkListenerUpdate,
|
||||||
|
Permission::SysNetworkListenerUpdate,
|
||||||
|
),
|
||||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||||
s,
|
s,
|
||||||
self,
|
self,
|
||||||
@@ -282,7 +293,8 @@ impl JmapAuthorization for AccessToken {
|
|||||||
| MethodObject::SieveScript
|
| MethodObject::SieveScript
|
||||||
| MethodObject::MaskedEmail
|
| MethodObject::MaskedEmail
|
||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits => Permission::JmapEmailChanges,
|
| MethodObject::AiLimits
|
||||||
|
| MethodObject::ProtocolPolicy => Permission::JmapEmailChanges,
|
||||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -221,6 +221,9 @@ impl RequestHandler for Server {
|
|||||||
SetResponseMethod::AiLimits(set_response) => {
|
SetResponseMethod::AiLimits(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
|
SetResponseMethod::ProtocolPolicy(set_response) => {
|
||||||
|
set_response.update_created_ids(&mut response);
|
||||||
|
}
|
||||||
SetResponseMethod::AddressBook(set_response) => {
|
SetResponseMethod::AddressBook(set_response) => {
|
||||||
set_response.update_created_ids(&mut response);
|
set_response.update_created_ids(&mut response);
|
||||||
}
|
}
|
||||||
@@ -376,6 +379,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:ProtocolPolicy/get (legacy protocols off)
|
||||||
|
GetRequestMethod::ProtocolPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::protocol_policy::get(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
GetRequestMethod::Principal(req) => {
|
GetRequestMethod::Principal(req) => {
|
||||||
self.principal_get(*req, access_token).await?.into()
|
self.principal_get(*req, access_token).await?.into()
|
||||||
}
|
}
|
||||||
@@ -617,6 +627,13 @@ impl RequestHandler for Server {
|
|||||||
.await?
|
.await?
|
||||||
.into()
|
.into()
|
||||||
}
|
}
|
||||||
|
// inbuxa: inbuxa:ProtocolPolicy/set (legacy protocols off)
|
||||||
|
SetRequestMethod::ProtocolPolicy(mut req) => {
|
||||||
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
|
crate::inbuxa::protocol_policy::set(self, access_token, *req)
|
||||||
|
.await?
|
||||||
|
.into()
|
||||||
|
}
|
||||||
SetRequestMethod::AddressBook(mut req) => {
|
SetRequestMethod::AddressBook(mut req) => {
|
||||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||||
|
|||||||
@@ -418,6 +418,7 @@ impl IntermediateChangesResponse {
|
|||||||
| MethodObject::MaskedEmail
|
| MethodObject::MaskedEmail
|
||||||
| MethodObject::DeletedAccount
|
| MethodObject::DeletedAccount
|
||||||
| MethodObject::AiLimits
|
| MethodObject::AiLimits
|
||||||
|
| MethodObject::ProtocolPolicy
|
||||||
| MethodObject::Registry(_) => unreachable!(),
|
| MethodObject::Registry(_) => unreachable!(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
pub mod access;
|
pub mod access;
|
||||||
pub mod ai_limits;
|
pub mod ai_limits;
|
||||||
|
pub mod protocol_policy;
|
||||||
pub mod deleted_account;
|
pub mod deleted_account;
|
||||||
pub mod fastmail;
|
pub mod fastmail;
|
||||||
pub mod masked_email;
|
pub mod masked_email;
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! `inbuxa:ProtocolPolicy/get` and `/set`: the server-wide legacy mail
|
||||||
|
//! protocols switch (legacy-protocols spec). Server-level: a principal in a
|
||||||
|
//! tenant can neither read nor change it, and turns its own switch instead
|
||||||
|
//! (LP-9).
|
||||||
|
//!
|
||||||
|
//! `/set` does not write the policy itself. It hands what was asked to
|
||||||
|
//! [`Server::set_protocol_policy`], which applies the locks (LP-21), removes
|
||||||
|
//! or restores the listener objects (LP-1, LP-5) and closes or opens their
|
||||||
|
//! sockets (LP-2). What comes back is what actually happened.
|
||||||
|
//!
|
||||||
|
//! [`validate_listener`] is the registry's side of it: while the switch is
|
||||||
|
//! off, no listener it would close may be created, or made by an update
|
||||||
|
//! (LP-4).
|
||||||
|
|
||||||
|
use crate::registry::mapping::{ObjectResponse, RegistrySetResponse, ValidationResult};
|
||||||
|
use common::{Server, auth::AccessToken, network::legacy::PolicyChange};
|
||||||
|
use inbuxa_features::security::{
|
||||||
|
listeners,
|
||||||
|
protocol_policy::{LOCKED_PROTOCOLS, LegacyProtocols, ProtocolPolicy as Policy, SavedListener},
|
||||||
|
};
|
||||||
|
use jmap_proto::{
|
||||||
|
error::set::SetError,
|
||||||
|
method::{
|
||||||
|
get::{GetRequest, GetResponse},
|
||||||
|
set::{SetRequest, SetResponse},
|
||||||
|
},
|
||||||
|
object::inbuxa_protocol_policy::{
|
||||||
|
ProtocolPolicy, ProtocolPolicyProperty as P, ProtocolPolicyValue,
|
||||||
|
},
|
||||||
|
request::IntoValid,
|
||||||
|
};
|
||||||
|
use jmap_tools::{Key, Map, Value};
|
||||||
|
use registry::schema::{prelude::Property, structs::NetworkListener};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
type PValue = Value<'static, P, ProtocolPolicyValue>;
|
||||||
|
|
||||||
|
const ALL: &[P] = &[
|
||||||
|
P::Id,
|
||||||
|
P::LegacyProtocols,
|
||||||
|
P::CloseSubmission,
|
||||||
|
P::SavedListeners,
|
||||||
|
P::ChangedAt,
|
||||||
|
P::ChangedBy,
|
||||||
|
P::LockedProtocols,
|
||||||
|
P::WouldClose,
|
||||||
|
];
|
||||||
|
|
||||||
|
fn assert_server_level(access_token: &AccessToken) -> trc::Result<()> {
|
||||||
|
if access_token.tenant_id().is_some() {
|
||||||
|
Err(trc::JmapEvent::Forbidden
|
||||||
|
.into_err()
|
||||||
|
.details("The server-wide protocol policy is server-level."))
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A saved or would-be-closed listener, as the confirmation shows it (LP-16).
|
||||||
|
fn listener_value(listener: &SavedListener) -> PValue {
|
||||||
|
let mut out = Map::with_capacity(3);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::Id),
|
||||||
|
Value::Str(listener.id.clone().into()),
|
||||||
|
);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::LegacyProtocols),
|
||||||
|
Value::Str(listener.protocol.clone().into()),
|
||||||
|
);
|
||||||
|
out.insert_unchecked(
|
||||||
|
Key::Property(P::WouldClose),
|
||||||
|
Value::Array(
|
||||||
|
listener
|
||||||
|
.ports
|
||||||
|
.iter()
|
||||||
|
.map(|port| Value::Number((*port as u64).into()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_value(policy: &Policy, would_close: &[SavedListener], properties: &[P]) -> PValue {
|
||||||
|
let mut out = Map::with_capacity(properties.len());
|
||||||
|
for property in properties {
|
||||||
|
let value = match property {
|
||||||
|
P::Id => Value::Element(ProtocolPolicyValue::Id(Id::singleton())),
|
||||||
|
P::LegacyProtocols => Value::Str(
|
||||||
|
match policy.legacy_protocols {
|
||||||
|
LegacyProtocols::Enabled => "enabled",
|
||||||
|
LegacyProtocols::Disabled => "disabled",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
P::CloseSubmission => Value::Bool(policy.close_submission),
|
||||||
|
P::SavedListeners => Value::Array(
|
||||||
|
policy
|
||||||
|
.saved_listeners
|
||||||
|
.iter()
|
||||||
|
.map(listener_value)
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
P::ChangedAt => policy
|
||||||
|
.changed_at
|
||||||
|
.map(|at| Value::Number(at.into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
P::ChangedBy => policy
|
||||||
|
.changed_by
|
||||||
|
.as_ref()
|
||||||
|
.map(|by| Value::Str(by.clone().into()))
|
||||||
|
.unwrap_or(Value::Null),
|
||||||
|
// The selector renders these locked rather than carrying its own
|
||||||
|
// list, so unlocking later needs no admin release (LP-21).
|
||||||
|
P::LockedProtocols => Value::Array(
|
||||||
|
LOCKED_PROTOCOLS
|
||||||
|
.iter()
|
||||||
|
.map(|protocol| Value::Str((*protocol).into()))
|
||||||
|
.collect(),
|
||||||
|
),
|
||||||
|
// Exactly what turning the switch on would close, by name and
|
||||||
|
// port, so the confirmation can say so before anything happens
|
||||||
|
// (LP-16).
|
||||||
|
P::WouldClose => Value::Array(would_close.iter().map(listener_value).collect()),
|
||||||
|
};
|
||||||
|
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||||
|
}
|
||||||
|
Value::Object(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The listeners turning the switch on would close, whatever it is now.
|
||||||
|
async fn would_close(server: &Server, policy: &Policy) -> trc::Result<Vec<SavedListener>> {
|
||||||
|
let mut hypothetical = policy.clone();
|
||||||
|
hypothetical.legacy_protocols = LegacyProtocols::Disabled;
|
||||||
|
hypothetical.apply_locks();
|
||||||
|
listeners::would_close(server.registry(), &hypothetical).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:ProtocolPolicy/get`.
|
||||||
|
pub async fn get(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: GetRequest<ProtocolPolicy>,
|
||||||
|
) -> trc::Result<GetResponse<ProtocolPolicy>> {
|
||||||
|
assert_server_level(access_token)?;
|
||||||
|
let properties = request.unwrap_properties(ALL);
|
||||||
|
let (ids, not_found) = request.unwrap_ids(1)?;
|
||||||
|
let mut response = GetResponse {
|
||||||
|
account_id: request.account_id.into(),
|
||||||
|
state: None,
|
||||||
|
list: Vec::new(),
|
||||||
|
not_found,
|
||||||
|
};
|
||||||
|
|
||||||
|
let policy = server.protocol_policy().await?;
|
||||||
|
// Only worth asking the registry when the answer is wanted.
|
||||||
|
let would_close = if properties.contains(&P::WouldClose) {
|
||||||
|
would_close(server, &policy).await?
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
match ids {
|
||||||
|
None => response
|
||||||
|
.list
|
||||||
|
.push(to_value(&policy, &would_close, &properties)),
|
||||||
|
Some(ids) => {
|
||||||
|
for id in ids {
|
||||||
|
if id.is_singleton() {
|
||||||
|
response
|
||||||
|
.list
|
||||||
|
.push(to_value(&policy, &would_close, &properties));
|
||||||
|
} else {
|
||||||
|
response.push_not_found(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(
|
||||||
|
policy: &mut Policy,
|
||||||
|
property: &P,
|
||||||
|
value: &Value<'_, P, ProtocolPolicyValue>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
match property {
|
||||||
|
P::LegacyProtocols => {
|
||||||
|
policy.legacy_protocols = match value.as_str().as_deref() {
|
||||||
|
Some("enabled") => LegacyProtocols::Enabled,
|
||||||
|
Some("disabled") => LegacyProtocols::Disabled,
|
||||||
|
_ => return Err(r#"must be "enabled" or "disabled""#.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
P::CloseSubmission => {
|
||||||
|
policy.close_submission = value
|
||||||
|
.as_bool()
|
||||||
|
.ok_or_else(|| "must be true or false".to_string())?
|
||||||
|
}
|
||||||
|
P::Id => return Err("is immutable".to_string()),
|
||||||
|
// savedListeners, changedAt, changedBy, lockedProtocols and wouldClose
|
||||||
|
// are the server's to say (LP-1, LP-16, LP-21).
|
||||||
|
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
||||||
|
_ => return Err("is immutable".to_string()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Puts a property back to its default (a `null` in `/set`).
|
||||||
|
fn reset(policy: &mut Policy, property: &P, defaults: &Policy) -> Result<(), String> {
|
||||||
|
match property {
|
||||||
|
P::LegacyProtocols => policy.legacy_protocols = defaults.legacy_protocols,
|
||||||
|
P::CloseSubmission => policy.close_submission = defaults.close_submission,
|
||||||
|
P::Id => return Err("is immutable".to_string()),
|
||||||
|
other if other.is_server_set() => return Err("is set by the server".to_string()),
|
||||||
|
_ => return Err("is immutable".to_string()),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the server made of the update, when that differs from what was asked.
|
||||||
|
///
|
||||||
|
/// A locked property is overruled rather than refused (LP-21), so the client
|
||||||
|
/// is told by being handed the value that was actually stored. `None` when
|
||||||
|
/// nothing was overruled, which JMAP reads as "exactly as you asked".
|
||||||
|
fn updated_value(change: &PolicyChange) -> Option<PValue> {
|
||||||
|
if change.overruled.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut out = Map::with_capacity(change.overruled.len());
|
||||||
|
for property in &change.overruled {
|
||||||
|
if *property == "closeSubmission" {
|
||||||
|
out.insert_unchecked(Key::Property(P::CloseSubmission), Value::Bool(false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(Value::Object(out))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `inbuxa:ProtocolPolicy/set`: turns the switch. Unset (`null`) restores a
|
||||||
|
/// property's default.
|
||||||
|
pub async fn set(
|
||||||
|
server: &Server,
|
||||||
|
access_token: &AccessToken,
|
||||||
|
mut request: SetRequest<'_, ProtocolPolicy>,
|
||||||
|
) -> trc::Result<SetResponse<ProtocolPolicy>> {
|
||||||
|
assert_server_level(access_token)?;
|
||||||
|
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||||
|
for (client_id, _) in request.unwrap_create() {
|
||||||
|
response.not_created.append(client_id, SetError::singleton());
|
||||||
|
}
|
||||||
|
for id in request.unwrap_destroy().into_valid() {
|
||||||
|
response.not_destroyed.append(id, SetError::singleton());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (id, value) in request.unwrap_update().into_valid() {
|
||||||
|
if !id.is_singleton() {
|
||||||
|
response.not_updated.append(id, SetError::not_found());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut policy = server.protocol_policy().await?;
|
||||||
|
let defaults = Policy::default();
|
||||||
|
let mut error = None;
|
||||||
|
|
||||||
|
for (key, value) in value.into_expanded_object() {
|
||||||
|
let Key::Property(property) = &key else {
|
||||||
|
error = Some(SetError::invalid_properties().with_property(key.into_owned()));
|
||||||
|
break;
|
||||||
|
};
|
||||||
|
let result = if matches!(value, Value::Null) {
|
||||||
|
reset(&mut policy, property, &defaults)
|
||||||
|
} else {
|
||||||
|
apply(&mut policy, property, &value)
|
||||||
|
};
|
||||||
|
if let Err(why) = result {
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property.clone())
|
||||||
|
.with_description(why),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if error.is_none()
|
||||||
|
&& let Err((property, why)) = policy.check()
|
||||||
|
{
|
||||||
|
error = Some(
|
||||||
|
SetError::invalid_properties()
|
||||||
|
.with_property(property.parse::<P>().unwrap_or(P::Id))
|
||||||
|
.with_description(format!("{property} {why}.")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
match error {
|
||||||
|
Some(error) => response.not_updated.append(id, error),
|
||||||
|
None => {
|
||||||
|
let change = server
|
||||||
|
.set_protocol_policy(policy, Some(Id::from(access_token.account_id()).to_string()))
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// An overruled property is reported, not refused: the value
|
||||||
|
// is specified and the lock is temporary (LP-21). The update
|
||||||
|
// succeeded, so the client is told by being handed what was
|
||||||
|
// actually stored.
|
||||||
|
response.updated.append(id, updated_value(&change));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LP-4: while legacy protocols are off, a listener the switch would close
|
||||||
|
/// may not be created, nor may an update make one. Otherwise a listener could
|
||||||
|
/// quietly reopen a port the switch is meant to keep closed.
|
||||||
|
///
|
||||||
|
/// The rule is the switch's own ([`listeners::closes`]), so a locked protocol
|
||||||
|
/// or the inbound port is never refused here, and what the switch would close
|
||||||
|
/// is exactly what can't be added. Putting saved listeners back (LP-5) goes
|
||||||
|
/// through the registry directly, not through `/set`, so it isn't affected.
|
||||||
|
pub(crate) async fn validate_listener(
|
||||||
|
set: &RegistrySetResponse<'_>,
|
||||||
|
listener: &NetworkListener,
|
||||||
|
) -> ValidationResult {
|
||||||
|
let policy = set.server.protocol_policy().await?;
|
||||||
|
Ok(match listener_refusal(&policy, listener) {
|
||||||
|
Some((property, why)) => Err(SetError::invalid_properties()
|
||||||
|
.with_property(property)
|
||||||
|
.with_description(why)),
|
||||||
|
None => Ok(ObjectResponse::default()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why this listener can't exist under this policy, naming the policy and the
|
||||||
|
/// property to change, or `None` when it can.
|
||||||
|
fn listener_refusal(policy: &Policy, listener: &NetworkListener) -> Option<(Property, String)> {
|
||||||
|
if !listeners::closes(policy, listener) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let protocol = listeners::protocol_name(listener.protocol);
|
||||||
|
// A submission listener closes because of its port, not its protocol
|
||||||
|
// (LP-3), so the port is what would have to change.
|
||||||
|
let property = if protocol == "smtp" {
|
||||||
|
Property::Bind
|
||||||
|
} else {
|
||||||
|
Property::Protocol
|
||||||
|
};
|
||||||
|
Some((
|
||||||
|
property,
|
||||||
|
format!(
|
||||||
|
"Legacy mail protocols are off (inbuxa:ProtocolPolicy), and this {protocol} \
|
||||||
|
listener would reopen a port the switch keeps closed. Turn legacy protocols \
|
||||||
|
back on first."
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use registry::{
|
||||||
|
schema::{enums::NetworkListenerProtocol, prelude::SocketAddr},
|
||||||
|
types::map::Map,
|
||||||
|
};
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
fn listener(protocol: NetworkListenerProtocol, bind: &str) -> NetworkListener {
|
||||||
|
NetworkListener {
|
||||||
|
name: "new".to_string(),
|
||||||
|
protocol,
|
||||||
|
bind: Map::new(vec![SocketAddr::from_str(bind).unwrap()]),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn off() -> Policy {
|
||||||
|
let mut policy = Policy {
|
||||||
|
legacy_protocols: LegacyProtocols::Disabled,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
policy.apply_locks();
|
||||||
|
policy
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn on_refuses_nothing() {
|
||||||
|
let on = Policy::default();
|
||||||
|
for protocol in [
|
||||||
|
NetworkListenerProtocol::Imap,
|
||||||
|
NetworkListenerProtocol::Pop3,
|
||||||
|
NetworkListenerProtocol::ManageSieve,
|
||||||
|
] {
|
||||||
|
assert!(listener_refusal(&on, &listener(protocol, "[::]:1993")).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_refuses_every_legacy_protocol_naming_the_policy() {
|
||||||
|
for protocol in [
|
||||||
|
NetworkListenerProtocol::Imap,
|
||||||
|
NetworkListenerProtocol::Pop3,
|
||||||
|
NetworkListenerProtocol::ManageSieve,
|
||||||
|
] {
|
||||||
|
let (property, why) =
|
||||||
|
listener_refusal(&off(), &listener(protocol, "[::]:1993")).expect("refused");
|
||||||
|
assert_eq!(property, Property::Protocol);
|
||||||
|
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn off_still_allows_what_the_switch_never_closes() {
|
||||||
|
// Locked (LP-21) and inbound (LP-3): the switch doesn't close them,
|
||||||
|
// so there is nothing for a new one to reopen.
|
||||||
|
for (protocol, bind) in [
|
||||||
|
(NetworkListenerProtocol::Smtp, "[::]:25"),
|
||||||
|
(NetworkListenerProtocol::Smtp, "[::]:587"),
|
||||||
|
(NetworkListenerProtocol::Http, "[::]:443"),
|
||||||
|
(NetworkListenerProtocol::Lmtp, "[::]:24"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
listener_refusal(&off(), &listener(protocol, bind)).is_none(),
|
||||||
|
"{protocol:?} on {bind}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -502,6 +502,11 @@ impl RegistrySet for Server {
|
|||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
}
|
}
|
||||||
|
// inbuxa: legacy-protocols LP-4
|
||||||
|
ObjectInner::NetworkListener(listener) => {
|
||||||
|
crate::inbuxa::protocol_policy::validate_listener(&set, listener)
|
||||||
|
.await?
|
||||||
|
}
|
||||||
// inbuxa: ME-12 to ME-17
|
// inbuxa: ME-12 to ME-17
|
||||||
ObjectInner::MaskedEmail(mask) => {
|
ObjectInner::MaskedEmail(mask) => {
|
||||||
let old = match &modification {
|
let old = match &modification {
|
||||||
|
|||||||
+78
-36
@@ -9,14 +9,21 @@
|
|||||||
#![warn(clippy::cast_possible_wrap)]
|
#![warn(clippy::cast_possible_wrap)]
|
||||||
#![warn(clippy::cast_sign_loss)]
|
#![warn(clippy::cast_sign_loss)]
|
||||||
|
|
||||||
use common::{BuildServer, config::server::ServerProtocol, manager::boot::BootManager};
|
use common::{
|
||||||
|
BuildServer, Inner,
|
||||||
|
config::server::{Listener, ServerProtocol},
|
||||||
|
manager::boot::BootManager,
|
||||||
|
network::TcpAcceptor,
|
||||||
|
};
|
||||||
use http::HttpSessionManager;
|
use http::HttpSessionManager;
|
||||||
use imap::core::ImapSessionManager;
|
use imap::core::ImapSessionManager;
|
||||||
use managesieve::core::ManageSieveSessionManager;
|
use managesieve::core::ManageSieveSessionManager;
|
||||||
use pop3::Pop3SessionManager;
|
use pop3::Pop3SessionManager;
|
||||||
use services::{StartServices, broadcast::subscriber::spawn_broadcast_subscriber};
|
use services::{StartServices, broadcast::subscriber::spawn_broadcast_subscriber};
|
||||||
use smtp::{StartQueueManager, core::SmtpSessionManager};
|
use smtp::{StartQueueManager, core::SmtpSessionManager};
|
||||||
|
use std::sync::Arc;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
use tokio::sync::watch;
|
||||||
use trc::Collector;
|
use trc::Collector;
|
||||||
use utils::wait_for_shutdown;
|
use utils::wait_for_shutdown;
|
||||||
|
|
||||||
@@ -70,42 +77,31 @@ async fn main() -> std::io::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Spawn servers
|
// Spawn servers
|
||||||
let (shutdown_tx, shutdown_rx) = init.servers.spawn(|server, acceptor, shutdown_rx| {
|
// Each listener gets its own shutdown channel, registered under its id, so
|
||||||
match &server.protocol {
|
// the legacy-protocols switch can close one protocol's ports and leave the
|
||||||
ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn(
|
// rest accepting (legacy-protocols LP-2). The registry lives in `Data` and
|
||||||
SmtpSessionManager::new(init.inner.clone()),
|
// so outlives the listeners, which it must: it owns the sending ends.
|
||||||
init.inner.clone(),
|
let listener_control = &init.inner.data.listener_control;
|
||||||
acceptor,
|
let spawn_inner = init.inner.clone();
|
||||||
shutdown_rx,
|
let (shutdown_tx, shutdown_rx) =
|
||||||
),
|
init.servers
|
||||||
ServerProtocol::Http => server.spawn(
|
.spawn_with_control(listener_control, |server, acceptor, shutdown_rx| {
|
||||||
HttpSessionManager::new(init.inner.clone()),
|
spawn_listener(&spawn_inner, server, acceptor, shutdown_rx);
|
||||||
init.inner.clone(),
|
});
|
||||||
acceptor,
|
|
||||||
shutdown_rx,
|
// Leave behind how to spawn a listener, so putting one back opens its port
|
||||||
),
|
// without a restart (LP-5). Only this file knows the session manager for a
|
||||||
ServerProtocol::Imap => server.spawn(
|
// protocol, so only this file can say.
|
||||||
ImapSessionManager::new(init.inner.clone()),
|
let spawn_inner = init.inner.clone();
|
||||||
init.inner.clone(),
|
init.inner
|
||||||
acceptor,
|
.data
|
||||||
shutdown_rx,
|
.listener_control
|
||||||
),
|
.set_spawner(Box::new(move |server, acceptor, shutdown_rx| {
|
||||||
ServerProtocol::Pop3 => server.spawn(
|
spawn_listener(&spawn_inner, server, acceptor, shutdown_rx);
|
||||||
Pop3SessionManager::new(init.inner.clone()),
|
}));
|
||||||
init.inner.clone(),
|
|
||||||
acceptor,
|
|
||||||
shutdown_rx,
|
|
||||||
),
|
|
||||||
ServerProtocol::ManageSieve => server.spawn(
|
|
||||||
ManageSieveSessionManager::new(init.inner.clone()),
|
|
||||||
init.inner.clone(),
|
|
||||||
acceptor,
|
|
||||||
shutdown_rx,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start broadcast subscriber
|
// Start broadcast subscriber
|
||||||
|
let inner = init.inner.clone();
|
||||||
spawn_broadcast_subscriber(init.inner, shutdown_rx);
|
spawn_broadcast_subscriber(init.inner, shutdown_rx);
|
||||||
|
|
||||||
// Wait for shutdown signal
|
// Wait for shutdown signal
|
||||||
@@ -114,11 +110,57 @@ async fn main() -> std::io::Result<()> {
|
|||||||
// Shutdown collector
|
// Shutdown collector
|
||||||
Collector::shutdown();
|
Collector::shutdown();
|
||||||
|
|
||||||
// Stop services
|
// Stop services, then the listeners: the shutdown sender no longer reaches
|
||||||
|
// them, since each holds its own channel (LP-2).
|
||||||
let _ = shutdown_tx.send(true);
|
let _ = shutdown_tx.send(true);
|
||||||
|
inner.data.listener_control.stop_all();
|
||||||
|
|
||||||
// Wait for services to finish
|
// Wait for services to finish
|
||||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Starts one listener under the session manager its protocol calls for.
|
||||||
|
///
|
||||||
|
/// Used twice: once for every listener at startup, and again whenever the
|
||||||
|
/// legacy-protocols switch puts a listener back (LP-5).
|
||||||
|
fn spawn_listener(
|
||||||
|
inner: &Arc<Inner>,
|
||||||
|
server: Listener,
|
||||||
|
acceptor: TcpAcceptor,
|
||||||
|
shutdown_rx: watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
|
match &server.protocol {
|
||||||
|
ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn(
|
||||||
|
SmtpSessionManager::new(inner.clone()),
|
||||||
|
inner.clone(),
|
||||||
|
acceptor,
|
||||||
|
shutdown_rx,
|
||||||
|
),
|
||||||
|
ServerProtocol::Http => server.spawn(
|
||||||
|
HttpSessionManager::new(inner.clone()),
|
||||||
|
inner.clone(),
|
||||||
|
acceptor,
|
||||||
|
shutdown_rx,
|
||||||
|
),
|
||||||
|
ServerProtocol::Imap => server.spawn(
|
||||||
|
ImapSessionManager::new(inner.clone()),
|
||||||
|
inner.clone(),
|
||||||
|
acceptor,
|
||||||
|
shutdown_rx,
|
||||||
|
),
|
||||||
|
ServerProtocol::Pop3 => server.spawn(
|
||||||
|
Pop3SessionManager::new(inner.clone()),
|
||||||
|
inner.clone(),
|
||||||
|
acceptor,
|
||||||
|
shutdown_rx,
|
||||||
|
),
|
||||||
|
ServerProtocol::ManageSieve => server.spawn(
|
||||||
|
ManageSieveSessionManager::new(inner.clone()),
|
||||||
|
inner.clone(),
|
||||||
|
acceptor,
|
||||||
|
shutdown_rx,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::core::{Command, Session, State, StatusResponse};
|
use crate::core::{Command, Session, State, StatusResponse};
|
||||||
use common::{
|
use common::{
|
||||||
auth::AuthRequest,
|
auth::AuthRequest,
|
||||||
network::{SessionStream, limiter::LimiterResult},
|
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||||
};
|
};
|
||||||
use directory::Credentials;
|
use directory::Credentials;
|
||||||
use imap_proto::{
|
use imap_proto::{
|
||||||
@@ -65,6 +67,11 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||||
|
self.server
|
||||||
|
.refuse_legacy_sign_in(LegacyProtocol::ManageSieve, &credentials)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
let access_token = self
|
let access_token = self
|
||||||
.server
|
.server
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -10,7 +12,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
use common::{
|
use common::{
|
||||||
auth::AuthRequest,
|
auth::AuthRequest,
|
||||||
network::{SessionStream, limiter::LimiterResult},
|
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||||
};
|
};
|
||||||
use directory::Credentials;
|
use directory::Credentials;
|
||||||
use mail_parser::decoders::base64::base64_decode;
|
use mail_parser::decoders::base64::base64_decode;
|
||||||
@@ -61,6 +63,11 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> {
|
pub async fn handle_auth(&mut self, credentials: Credentials) -> trc::Result<()> {
|
||||||
|
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||||
|
self.server
|
||||||
|
.refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials)
|
||||||
|
.await?;
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
let access_token = self
|
let access_token = self
|
||||||
.server
|
.server
|
||||||
|
|||||||
@@ -2,10 +2,15 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::core::Session;
|
use crate::core::Session;
|
||||||
use common::{auth::AuthRequest, network::SessionStream};
|
use common::{
|
||||||
|
auth::AuthRequest,
|
||||||
|
network::{SessionStream, legacy::LegacyProtocol},
|
||||||
|
};
|
||||||
use directory::Credentials;
|
use directory::Credentials;
|
||||||
use mail_parser::decoders::base64::base64_decode;
|
use mail_parser::decoders::base64::base64_decode;
|
||||||
use registry::schema::enums::Permission;
|
use registry::schema::enums::Permission;
|
||||||
@@ -108,6 +113,26 @@ impl<T: SessionStream> Session<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> {
|
pub async fn authenticate(&mut self, credentials: Credentials) -> Result<bool, ()> {
|
||||||
|
// inbuxa: legacy-protocols LP-6. Refused before the password is looked
|
||||||
|
// at, and not counted as an authentication error (LP-11). Only mail
|
||||||
|
// apps authenticate, so this never touches inbound delivery (LP-3).
|
||||||
|
if let Err(err) = self
|
||||||
|
.server
|
||||||
|
.refuse_legacy_sign_in(LegacyProtocol::Submission, &credentials)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
let refused = err.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused));
|
||||||
|
trc::error!(err.span_id(self.data.session_id));
|
||||||
|
if refused {
|
||||||
|
self.write(LegacyProtocol::Submission.refusal().as_bytes())
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Authenticate
|
// Authenticate
|
||||||
let result = self
|
let result = self
|
||||||
.server
|
.server
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ tokio = { version = "1.53", features = ["net", "macros"] }
|
|||||||
psl = "2"
|
psl = "2"
|
||||||
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
|
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
|
||||||
idna = "1.1"
|
idna = "1.1"
|
||||||
decancer = "3.3.3"
|
decancer = "4.0.0"
|
||||||
unicode-security = "0.1.2"
|
unicode-security = "0.1.2"
|
||||||
infer = "0.22"
|
infer = "0.22"
|
||||||
hashify = "0.2"
|
hashify = "0.2"
|
||||||
|
|||||||
@@ -1136,7 +1136,7 @@ impl<'x> Tokens<'x> {
|
|||||||
{
|
{
|
||||||
if word.len() > MAX_TOKEN_LENGTH {
|
if word.len() > MAX_TOKEN_LENGTH {
|
||||||
self.insert(Token::Word {
|
self.insert(Token::Word {
|
||||||
value: truncate_word(cured_word.as_str(), MAX_TOKEN_LENGTH)
|
value: truncate_word(&cured_word, MAX_TOKEN_LENGTH)
|
||||||
.to_string()
|
.to_string()
|
||||||
.into(),
|
.into(),
|
||||||
});
|
});
|
||||||
@@ -1282,7 +1282,6 @@ impl Token<'static> {
|
|||||||
} else if !is_ascii {
|
} else if !is_ascii {
|
||||||
let word: String = if let Ok(cured) = decancer::cure(s, decancer::Options::default()) {
|
let word: String = if let Ok(cured) = decancer::cure(s, decancer::Options::default()) {
|
||||||
cured
|
cured
|
||||||
.as_str()
|
|
||||||
.chars()
|
.chars()
|
||||||
.filter(|ch| ch.is_alphabetic())
|
.filter(|ch| ch.is_alphabetic())
|
||||||
.take(MAX_TOKEN_LENGTH)
|
.take(MAX_TOKEN_LENGTH)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ types = { path = "../types" }
|
|||||||
nlp = { path = "../nlp" }
|
nlp = { path = "../nlp" }
|
||||||
trc = { path = "../trc" }
|
trc = { path = "../trc" }
|
||||||
registry = { path = "../registry" }
|
registry = { path = "../registry" }
|
||||||
rocksdb = { version = "0.24", optional = true, features = ["multi-threaded-cf"] }
|
rocksdb = { version = "0.25", optional = true, features = ["multi-threaded-cf"] }
|
||||||
foundationdb = { version = "0.11", features = ["embedded-fdb-include", "fdb-7_4"], optional = true }
|
foundationdb = { version = "0.11", features = ["embedded-fdb-include", "fdb-7_4"], optional = true }
|
||||||
rusqlite = { version = "0.40", features = ["bundled"], optional = true }
|
rusqlite = { version = "0.40", features = ["bundled"], optional = true }
|
||||||
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"], optional = true }
|
rust-s3 = { version = "0.37", default-features = false, features = ["tokio-rustls-tls"], optional = true }
|
||||||
|
|||||||
@@ -8,8 +8,9 @@
|
|||||||
|
|
||||||
// This file is auto-generated. Do not edit directly.
|
// This file is auto-generated. Do not edit directly.
|
||||||
|
|
||||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54)
|
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
|
||||||
pub const TOTAL_EVENT_COUNT: usize = 642;
|
// auth.legacy-protocol-refused (legacy-protocols LP-6)
|
||||||
|
pub const TOTAL_EVENT_COUNT: usize = 643;
|
||||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
@@ -116,6 +117,8 @@ pub enum AuthEvent {
|
|||||||
Error = 34,
|
Error = 34,
|
||||||
Warning = 595,
|
Warning = 595,
|
||||||
CredentialExpired = 276,
|
CredentialExpired = 276,
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
LegacyProtocolRefused = 642,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ impl EventType {
|
|||||||
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
|
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
|
||||||
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
|
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
|
||||||
b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration),
|
b"auth.client-registration" => EventType::Auth(AuthEvent::ClientRegistration),
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
b"auth.legacy-protocol-refused" => EventType::Auth(AuthEvent::LegacyProtocolRefused),
|
||||||
b"auth.error" => EventType::Auth(AuthEvent::Error),
|
b"auth.error" => EventType::Auth(AuthEvent::Error),
|
||||||
b"auth.warning" => EventType::Auth(AuthEvent::Warning),
|
b"auth.warning" => EventType::Auth(AuthEvent::Warning),
|
||||||
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
|
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
|
||||||
@@ -705,6 +707,8 @@ impl EventType {
|
|||||||
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
|
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
|
||||||
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
|
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
|
||||||
EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration",
|
EventType::Auth(AuthEvent::ClientRegistration) => "auth.client-registration",
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "auth.legacy-protocol-refused",
|
||||||
EventType::Auth(AuthEvent::Error) => "auth.error",
|
EventType::Auth(AuthEvent::Error) => "auth.error",
|
||||||
EventType::Auth(AuthEvent::Warning) => "auth.warning",
|
EventType::Auth(AuthEvent::Warning) => "auth.warning",
|
||||||
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
|
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
|
||||||
@@ -1489,6 +1493,8 @@ impl EventType {
|
|||||||
EventType::Auth(AuthEvent::MfaRequired) => 36,
|
EventType::Auth(AuthEvent::MfaRequired) => 36,
|
||||||
EventType::Auth(AuthEvent::TooManyAttempts) => 38,
|
EventType::Auth(AuthEvent::TooManyAttempts) => 38,
|
||||||
EventType::Auth(AuthEvent::ClientRegistration) => 555,
|
EventType::Auth(AuthEvent::ClientRegistration) => 555,
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642,
|
||||||
EventType::Auth(AuthEvent::Error) => 34,
|
EventType::Auth(AuthEvent::Error) => 34,
|
||||||
EventType::Auth(AuthEvent::Warning) => 595,
|
EventType::Auth(AuthEvent::Warning) => 595,
|
||||||
EventType::Auth(AuthEvent::CredentialExpired) => 276,
|
EventType::Auth(AuthEvent::CredentialExpired) => 276,
|
||||||
@@ -2137,6 +2143,8 @@ impl EventType {
|
|||||||
36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
|
36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
|
||||||
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
|
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
|
||||||
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
|
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)),
|
||||||
34 => Some(EventType::Auth(AuthEvent::Error)),
|
34 => Some(EventType::Auth(AuthEvent::Error)),
|
||||||
595 => Some(EventType::Auth(AuthEvent::Warning)),
|
595 => Some(EventType::Auth(AuthEvent::Warning)),
|
||||||
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
|
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
|
||||||
@@ -2848,6 +2856,8 @@ impl EventType {
|
|||||||
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
|
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
|
||||||
EventType::Auth(AuthEvent::Success) => Level::Info,
|
EventType::Auth(AuthEvent::Success) => Level::Info,
|
||||||
EventType::Auth(AuthEvent::ClientRegistration) => Level::Info,
|
EventType::Auth(AuthEvent::ClientRegistration) => Level::Info,
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
EventType::Auth(AuthEvent::LegacyProtocolRefused) => Level::Info,
|
||||||
EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info,
|
EventType::Calendar(CalendarEvent::AlarmSent) => Level::Info,
|
||||||
EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info,
|
EventType::Calendar(CalendarEvent::ItipMessageSent) => Level::Info,
|
||||||
EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info,
|
EventType::Calendar(CalendarEvent::ItipMessageReceived) => Level::Info,
|
||||||
@@ -3187,6 +3197,8 @@ impl EventType {
|
|||||||
EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication",
|
EventType::Auth(AuthEvent::MfaRequired) => "Missing MFA token for authentication",
|
||||||
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
||||||
EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration",
|
EventType::Auth(AuthEvent::ClientRegistration) => "OAuth Client registration",
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "Legacy mail protocol sign-in refused",
|
||||||
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
||||||
EventType::Auth(AuthEvent::Warning) => "Authentication warning",
|
EventType::Auth(AuthEvent::Warning) => "Authentication warning",
|
||||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||||
@@ -3951,6 +3963,8 @@ impl EventType {
|
|||||||
}
|
}
|
||||||
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
||||||
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
|
EventType::Auth(AuthEvent::ClientRegistration) => "Authentication error",
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
EventType::Auth(AuthEvent::LegacyProtocolRefused) => "This server allows only INBUXA webmail and JMAP apps",
|
||||||
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
EventType::Auth(AuthEvent::Error) => "Authentication error",
|
||||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||||
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
|
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
|
||||||
@@ -4259,6 +4273,8 @@ impl EventType {
|
|||||||
EventType::Auth(AuthEvent::MfaRequired),
|
EventType::Auth(AuthEvent::MfaRequired),
|
||||||
EventType::Auth(AuthEvent::TooManyAttempts),
|
EventType::Auth(AuthEvent::TooManyAttempts),
|
||||||
EventType::Auth(AuthEvent::ClientRegistration),
|
EventType::Auth(AuthEvent::ClientRegistration),
|
||||||
|
// inbuxa: legacy-protocols LP-6
|
||||||
|
EventType::Auth(AuthEvent::LegacyProtocolRefused),
|
||||||
EventType::Auth(AuthEvent::Error),
|
EventType::Auth(AuthEvent::Error),
|
||||||
EventType::Auth(AuthEvent::Warning),
|
EventType::Auth(AuthEvent::Warning),
|
||||||
EventType::Auth(AuthEvent::CredentialExpired),
|
EventType::Auth(AuthEvent::CredentialExpired),
|
||||||
|
|||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
|
q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY
|
||||||
Executable
+367
@@ -0,0 +1,367 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Local end-to-end check of the legacy-protocols switch.
|
||||||
|
|
||||||
|
Run it with `python3 tests/e2e/legacy_protocols.py` after
|
||||||
|
`cargo build -p inbuxa`. Needs Docker. Working state goes under target/e2e.
|
||||||
|
|
||||||
|
Boots the debug binary, turns the switch off, and checks that the IMAP and
|
||||||
|
POP3 ports really stop accepting while SMTP, submission and JMAP keep going.
|
||||||
|
Then turns it back on and checks the ports come back.
|
||||||
|
|
||||||
|
This is the part unit tests cannot reach: whether a socket actually closes on
|
||||||
|
a running server (LP-2), and whether a listener put back actually binds again
|
||||||
|
(LP-5). Acceptance tests 15, 17 and 18.
|
||||||
|
|
||||||
|
It also checks the second lock (LP-6): while the switch is off, sign-in over
|
||||||
|
submission -- locked open -- is refused with the spec's words, with the right
|
||||||
|
password and with a wrong one, and refusals never add up to a disconnect
|
||||||
|
(LP-11). And that a normal IMAP sign-in works with the switch on, before and
|
||||||
|
after. And that while it is off, no listener the switch would close can be
|
||||||
|
created, or made by an update (LP-4, test 4).
|
||||||
|
|
||||||
|
Passwords are generated into files under target/e2e and never printed.
|
||||||
|
Everything is removed afterwards unless KEEP=1.
|
||||||
|
"""
|
||||||
|
import base64, json, os, secrets, shutil, socket, ssl, subprocess, sys, time, urllib.request, urllib.error
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
DIR = f"{ROOT}/target/e2e"
|
||||||
|
NAME = "inbuxa-legacy"
|
||||||
|
HTTP = "http://127.0.0.1:18080"
|
||||||
|
# port -> how to tell a live server from Docker's proxy. Publishing a port
|
||||||
|
# makes the host side accept connections whether or not anything is listening
|
||||||
|
# inside the container, so a bare connect proves nothing: each port has to be
|
||||||
|
# made to speak.
|
||||||
|
PORTS = {"imap": 18993, "pop3": 18995, "submissions": 18465, "smtp": 18025}
|
||||||
|
TLS_PORTS = {18993, 18995, 18465}
|
||||||
|
SMTP_REFUSAL = ("535 5.7.0 This server allows only INBUXA webmail and JMAP apps. "
|
||||||
|
"This mail app can't send.")
|
||||||
|
INBUXA = "urn:inbuxa:jmap"
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
|
||||||
|
def check(cond, what):
|
||||||
|
print(("ok " if cond else "FAIL ") + what)
|
||||||
|
if not cond:
|
||||||
|
failures.append(what)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_file(name, value=None):
|
||||||
|
path = f"{DIR}/secrets/{name}"
|
||||||
|
if value is None:
|
||||||
|
value = secrets.token_urlsafe(24)
|
||||||
|
with open(path, "w") as f:
|
||||||
|
f.write(value)
|
||||||
|
os.chmod(path, 0o600)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def docker(*args, check_rc=True):
|
||||||
|
return subprocess.run(["docker", *args], capture_output=True, text=True, check=check_rc)
|
||||||
|
|
||||||
|
|
||||||
|
def start(env_file=None):
|
||||||
|
args = ["run", "-d", "--name", NAME, "--user", f"{os.getuid()}:{os.getgid()}",
|
||||||
|
"--entrypoint", "/usr/local/bin/inbuxa",
|
||||||
|
"-v", f"{ROOT}/target/debug/inbuxa:/usr/local/bin/inbuxa:ro",
|
||||||
|
"-v", f"{DIR}/etc-legacy:/etc/inbuxa", "-v", f"{DIR}/data-legacy:/var/lib/inbuxa",
|
||||||
|
"-p", "127.0.0.1:18080:8080",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['submissions']}:465",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['imap']}:993",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['pop3']}:995",
|
||||||
|
"-p", f"127.0.0.1:{PORTS['smtp']}:25"]
|
||||||
|
if env_file:
|
||||||
|
args += ["--env-file", env_file]
|
||||||
|
args += ["stalwartlabs/stalwart:v0.16.22", "--config", "/etc/inbuxa/config.json"]
|
||||||
|
docker(*args)
|
||||||
|
for _ in range(120):
|
||||||
|
try:
|
||||||
|
urllib.request.urlopen(f"{HTTP}/.well-known/jmap", timeout=2)
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
return
|
||||||
|
except Exception:
|
||||||
|
time.sleep(1)
|
||||||
|
continue
|
||||||
|
return
|
||||||
|
sys.exit("server didn't come up: " + docker("logs", "--tail", "40", NAME, check_rc=False).stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def stop():
|
||||||
|
docker("rm", "-f", NAME, check_rc=False)
|
||||||
|
|
||||||
|
|
||||||
|
def jmap(user, password, calls, using=("urn:ietf:params:jmap:core", "urn:stalwart:jmap", INBUXA)):
|
||||||
|
body = json.dumps({"using": list(using), "methodCalls": calls}).encode()
|
||||||
|
req = urllib.request.Request(f"{HTTP}/jmap/", data=body, method="POST")
|
||||||
|
req.add_header("Content-Type", "application/json")
|
||||||
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.load(resp)["methodResponses"]
|
||||||
|
|
||||||
|
|
||||||
|
def one(user, password, method, args):
|
||||||
|
return jmap(user, password, [[method, args, "0"]])[0]
|
||||||
|
|
||||||
|
|
||||||
|
def session(user, password):
|
||||||
|
req = urllib.request.Request(f"{HTTP}/jmap/session")
|
||||||
|
req.add_header("Authorization", "Basic " + base64.b64encode(f"{user}:{password}".encode()).decode())
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
return json.load(resp)
|
||||||
|
|
||||||
|
|
||||||
|
def accepts(port, timeout=5):
|
||||||
|
"""Whether a server is really answering on this port.
|
||||||
|
|
||||||
|
Docker's published port accepts and then closes when nothing is listening
|
||||||
|
in the container, so connecting is not enough. A TLS port must complete a
|
||||||
|
handshake; a plain one must send its greeting.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), timeout=timeout) as raw:
|
||||||
|
if port in TLS_PORTS:
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
with ctx.wrap_socket(raw):
|
||||||
|
return True
|
||||||
|
raw.settimeout(timeout)
|
||||||
|
return bool(raw.recv(1))
|
||||||
|
except (OSError, ssl.SSLError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def tls(port, timeout=10):
|
||||||
|
ctx = ssl.create_default_context()
|
||||||
|
ctx.check_hostname = False
|
||||||
|
ctx.verify_mode = ssl.CERT_NONE
|
||||||
|
return ctx.wrap_socket(socket.create_connection(("127.0.0.1", port), timeout=timeout))
|
||||||
|
|
||||||
|
|
||||||
|
def lines(sock):
|
||||||
|
"""Yields reply lines, CRLF stripped."""
|
||||||
|
buf = b""
|
||||||
|
while True:
|
||||||
|
while b"\r\n" not in buf:
|
||||||
|
chunk = sock.recv(4096)
|
||||||
|
if not chunk:
|
||||||
|
return
|
||||||
|
buf += chunk
|
||||||
|
line, buf = buf.split(b"\r\n", 1)
|
||||||
|
yield line.decode(errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def imap_login(port, user, password):
|
||||||
|
"""The tagged reply to LOGIN, over implicit TLS."""
|
||||||
|
with tls(port) as sock:
|
||||||
|
read = lines(sock)
|
||||||
|
next(read) # greeting
|
||||||
|
quote = lambda v: '"' + v.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||||
|
sock.sendall(f"a1 LOGIN {quote(user)} {quote(password)}\r\n".encode())
|
||||||
|
for line in read:
|
||||||
|
if line.startswith("a1 "):
|
||||||
|
return line[3:]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def smtp_auths(port, user, passwords):
|
||||||
|
"""The reply to AUTH PLAIN for each password in turn, on one connection.
|
||||||
|
A reply of "" means the server hung up."""
|
||||||
|
# Every connection reaches the server from Docker's gateway, one IP, and
|
||||||
|
# the stock inbound throttle takes five a second from it. The port checks
|
||||||
|
# just before can use those up, so wait the second out.
|
||||||
|
time.sleep(1.1)
|
||||||
|
replies = []
|
||||||
|
with tls(port) as sock:
|
||||||
|
read = lines(sock)
|
||||||
|
next(read) # greeting
|
||||||
|
sock.sendall(b"EHLO e2e.test\r\n")
|
||||||
|
for line in read:
|
||||||
|
if line[3:4] == " ":
|
||||||
|
break
|
||||||
|
for password in passwords:
|
||||||
|
token = base64.b64encode(f"\0{user}\0{password}".encode()).decode()
|
||||||
|
try:
|
||||||
|
sock.sendall(f"AUTH PLAIN {token}\r\n".encode())
|
||||||
|
replies.append(next(read, ""))
|
||||||
|
except OSError:
|
||||||
|
replies.append("")
|
||||||
|
return replies
|
||||||
|
|
||||||
|
|
||||||
|
def settle(port, want, tries=30):
|
||||||
|
"""Wait for a port to reach the wanted state, so the check is not a race."""
|
||||||
|
for _ in range(tries):
|
||||||
|
if accepts(port) == want:
|
||||||
|
return True
|
||||||
|
time.sleep(0.5)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
stop()
|
||||||
|
# Start from nothing. A half-bootstrapped data directory left by an
|
||||||
|
# earlier run is no longer in bootstrap mode, and the recovery admin
|
||||||
|
# stops authenticating the moment a real admin exists.
|
||||||
|
for sub in ("etc-legacy", "data-legacy"):
|
||||||
|
shutil.rmtree(f"{DIR}/{sub}", ignore_errors=True)
|
||||||
|
for sub in ("etc-legacy", "data-legacy", "secrets"):
|
||||||
|
os.makedirs(f"{DIR}/{sub}", exist_ok=True)
|
||||||
|
os.chmod(f"{DIR}/secrets", 0o700)
|
||||||
|
stop()
|
||||||
|
|
||||||
|
# First boot, with a recovery admin from an env file.
|
||||||
|
recovery = secret_file("legacy-recovery")
|
||||||
|
env_file = f"{DIR}/secrets/legacy-env"
|
||||||
|
with open(env_file, "w") as f:
|
||||||
|
f.write(f"INBUXA_RECOVERY_ADMIN=admin:{recovery}\n")
|
||||||
|
os.chmod(env_file, 0o600)
|
||||||
|
start(env_file)
|
||||||
|
|
||||||
|
got = one("admin", recovery, "x:Bootstrap/get", {"ids": None})
|
||||||
|
singleton = got[1]["list"][0]["id"]
|
||||||
|
res = one("admin", recovery, "x:Bootstrap/set", {"update": {singleton: {
|
||||||
|
"serverHostname": "mail.legacy.test", "defaultDomain": "legacy.test",
|
||||||
|
"requestTlsCertificate": False}}})
|
||||||
|
updated = res[1].get("updated", {}).get(singleton)
|
||||||
|
check(bool(updated), "bootstrap completed")
|
||||||
|
if not updated:
|
||||||
|
sys.exit(json.dumps(res))
|
||||||
|
admin, admin_pw = updated["username"], secret_file("legacy-admin", updated["secret"])
|
||||||
|
|
||||||
|
stop()
|
||||||
|
start()
|
||||||
|
|
||||||
|
sess = session(admin, admin_pw)
|
||||||
|
account = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
|
||||||
|
policy_get = {"accountId": account, "ids": None}
|
||||||
|
policy_set = lambda update: {"accountId": account, "update": {"singleton": update}}
|
||||||
|
|
||||||
|
# The ports we expect a default install to be accepting on.
|
||||||
|
check(accepts(PORTS["imap"]), "IMAP accepts before the switch")
|
||||||
|
check(accepts(PORTS["pop3"]), "POP3 accepts before the switch")
|
||||||
|
check(accepts(PORTS["submissions"]), "submission accepts before the switch")
|
||||||
|
check(accepts(PORTS["smtp"]), "inbound SMTP accepts before the switch")
|
||||||
|
|
||||||
|
# A normal sign-in works with the switch on -- the control for LP-6.
|
||||||
|
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||||
|
"IMAP sign-in works with the switch on")
|
||||||
|
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
|
||||||
|
"submission sign-in works with the switch on")
|
||||||
|
|
||||||
|
# What the screen reads: the locked set and what would close (LP-16, LP-21).
|
||||||
|
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||||
|
if got[0] != "inbuxa:ProtocolPolicy/get":
|
||||||
|
sys.exit("ProtocolPolicy/get failed: " + json.dumps(got))
|
||||||
|
policy = got[1]["list"][0]
|
||||||
|
check(policy["legacyProtocols"] == "enabled", "switch starts enabled")
|
||||||
|
check(set(policy["lockedProtocols"]) >= {"smtp", "http"},
|
||||||
|
"SMTP and JMAP report as locked (LP-21)")
|
||||||
|
would = {l["id"] for l in policy["wouldClose"]}
|
||||||
|
print(" wouldClose:", sorted(would))
|
||||||
|
check(would, "wouldClose names the listeners that would close (LP-16)")
|
||||||
|
|
||||||
|
# Turn it off, and ask for submission to close too: the lock must overrule.
|
||||||
|
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
policy_set({"legacyProtocols": "disabled", "closeSubmission": True}))
|
||||||
|
if not res[1].get("updated"):
|
||||||
|
sys.exit("ProtocolPolicy/set failed: " + json.dumps(res))
|
||||||
|
overruled = res[1]["updated"].get("singleton")
|
||||||
|
check(overruled is not None and overruled.get("closeSubmission") is False,
|
||||||
|
"closeSubmission overruled to false and reported (LP-21, test 18)")
|
||||||
|
|
||||||
|
# The ports themselves (LP-1, LP-2, LP-3, test 15).
|
||||||
|
check(settle(PORTS["imap"], False), "IMAP stopped accepting")
|
||||||
|
check(settle(PORTS["pop3"], False), "POP3 stopped accepting")
|
||||||
|
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts (LP-3)")
|
||||||
|
check(accepts(PORTS["submissions"]), "submission still accepts, being locked (LP-21)")
|
||||||
|
|
||||||
|
# JMAP still works, which is the whole point of locking it.
|
||||||
|
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||||
|
check(got[0] == "inbuxa:ProtocolPolicy/get", "JMAP still works while the switch is off")
|
||||||
|
policy = got[1]["list"][0]
|
||||||
|
check(policy["legacyProtocols"] == "disabled", "switch reads back disabled")
|
||||||
|
saved = {l["id"] for l in policy["savedListeners"]}
|
||||||
|
print(" savedListeners:", sorted(saved))
|
||||||
|
check(saved, "the closed listeners were saved (LP-1)")
|
||||||
|
|
||||||
|
# The second lock (LP-6). Submission stays open, being locked, so sign-in
|
||||||
|
# over it is refused instead -- right password or wrong, the same words,
|
||||||
|
# and never enough of them to be thrown off (LP-11, test 2, test 18).
|
||||||
|
replies = smtp_auths(PORTS["submissions"], admin, [admin_pw] + ["wrong"] * 6)
|
||||||
|
check(replies[0] == SMTP_REFUSAL, "submission refuses the right password (LP-6)")
|
||||||
|
check(all(r == SMTP_REFUSAL for r in replies[1:]),
|
||||||
|
"submission refuses wrong passwords the same way, and doesn't hang up (LP-11)")
|
||||||
|
if not all(r == SMTP_REFUSAL for r in replies):
|
||||||
|
print(" replies:", replies)
|
||||||
|
|
||||||
|
# No listener the switch would close can be added while it is off (LP-4,
|
||||||
|
# test 4), and the refusal names the policy.
|
||||||
|
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"m": {
|
||||||
|
"name": "imap-new", "protocol": "imap", "bind": {"0.0.0.0:1993": True},
|
||||||
|
"tlsImplicit": True}}})
|
||||||
|
refused = (res[1].get("notCreated") or {}).get("m") or {}
|
||||||
|
check(refused.get("type") == "invalidProperties"
|
||||||
|
and "protocol" in (refused.get("properties") or [])
|
||||||
|
and "inbuxa:ProtocolPolicy" in (refused.get("description") or ""),
|
||||||
|
"creating an IMAP listener is refused, naming the policy (LP-4)")
|
||||||
|
if not refused:
|
||||||
|
print(" reply:", json.dumps(res[1])[:300])
|
||||||
|
|
||||||
|
# What the switch never closes can still be added; turning it into a
|
||||||
|
# listener the switch would close is refused like creating one.
|
||||||
|
res = one(admin, admin_pw, "x:NetworkListener/set", {"create": {"s": {
|
||||||
|
"name": "submission-extra", "protocol": "smtp", "bind": {"0.0.0.0:2587": True}}}})
|
||||||
|
extra = (res[1].get("created") or {}).get("s", {}).get("id")
|
||||||
|
check(extra is not None, "an SMTP listener can still be created, being locked (LP-4, LP-21)")
|
||||||
|
if extra:
|
||||||
|
res = one(admin, admin_pw, "x:NetworkListener/set",
|
||||||
|
{"update": {extra: {"protocol": "imap"}}})
|
||||||
|
refused = (res[1].get("notUpdated") or {}).get(extra) or {}
|
||||||
|
check(refused.get("type") == "invalidProperties",
|
||||||
|
"turning it into an IMAP listener is refused (LP-4)")
|
||||||
|
one(admin, admin_pw, "x:NetworkListener/set", {"destroy": [extra]})
|
||||||
|
|
||||||
|
# A restart must not reopen them: the objects are gone, not just the sockets.
|
||||||
|
stop()
|
||||||
|
start()
|
||||||
|
check(settle(PORTS["imap"], False), "IMAP still closed after a restart")
|
||||||
|
check(accepts(PORTS["smtp"]), "inbound SMTP still accepts after a restart")
|
||||||
|
|
||||||
|
# Turn it back on: the listeners come back and bind again (LP-5).
|
||||||
|
res = one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||||
|
policy_set({"legacyProtocols": "enabled"}))
|
||||||
|
if "updated" not in res[1]:
|
||||||
|
sys.exit("ProtocolPolicy/set back on failed: " + json.dumps(res))
|
||||||
|
check(settle(PORTS["imap"], True), "IMAP accepts again without a restart (LP-5)")
|
||||||
|
check(settle(PORTS["pop3"], True), "POP3 accepts again without a restart (LP-5)")
|
||||||
|
|
||||||
|
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get", policy_get)
|
||||||
|
policy = got[1]["list"][0]
|
||||||
|
check(policy["legacyProtocols"] == "enabled", "switch reads back enabled")
|
||||||
|
check(not policy["savedListeners"], "savedListeners is empty again (LP-5)")
|
||||||
|
|
||||||
|
# And sign-in works again, with no restart.
|
||||||
|
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||||
|
"IMAP sign-in works again once the switch is back on")
|
||||||
|
check(smtp_auths(PORTS["submissions"], admin, [admin_pw])[0].startswith("235"),
|
||||||
|
"submission sign-in works again once the switch is back on")
|
||||||
|
|
||||||
|
print()
|
||||||
|
if failures:
|
||||||
|
print(f"{len(failures)} FAILED:")
|
||||||
|
for f in failures:
|
||||||
|
print(" - " + f)
|
||||||
|
else:
|
||||||
|
print("all checks passed")
|
||||||
|
return 1 if failures else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
rc = 1
|
||||||
|
try:
|
||||||
|
rc = main()
|
||||||
|
finally:
|
||||||
|
if not os.environ.get("KEEP"):
|
||||||
|
stop()
|
||||||
|
sys.exit(rc)
|
||||||
Reference in New Issue
Block a user