Compare commits
33
Commits
v2026.9.20
...
ef068abbb1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef068abbb1 | ||
|
|
b37d252660 | ||
|
|
521b8449bf | ||
|
|
1ee2e2a6a3 | ||
|
|
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,62 @@
|
||||
# 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:
|
||||
# Either runner (host1 or host2): the build needs no docker socket.
|
||||
runs-on: light
|
||||
container:
|
||||
image: rust:1-bookworm@sha256:93ce27a88655056a51dbdd8f5f2d7ddc071c7b0070fb288a37b5a285fc83971e # 1-bookworm
|
||||
# A named volume per host that outlives the job: Cargo's registry/git
|
||||
# cache and the target dir. Without it every run recompiled RocksDB and
|
||||
# the rest of the dependency tree from scratch. Each runner allows this
|
||||
# one volume in its valid_volumes; each host keeps its own copy.
|
||||
volumes:
|
||||
- inbuxa-server-cargo:/cache
|
||||
env:
|
||||
CARGO_HOME: /cache/cargo-home
|
||||
CARGO_TARGET_DIR: /cache/target
|
||||
# Dependencies are reused whole; incremental data for the workspace
|
||||
# crates would only bloat a shared target dir.
|
||||
CARGO_INCREMENTAL: "0"
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
# Cargo sizes its parallelism from the host's core count, not the job's
|
||||
# CPU cap (2 on host2, 4 on host1); a C++ build of RocksDB at 8-way
|
||||
# parallelism inside 6 GB gets OOM-killed. Match jobs to the cap.
|
||||
- run: |
|
||||
jobs=$(awk '$1 != "max" { printf "%d", $1 / $2 }' /sys/fs/cgroup/cpu.max 2>/dev/null)
|
||||
echo "CARGO_BUILD_JOBS=${jobs:-$(nproc)}" >> "$GITHUB_ENV"
|
||||
echo "cargo jobs: ${jobs:-$(nproc)}; cache: $(du -sh /cache 2>/dev/null | cut -f1)"
|
||||
- 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
|
||||
# Keep the cache from growing without bound: past 60 GB the target dir
|
||||
# is dropped and the next build starts cold. The download cache stays.
|
||||
# Two builds (dev + test profiles) already fill ~22 GB, so the limit
|
||||
# has to sit well above that or it would wipe a warm cache every run.
|
||||
- if: always()
|
||||
run: |
|
||||
used=$(du -s --block-size=1G /cache/target 2>/dev/null | cut -f1)
|
||||
echo "target dir: ${used:-0} GB"
|
||||
if [ "${used:-0}" -gt 60 ]; then rm -rf /cache/target && echo "over 60 GB: target dir cleared"; fi
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
# including one pushed by whoever compromises the account. Dependabot
|
||||
# updates both halves together -- do not "simplify" a pin back to a tag.
|
||||
- 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
|
||||
# foundationdb and the search backends are off by default, but the
|
||||
# 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
|
||||
!.gitattributes
|
||||
!.github
|
||||
!.gitlab-ci.yml
|
||||
!.gitea
|
||||
CLAUDE.md
|
||||
|
||||
# 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.
|
||||
|
||||
## 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
|
||||
|
||||
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"
|
||||
checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -1041,16 +1035,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "calcard"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b779382e675380a1ff8a4873acee5ba60158be7a00458fb98e6b85aad1f2ee"
|
||||
checksum = "c601473ec15a875626bce73db1a1f0fc81e9c949ca25f1463b714947c0a70a1f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"hashify",
|
||||
"jmap-tools",
|
||||
"mail-builder 0.5.0",
|
||||
"mail-builder 1.0.0",
|
||||
"mail-parser",
|
||||
"rkyv",
|
||||
"serde",
|
||||
@@ -1987,11 +1981,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "decancer"
|
||||
version = "3.3.3"
|
||||
version = "4.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9244323129647178bf41ac861a2cdb9d9c81b9b09d3d0d1de9cd302b33b8a1d"
|
||||
checksum = "453589e364ce786381e7bcbf0d659088ff7e4d3e77f948dd1cf861344acf7a86"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"regex",
|
||||
]
|
||||
|
||||
@@ -2309,11 +2302,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ece"
|
||||
version = "2.3.1"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
|
||||
checksum = "c2467bac73e5a36d75e16cab0fa8d40676f075db6afde7d78b35f033e1f66e37"
|
||||
dependencies = [
|
||||
"base64 0.21.7",
|
||||
"base64 0.22.1",
|
||||
"byteorder",
|
||||
"hex",
|
||||
"hkdf 0.12.4",
|
||||
@@ -2322,7 +2315,7 @@ dependencies = [
|
||||
"openssl",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"thiserror 2.0.20",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3540,7 +3533,7 @@ dependencies = [
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2 0.6.5",
|
||||
"socket2 0.5.10",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
@@ -4401,9 +4394,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jsonwebtoken"
|
||||
version = "11.0.0"
|
||||
version = "11.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65"
|
||||
checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"base64 0.22.1",
|
||||
@@ -4616,9 +4609,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
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"
|
||||
checksum = "cef2a00ee60fe526157c9023edab23943fae1ce2ab6f4abb2a807c1746835de9"
|
||||
checksum = "4f45e86edad8e88efe97dbf384b4e48e1ff0f111eabf154c7b09d7a1e5fb573c"
|
||||
dependencies = [
|
||||
"bindgen",
|
||||
"bzip2-sys",
|
||||
@@ -4626,6 +4619,7 @@ dependencies = [
|
||||
"libc",
|
||||
"libz-sys",
|
||||
"lz4-sys",
|
||||
"rustflags",
|
||||
"zstd-sys",
|
||||
]
|
||||
|
||||
@@ -5532,8 +5526,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
||||
version = "0.32.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
@@ -5545,8 +5539,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-http"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
||||
version = "0.32.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"bytes",
|
||||
@@ -5557,10 +5551,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-otlp"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
||||
version = "0.32.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||
dependencies = [
|
||||
"http 1.5.0",
|
||||
"httpdate",
|
||||
"opentelemetry",
|
||||
"opentelemetry-http",
|
||||
"opentelemetry-proto",
|
||||
@@ -5575,8 +5570,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-proto"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
||||
version = "0.32.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||
dependencies = [
|
||||
"opentelemetry",
|
||||
"opentelemetry_sdk",
|
||||
@@ -5587,13 +5582,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-semantic-conventions"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
||||
version = "0.32.1"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry_sdk"
|
||||
version = "0.31.0"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#274b4d324794280ce6f4def095a3428197a9e6e3"
|
||||
version = "0.32.1"
|
||||
source = "git+https://github.com/stalwartlabs/opentelemetry-rust#80a14a3b6846f62f85506d68d2600c948fccc9d2"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-executor",
|
||||
@@ -6265,7 +6260,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"itertools 0.14.0",
|
||||
"itertools 0.13.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
@@ -6404,7 +6399,7 @@ dependencies = [
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2 0.6.5",
|
||||
"socket2 0.5.10",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -6445,7 +6440,7 @@ dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2 0.6.5",
|
||||
"socket2 0.5.10",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -7070,9 +7065,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rocksdb"
|
||||
version = "0.24.0"
|
||||
version = "0.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddb7af00d2b17dbd07d82c0063e25411959748ff03e8d4f96134c2ff41fce34f"
|
||||
checksum = "d8d90add70d1d420ee487bce4a1449880a8d147451c6051b2ee5f8354553dcbf"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"librocksdb-sys",
|
||||
@@ -7213,6 +7208,12 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustflags"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a39e0e9135d7a7208ee80aa4e3e4b88f0f5ad7be92153ed70686c38a03db2e63"
|
||||
|
||||
[[package]]
|
||||
name = "rusticata-macros"
|
||||
version = "4.1.0"
|
||||
@@ -7237,9 +7238,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.44"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6725596c3f2c3a0aef021139e145d4eafe314a6623e4680ca83852b2c67ab2ba"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
@@ -7579,7 +7580,7 @@ dependencies = [
|
||||
"sha2 0.10.9",
|
||||
"sha3 0.10.9",
|
||||
"slh-dsa",
|
||||
"thiserror 2.0.20",
|
||||
"thiserror 1.0.69",
|
||||
"twofish",
|
||||
"typenum",
|
||||
"x25519-dalek",
|
||||
@@ -8736,18 +8737,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b"
|
||||
dependencies = [
|
||||
"tinyvec_macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinyvec_macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
|
||||
|
||||
[[package]]
|
||||
name = "tls-listener"
|
||||
|
||||
@@ -54,7 +54,7 @@ sha2 = "0.11"
|
||||
md5 = "0.8.1"
|
||||
whatlang = "0.18"
|
||||
idna = "1.1"
|
||||
decancer = "3.3.3"
|
||||
decancer = "4.0.0"
|
||||
unicode-security = "0.1.2"
|
||||
infer = "0.22"
|
||||
bincode = { version = "2.0.1", features = ["serde"] }
|
||||
|
||||
@@ -67,6 +67,7 @@ impl Data {
|
||||
|
||||
Data {
|
||||
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
|
||||
listener_control: Default::default(),
|
||||
tls_certificates: ArcSwap::from_pointee(certificates),
|
||||
tls_self_signed_cert: build_self_signed_cert(
|
||||
subject_names
|
||||
@@ -222,6 +223,7 @@ impl Default for Data {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
spam_classifier: Default::default(),
|
||||
listener_control: Default::default(),
|
||||
tls_certificates: Default::default(),
|
||||
tls_self_signed_cert: Default::default(),
|
||||
blocked_ips: Default::default(),
|
||||
|
||||
@@ -150,6 +150,10 @@ pub struct Data {
|
||||
pub blocked_ips: RwLock<BlockedIps>,
|
||||
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 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 utils::UnwrapFailure;
|
||||
|
||||
use super::control::ListenerControl;
|
||||
|
||||
impl Listener {
|
||||
pub fn spawn(
|
||||
self,
|
||||
@@ -324,8 +326,14 @@ impl SocketOpts {
|
||||
}
|
||||
|
||||
impl Listeners {
|
||||
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
|
||||
// Bind as root
|
||||
/// Binds every socket, reporting each failure against its listener.
|
||||
///
|
||||
/// 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 listener in &server.listeners {
|
||||
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
|
||||
#[cfg(not(target_env = "msvc"))]
|
||||
@@ -370,6 +383,38 @@ impl Listeners {
|
||||
}
|
||||
(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 {
|
||||
|
||||
@@ -33,8 +33,10 @@ use utils::snowflake::SnowflakeIdGenerator;
|
||||
pub mod acme;
|
||||
pub mod asn;
|
||||
pub mod autoconfig;
|
||||
pub mod control;
|
||||
pub mod dkim;
|
||||
pub mod dns;
|
||||
pub mod legacy;
|
||||
pub mod limiter;
|
||||
pub mod listen;
|
||||
pub mod mta;
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
pub mod ai;
|
||||
pub mod branding;
|
||||
pub mod masked_email;
|
||||
pub mod security;
|
||||
pub mod tenancy;
|
||||
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-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::core::{Session, SessionData, State};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use imap_proto::{
|
||||
@@ -67,6 +69,12 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
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
|
||||
let access_token = self
|
||||
.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 fastmail_masked_email; // inbuxa: masked email
|
||||
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 file_node;
|
||||
pub mod identity;
|
||||
|
||||
@@ -61,6 +61,9 @@ impl Response<'_> {
|
||||
GetResponseMethod::AiLimits(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::ProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::Principal(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ impl Response<'_> {
|
||||
GetRequestMethod::MaskedEmail(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::DeletedAccount(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::Quota(request) => request.resolve_references(self)?,
|
||||
GetRequestMethod::Blob(request) => request.resolve_references(self)?,
|
||||
@@ -89,6 +90,9 @@ impl Response<'_> {
|
||||
SetRequestMethod::AiLimits(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::ProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::AddressBook(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -49,6 +49,7 @@ pub enum MethodObject {
|
||||
DeletedAccount,
|
||||
// inbuxa: AI call limits
|
||||
AiLimits,
|
||||
ProtocolPolicy,
|
||||
}
|
||||
|
||||
impl MethodObject {
|
||||
@@ -75,6 +76,7 @@ impl MethodObject {
|
||||
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||
MethodObject::AiLimits => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +254,8 @@ impl MethodName {
|
||||
(MethodFunction::Set, MethodObject::DeletedAccount) => "inbuxa:DeletedAccount/set",
|
||||
(MethodFunction::Get, MethodObject::AiLimits) => "inbuxa:AiLimits/get",
|
||||
(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)) => {
|
||||
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:AiLimits/get" => (MethodObject::AiLimits, MethodFunction::Get),
|
||||
"inbuxa:AiLimits/set" => (MethodObject::AiLimits, MethodFunction::Set),
|
||||
"inbuxa:ProtocolPolicy/get" => (MethodObject::ProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:ProtocolPolicy/set" => (MethodObject::ProtocolPolicy, MethodFunction::Set),
|
||||
|
||||
).or_else(|| {
|
||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||
@@ -430,6 +436,7 @@ impl Display for MethodObject {
|
||||
MethodObject::MaskedEmail => "MaskedEmail",
|
||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
f.write_str("x:")?;
|
||||
return f.write_str(obj.as_str());
|
||||
|
||||
@@ -116,6 +116,7 @@ pub enum GetRequestMethod {
|
||||
MaskedEmail(Box<GetRequest<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<GetRequest<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<GetRequest<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
ProtocolPolicy(Box<GetRequest<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -139,6 +140,7 @@ pub enum SetRequestMethod<'x> {
|
||||
MaskedEmail(Box<SetRequest<'x, crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetRequest<'x, crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetRequest<'x, crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
ProtocolPolicy(Box<SetRequest<'x, crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -169,6 +169,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
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() {
|
||||
Ok(Some(value)) => RequestMethod::Get(GetRequestMethod::VacationResponse(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
@@ -334,6 +341,13 @@ impl<'de> Visitor<'de> for CallVisitor {
|
||||
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() {
|
||||
Ok(Some(value)) => RequestMethod::Set(SetRequestMethod::VacationResponse(value)),
|
||||
Err(err) => RequestMethod::invalid(err),
|
||||
|
||||
@@ -103,6 +103,7 @@ pub enum GetResponseMethod {
|
||||
MaskedEmail(GetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>),
|
||||
DeletedAccount(GetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>),
|
||||
AiLimits(GetResponse<crate::object::inbuxa_ai_limits::AiLimits>),
|
||||
ProtocolPolicy(GetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -127,6 +128,7 @@ pub enum SetResponseMethod {
|
||||
MaskedEmail(Box<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEmail>>),
|
||||
DeletedAccount(Box<SetResponse<crate::object::inbuxa_deleted_account::DeletedAccount>>),
|
||||
AiLimits(Box<SetResponse<crate::object::inbuxa_ai_limits::AiLimits>>),
|
||||
ProtocolPolicy(Box<SetResponse<crate::object::inbuxa_protocol_policy::ProtocolPolicy>>),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -287,6 +289,22 @@ impl<'x> From<SetResponse<crate::object::fastmail_masked_email::FastmailMaskedEm
|
||||
}
|
||||
|
||||
// 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> {
|
||||
fn from(value: GetResponse<crate::object::inbuxa_ai_limits::AiLimits>) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::AiLimits(value))
|
||||
|
||||
@@ -77,6 +77,9 @@ impl JmapAuthorization for AccessToken {
|
||||
GetRequestMethod::DeletedAccount(_) => Permission::SysAccountGet,
|
||||
// inbuxa: AI call limits, with the classifier's permissions
|
||||
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::Quota(_) => Permission::JmapQuotaGet,
|
||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||
@@ -173,6 +176,14 @@ impl JmapAuthorization for AccessToken {
|
||||
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(
|
||||
s,
|
||||
self,
|
||||
@@ -282,7 +293,8 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::SieveScript
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits => Permission::JmapEmailChanges,
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::ProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||
},
|
||||
|
||||
@@ -221,6 +221,9 @@ impl RequestHandler for Server {
|
||||
SetResponseMethod::AiLimits(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::ProtocolPolicy(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AddressBook(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -376,6 +379,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.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) => {
|
||||
self.principal_get(*req, access_token).await?.into()
|
||||
}
|
||||
@@ -617,6 +627,13 @@ impl RequestHandler for Server {
|
||||
.await?
|
||||
.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) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
access_token.assert_has_access(req.account_id, Collection::AddressBook)?;
|
||||
|
||||
@@ -418,6 +418,7 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod access;
|
||||
pub mod ai_limits;
|
||||
pub mod protocol_policy;
|
||||
pub mod deleted_account;
|
||||
pub mod fastmail;
|
||||
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?
|
||||
}
|
||||
// inbuxa: legacy-protocols LP-4
|
||||
ObjectInner::NetworkListener(listener) => {
|
||||
crate::inbuxa::protocol_policy::validate_listener(&set, listener)
|
||||
.await?
|
||||
}
|
||||
// inbuxa: ME-12 to ME-17
|
||||
ObjectInner::MaskedEmail(mask) => {
|
||||
let old = match &modification {
|
||||
|
||||
+78
-36
@@ -9,14 +9,21 @@
|
||||
#![warn(clippy::cast_possible_wrap)]
|
||||
#![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 imap::core::ImapSessionManager;
|
||||
use managesieve::core::ManageSieveSessionManager;
|
||||
use pop3::Pop3SessionManager;
|
||||
use services::{StartServices, broadcast::subscriber::spawn_broadcast_subscriber};
|
||||
use smtp::{StartQueueManager, core::SmtpSessionManager};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use trc::Collector;
|
||||
use utils::wait_for_shutdown;
|
||||
|
||||
@@ -70,42 +77,31 @@ async fn main() -> std::io::Result<()> {
|
||||
}
|
||||
|
||||
// Spawn servers
|
||||
let (shutdown_tx, shutdown_rx) = init.servers.spawn(|server, acceptor, shutdown_rx| {
|
||||
match &server.protocol {
|
||||
ServerProtocol::Smtp | ServerProtocol::Lmtp => server.spawn(
|
||||
SmtpSessionManager::new(init.inner.clone()),
|
||||
init.inner.clone(),
|
||||
acceptor,
|
||||
shutdown_rx,
|
||||
),
|
||||
ServerProtocol::Http => server.spawn(
|
||||
HttpSessionManager::new(init.inner.clone()),
|
||||
init.inner.clone(),
|
||||
acceptor,
|
||||
shutdown_rx,
|
||||
),
|
||||
ServerProtocol::Imap => server.spawn(
|
||||
ImapSessionManager::new(init.inner.clone()),
|
||||
init.inner.clone(),
|
||||
acceptor,
|
||||
shutdown_rx,
|
||||
),
|
||||
ServerProtocol::Pop3 => server.spawn(
|
||||
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,
|
||||
),
|
||||
};
|
||||
});
|
||||
// Each listener gets its own shutdown channel, registered under its id, so
|
||||
// the legacy-protocols switch can close one protocol's ports and leave the
|
||||
// rest accepting (legacy-protocols LP-2). The registry lives in `Data` and
|
||||
// so outlives the listeners, which it must: it owns the sending ends.
|
||||
let listener_control = &init.inner.data.listener_control;
|
||||
let spawn_inner = init.inner.clone();
|
||||
let (shutdown_tx, shutdown_rx) =
|
||||
init.servers
|
||||
.spawn_with_control(listener_control, |server, acceptor, shutdown_rx| {
|
||||
spawn_listener(&spawn_inner, server, 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
|
||||
// protocol, so only this file can say.
|
||||
let spawn_inner = init.inner.clone();
|
||||
init.inner
|
||||
.data
|
||||
.listener_control
|
||||
.set_spawner(Box::new(move |server, acceptor, shutdown_rx| {
|
||||
spawn_listener(&spawn_inner, server, acceptor, shutdown_rx);
|
||||
}));
|
||||
|
||||
// Start broadcast subscriber
|
||||
let inner = init.inner.clone();
|
||||
spawn_broadcast_subscriber(init.inner, shutdown_rx);
|
||||
|
||||
// Wait for shutdown signal
|
||||
@@ -114,11 +110,57 @@ async fn main() -> std::io::Result<()> {
|
||||
// Shutdown collector
|
||||
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);
|
||||
inner.data.listener_control.stop_all();
|
||||
|
||||
// Wait for services to finish
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
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-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 common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
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
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::{
|
||||
@@ -10,7 +12,7 @@ use crate::{
|
||||
};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, limiter::LimiterResult},
|
||||
network::{SessionStream, legacy::LegacyProtocol, limiter::LimiterResult},
|
||||
};
|
||||
use directory::Credentials;
|
||||
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<()> {
|
||||
// inbuxa: legacy-protocols LP-6, before the password is looked at
|
||||
self.server
|
||||
.refuse_legacy_sign_in(LegacyProtocol::Pop3, &credentials)
|
||||
.await?;
|
||||
|
||||
// Authenticate
|
||||
let access_token = self
|
||||
.server
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*
|
||||
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||
*/
|
||||
|
||||
use crate::core::Session;
|
||||
use common::{auth::AuthRequest, network::SessionStream};
|
||||
use common::{
|
||||
auth::AuthRequest,
|
||||
network::{SessionStream, legacy::LegacyProtocol},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use mail_parser::decoders::base64::base64_decode;
|
||||
use registry::schema::enums::Permission;
|
||||
@@ -108,6 +113,26 @@ impl<T: SessionStream> Session<T> {
|
||||
}
|
||||
|
||||
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
|
||||
let result = self
|
||||
.server
|
||||
|
||||
@@ -19,7 +19,7 @@ tokio = { version = "1.53", features = ["net", "macros"] }
|
||||
psl = "2"
|
||||
hyper = { version = "1.11.1", features = ["server", "http1", "http2"] }
|
||||
idna = "1.1"
|
||||
decancer = "3.3.3"
|
||||
decancer = "4.0.0"
|
||||
unicode-security = "0.1.2"
|
||||
infer = "0.22"
|
||||
hashify = "0.2"
|
||||
|
||||
@@ -1136,7 +1136,7 @@ impl<'x> Tokens<'x> {
|
||||
{
|
||||
if word.len() > MAX_TOKEN_LENGTH {
|
||||
self.insert(Token::Word {
|
||||
value: truncate_word(cured_word.as_str(), MAX_TOKEN_LENGTH)
|
||||
value: truncate_word(&cured_word, MAX_TOKEN_LENGTH)
|
||||
.to_string()
|
||||
.into(),
|
||||
});
|
||||
@@ -1282,7 +1282,6 @@ impl Token<'static> {
|
||||
} else if !is_ascii {
|
||||
let word: String = if let Ok(cured) = decancer::cure(s, decancer::Options::default()) {
|
||||
cured
|
||||
.as_str()
|
||||
.chars()
|
||||
.filter(|ch| ch.is_alphabetic())
|
||||
.take(MAX_TOKEN_LENGTH)
|
||||
|
||||
@@ -9,7 +9,7 @@ types = { path = "../types" }
|
||||
nlp = { path = "../nlp" }
|
||||
trc = { path = "../trc" }
|
||||
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 }
|
||||
rusqlite = { version = "0.40", features = ["bundled"], 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.
|
||||
|
||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 642;
|
||||
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
|
||||
// auth.legacy-protocol-refused (legacy-protocols LP-6)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 643;
|
||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -116,6 +117,8 @@ pub enum AuthEvent {
|
||||
Error = 34,
|
||||
Warning = 595,
|
||||
CredentialExpired = 276,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
LegacyProtocolRefused = 642,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
|
||||
@@ -56,6 +56,8 @@ impl EventType {
|
||||
b"auth.mfa-required" => EventType::Auth(AuthEvent::MfaRequired),
|
||||
b"auth.too-many-attempts" => EventType::Auth(AuthEvent::TooManyAttempts),
|
||||
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.warning" => EventType::Auth(AuthEvent::Warning),
|
||||
b"auth.credential-expired" => EventType::Auth(AuthEvent::CredentialExpired),
|
||||
@@ -705,6 +707,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => "auth.mfa-required",
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => "auth.too-many-attempts",
|
||||
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::Warning) => "auth.warning",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "auth.credential-expired",
|
||||
@@ -1489,6 +1493,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired) => 36,
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => 38,
|
||||
EventType::Auth(AuthEvent::ClientRegistration) => 555,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused) => 642,
|
||||
EventType::Auth(AuthEvent::Error) => 34,
|
||||
EventType::Auth(AuthEvent::Warning) => 595,
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => 276,
|
||||
@@ -2137,6 +2143,8 @@ impl EventType {
|
||||
36 => Some(EventType::Auth(AuthEvent::MfaRequired)),
|
||||
38 => Some(EventType::Auth(AuthEvent::TooManyAttempts)),
|
||||
555 => Some(EventType::Auth(AuthEvent::ClientRegistration)),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
642 => Some(EventType::Auth(AuthEvent::LegacyProtocolRefused)),
|
||||
34 => Some(EventType::Auth(AuthEvent::Error)),
|
||||
595 => Some(EventType::Auth(AuthEvent::Warning)),
|
||||
276 => Some(EventType::Auth(AuthEvent::CredentialExpired)),
|
||||
@@ -2848,6 +2856,8 @@ impl EventType {
|
||||
EventType::Acme(AcmeEvent::TlsAlpnReceived) => Level::Info,
|
||||
EventType::Auth(AuthEvent::Success) => 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::ItipMessageSent) => 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::TooManyAttempts) => "Too many authentication attempts",
|
||||
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::Warning) => "Authentication warning",
|
||||
EventType::Auth(AuthEvent::CredentialExpired) => "Credential expired",
|
||||
@@ -3951,6 +3963,8 @@ impl EventType {
|
||||
}
|
||||
EventType::Auth(AuthEvent::TooManyAttempts) => "Too many authentication attempts",
|
||||
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::CredentialExpired) => "Credential expired",
|
||||
EventType::Imap(ImapEvent::ConnectionStart) => "IMAP error",
|
||||
@@ -4259,6 +4273,8 @@ impl EventType {
|
||||
EventType::Auth(AuthEvent::MfaRequired),
|
||||
EventType::Auth(AuthEvent::TooManyAttempts),
|
||||
EventType::Auth(AuthEvent::ClientRegistration),
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
EventType::Auth(AuthEvent::LegacyProtocolRefused),
|
||||
EventType::Auth(AuthEvent::Error),
|
||||
EventType::Auth(AuthEvent::Warning),
|
||||
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