Compare commits
51
Commits
v2026.9.20
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a428a4ce | ||
|
|
367bb2c641 | ||
|
|
10bd747a7b | ||
|
|
ae10c32271 | ||
|
|
c90064f9d8 | ||
|
|
8846a280f1 | ||
|
|
e3717f7990 | ||
|
|
ef068abbb1 | ||
|
|
7d2c2d2322 | ||
|
|
b37d252660 | ||
|
|
521b8449bf | ||
|
|
1ee2e2a6a3 | ||
|
|
825f49671e | ||
|
|
29bcfecb80 | ||
|
|
6c6fe91d0c | ||
|
|
840215d109 | ||
|
|
d2f41bce26 | ||
|
|
96c7bab032 | ||
|
|
79b6787397 | ||
|
|
3f40b36032 | ||
|
|
cd99037ca4 | ||
|
|
b65afb66f9 | ||
|
|
e953c68e2e | ||
|
|
64cddc9246 | ||
|
|
9379c1f151 | ||
|
|
4b585905d7 | ||
|
|
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
|
||||
@@ -0,0 +1,138 @@
|
||||
# Publish the container image, ported from .github/workflows/publish.yml when
|
||||
# the project moved to the self-hosted Gitea (2026-09-22). Starts on a v* tag,
|
||||
# whether a person pushed it or weekly-release.yml created it through the
|
||||
# releases API.
|
||||
#
|
||||
# The image is multi-arch (linux/amd64, linux/arm64) as before, but built in
|
||||
# one buildx run on host1 instead of one native runner per architecture: the
|
||||
# Dockerfile's builder stage runs on the build platform and cross-compiles
|
||||
# with an aarch64 linker, so only the small final stage (apt, setcap) goes
|
||||
# through QEMU for arm64. No digest-joining job is needed.
|
||||
#
|
||||
# Two guards before anything is pushed:
|
||||
# * the tag must be v<brand_version!>. The version is a string in
|
||||
# crates/types/src/branding.rs, not Cargo.toml, and the image is tagged
|
||||
# with it, so a tag beside an unbumped macro would publish an image that
|
||||
# reports a different version from its tag.
|
||||
# * the tag must be on main, so an image never describes code that was never
|
||||
# reviewed onto the default branch.
|
||||
#
|
||||
# :latest moves with every published tag: tags are cut by the weekly release
|
||||
# (or by hand for a real release); there are no prerelease tags here.
|
||||
#
|
||||
# The push logs in with PACKAGE_TOKEN (jcoffey-dev, write:package): the job's
|
||||
# own token is refused by the container registry.
|
||||
name: publish
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
jobs:
|
||||
version:
|
||||
runs-on: light
|
||||
container:
|
||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
steps:
|
||||
# Full history: the ancestry check cannot be answered from a shallow
|
||||
# clone. The checkout also fetches every branch as origin/*.
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- id: v
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
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; }
|
||||
if [ "$TAG" != "v$V" ]; then
|
||||
echo "Tag $TAG names a commit whose brand_version! says $V." >&2
|
||||
echo "Refusing to publish an image that would report the wrong version." >&2
|
||||
exit 1
|
||||
fi
|
||||
git merge-base --is-ancestor "$(git rev-parse "${TAG}^{commit}")" origin/main \
|
||||
|| { echo "$TAG is not on main" >&2; exit 1; }
|
||||
echo "version=$V" >> "$GITHUB_OUTPUT"
|
||||
echo "version $V"
|
||||
|
||||
publish:
|
||||
needs: [version]
|
||||
runs-on: docker
|
||||
container:
|
||||
image: docker:28-cli@sha256:625d9431a9f54c5a2bc90f24f0e1c3d55b1349fd857dd85035f98c2c9acbdd4d # 28-cli
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
env:
|
||||
DOCKER_BUILDKIT: "1"
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
IMAGE: ${{ vars.REGISTRY }}/${{ github.repository }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
- run: |
|
||||
test -n "$REGISTRY" && test -n "$VERSION"
|
||||
test -n "$PACKAGE_TOKEN" || { echo "PACKAGE_TOKEN secret is not set on this repository" >&2; exit 1; }
|
||||
echo "$PACKAGE_TOKEN" | docker login -u jcoffey-dev --password-stdin "$REGISTRY"
|
||||
docker run --privileged --rm tonistiigi/binfmt --install arm64
|
||||
docker buildx create --use --name gitea-builder --driver docker-container || docker buildx use gitea-builder
|
||||
# Attestations off, as before: they add manifests of their own to the
|
||||
# index, and the index should hold the two images and nothing else.
|
||||
- run: |
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--provenance=false --sbom=false \
|
||||
--tag "$IMAGE:$VERSION" \
|
||||
--tag "$IMAGE:latest" \
|
||||
--push .
|
||||
docker buildx imagetools inspect "$IMAGE:$VERSION"
|
||||
# Gitea keeps a container package on its owner; linking it shows it on
|
||||
# the repository's Packages tab. Idempotent.
|
||||
- run: |
|
||||
apk add --no-cache -q curl
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $PACKAGE_TOKEN" \
|
||||
"$CI_SERVER_INTERNAL/api/v1/packages/${GITHUB_REPOSITORY%%/*}/container/${GITHUB_REPOSITORY#*/}/-/link/${GITHUB_REPOSITORY#*/}" \
|
||||
|| echo "package already linked (or link refused); not fatal"
|
||||
- if: always()
|
||||
run: docker logout "$REGISTRY" || true
|
||||
|
||||
# The weekly release creates its Release (and so the tag) first; a tag
|
||||
# pushed by hand has none. Either way the tag ends up with exactly one
|
||||
# Release, created after the image exists so its pull instructions work.
|
||||
release:
|
||||
needs: [version, publish]
|
||||
runs-on: light
|
||||
container:
|
||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||
steps:
|
||||
- shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ needs.version.outputs.version }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
REGISTRY: ${{ vars.REGISTRY }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, os, urllib.request, urllib.error
|
||||
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||
h = {"Authorization": f"token {os.environ['TOKEN']}", "Content-Type": "application/json"}
|
||||
tag, version = os.environ["TAG"], os.environ["VERSION"]
|
||||
try:
|
||||
urllib.request.urlopen(urllib.request.Request(f"{api}/releases/tags/{tag}", headers=h))
|
||||
print(f"{tag} already has a release"); raise SystemExit
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code != 404: raise
|
||||
image = f"{os.environ['REGISTRY']}/{os.environ['REPO']}:{version}"
|
||||
body = f"Container image: `{image}` (linux/amd64, linux/arm64); also `:latest`."
|
||||
data = json.dumps({"tag_name": tag, "name": f"INBUXA {version}", "body": body}).encode()
|
||||
r = json.load(urllib.request.urlopen(urllib.request.Request(f"{api}/releases", data=data, headers=h)))
|
||||
print(f"created release {r['tag_name']}")
|
||||
PY
|
||||
@@ -0,0 +1,135 @@
|
||||
# Weekly release, ported from .github/workflows/release.yml when the project
|
||||
# moved to the self-hosted Gitea (2026-09-22): cut a release once a week, but
|
||||
# only if there is something in it. A release with nothing in it moves
|
||||
# :latest to an identical build, spends a version number, and notifies
|
||||
# everybody about nothing.
|
||||
#
|
||||
# The version is the date, YYYY.M.D unpadded, with a .N suffix from 2 for a
|
||||
# second release on one day. It lives in crates/types/src/branding.rs
|
||||
# (brand_version!), deliberately not in Cargo.toml so upstream's version bumps
|
||||
# merge without conflicts. The bump is committed to main and the tag names that
|
||||
# commit, so the tree a tag points at reports the version the tag claims --
|
||||
# publish.yml refuses a tag that doesn't.
|
||||
#
|
||||
# Mondays 10:07 UTC, last of the three INBUXA releases: Admin and the webmail
|
||||
# release ahead of the server they talk to. Run it by hand with
|
||||
# workflow_dispatch; dry_run defaults to true.
|
||||
#
|
||||
# NOT LIVE YET: this only ever dry-runs unless the Actions variable
|
||||
# RELEASE_LIVE is '1' (repo or org). Going live also needs a repo secret
|
||||
# RELEASE_TOKEN (jcoffey-dev, write:repository, allowed to push to main):
|
||||
# * a tag Gitea creates for the job's own token raises no event, and the
|
||||
# tag must start publish.yml;
|
||||
# * the bump is committed through the contents API. Gitea has no "only if
|
||||
# the branch is still at X" guard, so the job checks main's head right
|
||||
# before writing and refuses if it moved since the commit it counted from;
|
||||
# run it again. (The API does refuse if the file itself changed, via its
|
||||
# blob sha.)
|
||||
name: weekly-release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '7 10 * * 1'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: Show the decision and stop
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# One at a time: two overlapping runs would race to write the same version and
|
||||
# create the same tag.
|
||||
concurrency:
|
||||
group: weekly-release
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
weekly-release:
|
||||
runs-on: light
|
||||
container:
|
||||
image: python:3.13-slim@sha256:8d9d0b8bcf6506481eae4907c18f5e3e7902e629f5f6d684f9e7c32e85e3ddf0 # 3.13-slim
|
||||
env:
|
||||
READ_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
# Live only with RELEASE_LIVE=1 AND either the schedule or a manual run
|
||||
# with dry_run unticked.
|
||||
DRY_RUN: ${{ (vars.RELEASE_LIVE == '1' && (github.event_name == 'schedule' || inputs.dry_run == false || inputs.dry_run == 'false')) && '0' || '1' }}
|
||||
RELEASE_LIVE: ${{ vars.RELEASE_LIVE }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- uses: coffey-labs/actions/checkout@fab0c4d45e0162963965f1555df27b7bed5e20ec
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import base64, datetime, json, os, re, subprocess, sys, urllib.request
|
||||
|
||||
api = f"{os.environ['CI_SERVER_INTERNAL']}/api/v1/repos/{os.environ['REPO']}"
|
||||
def call(method, path, token, body=None):
|
||||
req = urllib.request.Request(api + path, method=method,
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
headers={"Authorization": f"token {token}", "Content-Type": "application/json"})
|
||||
with urllib.request.urlopen(req) as r:
|
||||
return json.load(r)
|
||||
def git(*a):
|
||||
return subprocess.run(["git", *a], check=True, capture_output=True, text=True).stdout.strip()
|
||||
def has_tag(t):
|
||||
# show-ref matches an exact ref; rev-parse --verify on this git
|
||||
# can read some tag names as describe output and "find" a tag
|
||||
# that isn't there.
|
||||
return subprocess.run(["git", "show-ref", "--verify", "--quiet", f"refs/tags/{t}"]).returncode == 0
|
||||
|
||||
sha = git("rev-parse", "HEAD")
|
||||
# The newest published release, or empty on a project that has never
|
||||
# had one -- in which case everything counts as new. A release can
|
||||
# outlive its tag; falling back to the whole history over-counts,
|
||||
# which cuts a release that was due anyway.
|
||||
rels = call("GET", "/releases?draft=false&pre-release=false&limit=1", os.environ["READ_TOKEN"])
|
||||
previous = rels[0]["tag_name"] if rels else ""
|
||||
rng = f"{previous}..HEAD" if previous and has_tag(previous) else "HEAD"
|
||||
count = int(git("rev-list", "--count", rng))
|
||||
if count == 0:
|
||||
print(f"Nothing to release: no commits since {previous}."); sys.exit(0)
|
||||
|
||||
d = datetime.datetime.now(datetime.timezone.utc)
|
||||
today = f"{d.year}.{d.month}.{d.day}"
|
||||
version, n = today, 2
|
||||
while has_tag(f"v{version}"):
|
||||
version, n = f"{today}.{n}", n + 1
|
||||
tag = f"v{version}"
|
||||
print(f"Releasing {tag} -- {count} commit(s) since {previous or 'the beginning'}, from {sha}.")
|
||||
if os.environ["DRY_RUN"] == "1":
|
||||
print(f"Dry run (RELEASE_LIVE='{os.environ.get('RELEASE_LIVE', '')}'): stopping here."); sys.exit(0)
|
||||
|
||||
token = os.environ.get("RELEASE_TOKEN", "")
|
||||
if not token:
|
||||
print("RELEASE_TOKEN secret is not set on this repository", file=sys.stderr); sys.exit(1)
|
||||
|
||||
# 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.
|
||||
path = "crates/types/src/branding.rs"
|
||||
src = open(path, encoding="utf-8").read()
|
||||
out, hits = re.subn(r'(macro_rules! brand_version \{\s*\(\) => \{\s*")[^"]+(")',
|
||||
lambda m: m.group(1) + version + m.group(2), src, count=1)
|
||||
assert hits == 1, f"brand_version! not found in {path}"
|
||||
|
||||
head = call("GET", "/branches/main", token)["commit"]["id"]
|
||||
if head != sha:
|
||||
print(f"main moved from {sha} to {head} since this run counted; run it again.", file=sys.stderr); sys.exit(1)
|
||||
blob = call("GET", f"/contents/{path}?ref={sha}", token)["sha"]
|
||||
bump = call("PUT", f"/contents/{path}", token, {
|
||||
"branch": "main", "message": f"Version {version}", "sha": blob,
|
||||
"content": base64.b64encode(out.encode()).decode()})["commit"]["sha"]
|
||||
print(f"committed the bump as {bump}")
|
||||
|
||||
# Notes bounded to what is new: one line per change on main's
|
||||
# first-parent history. Creating the release creates the tag, which
|
||||
# is an ordinary push, so publish.yml builds and pushes the image.
|
||||
notes = git("log", "--first-parent", "--format=- %s", rng)
|
||||
rel = call("POST", "/releases", token, {
|
||||
"tag_name": tag, "target_commitish": bump, "name": f"INBUXA {version}",
|
||||
"body": f"{count} commit(s) since {previous or 'the beginning'}.\n\n{notes}"})
|
||||
print(f"created release {rel['tag_name']}")
|
||||
PY
|
||||
@@ -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"
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie AS chef
|
||||
FROM --platform=$BUILDPLATFORM docker.io/lukemathwalker/cargo-chef:latest-rust-slim-trixie@sha256:38dfdbf4fda95c516f873f33032e490baa988b75f7d83c7d12f788f770785b36 AS chef
|
||||
WORKDIR /build
|
||||
|
||||
FROM --platform=$BUILDPLATFORM chef AS planner
|
||||
|
||||
+2
-2
@@ -35,8 +35,8 @@ to Stalwart Labs with credit to you, and you'll be told that has happened.
|
||||
|
||||
This repository is the mail server. The web front ends have their own:
|
||||
|
||||
- [inbuxa-admin](https://github.com/inbuxa/inbuxa-admin)
|
||||
- [ihasmail-inbuxa](https://github.com/inbuxa/ihasmail-inbuxa)
|
||||
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
||||
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
|
||||
|
||||
Upstream's own security documents are kept in `.github-upstream/` for
|
||||
reference. They describe Stalwart Labs' process, not this project's.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -47,6 +47,9 @@ pub struct Network {
|
||||
#[derive(Clone)]
|
||||
pub struct NetworkInfo {
|
||||
pub pacc: Pacc,
|
||||
/// inbuxa: the same document without IMAP, POP3, SMTP and ManageSieve,
|
||||
/// served while legacy protocols are off (legacy-protocols LP-7).
|
||||
pub pacc_jmap_only: Pacc,
|
||||
pub mxs: Vec<MailExchanger>,
|
||||
pub services: VecMap<ServiceProtocol, Service>,
|
||||
}
|
||||
@@ -320,11 +323,26 @@ impl Network {
|
||||
}
|
||||
}
|
||||
|
||||
let (prefix, suffix) = serde_json::to_string(&pacc)
|
||||
let split = |pacc: &Configuration| {
|
||||
serde_json::to_string(pacc)
|
||||
.unwrap_or_default()
|
||||
.rsplit_once(SPLIT_HERE)
|
||||
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
|
||||
.unwrap();
|
||||
.map(|(prefix, suffix)| Pacc {
|
||||
prefix: prefix.to_string(),
|
||||
suffix: suffix.to_string(),
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
// inbuxa: legacy-protocols LP-7
|
||||
let pacc_jmap_only = {
|
||||
let mut pacc = pacc.clone();
|
||||
pacc.protocols.imap = None;
|
||||
pacc.protocols.pop3 = None;
|
||||
pacc.protocols.smtp = None;
|
||||
pacc.protocols.managesieve = None;
|
||||
split(&pacc)
|
||||
};
|
||||
let pacc = split(&pacc);
|
||||
let mut network = Network {
|
||||
node_id: bp.node_id() as u64,
|
||||
server_name: default_hostname.to_string(),
|
||||
@@ -339,7 +357,8 @@ impl Network {
|
||||
info: NetworkInfo {
|
||||
mxs: system.mail_exchangers.into_iter().collect(),
|
||||
services: system.services,
|
||||
pacc: Pacc { prefix, suffix },
|
||||
pacc,
|
||||
pacc_jmap_only,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* 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::{Server, manager::application::Resource};
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use quick_xml::Reader;
|
||||
use quick_xml::XmlVersion;
|
||||
use quick_xml::events::Event;
|
||||
@@ -55,7 +57,15 @@ impl Server {
|
||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = match emailaddress.rsplit_once('@') {
|
||||
Some((_, domain)) => self.legacy_protocols_off_for(domain).await?,
|
||||
None => self.legacy_protocols_off_for("").await?,
|
||||
};
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, ports) = match protocol {
|
||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
* 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::{Server, manager::application::Resource};
|
||||
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
use std::fmt::Write;
|
||||
use utils::url_params::UrlParams;
|
||||
@@ -28,6 +30,9 @@ impl Server {
|
||||
("%EMAILADDRESS%", default_host.as_str())
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_protocols_off_for(domain).await?;
|
||||
|
||||
// Build XML response
|
||||
let mut config = String::with_capacity(1024);
|
||||
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
@@ -40,6 +45,9 @@ impl Server {
|
||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||
);
|
||||
for (protocol, service) in &self.core.network.info.services {
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
continue;
|
||||
}
|
||||
let (protocol, tag, ports) = match protocol {
|
||||
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
||||
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -2,9 +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::{Server, config::network::Pacc, network::dkim::generate_dkim_dns_record};
|
||||
use crate::{
|
||||
Server,
|
||||
config::network::Pacc,
|
||||
network::{dkim::generate_dkim_dns_record, legacy::is_legacy_service},
|
||||
};
|
||||
use ahash::{AHashMap, AHashSet};
|
||||
use base64::{Engine, engine::general_purpose};
|
||||
use dns_update::{
|
||||
@@ -34,6 +40,8 @@ impl Server {
|
||||
let network = &self.core.network;
|
||||
let default_host = network.server_name.as_str();
|
||||
let domain_name = domain.name.as_str();
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let legacy_off = self.legacy_protocols_off_for(domain_name).await?;
|
||||
let domain_name_suffix = format!(".{domain_name}");
|
||||
|
||||
for record_type in record_types {
|
||||
@@ -193,6 +201,25 @@ impl Server {
|
||||
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
|
||||
};
|
||||
|
||||
// inbuxa: legacy-protocols LP-7. While they are off, every
|
||||
// name says "not offered" -- target "." (RFC 6186 section
|
||||
// 3.4) -- rather than vanishing, so a client that looks
|
||||
// is told, and an old record left in the zone is replaced.
|
||||
if legacy_off && is_legacy_service(protocol) {
|
||||
for (service_name, _) in services {
|
||||
records.push(NamedDnsRecord {
|
||||
name: format!("_{service_name}._tcp.{domain_name}."),
|
||||
record: DnsRecord::SRV(SRVRecord {
|
||||
target: ".".to_string(),
|
||||
priority: 0,
|
||||
weight: 0,
|
||||
port: 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
|
||||
if is_tls == 1 || service.cleartext {
|
||||
records.push(NamedDnsRecord {
|
||||
@@ -277,6 +304,14 @@ impl Server {
|
||||
for (protocol, service) in &network.info.services {
|
||||
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
||||
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
||||
// inbuxa: legacy-protocols LP-7. No TLS pin for a port
|
||||
// the switch has closed. Submission's port stays open
|
||||
// (the SMTP lock), so its record stays.
|
||||
if legacy_off
|
||||
&& matches!(protocol, ServiceProtocol::Imap | ServiceProtocol::Pop3)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let port = match protocol {
|
||||
ServiceProtocol::Imap => 993,
|
||||
ServiceProtocol::Pop3 => 995,
|
||||
@@ -382,6 +417,12 @@ impl Server {
|
||||
}
|
||||
|
||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
||||
// inbuxa: legacy-protocols LP-7, LP-14a
|
||||
let pacc = if self.legacy_protocols_off_for(domain_name).await? {
|
||||
&self.core.network.info.pacc_jmap_only
|
||||
} else {
|
||||
&self.core.network.info.pacc
|
||||
};
|
||||
self.get_directory_for_domain(domain_name)
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
@@ -390,15 +431,9 @@ impl Server {
|
||||
.and_then(|directory| {
|
||||
directory
|
||||
.oidc_discovery_document()
|
||||
.map(|doc| self.core.network.info.pacc.build(&doc.url))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.pacc
|
||||
.build(&self.core.network.http.url_https)
|
||||
.map(|doc| pacc.build(&doc.url))
|
||||
})
|
||||
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,633 @@
|
||||
/*
|
||||
* 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.
|
||||
//!
|
||||
//! And nothing advertises what is closed (LP-7): client configuration and
|
||||
//! the suggested DNS records leave the legacy services out, or mark them as
|
||||
//! not offered, while the switch is off -- the server's, or for a tenant's
|
||||
//! domains, the tenant's (LP-14a).
|
||||
//!
|
||||
//! 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, auth::AccessToken, config::server::Listeners, network::TcpAcceptor};
|
||||
use directory::Credentials;
|
||||
use inbuxa_features::security::{
|
||||
legacy_use::{self, LegacyUse},
|
||||
listeners,
|
||||
protocol_policy::{self, ProtocolPolicy, SavedListener},
|
||||
tenant_protocol_policy,
|
||||
};
|
||||
use registry::schema::enums::ServiceProtocol;
|
||||
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?;
|
||||
|
||||
// LP-8. Raised here rather than by the JMAP method, so whatever turns
|
||||
// the switch is reported. A /set that changed nothing -- the switch
|
||||
// already where it was asked to be, nothing to close or reopen -- is
|
||||
// not a change.
|
||||
if previous.legacy_protocols != policy.legacy_protocols || !change.is_empty() {
|
||||
let (moved, direction) = if policy.legacy_protocols.is_disabled() {
|
||||
(&change.closed, "closed")
|
||||
} else {
|
||||
(&change.reopened, "reopened")
|
||||
};
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "server",
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
AccountId = policy.changed_by.clone(),
|
||||
Details = direction,
|
||||
ListenerId = listener_names(moved.iter().map(|l| l.id.clone())),
|
||||
// Only when a listener could not be put back (LP-5).
|
||||
Reason = (!change.failed.is_empty()).then(|| listener_names(
|
||||
change
|
||||
.failed
|
||||
.iter()
|
||||
.map(|(l, why)| format!("{}: {why}", l.id))
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
|
||||
/// Names for an event field: the listeners a change closed, reopened or
|
||||
/// failed to reopen (LP-8).
|
||||
fn listener_names<T: Into<trc::Value>>(names: impl Iterator<Item = T>) -> trc::Value {
|
||||
trc::Value::Array(names.map(Into::into).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",
|
||||
}
|
||||
}
|
||||
|
||||
/// The same protocol, as the impact panel's record names it (LP-15).
|
||||
pub fn as_use(&self) -> LegacyUse {
|
||||
match self {
|
||||
LegacyProtocol::Imap => LegacyUse::Imap,
|
||||
LegacyProtocol::Pop3 => LegacyUse::Pop3,
|
||||
LegacyProtocol::ManageSieve => LegacyUse::ManageSieve,
|
||||
LegacyProtocol::Submission => LegacyUse::Submission,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the mail app is told (LP-12). 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. At server scope "Your
|
||||
/// organization" reads "This server" (LP-6).
|
||||
pub fn refusal(&self, scope: RefusalScope) -> &'static str {
|
||||
match (scope, self) {
|
||||
(RefusalScope::Server, LegacyProtocol::Imap) => {
|
||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
}
|
||||
(RefusalScope::Server, LegacyProtocol::Pop3) => {
|
||||
"[AUTH] This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
}
|
||||
(RefusalScope::Server, LegacyProtocol::ManageSieve) => {
|
||||
"This server allows only INBUXA webmail and JMAP apps."
|
||||
}
|
||||
(RefusalScope::Server, LegacyProtocol::Submission) => {
|
||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||
}
|
||||
(RefusalScope::Tenant(_), LegacyProtocol::Imap) => {
|
||||
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
}
|
||||
(RefusalScope::Tenant(_), LegacyProtocol::Pop3) => {
|
||||
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
}
|
||||
(RefusalScope::Tenant(_), LegacyProtocol::ManageSieve) => {
|
||||
"Your organization allows only INBUXA webmail and JMAP apps."
|
||||
}
|
||||
(RefusalScope::Tenant(_), LegacyProtocol::Submission) => {
|
||||
"535 5.7.0 Your organization 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, the scope and the domain,
|
||||
/// never the account; the session adds the remote IP.
|
||||
///
|
||||
/// Not the tenant's id: `Id` is what IMAP answers a command's tag from,
|
||||
/// so an error carrying one is sent under the wrong tag and the mail app
|
||||
/// waits for a reply that never comes. The domain names the tenant.
|
||||
pub fn refused(&self, scope: RefusalScope, domain: Option<String>) -> trc::Error {
|
||||
trc::AuthEvent::LegacyProtocolRefused
|
||||
.into_err()
|
||||
.details(self.refusal(scope))
|
||||
.ctx(trc::Key::Source, self.as_str())
|
||||
.ctx(
|
||||
trc::Key::Policy,
|
||||
match scope {
|
||||
RefusalScope::Server => "server",
|
||||
RefusalScope::Tenant(_) => "tenant",
|
||||
},
|
||||
)
|
||||
.ctx_opt(trc::Key::Domain, domain)
|
||||
}
|
||||
}
|
||||
|
||||
/// One account's last sign-in over one legacy protocol, as the impact panel
|
||||
/// shows it (LP-15).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RecentUse {
|
||||
pub account_id: u32,
|
||||
pub name: String,
|
||||
pub protocol: &'static str,
|
||||
/// Seconds since the epoch.
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
/// Whose switch refused a sign-in: the server's (LP-6) or a tenant's (LP-10).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RefusalScope {
|
||||
Server,
|
||||
Tenant(u32),
|
||||
}
|
||||
|
||||
/// 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), or while the switch of the tenant that owns the named
|
||||
/// domain is (LP-10). Called before the credentials are checked, so the
|
||||
/// answer is the same for a right password, a wrong one and an address
|
||||
/// that doesn't exist (LP-11): a tenant's domain answers for every address
|
||||
/// on it.
|
||||
///
|
||||
/// Read from the store on each sign-in rather than cached, so every node
|
||||
/// of a cluster answers the same the moment a switch turns.
|
||||
pub async fn refuse_legacy_sign_in(
|
||||
&self,
|
||||
protocol: LegacyProtocol,
|
||||
credentials: &Credentials,
|
||||
) -> trc::Result<()> {
|
||||
let domain = domain_of(credentials);
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Err(protocol.refused(RefusalScope::Server, domain));
|
||||
}
|
||||
if let Some(name) = &domain
|
||||
&& let Some(domain) = self.domain(name).await?
|
||||
&& let Some(tenant_id) = domain.id_tenant
|
||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||
{
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), Some(name.clone())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Once the account is known: refuses it if its tenant has legacy
|
||||
/// protocols off, and otherwise records the sign-in for the impact panel.
|
||||
///
|
||||
/// The refusal is LP-10 again for a bearer token, which needn't name an
|
||||
/// account and so can't be judged by its domain beforehand; for a
|
||||
/// password sign-in it has already been decided. The record is LP-15's:
|
||||
/// one timestamp per account and protocol, at most hourly. A record that
|
||||
/// can't be written is logged and the sign-in goes ahead -- a panel is
|
||||
/// not worth locking anyone out over.
|
||||
pub async fn admit_legacy_session(
|
||||
&self,
|
||||
protocol: LegacyProtocol,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<()> {
|
||||
if let Some(tenant_id) = access_token.tenant_id()
|
||||
&& self.tenant_legacy_protocols_off(tenant_id).await?
|
||||
{
|
||||
return Err(protocol.refused(RefusalScope::Tenant(tenant_id), None));
|
||||
}
|
||||
if let Err(err) = legacy_use::record(
|
||||
&self.core.storage.data,
|
||||
access_token.account_id(),
|
||||
protocol.as_use(),
|
||||
store::write::now(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
trc::error!(err.details("Failed to record a legacy sign-in (LP-15)."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Who signed in over a legacy protocol in the last 30 days, most recent
|
||||
/// first, for the impact panel (LP-15): everyone at server scope, or one
|
||||
/// tenant's accounts. Accounts that no longer exist are left out.
|
||||
pub async fn recent_legacy_use(&self, tenant_id: Option<u32>) -> trc::Result<Vec<RecentUse>> {
|
||||
let mut recent = Vec::new();
|
||||
for entry in legacy_use::recent(&self.core.storage.data, store::write::now()).await? {
|
||||
let Some(account) = self.try_account(entry.account_id).await? else {
|
||||
continue;
|
||||
};
|
||||
if tenant_id.is_some() && account.id_tenant != tenant_id {
|
||||
continue;
|
||||
}
|
||||
recent.push(RecentUse {
|
||||
account_id: entry.account_id,
|
||||
name: account.name.to_string(),
|
||||
protocol: entry.protocol.as_str(),
|
||||
at: entry.at,
|
||||
});
|
||||
}
|
||||
recent.sort_by(|a, b| b.at.cmp(&a.at).then_with(|| a.name.cmp(&b.name)));
|
||||
Ok(recent)
|
||||
}
|
||||
|
||||
/// Whether legacy protocols are off for this account: the stricter of the
|
||||
/// server's switch and its tenant's. What the JMAP session tells the
|
||||
/// account's apps (legacy-protocols spec, Interfaces), so the webmail can
|
||||
/// say why a mail app won't connect (LP-19).
|
||||
pub async fn legacy_protocols_off_for_account(
|
||||
&self,
|
||||
access_token: &AccessToken,
|
||||
) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
}
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a tenant has turned legacy protocols off for itself (LP-10).
|
||||
pub async fn tenant_legacy_protocols_off(&self, tenant_id: u32) -> trc::Result<bool> {
|
||||
Ok(
|
||||
tenant_protocol_policy::get(&self.core.storage.data, tenant_id)
|
||||
.await?
|
||||
.legacy_protocols
|
||||
.is_disabled(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The services mail apps sign in to, which the switch turns off: nothing may
|
||||
/// offer them while it is (LP-7). SMTP here is submission -- mail apps
|
||||
/// sending -- since inbound mail is never a configured service.
|
||||
pub fn is_legacy_service(protocol: &ServiceProtocol) -> bool {
|
||||
matches!(
|
||||
protocol,
|
||||
ServiceProtocol::Imap
|
||||
| ServiceProtocol::Pop3
|
||||
| ServiceProtocol::Smtp
|
||||
| ServiceProtocol::Managesieve
|
||||
)
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Whether legacy services are off for this domain, for the answers that
|
||||
/// must stop offering them: off for the whole server (LP-7), or for the
|
||||
/// tenant the domain belongs to (LP-14a). Read per answer, as sign-in
|
||||
/// reads it. A name that is no domain here answers for the server alone.
|
||||
pub async fn legacy_protocols_off_for(&self, domain_name: &str) -> trc::Result<bool> {
|
||||
if self.protocol_policy().await?.legacy_protocols.is_disabled() {
|
||||
return Ok(true);
|
||||
}
|
||||
match self.domain(domain_name).await? {
|
||||
Some(domain) => match domain.id_tenant {
|
||||
Some(tenant_id) => self.tenant_legacy_protocols_off(tenant_id).await,
|
||||
None => Ok(false),
|
||||
},
|
||||
None => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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).
|
||||
let server = RefusalScope::Server;
|
||||
assert_eq!(
|
||||
LegacyProtocol::Imap.refusal(server),
|
||||
"This server allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
);
|
||||
assert!(
|
||||
LegacyProtocol::Pop3
|
||||
.refusal(server)
|
||||
.starts_with("[AUTH] This server allows")
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::ManageSieve.refusal(server),
|
||||
"This server allows only INBUXA webmail and JMAP apps."
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::Submission.refusal(server),
|
||||
"535 5.7.0 This server allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_refusal_speaks_for_the_organization() {
|
||||
// LP-12, exactly as the spec writes them.
|
||||
let tenant = RefusalScope::Tenant(7);
|
||||
assert_eq!(
|
||||
LegacyProtocol::Imap.refusal(tenant),
|
||||
"Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::Pop3.refusal(tenant),
|
||||
"[AUTH] Your organization allows only INBUXA webmail and JMAP apps. This mail app can't sign in."
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::ManageSieve.refusal(tenant),
|
||||
"Your organization allows only INBUXA webmail and JMAP apps."
|
||||
);
|
||||
assert_eq!(
|
||||
LegacyProtocol::Submission.refusal(tenant),
|
||||
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. This mail app can't send.\r\n"
|
||||
);
|
||||
let err = LegacyProtocol::Imap.refused(tenant, Some("example.org".into()));
|
||||
assert_eq!(err.value_as_str(trc::Key::Policy), Some("tenant"));
|
||||
// IMAP answers the command's tag from Id; the refusal must leave it be.
|
||||
assert!(err.value(trc::Key::Id).is_none());
|
||||
assert!(err.matches(trc::EventType::Auth(trc::AuthEvent::LegacyProtocolRefused)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refusal_is_not_a_failed_sign_in() {
|
||||
let err = LegacyProtocol::Imap
|
||||
.refused(RefusalScope::Server, domain_of(&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 only_the_services_mail_apps_sign_in_to_are_legacy() {
|
||||
for protocol in [
|
||||
ServiceProtocol::Imap,
|
||||
ServiceProtocol::Pop3,
|
||||
ServiceProtocol::Smtp,
|
||||
ServiceProtocol::Managesieve,
|
||||
] {
|
||||
assert!(is_legacy_service(&protocol), "{protocol:?}");
|
||||
}
|
||||
for protocol in [
|
||||
ServiceProtocol::Jmap,
|
||||
ServiceProtocol::Caldav,
|
||||
ServiceProtocol::Carddav,
|
||||
ServiceProtocol::Webdav,
|
||||
] {
|
||||
assert!(!is_legacy_service(&protocol), "{protocol:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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,220 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! When each account last signed in over each legacy protocol, for the
|
||||
//! impact panel (legacy-protocols spec, LP-15, "Last use per protocol").
|
||||
//!
|
||||
//! One timestamp per account per protocol, and nothing else: no address, no
|
||||
//! IP, no client. It is written at most once an hour per account and
|
||||
//! protocol, so a mail app polling every minute costs one read per sign-in
|
||||
//! and one write an hour. Stored under `P` `u`, the account id and a protocol
|
||||
//! byte, in the fork's subspace.
|
||||
|
||||
use store::{
|
||||
Deserialize, IterateParams, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
/// How long a recorded use stands before the next sign-in rewrites it.
|
||||
pub const WRITE_EVERY_SECS: u64 = 3600;
|
||||
|
||||
/// How far back the impact panel looks (LP-15).
|
||||
pub const RECENT_SECS: u64 = 30 * 24 * 3600;
|
||||
|
||||
/// The protocols the panel names, as they are spelled over JMAP.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum LegacyUse {
|
||||
Imap,
|
||||
Pop3,
|
||||
ManageSieve,
|
||||
Submission,
|
||||
}
|
||||
|
||||
impl LegacyUse {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
LegacyUse::Imap => "imap",
|
||||
LegacyUse::Pop3 => "pop3",
|
||||
LegacyUse::ManageSieve => "manageSieve",
|
||||
LegacyUse::Submission => "submission",
|
||||
}
|
||||
}
|
||||
|
||||
fn byte(&self) -> u8 {
|
||||
match self {
|
||||
LegacyUse::Imap => b'i',
|
||||
LegacyUse::Pop3 => b'p',
|
||||
LegacyUse::ManageSieve => b's',
|
||||
LegacyUse::Submission => b'm',
|
||||
}
|
||||
}
|
||||
|
||||
fn from_byte(byte: u8) -> Option<Self> {
|
||||
match byte {
|
||||
b'i' => Some(LegacyUse::Imap),
|
||||
b'p' => Some(LegacyUse::Pop3),
|
||||
b's' => Some(LegacyUse::ManageSieve),
|
||||
b'm' => Some(LegacyUse::Submission),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One account's last use of one protocol.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Use {
|
||||
pub account_id: u32,
|
||||
pub protocol: LegacyUse,
|
||||
/// Seconds since the epoch.
|
||||
pub at: u64,
|
||||
}
|
||||
|
||||
fn key(account_id: u32, protocol: Option<LegacyUse>) -> ValueKey<ValueClass> {
|
||||
let mut key = Vec::with_capacity(7);
|
||||
key.extend_from_slice(b"Pu");
|
||||
key.extend_from_slice(&account_id.to_be_bytes());
|
||||
key.push(protocol.map_or(0, |p| p.byte()));
|
||||
ValueKey::from(ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Reads a stored key back into who and what, if it is one of ours.
|
||||
fn parse_key(key: &[u8]) -> Option<(u32, LegacyUse)> {
|
||||
// The iterator may or may not hand back the subspace byte; the tail is
|
||||
// what identifies an entry: two bytes of prefix, four of account id and
|
||||
// one of protocol.
|
||||
let tail = key.get(key.len().checked_sub(7)?..)?;
|
||||
(tail[..2] == *b"Pu").then_some(())?;
|
||||
let account_id = u32::from_be_bytes(tail[2..6].try_into().ok()?);
|
||||
Some((account_id, LegacyUse::from_byte(tail[6])?))
|
||||
}
|
||||
|
||||
struct At(u64);
|
||||
|
||||
impl Deserialize for At {
|
||||
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||
bytes
|
||||
.try_into()
|
||||
.map(|bytes| At(u64::from_be_bytes(bytes)))
|
||||
.map_err(|_| trc::StoreEvent::DataCorruption.caused_by(trc::location!()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a use at `at` is recent enough for the panel at `now` (LP-15).
|
||||
pub fn is_recent(at: u64, now: u64) -> bool {
|
||||
at >= now.saturating_sub(RECENT_SECS)
|
||||
}
|
||||
|
||||
/// Whether a use at `now` should be written over one stored at `stored`.
|
||||
fn due(stored: Option<u64>, now: u64) -> bool {
|
||||
stored.is_none_or(|stored| now.saturating_sub(stored) >= WRITE_EVERY_SECS)
|
||||
}
|
||||
|
||||
/// Records a successful sign-in, unless one was recorded within the hour.
|
||||
pub async fn record(
|
||||
data: &Store,
|
||||
account_id: u32,
|
||||
protocol: LegacyUse,
|
||||
now: u64,
|
||||
) -> trc::Result<()> {
|
||||
let stored = data
|
||||
.get_value::<At>(key(account_id, Some(protocol)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|At(at)| at);
|
||||
if !due(stored, now) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.set(
|
||||
key(account_id, Some(protocol)).class,
|
||||
now.to_be_bytes().to_vec(),
|
||||
);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Every use recent at `now` (LP-15), across all accounts.
|
||||
pub async fn recent(data: &Store, now: u64) -> trc::Result<Vec<Use>> {
|
||||
let mut uses = Vec::new();
|
||||
data.iterate(
|
||||
IterateParams::new(key(0, None), key(u32::MAX, Some(LegacyUse::Submission))).ascending(),
|
||||
|key, value| {
|
||||
if let Some((account_id, protocol)) = parse_key(key)
|
||||
&& let Ok(At(at)) = At::deserialize(value)
|
||||
&& is_recent(at, now)
|
||||
{
|
||||
uses.push(Use {
|
||||
account_id,
|
||||
protocol,
|
||||
at,
|
||||
});
|
||||
}
|
||||
Ok(true)
|
||||
},
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
Ok(uses)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn written_at_most_once_an_hour() {
|
||||
assert!(due(None, 100));
|
||||
assert!(!due(Some(100), 100 + WRITE_EVERY_SECS - 1));
|
||||
assert!(due(Some(100), 100 + WRITE_EVERY_SECS));
|
||||
// A clock that went backwards doesn't write.
|
||||
assert!(!due(Some(100), 50));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_panel_looks_back_thirty_days() {
|
||||
// Acceptance test 11: three days ago is listed, forty days ago isn't.
|
||||
let now = 1_800_000_000;
|
||||
let day = 24 * 3600;
|
||||
assert!(is_recent(now - 3 * day, now));
|
||||
assert!(is_recent(now - 30 * day, now));
|
||||
assert!(!is_recent(now - 30 * day - 1, now));
|
||||
assert!(!is_recent(now - 40 * day, now));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_read_back() {
|
||||
for protocol in [
|
||||
LegacyUse::Imap,
|
||||
LegacyUse::Pop3,
|
||||
LegacyUse::ManageSieve,
|
||||
LegacyUse::Submission,
|
||||
] {
|
||||
let ValueClass::Any(any) = key(42, Some(protocol)).class else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(parse_key(&any.key), Some((42, protocol)));
|
||||
// With the subspace byte in front, too.
|
||||
let mut with_subspace = vec![SUBSPACE_INBUXA];
|
||||
with_subspace.extend_from_slice(&any.key);
|
||||
assert_eq!(parse_key(&with_subspace), Some((42, protocol)));
|
||||
}
|
||||
assert_eq!(parse_key(b"Pp"), None);
|
||||
assert_eq!(parse_key(b"Xx\0\0\0\x2ai"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_protocols_are_recorded() {
|
||||
// Nothing but the four legacy protocols has a byte of its own.
|
||||
assert_eq!(LegacyUse::from_byte(0), None);
|
||||
assert_eq!(LegacyUse::from_byte(b'x'), None);
|
||||
}
|
||||
}
|
||||
@@ -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,16 @@
|
||||
/*
|
||||
* 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 legacy_use;
|
||||
pub mod listeners;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:TenantProtocolPolicy`, one tenant's legacy mail protocols switch
|
||||
//! (legacy-protocols spec, LP-9 to LP-14a). Stored as JSON under `P` `t` and
|
||||
//! the tenant id in the fork's subspace; a tenant with nothing stored has
|
||||
//! legacy protocols on.
|
||||
//!
|
||||
//! A tenant's switch closes no port -- other tenants share them (LP-13). It
|
||||
//! refuses sign-in on the tenant's domains, and keeps client configuration
|
||||
//! for them from offering what's refused. That is all it is: one fact per
|
||||
//! tenant, easy to turn back, touching no listener, role or permission.
|
||||
|
||||
use crate::security::protocol_policy::{LegacyProtocols, ProtocolPolicy};
|
||||
use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize};
|
||||
use store::{
|
||||
Deserialize, SUBSPACE_INBUXA, Store, ValueKey,
|
||||
write::{AnyClass, BatchBuilder, ValueClass},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
/// One tenant's switch.
|
||||
#[derive(Debug, Clone, PartialEq, Default, SerdeSerialize, SerdeDeserialize)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct TenantProtocolPolicy {
|
||||
/// The switch itself.
|
||||
pub legacy_protocols: LegacyProtocols,
|
||||
/// When it last changed, in milliseconds since the epoch.
|
||||
pub changed_at: Option<u64>,
|
||||
/// The account that last changed it.
|
||||
pub changed_by: Option<String>,
|
||||
}
|
||||
|
||||
/// Why a tenant's switch can't be set this way, if it can't (LP-9).
|
||||
///
|
||||
/// A tenant can always turn legacy protocols off for itself. It can turn
|
||||
/// them back on only while the server has them on: server off means off for
|
||||
/// everyone.
|
||||
pub fn refusal(server: &ProtocolPolicy, requested: LegacyProtocols) -> Option<&'static str> {
|
||||
(server.legacy_protocols.is_disabled() && !requested.is_disabled()).then_some(
|
||||
"Legacy mail protocols are off for the whole server (inbuxa:ProtocolPolicy), \
|
||||
so they can't be turned back on for one organization.",
|
||||
)
|
||||
}
|
||||
|
||||
fn key(tenant_id: u32) -> ValueClass {
|
||||
let mut key = Vec::with_capacity(6);
|
||||
key.extend_from_slice(b"Pt");
|
||||
key.extend_from_slice(&tenant_id.to_be_bytes());
|
||||
ValueClass::Any(AnyClass {
|
||||
subspace: SUBSPACE_INBUXA,
|
||||
key,
|
||||
})
|
||||
}
|
||||
|
||||
struct Json(TenantProtocolPolicy);
|
||||
|
||||
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 tenant's policy, or the default (on) when it has never been set.
|
||||
pub async fn get(data: &Store, tenant_id: u32) -> trc::Result<TenantProtocolPolicy> {
|
||||
Ok(data
|
||||
.get_value::<Json>(ValueKey::from(key(tenant_id)))
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.map(|Json(policy)| policy)
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Stores the tenant's policy.
|
||||
pub async fn set(data: &Store, tenant_id: u32, policy: &TenantProtocolPolicy) -> 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(tenant_id), bytes);
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Forgets a tenant's switch, when the tenant is deleted. Otherwise a tenant
|
||||
/// that came to have the same id would start with the old one's switch.
|
||||
pub async fn remove(data: &Store, tenant_id: u32) -> trc::Result<()> {
|
||||
let mut batch = BatchBuilder::new();
|
||||
batch.clear(key(tenant_id));
|
||||
data.write(batch.build_all())
|
||||
.await
|
||||
.caused_by(trc::location!())
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn server(legacy_protocols: LegacyProtocols) -> ProtocolPolicy {
|
||||
ProtocolPolicy {
|
||||
legacy_protocols,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_starts_with_legacy_protocols_on() {
|
||||
assert!(
|
||||
!TenantProtocolPolicy::default()
|
||||
.legacy_protocols
|
||||
.is_disabled()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_always_turn_them_off() {
|
||||
for s in [LegacyProtocols::Enabled, LegacyProtocols::Disabled] {
|
||||
assert_eq!(refusal(&server(s), LegacyProtocols::Disabled), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tenant_can_turn_them_on_only_while_the_server_has_them_on() {
|
||||
// LP-9, acceptance test 9.
|
||||
assert_eq!(
|
||||
refusal(&server(LegacyProtocols::Enabled), LegacyProtocols::Enabled),
|
||||
None
|
||||
);
|
||||
let why =
|
||||
refusal(&server(LegacyProtocols::Disabled), LegacyProtocols::Enabled).expect("refused");
|
||||
assert!(why.contains("inbuxa:ProtocolPolicy"), "{why}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_per_tenant_and_clear_of_the_server_policy() {
|
||||
let ValueClass::Any(a) = key(1) else { panic!() };
|
||||
let ValueClass::Any(b) = key(2) else { panic!() };
|
||||
assert_ne!(a.key, b.key);
|
||||
assert_eq!(&a.key[..2], b"Pt");
|
||||
assert_ne!(a.key, b"Pp".to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_json_reads_back() {
|
||||
let policy = TenantProtocolPolicy {
|
||||
legacy_protocols: LegacyProtocols::Disabled,
|
||||
changed_at: Some(1),
|
||||
changed_by: Some("b".into()),
|
||||
};
|
||||
let Json(back) = Json::deserialize(&serde_json::to_vec(&policy).unwrap()).unwrap();
|
||||
assert_eq!(back, policy);
|
||||
// Unknown and missing fields read as defaults.
|
||||
let Json(back) = Json::deserialize(br#"{"futureField":1}"#).unwrap();
|
||||
assert_eq!(back, TenantProtocolPolicy::default());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -92,6 +100,13 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.and_then(|token| token.assert_has_permission(Permission::ImapAuthenticate))?;
|
||||
|
||||
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
|
||||
// account, and LP-15: the sign-in is recorded for the impact panel
|
||||
self.server
|
||||
.admit_legacy_session(LegacyProtocol::Imap, &access_token)
|
||||
.await
|
||||
.map_err(|err| err.code(ResponseCode::Alert).id(tag.clone()))?;
|
||||
|
||||
// Enforce concurrency limits
|
||||
let in_flight = match access_token.is_imap_request_allowed() {
|
||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
/*
|
||||
* 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,
|
||||
/// Server-set: who signed in over a legacy protocol in the last 30
|
||||
/// days, and when, for the impact panel (LP-15).
|
||||
RecentLegacyUse,
|
||||
}
|
||||
|
||||
#[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::RecentLegacyUse => "recentLegacyUse",
|
||||
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"recentLegacyUse" => ProtocolPolicyProperty::RecentLegacyUse,
|
||||
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::RecentLegacyUse
|
||||
| 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:TenantProtocolPolicy/get` and `/set` under `urn:inbuxa:jmap`: one
|
||||
//! tenant's legacy mail protocols switch (legacy-protocols spec, LP-9 to
|
||||
//! LP-14). One per tenant; its id is the tenant's id.
|
||||
//!
|
||||
//! `tenantId`, `changedAt` and `changedBy` are the server's to say. 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 TenantProtocolPolicy;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TenantProtocolPolicyProperty {
|
||||
Id,
|
||||
/// Server-set: the tenant this is the switch of.
|
||||
TenantId,
|
||||
/// The switch: `enabled` or `disabled`.
|
||||
LegacyProtocols,
|
||||
ChangedAt,
|
||||
ChangedBy,
|
||||
/// Server-set: who signed in over a legacy protocol in the last 30
|
||||
/// days, and when, for the impact panel (LP-15).
|
||||
RecentLegacyUse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub enum TenantProtocolPolicyValue {
|
||||
Id(Id),
|
||||
}
|
||||
|
||||
impl Property for TenantProtocolPolicyProperty {
|
||||
fn try_parse(_: Option<&Key<'_, Self>>, value: &str) -> Option<Self> {
|
||||
TenantProtocolPolicyProperty::parse(value)
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
TenantProtocolPolicyProperty::Id => "id",
|
||||
TenantProtocolPolicyProperty::TenantId => "tenantId",
|
||||
TenantProtocolPolicyProperty::LegacyProtocols => "legacyProtocols",
|
||||
TenantProtocolPolicyProperty::ChangedAt => "changedAt",
|
||||
TenantProtocolPolicyProperty::ChangedBy => "changedBy",
|
||||
TenantProtocolPolicyProperty::RecentLegacyUse => "recentLegacyUse",
|
||||
}
|
||||
.into()
|
||||
}
|
||||
}
|
||||
|
||||
impl TenantProtocolPolicyProperty {
|
||||
fn parse(value: &str) -> Option<Self> {
|
||||
hashify::tiny_map!(value.as_bytes(),
|
||||
b"id" => TenantProtocolPolicyProperty::Id,
|
||||
b"tenantId" => TenantProtocolPolicyProperty::TenantId,
|
||||
b"legacyProtocols" => TenantProtocolPolicyProperty::LegacyProtocols,
|
||||
b"changedAt" => TenantProtocolPolicyProperty::ChangedAt,
|
||||
b"changedBy" => TenantProtocolPolicyProperty::ChangedBy,
|
||||
b"recentLegacyUse" => TenantProtocolPolicyProperty::RecentLegacyUse,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl TenantProtocolPolicyProperty {
|
||||
/// 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,
|
||||
TenantProtocolPolicyProperty::TenantId
|
||||
| TenantProtocolPolicyProperty::ChangedAt
|
||||
| TenantProtocolPolicyProperty::ChangedBy
|
||||
| TenantProtocolPolicyProperty::RecentLegacyUse
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TenantProtocolPolicyProperty {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
TenantProtocolPolicyProperty::parse(s).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Element for TenantProtocolPolicyValue {
|
||||
type Property = TenantProtocolPolicyProperty;
|
||||
|
||||
fn try_parse<P>(key: &Key<'_, Self::Property>, value: &str) -> Option<Self> {
|
||||
match key {
|
||||
Key::Property(TenantProtocolPolicyProperty::Id) => {
|
||||
Id::from_str(value).ok().map(TenantProtocolPolicyValue::Id)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_cow(&self) -> Cow<'static, str> {
|
||||
match self {
|
||||
TenantProtocolPolicyValue::Id(id) => id.to_string().into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObject for TenantProtocolPolicy {
|
||||
type Property = TenantProtocolPolicyProperty;
|
||||
|
||||
type Element = TenantProtocolPolicyValue;
|
||||
|
||||
type Id = Id;
|
||||
|
||||
type Filter = ();
|
||||
|
||||
type Comparator = ();
|
||||
|
||||
type GetArguments = ();
|
||||
|
||||
type SetArguments<'de> = ();
|
||||
|
||||
type QueryArguments = ();
|
||||
|
||||
type CopyArguments = ();
|
||||
|
||||
type ParseArguments = ();
|
||||
|
||||
const ID_PROPERTY: Self::Property = TenantProtocolPolicyProperty::Id;
|
||||
}
|
||||
|
||||
impl From<Id> for TenantProtocolPolicyValue {
|
||||
fn from(id: Id) -> Self {
|
||||
TenantProtocolPolicyValue::Id(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for TenantProtocolPolicyValue {
|
||||
fn as_id(&self) -> Option<Id> {
|
||||
match self {
|
||||
TenantProtocolPolicyValue::Id(id) => Some(*id),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_any_id(&self) -> Option<AnyId> {
|
||||
match self {
|
||||
TenantProtocolPolicyValue::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 = TenantProtocolPolicyValue::Id(id);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl JmapObjectId for TenantProtocolPolicyProperty {
|
||||
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,8 @@ 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_tenant_protocol_policy; // inbuxa: legacy protocols off, per tenant
|
||||
pub mod inbuxa_deleted_account; // inbuxa: undelete
|
||||
pub mod file_node;
|
||||
pub mod identity;
|
||||
|
||||
@@ -61,6 +61,12 @@ impl Response<'_> {
|
||||
GetResponseMethod::AiLimits(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::ProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::TenantProtocolPolicy(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
GetResponseMethod::Principal(response) => {
|
||||
response.eval_jptr(path, &mut results)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,10 @@ 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::TenantProtocolPolicy(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 +93,12 @@ impl Response<'_> {
|
||||
SetRequestMethod::AiLimits(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::ProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::TenantProtocolPolicy(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
SetRequestMethod::AddressBook(request) => {
|
||||
request.resolve_references(self, 1, false)?
|
||||
}
|
||||
|
||||
@@ -142,6 +142,11 @@ pub struct InbuxaAccountCapabilities {
|
||||
/// The logo that applies to the principal (MT-22): a URL or a data URL.
|
||||
#[serde(rename(serialize = "logo"))]
|
||||
pub logo: Option<String>,
|
||||
/// Whether legacy mail protocols are `enabled` or `disabled` for the
|
||||
/// principal: the stricter of the server's switch and its tenant's
|
||||
/// (legacy-protocols spec, Interfaces; LP-19).
|
||||
#[serde(rename(serialize = "legacyProtocols"))]
|
||||
pub legacy_protocols: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
|
||||
@@ -49,6 +49,8 @@ pub enum MethodObject {
|
||||
DeletedAccount,
|
||||
// inbuxa: AI call limits
|
||||
AiLimits,
|
||||
ProtocolPolicy,
|
||||
TenantProtocolPolicy,
|
||||
}
|
||||
|
||||
impl MethodObject {
|
||||
@@ -75,6 +77,8 @@ impl MethodObject {
|
||||
MethodObject::MaskedEmail => Capability::FastmailMaskedEmail,
|
||||
MethodObject::DeletedAccount => Capability::Inbuxa,
|
||||
MethodObject::AiLimits => Capability::Inbuxa,
|
||||
MethodObject::ProtocolPolicy => Capability::Inbuxa,
|
||||
MethodObject::TenantProtocolPolicy => Capability::Inbuxa,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +256,14 @@ 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",
|
||||
(MethodFunction::Get, MethodObject::TenantProtocolPolicy) => {
|
||||
"inbuxa:TenantProtocolPolicy/get"
|
||||
}
|
||||
(MethodFunction::Set, MethodObject::TenantProtocolPolicy) => {
|
||||
"inbuxa:TenantProtocolPolicy/set"
|
||||
}
|
||||
(method, MethodObject::Registry(obj)) => {
|
||||
return Cow::Owned(format!("x:{}/{}", obj.as_str(), method.as_str()));
|
||||
}
|
||||
@@ -377,6 +389,10 @@ 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),
|
||||
"inbuxa:TenantProtocolPolicy/get" => (MethodObject::TenantProtocolPolicy, MethodFunction::Get),
|
||||
"inbuxa:TenantProtocolPolicy/set" => (MethodObject::TenantProtocolPolicy, MethodFunction::Set),
|
||||
|
||||
).or_else(|| {
|
||||
let (obj, fnc) = s.strip_prefix("x:")?.split_once('/')?;
|
||||
@@ -430,6 +446,8 @@ impl Display for MethodObject {
|
||||
MethodObject::MaskedEmail => "MaskedEmail",
|
||||
MethodObject::DeletedAccount => "inbuxa:DeletedAccount",
|
||||
MethodObject::AiLimits => "inbuxa:AiLimits",
|
||||
MethodObject::ProtocolPolicy => "inbuxa:ProtocolPolicy",
|
||||
MethodObject::TenantProtocolPolicy => "inbuxa:TenantProtocolPolicy",
|
||||
MethodObject::Registry(obj) => {
|
||||
f.write_str("x:")?;
|
||||
return f.write_str(obj.as_str());
|
||||
|
||||
@@ -116,6 +116,10 @@ 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>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<GetRequest<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -139,6 +143,10 @@ 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>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetRequest<'x, crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -169,6 +169,22 @@ 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::TenantProtocolPolicy) => match seq.next_element() {
|
||||
Ok(Some(value)) => {
|
||||
RequestMethod::Get(GetRequestMethod::TenantProtocolPolicy(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 +350,22 @@ 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::TenantProtocolPolicy) => match seq.next_element() {
|
||||
Ok(Some(value)) => {
|
||||
RequestMethod::Set(SetRequestMethod::TenantProtocolPolicy(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,10 @@ 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>),
|
||||
TenantProtocolPolicy(
|
||||
GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -127,6 +131,10 @@ 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>>),
|
||||
TenantProtocolPolicy(
|
||||
Box<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>,
|
||||
),
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
@@ -287,6 +295,42 @@ 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_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||
for ResponseMethod<'x>
|
||||
{
|
||||
fn from(
|
||||
value: GetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
) -> Self {
|
||||
ResponseMethod::Get(GetResponseMethod::TenantProtocolPolicy(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl<'x> From<SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>>
|
||||
for ResponseMethod<'x>
|
||||
{
|
||||
fn from(
|
||||
value: SetResponse<crate::object::inbuxa_tenant_protocol_policy::TenantProtocolPolicy>,
|
||||
) -> Self {
|
||||
ResponseMethod::Set(SetResponseMethod::TenantProtocolPolicy(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,13 @@ 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,
|
||||
// inbuxa: legacy protocols off, per tenant. It governs
|
||||
// sign-in on the tenant's domains, so it takes the domain's
|
||||
// permissions, which a tenant administrator already holds.
|
||||
GetRequestMethod::TenantProtocolPolicy(_) => Permission::SysDomainGet,
|
||||
GetRequestMethod::Principal(_) => Permission::JmapPrincipalGet,
|
||||
GetRequestMethod::Quota(_) => Permission::JmapQuotaGet,
|
||||
GetRequestMethod::Blob(_) => Permission::JmapBlobGet,
|
||||
@@ -173,6 +180,22 @@ 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,
|
||||
),
|
||||
// inbuxa: legacy protocols off, per tenant, with the domain's
|
||||
SetRequestMethod::TenantProtocolPolicy(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
Permission::SysDomainUpdate,
|
||||
),
|
||||
SetRequestMethod::VacationResponse(s) => validate_set(
|
||||
s,
|
||||
self,
|
||||
@@ -282,7 +305,9 @@ impl JmapAuthorization for AccessToken {
|
||||
| MethodObject::SieveScript
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits => Permission::JmapEmailChanges,
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy => Permission::JmapEmailChanges,
|
||||
// inbuxa: x:MaskedEmail/changes reads what /get reads
|
||||
MethodObject::Registry(object_type) => object_type.get_permission(),
|
||||
},
|
||||
|
||||
@@ -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::blob::UploadResponse;
|
||||
@@ -186,7 +188,10 @@ impl ToRequestError for trc::Error {
|
||||
trc::SecurityEvent::Unauthorized | trc::SecurityEvent::IpUnauthorized => {
|
||||
RequestError::forbidden()
|
||||
}
|
||||
trc::SecurityEvent::IpBlockExpired | trc::SecurityEvent::IpAllowExpired => {
|
||||
// inbuxa: legacy-protocols LP-8 is an event, never an error
|
||||
trc::SecurityEvent::IpBlockExpired
|
||||
| trc::SecurityEvent::IpAllowExpired
|
||||
| trc::SecurityEvent::LegacyProtocolsChanged => {
|
||||
RequestError::internal_server_error()
|
||||
}
|
||||
},
|
||||
|
||||
@@ -221,6 +221,12 @@ 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::TenantProtocolPolicy(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
SetResponseMethod::AddressBook(set_response) => {
|
||||
set_response.update_created_ids(&mut response);
|
||||
}
|
||||
@@ -376,6 +382,20 @@ 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()
|
||||
}
|
||||
// inbuxa: inbuxa:TenantProtocolPolicy/get (legacy protocols off, per tenant)
|
||||
GetRequestMethod::TenantProtocolPolicy(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::tenant_protocol_policy::get(self, access_token, *req)
|
||||
.await?
|
||||
.into()
|
||||
}
|
||||
GetRequestMethod::Principal(req) => {
|
||||
self.principal_get(*req, access_token).await?.into()
|
||||
}
|
||||
@@ -617,6 +637,20 @@ 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()
|
||||
}
|
||||
// inbuxa: inbuxa:TenantProtocolPolicy/set (legacy protocols off, per tenant)
|
||||
SetRequestMethod::TenantProtocolPolicy(mut req) => {
|
||||
resolve_account_id(&mut req.account_id, method_name.obj, access_token)?;
|
||||
crate::inbuxa::tenant_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)?;
|
||||
|
||||
@@ -66,9 +66,18 @@ impl SessionHandler for Server {
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Empty(EmptyCapabilities::default()),
|
||||
);
|
||||
// inbuxa: legacy-protocols, Interfaces: whichever switch is stricter
|
||||
let legacy_protocols = if self.legacy_protocols_off_for_account(access_token).await? {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
};
|
||||
account.account_capabilities.append(
|
||||
Capability::Inbuxa,
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities { logo }),
|
||||
Capabilities::Inbuxa(InbuxaAccountCapabilities {
|
||||
logo,
|
||||
legacy_protocols,
|
||||
}),
|
||||
);
|
||||
// inbuxa: Fastmail's Masked Email API, for accounts that may hold masks
|
||||
if access_token.has_permission(Permission::SysMaskedEmailGet) {
|
||||
|
||||
@@ -418,6 +418,8 @@ impl IntermediateChangesResponse {
|
||||
| MethodObject::MaskedEmail
|
||||
| MethodObject::DeletedAccount
|
||||
| MethodObject::AiLimits
|
||||
| MethodObject::ProtocolPolicy
|
||||
| MethodObject::TenantProtocolPolicy
|
||||
| MethodObject::Registry(_) => unreachable!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
|
||||
pub mod access;
|
||||
pub mod ai_limits;
|
||||
pub mod protocol_policy;
|
||||
pub mod tenant_protocol_policy;
|
||||
pub mod deleted_account;
|
||||
pub mod fastmail;
|
||||
pub mod masked_email;
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
/*
|
||||
* 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, RecentUse},
|
||||
};
|
||||
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,
|
||||
P::RecentLegacyUse,
|
||||
];
|
||||
|
||||
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],
|
||||
recent: &[RecentUse],
|
||||
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()),
|
||||
// Who would notice, before anything changes (LP-15).
|
||||
P::RecentLegacyUse => recent_value(recent, |id| ProtocolPolicyValue::Id(Id::from(id))),
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// The impact panel's list (LP-15): who, over what, and when, in
|
||||
/// milliseconds as `changedAt` is. Shared with the tenant's switch.
|
||||
pub(crate) fn recent_value<Pr, V>(
|
||||
recent: &[RecentUse],
|
||||
id: impl Fn(u32) -> V,
|
||||
) -> Value<'static, Pr, V>
|
||||
where
|
||||
Pr: jmap_tools::Property,
|
||||
V: jmap_tools::Element<Property = Pr>,
|
||||
{
|
||||
Value::Array(
|
||||
recent
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let mut out = Map::with_capacity(4);
|
||||
out.insert_unchecked(
|
||||
Key::Borrowed("accountId"),
|
||||
Value::Element(id(entry.account_id)),
|
||||
);
|
||||
out.insert_unchecked(Key::Borrowed("name"), Value::Str(entry.name.clone().into()));
|
||||
out.insert_unchecked(Key::Borrowed("protocol"), Value::Str(entry.protocol.into()));
|
||||
out.insert_unchecked(
|
||||
Key::Borrowed("lastUsedAt"),
|
||||
Value::Number((entry.at * 1000).into()),
|
||||
);
|
||||
Value::Object(out)
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// 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()
|
||||
};
|
||||
let recent = if properties.contains(&P::RecentLegacyUse) {
|
||||
server.recent_legacy_use(None).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
match ids {
|
||||
None => response
|
||||
.list
|
||||
.push(to_value(&policy, &would_close, &recent, &properties)),
|
||||
Some(ids) => {
|
||||
for id in ids {
|
||||
if id.is_singleton() {
|
||||
response
|
||||
.list
|
||||
.push(to_value(&policy, &would_close, &recent, &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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
//! `inbuxa:TenantProtocolPolicy/get` and `/set`: one tenant's legacy mail
|
||||
//! protocols switch (legacy-protocols spec, LP-9 to LP-14). There is one per
|
||||
//! tenant, and its id is the tenant's.
|
||||
//!
|
||||
//! Inside a tenant, a principal reaches only its own tenant's (MT-1): `/get`
|
||||
//! with no ids answers with it, and any other id is `notFound`. At server
|
||||
//! level, `/get` with no ids answers with every tenant's.
|
||||
//!
|
||||
//! Turning it off never needs the server's leave; turning it back on is
|
||||
//! refused with `forbidden` while the server has legacy protocols off (LP-9).
|
||||
//! A tenant's switch closes no port (LP-13) -- sign-in and client
|
||||
//! configuration read it (LP-10, LP-14a).
|
||||
|
||||
use crate::inbuxa::protocol_policy::recent_value;
|
||||
use common::{Server, auth::AccessToken, network::legacy::RecentUse};
|
||||
use inbuxa_features::{
|
||||
security::{
|
||||
protocol_policy::LegacyProtocols,
|
||||
tenant_protocol_policy::{self, TenantProtocolPolicy as Policy, refusal},
|
||||
},
|
||||
tenancy::quota::all_tenants,
|
||||
};
|
||||
use jmap_proto::{
|
||||
error::set::SetError,
|
||||
method::{
|
||||
get::{GetRequest, GetResponse},
|
||||
set::{SetRequest, SetResponse},
|
||||
},
|
||||
object::inbuxa_tenant_protocol_policy::{
|
||||
TenantProtocolPolicy, TenantProtocolPolicyProperty as P, TenantProtocolPolicyValue,
|
||||
},
|
||||
request::IntoValid,
|
||||
};
|
||||
use jmap_tools::{Key, Map, Value};
|
||||
use types::id::Id;
|
||||
|
||||
type PValue = Value<'static, P, TenantProtocolPolicyValue>;
|
||||
|
||||
const ALL: &[P] = &[
|
||||
P::Id,
|
||||
P::TenantId,
|
||||
P::LegacyProtocols,
|
||||
P::ChangedAt,
|
||||
P::ChangedBy,
|
||||
P::RecentLegacyUse,
|
||||
];
|
||||
|
||||
/// The tenants this principal may reach: its own inside a tenant (MT-1),
|
||||
/// every tenant at server level.
|
||||
async fn reachable(server: &Server, access_token: &AccessToken) -> trc::Result<Vec<u32>> {
|
||||
match access_token.tenant_id() {
|
||||
Some(tenant_id) => Ok(vec![tenant_id]),
|
||||
None => all_tenants(server.registry()).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn to_value(tenant_id: u32, policy: &Policy, recent: &[RecentUse], properties: &[P]) -> PValue {
|
||||
let mut out = Map::with_capacity(properties.len());
|
||||
for property in properties {
|
||||
let value = match property {
|
||||
P::Id | P::TenantId => {
|
||||
Value::Element(TenantProtocolPolicyValue::Id(Id::from(tenant_id)))
|
||||
}
|
||||
P::LegacyProtocols => Value::Str(
|
||||
match policy.legacy_protocols {
|
||||
LegacyProtocols::Enabled => "enabled",
|
||||
LegacyProtocols::Disabled => "disabled",
|
||||
}
|
||||
.into(),
|
||||
),
|
||||
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),
|
||||
P::RecentLegacyUse => {
|
||||
recent_value(recent, |id| TenantProtocolPolicyValue::Id(Id::from(id)))
|
||||
}
|
||||
};
|
||||
out.insert_unchecked(Key::Property(property.clone()), value);
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// `inbuxa:TenantProtocolPolicy/get`.
|
||||
pub async fn get(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: GetRequest<TenantProtocolPolicy>,
|
||||
) -> trc::Result<GetResponse<TenantProtocolPolicy>> {
|
||||
let properties = request.unwrap_properties(ALL);
|
||||
let (ids, not_found) = request.unwrap_ids(server.core.jmap.get_max_objects)?;
|
||||
let mut response = GetResponse {
|
||||
account_id: request.account_id.into(),
|
||||
state: None,
|
||||
list: Vec::new(),
|
||||
not_found,
|
||||
};
|
||||
|
||||
let reachable = reachable(server, access_token).await?;
|
||||
let wanted = match ids {
|
||||
None => reachable.iter().map(|id| Id::from(*id)).collect(),
|
||||
Some(ids) => ids,
|
||||
};
|
||||
for id in wanted {
|
||||
let tenant_id = id.document_id();
|
||||
if reachable.contains(&tenant_id) {
|
||||
let policy = tenant_protocol_policy::get(&server.core.storage.data, tenant_id).await?;
|
||||
// The tenant's own people only (LP-15, MT-1).
|
||||
let recent = if properties.contains(&P::RecentLegacyUse) {
|
||||
server.recent_legacy_use(Some(tenant_id)).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
response
|
||||
.list
|
||||
.push(to_value(tenant_id, &policy, &recent, &properties));
|
||||
} else {
|
||||
response.push_not_found(id);
|
||||
}
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
/// `inbuxa:TenantProtocolPolicy/set`: turns one tenant's switch. Unset
|
||||
/// (`null`) puts legacy protocols back on, which LP-9 may refuse.
|
||||
pub async fn set(
|
||||
server: &Server,
|
||||
access_token: &AccessToken,
|
||||
mut request: SetRequest<'_, TenantProtocolPolicy>,
|
||||
) -> trc::Result<SetResponse<TenantProtocolPolicy>> {
|
||||
let mut response = SetResponse::from_request(&request, server.core.jmap.set_max_objects)?;
|
||||
// A tenant's switch comes and goes with the tenant; it is only turned.
|
||||
for (client_id, _) in request.unwrap_create() {
|
||||
response.not_created.append(
|
||||
client_id,
|
||||
SetError::forbidden().with_description("A tenant's switch exists with the tenant."),
|
||||
);
|
||||
}
|
||||
for id in request.unwrap_destroy().into_valid() {
|
||||
response.not_destroyed.append(
|
||||
id,
|
||||
SetError::forbidden().with_description("A tenant's switch exists with the tenant."),
|
||||
);
|
||||
}
|
||||
|
||||
let reachable = reachable(server, access_token).await?;
|
||||
for (id, value) in request.unwrap_update().into_valid() {
|
||||
let tenant_id = id.document_id();
|
||||
if !reachable.contains(&tenant_id) {
|
||||
response.not_updated.append(id, SetError::not_found());
|
||||
continue;
|
||||
}
|
||||
|
||||
let data = &server.core.storage.data;
|
||||
let previous = tenant_protocol_policy::get(data, tenant_id).await?;
|
||||
let mut policy = previous.clone();
|
||||
let mut error = None;
|
||||
for (key, value) in value.into_expanded_object() {
|
||||
let result = match &key {
|
||||
Key::Property(P::LegacyProtocols) => match value {
|
||||
Value::Null => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
}
|
||||
value => match value.as_str().as_deref() {
|
||||
Some("enabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Enabled;
|
||||
Ok(())
|
||||
}
|
||||
Some("disabled") => {
|
||||
policy.legacy_protocols = LegacyProtocols::Disabled;
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(r#"must be "enabled" or "disabled""#),
|
||||
},
|
||||
},
|
||||
Key::Property(P::Id) => Err("is immutable"),
|
||||
Key::Property(_) => Err("is set by the server"),
|
||||
_ => Err("is not a property of inbuxa:TenantProtocolPolicy"),
|
||||
};
|
||||
if let Err(why) = result {
|
||||
error = Some(
|
||||
SetError::invalid_properties()
|
||||
.with_property(key.into_owned())
|
||||
.with_description(why),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
response.not_updated.append(id, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
// LP-9: server off means off for everyone.
|
||||
if let Some(why) = refusal(&server.protocol_policy().await?, policy.legacy_protocols) {
|
||||
response
|
||||
.not_updated
|
||||
.append(id, SetError::forbidden().with_description(why));
|
||||
continue;
|
||||
}
|
||||
|
||||
if policy.legacy_protocols != previous.legacy_protocols {
|
||||
policy.changed_at = Some(store::write::now() * 1000);
|
||||
policy.changed_by = Some(Id::from(access_token.account_id()).to_string());
|
||||
tenant_protocol_policy::set(data, tenant_id, &policy).await?;
|
||||
|
||||
// LP-14. A tenant's switch closes and reopens nothing (LP-13).
|
||||
trc::event!(
|
||||
Security(trc::SecurityEvent::LegacyProtocolsChanged),
|
||||
Policy = "tenant",
|
||||
Id = tenant_id,
|
||||
Value = if policy.legacy_protocols.is_disabled() {
|
||||
"disabled"
|
||||
} else {
|
||||
"enabled"
|
||||
},
|
||||
AccountId = policy.changed_by.clone(),
|
||||
);
|
||||
}
|
||||
response.updated.append(id, None);
|
||||
}
|
||||
Ok(response)
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -846,6 +851,14 @@ impl RegistrySet for Server {
|
||||
if let ObjectInner::MaskedEmail(mask) = &object.inner {
|
||||
crate::inbuxa::masked_email::destroyed(self, id, mask).await?;
|
||||
}
|
||||
// inbuxa: legacy-protocols, a tenant's switch goes with it
|
||||
if matches!(object.inner, ObjectInner::Tenant(_)) {
|
||||
inbuxa_features::security::tenant_protocol_policy::remove(
|
||||
&self.core.storage.data,
|
||||
id.document_id(),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
cache_invalidator.process_delete(id, &object);
|
||||
set.response.destroyed.push(id);
|
||||
}
|
||||
|
||||
+77
-35
@@ -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 <hello@stalw.art>
|
||||
*
|
||||
* 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
|
||||
@@ -94,6 +101,12 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.and_then(|token| token.assert_has_permission(Permission::SieveAuthenticate))?;
|
||||
|
||||
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
|
||||
// account, and LP-15: the sign-in is recorded for the impact panel
|
||||
self.server
|
||||
.admit_legacy_session(LegacyProtocol::ManageSieve, &access_token)
|
||||
.await?;
|
||||
|
||||
// Enforce concurrency limits
|
||||
let in_flight = match access_token.is_imap_request_allowed() {
|
||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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
|
||||
@@ -92,6 +99,12 @@ impl<T: SessionStream> Session<T> {
|
||||
})
|
||||
.and_then(|token| token.assert_has_permission(Permission::Pop3Authenticate))?;
|
||||
|
||||
// inbuxa: legacy-protocols LP-10 for a bearer token that named no
|
||||
// account, and LP-15: the sign-in is recorded for the impact panel
|
||||
self.server
|
||||
.admit_legacy_session(LegacyProtocol::Pop3, &access_token)
|
||||
.await?;
|
||||
|
||||
// Enforce concurrency limits
|
||||
let in_flight = match access_token.is_imap_request_allowed() {
|
||||
LimiterResult::Allowed(in_flight) => Some(in_flight),
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* 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,17 @@ 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
|
||||
{
|
||||
return self.legacy_refusal(err).await;
|
||||
}
|
||||
|
||||
// Authenticate
|
||||
let result = self
|
||||
.server
|
||||
@@ -119,6 +135,18 @@ impl<T: SessionStream> Session<T> {
|
||||
.await
|
||||
.and_then(|access_token| access_token.assert_has_permission(Permission::EmailSend));
|
||||
|
||||
// inbuxa: legacy-protocols LP-10, for a bearer token that named no
|
||||
// account and so couldn't be judged by its domain beforehand; and
|
||||
// LP-15, the sign-in is recorded for the impact panel.
|
||||
if let Ok(access_token) = &result
|
||||
&& let Err(err) = self
|
||||
.server
|
||||
.admit_legacy_session(LegacyProtocol::Submission, access_token)
|
||||
.await
|
||||
{
|
||||
return self.legacy_refusal(err).await;
|
||||
}
|
||||
|
||||
let result = match result {
|
||||
Ok(access_token) => self.server.account_info(access_token.account_id()).await,
|
||||
Err(err) => Err(err),
|
||||
@@ -182,6 +210,26 @@ impl<T: SessionStream> Session<T> {
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// inbuxa: legacy-protocols LP-6, LP-10. A refusal is written with the
|
||||
/// words the error carries, which know whose switch refused; anything
|
||||
/// else that went wrong deciding is a temporary failure. Neither counts
|
||||
/// as an authentication error (LP-11).
|
||||
async fn legacy_refusal(&mut self, err: trc::Error) -> Result<bool, ()> {
|
||||
let reply = err
|
||||
.matches(trc::EventType::Auth(AuthEvent::LegacyProtocolRefused))
|
||||
.then(|| err.value_as_str(trc::Key::Details).map(str::to_string))
|
||||
.flatten();
|
||||
trc::error!(err.span_id(self.data.session_id));
|
||||
match reply {
|
||||
Some(reply) => self.write(reply.as_bytes()).await?,
|
||||
None => {
|
||||
self.write(b"454 4.7.0 Temporary authentication failure\r\n")
|
||||
.await?
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn auth_error(&mut self, response: &[u8]) -> Result<bool, ()> {
|
||||
tokio::time::sleep(self.params.auth_errors_wait).await;
|
||||
self.data.auth_errors += 1;
|
||||
|
||||
@@ -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,10 @@
|
||||
|
||||
// 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); 643 is
|
||||
// security.legacy-protocols-changed (LP-8)
|
||||
pub const TOTAL_EVENT_COUNT: usize = 644;
|
||||
pub const TOTAL_METRIC_COUNT: usize = 369;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -116,6 +118,8 @@ pub enum AuthEvent {
|
||||
Error = 34,
|
||||
Warning = 595,
|
||||
CredentialExpired = 276,
|
||||
// inbuxa: legacy-protocols LP-6
|
||||
LegacyProtocolRefused = 642,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
@@ -652,6 +656,8 @@ pub enum SecurityEvent {
|
||||
IpAllowExpired = 594,
|
||||
IpUnauthorized = 279,
|
||||
Unauthorized = 552,
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
LegacyProtocolsChanged = 643,
|
||||
}
|
||||
|
||||
#[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),
|
||||
@@ -444,6 +446,8 @@ impl EventType {
|
||||
b"security.ip-allow-expired" => EventType::Security(SecurityEvent::IpAllowExpired),
|
||||
b"security.ip-unauthorized" => EventType::Security(SecurityEvent::IpUnauthorized),
|
||||
b"security.unauthorized" => EventType::Security(SecurityEvent::Unauthorized),
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
b"security.legacy-protocols-changed" => EventType::Security(SecurityEvent::LegacyProtocolsChanged),
|
||||
b"server.startup" => EventType::Server(ServerEvent::Startup),
|
||||
b"server.shutdown" => EventType::Server(ServerEvent::Shutdown),
|
||||
b"server.startup-error" => EventType::Server(ServerEvent::StartupError),
|
||||
@@ -705,6 +709,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",
|
||||
@@ -1207,6 +1213,10 @@ impl EventType {
|
||||
EventType::Security(SecurityEvent::IpAllowExpired) => "security.ip-allow-expired",
|
||||
EventType::Security(SecurityEvent::IpUnauthorized) => "security.ip-unauthorized",
|
||||
EventType::Security(SecurityEvent::Unauthorized) => "security.unauthorized",
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
|
||||
"security.legacy-protocols-changed"
|
||||
}
|
||||
EventType::Server(ServerEvent::Startup) => "server.startup",
|
||||
EventType::Server(ServerEvent::Shutdown) => "server.shutdown",
|
||||
EventType::Server(ServerEvent::StartupError) => "server.startup-error",
|
||||
@@ -1489,6 +1499,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,
|
||||
@@ -1877,6 +1889,8 @@ impl EventType {
|
||||
EventType::Security(SecurityEvent::IpAllowExpired) => 594,
|
||||
EventType::Security(SecurityEvent::IpUnauthorized) => 279,
|
||||
EventType::Security(SecurityEvent::Unauthorized) => 552,
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => 643,
|
||||
EventType::Server(ServerEvent::Startup) => 393,
|
||||
EventType::Server(ServerEvent::Shutdown) => 392,
|
||||
EventType::Server(ServerEvent::StartupError) => 394,
|
||||
@@ -2137,6 +2151,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)),
|
||||
@@ -2563,6 +2579,8 @@ impl EventType {
|
||||
594 => Some(EventType::Security(SecurityEvent::IpAllowExpired)),
|
||||
279 => Some(EventType::Security(SecurityEvent::IpUnauthorized)),
|
||||
552 => Some(EventType::Security(SecurityEvent::Unauthorized)),
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
643 => Some(EventType::Security(SecurityEvent::LegacyProtocolsChanged)),
|
||||
393 => Some(EventType::Server(ServerEvent::Startup)),
|
||||
392 => Some(EventType::Server(ServerEvent::Shutdown)),
|
||||
394 => Some(EventType::Server(ServerEvent::StartupError)),
|
||||
@@ -2848,6 +2866,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,
|
||||
@@ -2980,6 +3000,8 @@ impl EventType {
|
||||
EventType::Security(SecurityEvent::IpAllowExpired) => Level::Info,
|
||||
EventType::Security(SecurityEvent::IpUnauthorized) => Level::Info,
|
||||
EventType::Security(SecurityEvent::Unauthorized) => Level::Info,
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => Level::Info,
|
||||
EventType::Server(ServerEvent::Startup) => Level::Info,
|
||||
EventType::Server(ServerEvent::Shutdown) => Level::Info,
|
||||
EventType::Server(ServerEvent::Licensing) => Level::Info,
|
||||
@@ -3187,6 +3209,10 @@ 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",
|
||||
@@ -3699,6 +3725,10 @@ impl EventType {
|
||||
EventType::Security(SecurityEvent::IpAllowExpired) => "IP allow expired",
|
||||
EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address",
|
||||
EventType::Security(SecurityEvent::Unauthorized) => "Unauthorized access",
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
|
||||
"Legacy mail protocols switch changed"
|
||||
}
|
||||
EventType::Server(ServerEvent::Startup) => "Starting INBUXA Server",
|
||||
EventType::Server(ServerEvent::Shutdown) => "Shutting down INBUXA Server",
|
||||
EventType::Server(ServerEvent::StartupError) => "Server startup error",
|
||||
@@ -3951,6 +3981,10 @@ 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",
|
||||
@@ -4088,6 +4122,10 @@ impl EventType {
|
||||
EventType::Security(SecurityEvent::IpAllowExpired) => "Insufficient permissions",
|
||||
EventType::Security(SecurityEvent::IpUnauthorized) => "Unauthorized IP address",
|
||||
EventType::Security(SecurityEvent::Unauthorized) => "Insufficient permissions",
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged) => {
|
||||
"Legacy mail protocols switch changed"
|
||||
}
|
||||
EventType::Smtp(SmtpEvent::ConnectionStart) => "SMTP error",
|
||||
EventType::Smtp(SmtpEvent::ConnectionEnd) => "SMTP error",
|
||||
EventType::Smtp(SmtpEvent::Error) => "SMTP error",
|
||||
@@ -4259,6 +4297,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),
|
||||
@@ -4647,6 +4687,8 @@ impl EventType {
|
||||
EventType::Security(SecurityEvent::IpAllowExpired),
|
||||
EventType::Security(SecurityEvent::IpUnauthorized),
|
||||
EventType::Security(SecurityEvent::Unauthorized),
|
||||
// inbuxa: legacy-protocols LP-8
|
||||
EventType::Security(SecurityEvent::LegacyProtocolsChanged),
|
||||
EventType::Server(ServerEvent::Startup),
|
||||
EventType::Server(ServerEvent::Shutdown),
|
||||
EventType::Server(ServerEvent::StartupError),
|
||||
|
||||
@@ -71,7 +71,7 @@ pub fn env_var(name: &str) -> Result<String, std::env::VarError> {
|
||||
#[macro_export]
|
||||
macro_rules! brand_version {
|
||||
() => {
|
||||
"2026.9.20"
|
||||
"2026.9.22"
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,12 @@ Each has an ID, and tests name the IDs they check.
|
||||
In `accountCapabilities`, the signed-in principal's own account carries
|
||||
`urn:inbuxa:jmap` with `logo`: the logo that applies to it (multi-tenancy
|
||||
MT-22), a string (URL or data URL) or `null`. Added 2026-09-18.
|
||||
|
||||
It also carries `legacyProtocols`: `enabled` or `disabled`, whether IMAP,
|
||||
POP3, ManageSieve and SMTP submission are off for the principal -- the
|
||||
stricter of the server's switch and its tenant's (legacy-protocols spec,
|
||||
Interfaces). A front end uses it to say why a mail app can't connect
|
||||
(LP-19). Added 2026-09-21.
|
||||
- **C-2.** Each front end states the contract versions it supports and checks
|
||||
`contract` after signing in. Outside its range it stops, with a message
|
||||
naming both versions. For ihasmail-inbuxa this replaces public ihasmail's
|
||||
|
||||
+1
-1
@@ -21,6 +21,6 @@ echo >&2
|
||||
echo "A server started with no configuration comes up in bootstrap mode;" >&2
|
||||
echo "INBUXA Admin's setup wizard completes first boot over JMAP." >&2
|
||||
echo >&2
|
||||
echo "Releases: https://github.com/inbuxa/inbuxa-server/releases" >&2
|
||||
echo "Releases: https://git.coffeylabs.org/inbuxa/inbuxa-server/releases" >&2
|
||||
echo "Docs: https://docs.inbuxa.org/install/fresh/" >&2
|
||||
exit 1
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
-DHPbeChvvEHbLbAO3wDU6KCP8HrzaWZHfkka30YoIU
|
||||
rLRbZKj15KvcPMVmnisfCDsXXZEKks6BXpMkMpS0mlI
|
||||
Executable
+665
@@ -0,0 +1,665 @@
|
||||
#!/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), and nothing advertises what is
|
||||
closed: autoconfig, autodiscover and PACC offer no IMAP, POP3 or submission,
|
||||
and the suggested zone marks their SRV names not offered (LP-7, test 5).
|
||||
Every change of the switch, and every refused sign-in, is an event in the
|
||||
server's log (LP-8, test 14; LP-6).
|
||||
|
||||
Then a tenant's own switch (LP-9 to LP-14a): a tenant administrator turns it
|
||||
off for its tenant, which refuses sign-in on the tenant's domains -- real
|
||||
address or made-up, right password or wrong -- in the organization's words,
|
||||
leaves every other domain alone, and stops client configuration offering
|
||||
legacy servers for those domains. It reaches only its own tenant's switch,
|
||||
and can't turn it back on while the server has legacy protocols off
|
||||
(acceptance tests 6 to 10, 14). Throughout, the JMAP session tells each
|
||||
account which way its switches point (test 13), and the impact panel's
|
||||
list names who signed in over what: every account at server scope, only the
|
||||
tenant's own at tenant scope, rewritten at most once an hour (LP-15).
|
||||
|
||||
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 advertised(admin, admin_pw):
|
||||
"""What each client-configuration answer and the suggested zone offer."""
|
||||
with urllib.request.urlopen(f"{HTTP}/mail/[email protected]",
|
||||
timeout=30) as resp:
|
||||
autoconfig = resp.read().decode()
|
||||
body = ('<?xml version="1.0" encoding="utf-8"?><Autodiscover xmlns="http://schemas.'
|
||||
'microsoft.com/exchange/autodiscover/outlook/requestschema/2006"><Request>'
|
||||
'<EMailAddress>[email protected]</EMailAddress><AcceptableResponseSchema>http://'
|
||||
'schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a'
|
||||
'</AcceptableResponseSchema></Request></Autodiscover>').encode()
|
||||
req = urllib.request.Request(f"{HTTP}/autodiscover/autodiscover.xml", data=body, method="POST")
|
||||
req.add_header("Content-Type", "text/xml")
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
autodiscover = resp.read().decode()
|
||||
with urllib.request.urlopen(f"{HTTP}/.well-known/user-agent-configuration.json",
|
||||
timeout=30) as resp:
|
||||
pacc = json.load(resp).get("protocols", {})
|
||||
got = one(admin, admin_pw, "x:Domain/get", {"ids": None, "properties": ["name", "dnsZoneFile"]})
|
||||
zone = next((d.get("dnsZoneFile") or "" for d in got[1].get("list", [])
|
||||
if d.get("name") == "legacy.test"), "")
|
||||
srv = {}
|
||||
for line in zone.splitlines():
|
||||
fields = line.split()
|
||||
if "SRV" in fields and fields[0].startswith("_"):
|
||||
srv[fields[0].split(".")[0] + "." + fields[0].split(".")[1]] = fields[-1]
|
||||
return {
|
||||
"autoconfig": {t for t in ("imap", "pop3", "smtp") if f'type="{t}"' in autoconfig},
|
||||
"autodiscover": {t for t in ("IMAP", "POP3", "SMTP") if f"<Type>{t}</Type>" in autodiscover},
|
||||
"pacc": {t for t in ("imap", "pop3", "smtp", "managesieve") if t in pacc},
|
||||
"jmap": "jmap" in pacc,
|
||||
"srv": srv,
|
||||
}
|
||||
|
||||
|
||||
def events(name):
|
||||
"""The server's log lines for one event, from its stdout tracer. The log
|
||||
is the container's, so it starts afresh at every restart."""
|
||||
out = docker("logs", NAME, check_rc=False)
|
||||
return [l for l in (out.stdout + out.stderr).splitlines() if f"({name})" in l]
|
||||
|
||||
|
||||
def pop3_login(port, user, password):
|
||||
"""The reply to PASS, over implicit TLS."""
|
||||
with tls(port) as sock:
|
||||
read = lines(sock)
|
||||
next(read) # greeting
|
||||
sock.sendall(f"USER {user}\r\n".encode())
|
||||
next(read)
|
||||
sock.sendall(f"PASS {password}\r\n".encode())
|
||||
return next(read, "")
|
||||
|
||||
|
||||
def created(res, key, what):
|
||||
obj = (res[1].get("created") or {}).get(key)
|
||||
if not obj:
|
||||
sys.exit(f"creating {what} failed: " + json.dumps(res[1])[:600])
|
||||
return obj["id"]
|
||||
|
||||
|
||||
def tenant_checks(admin, admin_pw, account):
|
||||
"""LP-9 to LP-14a, on a tenant with its own domain, user and admin."""
|
||||
t = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t"}}}),
|
||||
"t", "tenant")
|
||||
t2 = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t2"}}}),
|
||||
"t", "second tenant")
|
||||
domain = created(one(admin, admin_pw, "x:Domain/set", {"create": {"d": {
|
||||
"name": "t.legacy.test", "isEnabled": True, "memberTenantId": t,
|
||||
"certificateManagement": {"@type": "Manual"}, "dnsManagement": {"@type": "Manual"},
|
||||
"dkimManagement": {"@type": "Manual"}}}}), "d", "tenant domain")
|
||||
user_pw = secret_file("legacy-tenant-user")
|
||||
tadmin_pw = secret_file("legacy-tenant-admin")
|
||||
def user(name, password, extra=None):
|
||||
body = {"@type": "User", "name": name, "domainId": domain,
|
||||
"credentials": {"0": {"@type": "Password", "secret": password}}}
|
||||
body.update(extra or {})
|
||||
return created(one(admin, admin_pw, "x:Account/set", {"create": {"a": body}}),
|
||||
"a", f"account {name}")
|
||||
user("u", user_pw)
|
||||
user("tadmin", tadmin_pw, {"roles": {"@type": "Admin"}})
|
||||
tu, ta = "[email protected]", "[email protected]"
|
||||
|
||||
tsess = session(ta, tadmin_pw)
|
||||
tacct = tsess["primaryAccounts"].get(INBUXA) or list(tsess["accounts"])[0]
|
||||
tget = lambda ids=None: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/get",
|
||||
{"accountId": tacct, "ids": ids})
|
||||
tset = lambda value: one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set",
|
||||
{"accountId": tacct, "update": {t: {"legacyProtocols": value}}})
|
||||
|
||||
check(session_flag(tu, user_pw) == "enabled",
|
||||
"the session says enabled for the tenant's user while both switches are on (test 13)")
|
||||
imap_login(PORTS["imap"], tu, user_pw)
|
||||
got = tget()
|
||||
recent = got[1]["list"][0].get("recentLegacyUse", [])
|
||||
names = {(r["name"], r["protocol"]) for r in recent}
|
||||
check((tu, "imap") in names and not any(n == admin for n, _ in names),
|
||||
"the tenant's panel lists its own user's IMAP sign-in and nobody outside it (LP-15, MT-1)")
|
||||
if (tu, "imap") not in names:
|
||||
print(" recent:", recent)
|
||||
|
||||
# Before: the tenant's user signs in, and its domain is offered IMAP.
|
||||
check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"),
|
||||
"a tenant's user signs in over IMAP with the tenant's switch on")
|
||||
got = tget()
|
||||
mine = [p["id"] for p in got[1].get("list", [])]
|
||||
check(got[0] == "inbuxa:TenantProtocolPolicy/get" and mine == [t],
|
||||
"a tenant admin's /get answers with its own tenant's switch only (test 10)")
|
||||
if mine != [t]:
|
||||
print(" reply:", json.dumps(got)[:400])
|
||||
got = tget([t2])
|
||||
check(got[1].get("notFound") == [t2], "another tenant's switch is not found (test 10, MT-1)")
|
||||
res = one(ta, tadmin_pw, "inbuxa:TenantProtocolPolicy/set",
|
||||
{"accountId": tacct, "update": {t2: {"legacyProtocols": "disabled"}}})
|
||||
check(t2 in (res[1].get("notUpdated") or {}), "nor can it be changed (test 10)")
|
||||
|
||||
# The tenant admin turns it off for its tenant (LP-9).
|
||||
res = tset("disabled")
|
||||
check(t in (res[1].get("updated") or {}), "a tenant admin turns legacy protocols off (LP-9)")
|
||||
if t not in (res[1].get("updated") or {}):
|
||||
print(" reply:", json.dumps(res)[:400])
|
||||
check(events_matching("security.legacy-protocols-changed", 'policy = "tenant"',
|
||||
'value = "disabled"'),
|
||||
"and it is an event, scope tenant (LP-14, test 14)")
|
||||
|
||||
check(session_flag(tu, user_pw) == "disabled",
|
||||
"the session says disabled for the tenant's user once its tenant turns it off (test 13)")
|
||||
check(session_flag(admin, admin_pw) == "enabled",
|
||||
"and still enabled for an account outside the tenant (test 13)")
|
||||
|
||||
# Refused on the tenant's domain, every way in the same words (tests 6-8).
|
||||
imap_no = ("NO [ALERT] Your organization allows only INBUXA webmail and JMAP apps. "
|
||||
"This mail app can't sign in.")
|
||||
check(imap_login(PORTS["imap"], tu, user_pw) == imap_no,
|
||||
"the tenant's user is refused over IMAP with the right password (test 6)")
|
||||
check(imap_login(PORTS["imap"], tu, "wrong") == imap_no, "and with a wrong one (test 6)")
|
||||
check(imap_login(PORTS["imap"], "[email protected]", "x") == imap_no,
|
||||
"and a made-up address on the domain gets the same (test 7)")
|
||||
check(pop3_login(PORTS["pop3"], tu, user_pw) ==
|
||||
"-ERR [AUTH] Your organization allows only INBUXA webmail and JMAP apps. "
|
||||
"This mail app can't sign in.", "POP3 refuses in its own form (test 8)")
|
||||
check(smtp_auths(PORTS["submissions"], tu, [user_pw])[0] ==
|
||||
"535 5.7.0 Your organization allows only INBUXA webmail and JMAP apps. "
|
||||
"This mail app can't send.", "submission refuses in its own form (test 8)")
|
||||
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),
|
||||
"an account on another domain signs in over IMAP normally (test 6)")
|
||||
check(session(tu, user_pw).get("accounts"), "the tenant's user still has JMAP (test 8)")
|
||||
check(not events("auth.failed"), "no refusal counted as a failed sign-in (LP-11)")
|
||||
|
||||
# Client configuration for the tenant's domain only (LP-14a).
|
||||
with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress={tu}", timeout=30) as r:
|
||||
tenant_cfg = r.read().decode()
|
||||
with urllib.request.urlopen(f"{HTTP}/mail/config-v1.1.xml?emailaddress={admin}", timeout=30) as r:
|
||||
other_cfg = r.read().decode()
|
||||
check('type="imap"' not in tenant_cfg and 'type="imap"' in other_cfg,
|
||||
"autoconfig offers no IMAP for the tenant's domain, and still does elsewhere (LP-14a)")
|
||||
|
||||
# Server off means off for everyone: the tenant can't turn it back on (test 9).
|
||||
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
{"accountId": account, "update": {"singleton": {"legacyProtocols": "disabled"}}})
|
||||
check(session_flag(admin, admin_pw) == "disabled",
|
||||
"with the server off, the session says disabled for everyone (test 13)")
|
||||
res = tset("enabled")
|
||||
refused = (res[1].get("notUpdated") or {}).get(t) or {}
|
||||
check(refused.get("type") == "forbidden"
|
||||
and "inbuxa:ProtocolPolicy" in (refused.get("description") or ""),
|
||||
"with the server off, the tenant can't turn them back on (LP-9, test 9)")
|
||||
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set",
|
||||
{"accountId": account, "update": {"singleton": {"legacyProtocols": "enabled"}}})
|
||||
check(settle(PORTS["imap"], True), "IMAP is back after the server switch returns")
|
||||
|
||||
# And back on, the tenant's user signs in again.
|
||||
res = tset("enabled")
|
||||
check(t in (res[1].get("updated") or {}), "with the server on, the tenant turns them back on")
|
||||
check(imap_login(PORTS["imap"], tu, user_pw).startswith("OK"),
|
||||
"and its user signs in over IMAP again")
|
||||
check(session_flag(tu, user_pw) == "enabled", "and its session says enabled again (test 13)")
|
||||
|
||||
# A deleted tenant's switch goes with it, so a tenant that later gets the
|
||||
# same id doesn't start with legacy protocols off.
|
||||
sget = lambda ids: one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/get",
|
||||
{"accountId": account, "ids": ids})
|
||||
one(admin, admin_pw, "inbuxa:TenantProtocolPolicy/set",
|
||||
{"accountId": account, "update": {t2: {"legacyProtocols": "disabled"}}})
|
||||
check(sget([t2])[1]["list"][0]["legacyProtocols"] == "disabled",
|
||||
"a server admin turns another tenant's switch off")
|
||||
res = one(admin, admin_pw, "x:Tenant/set", {"destroy": [t2]})
|
||||
check(t2 in (res[1].get("destroyed") or []), "that tenant can be deleted")
|
||||
t3 = created(one(admin, admin_pw, "x:Tenant/set", {"create": {"t": {"name": "legacy-t3"}}}),
|
||||
"t", "third tenant")
|
||||
if t3 == t2:
|
||||
check(sget([t3])[1]["list"][0]["legacyProtocols"] == "enabled",
|
||||
"a new tenant with the deleted one's id starts with legacy protocols on")
|
||||
else:
|
||||
print(f" (the registry gave the new tenant a fresh id, {t3} not {t2}: reuse not observable)")
|
||||
|
||||
|
||||
def session_flag(user, password):
|
||||
"""legacyProtocols from the account's urn:inbuxa:jmap capability."""
|
||||
sess = session(user, password)
|
||||
acct = sess["primaryAccounts"].get(INBUXA) or list(sess["accounts"])[0]
|
||||
return sess["accounts"][acct]["accountCapabilities"].get(INBUXA, {}).get("legacyProtocols")
|
||||
|
||||
|
||||
def events_matching(name, *parts):
|
||||
return any(all(p in line for p in parts) for line in events(name))
|
||||
|
||||
|
||||
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()
|
||||
|
||||
# A tracer to stdout, so the events can be read back from the container's
|
||||
# log. It takes effect from the next start.
|
||||
res = one(admin, admin_pw, "x:Tracer/set", {"create": {"t": {
|
||||
"@type": "Stdout", "level": "info", "buffered": False, "ansi": False}}})
|
||||
if not (res[1].get("created") or {}).get("t"):
|
||||
sys.exit("tracer create failed: " + json.dumps(res))
|
||||
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")
|
||||
|
||||
# What is advertised with the switch on -- the control for LP-7.
|
||||
before = advertised(admin, admin_pw)
|
||||
print(" advertised before:", {k: sorted(v) if isinstance(v, set) else v
|
||||
for k, v in before.items() if k != "srv"})
|
||||
check(before["autoconfig"] and before["autodiscover"],
|
||||
"autoconfig and autodiscover offer mail apps a server with the switch on")
|
||||
check(before["srv"].get("_imaps._tcp", ".") != ".",
|
||||
"the suggested zone offers IMAP with the switch on")
|
||||
|
||||
# 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")
|
||||
|
||||
# The impact panel (LP-15): the sign-ins above are on it, once each.
|
||||
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
|
||||
{"accountId": account, "ids": None, "properties": ["recentLegacyUse"]})
|
||||
recent = got[1]["list"][0].get("recentLegacyUse", [])
|
||||
mine = {r["protocol"]: r for r in recent if r["name"] == admin}
|
||||
check(set(mine) == {"imap", "submission"} and all(r["lastUsedAt"] > 0 for r in mine.values()),
|
||||
"the panel lists the admin's IMAP and submission sign-ins, and when (LP-15)")
|
||||
if set(mine) != {"imap", "submission"}:
|
||||
print(" recent:", recent)
|
||||
imap_login(PORTS["imap"], admin, admin_pw)
|
||||
got = one(admin, admin_pw, "inbuxa:ProtocolPolicy/get",
|
||||
{"accountId": account, "ids": None, "properties": ["recentLegacyUse"]})
|
||||
again = {r["protocol"]: r for r in got[1]["list"][0].get("recentLegacyUse", []) if r["name"] == admin}
|
||||
check(again.get("imap", {}).get("lastUsedAt") == mine.get("imap", {}).get("lastUsedAt"),
|
||||
"a second sign-in within the hour isn't written again (LP-15)")
|
||||
|
||||
# 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)
|
||||
|
||||
# Nothing advertises what is closed (LP-7, test 5).
|
||||
during = advertised(admin, admin_pw)
|
||||
check(not during["autoconfig"], "autoconfig offers no IMAP, POP3 or submission (LP-7)")
|
||||
check(not during["autodiscover"], "autodiscover offers no IMAP, POP3 or submission (LP-7)")
|
||||
check(not during["pacc"] and during["jmap"], "PACC offers JMAP and nothing legacy (LP-7)")
|
||||
names = ("_imap._tcp", "_imaps._tcp", "_pop3._tcp", "_pop3s._tcp",
|
||||
"_submission._tcp", "_submissions._tcp")
|
||||
offered = {n: t for n, t in during["srv"].items() if n in names and t != "."}
|
||||
check(not offered and "_imaps._tcp" in during["srv"],
|
||||
"the suggested zone marks the legacy SRV names not offered, target . (LP-7)")
|
||||
if offered or "_imaps._tcp" not in during["srv"]:
|
||||
print(" srv:", during["srv"])
|
||||
|
||||
# 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]})
|
||||
|
||||
# The change was reported (LP-8, test 14), with who made it and what closed.
|
||||
changed = events("security.legacy-protocols-changed")
|
||||
check(len(changed) == 1 and 'value = "disabled"' in changed[0]
|
||||
and 'policy = "server"' in changed[0] and 'details = "closed"' in changed[0]
|
||||
and '"imaps"' in changed[0] and "accountId = " in changed[0],
|
||||
"turning it off is one event: scope, new value, who, listeners closed (LP-8)")
|
||||
if len(changed) != 1:
|
||||
print(" events:", changed)
|
||||
# Asking for what already holds is not a change.
|
||||
one(admin, admin_pw, "inbuxa:ProtocolPolicy/set", policy_set({"legacyProtocols": "disabled"}))
|
||||
check(len(events("security.legacy-protocols-changed")) == 1,
|
||||
"setting it off again when it is off raises no event (LP-8)")
|
||||
# Every refused sign-in is an event too, and none is a failed sign-in.
|
||||
refused = events("auth.legacy-protocol-refused")
|
||||
check(len(refused) == 7 and all('source = "submission"' in l for l in refused),
|
||||
"each refused sign-in is an auth.legacy-protocol-refused event (LP-6)")
|
||||
check(not events("auth.failed") and not events("auth.too-many-attempts"),
|
||||
"and none is logged as a failed sign-in (LP-11)")
|
||||
|
||||
# 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)")
|
||||
changed = events("security.legacy-protocols-changed")
|
||||
check(len(changed) == 1 and 'value = "enabled"' in changed[0]
|
||||
and 'details = "reopened"' in changed[0] and '"imaps"' in changed[0],
|
||||
"turning it back on is one event, naming the listeners reopened (LP-8)")
|
||||
|
||||
after = advertised(admin, admin_pw)
|
||||
check(after["autoconfig"] == before["autoconfig"] and after["srv"] == before["srv"],
|
||||
"autoconfig and the suggested zone offer them again once back on")
|
||||
|
||||
# A tenant's own switch (LP-9 to LP-14a).
|
||||
tenant_checks(admin, admin_pw, account)
|
||||
|
||||
# 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