Author SHA1 Message Date
jcoffey-dev 79b6787397 Merge main, and put LP-8's event on top of the Hardening link's schema
The Hardening link merged to main changed the packaged schema, which this
branch also changes. The file is gzipped, so the two can't be merged line
by line: this takes main's schema and adds security.legacy-protocols-changed
to it again, with the hash recomputed.
2026-09-21 13:48:25 -07:00
jcoffey-dev 64cddc9246 The switch reports every change as an event (LP-8)
Turning legacy mail protocols off or back on raises
security.legacy-protocols-changed (id 643, info level, also in the packaged
schema), with the scope (policy = server), the new value, who made the
change (accountId), whether listeners closed or reopened (details), which
ones (listenerId), and -- only when a listener could not be put back --
which and why (reason).

It is raised in Server::set_protocol_policy rather than by the JMAP
method, so whatever turns the switch is reported. A /set that changes
nothing -- the switch already where it was asked to be, nothing to close
or reopen -- is not a change and raises nothing.

The event is never an error, but jmap's exhaustive map from security
events to HTTP errors has to name it; it joins the other two that can't
occur there. rustfmt now also wraps LP-6's two over-long lines in
enums_impl.rs, which it flagged along with this change's.

tests/e2e/legacy_protocols.py now gives the server a stdout tracer and
reads events from the container's log: turning the switch off is exactly
one event naming the scope, value, author and listeners closed; setting it
off again raises none; turning it on is one event naming the listeners
reopened. It also proves LP-6's side: seven refused submission sign-ins
are seven auth.legacy-protocol-refused events, and there is no auth.failed
or auth.too-many-attempts among them. All checks pass.
2026-09-21 10:53:01 -07:00
jcoffey-dev 4b585905d7 Nothing advertises the legacy protocols while they are off (LP-7)
While the switch is off, the answers that tell a mail app where to connect
stop offering what the switch closed, so a new phone or desktop app is not
sent to a port that is shut or a sign-in that will be refused:

- Thunderbird-style autoconfig (/mail/config-v1.1.xml and its other
  paths) and Outlook autodiscover leave out IMAP, POP3 and SMTP
  submission.
- PACC (/.well-known/user-agent-configuration.json) offers JMAP, CalDAV,
  CardDAV and WebDAV, and no IMAP, POP3, SMTP or ManageSieve. The document
  is rendered once per configuration load, so the JMAP-only version is
  rendered beside it and chosen per request; the _ua-auto-config digest in
  the suggested zone follows, since it hashes the same document.
- The suggested zone publishes _imap, _imaps, _pop3, _pop3s, _submission
  and _submissions with target "." -- "not offered", RFC 6186 section 3.4 --
  the spec's decision, rather than dropping them: a client that looks is
  told, and an automatically managed zone replaces the old records instead
  of leaving them behind.
- It also drops the TLSA records for ports 993 and 995. A TLS pin for a
  port the switch has closed advertises a service that is not there.
  Submission's 465 keeps its record: the SMTP lock keeps that port open.

The switch is read per answer, as sign-in reads it, so every node agrees
the moment it turns. Inbound mail, MX records and the JMAP, CalDAV and
CardDAV answers are untouched.

tests/e2e/legacy_protocols.py checks all four on a running server: with the
switch on they offer IMAP, POP3 and SMTP (the control); while it is off
they offer none of them and every legacy SRV name has target "."; and once
it is back on, autoconfig and the zone read as they did before. All checks
pass.
2026-09-21 10:41:20 -07:00
17 changed files with 316 additions and 363 deletions
-62
View File
@@ -1,62 +0,0 @@
# 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
-138
View File
@@ -1,138 +0,0 @@
# 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
-135
View File
@@ -1,135 +0,0 @@
# 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
-1
View File
@@ -10,7 +10,6 @@ run.sh
!.gitattributes
!.github
!.gitlab-ci.yml
!.gitea
CLAUDE.md
# The cutover rehearsal writes its fixture and state here.
+2 -2
View File
@@ -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://git.coffeylabs.org/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
- [inbuxa-admin](https://github.com/inbuxa/inbuxa-admin)
- [ihasmail-inbuxa](https://github.com/inbuxa/ihasmail-inbuxa)
Upstream's own security documents are kept in `.github-upstream/` for
reference. They describe Stalwart Labs' process, not this project's.
+25 -6
View File
@@ -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)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
.unwrap();
let split = |pacc: &Configuration| {
serde_json::to_string(pacc)
.unwrap_or_default()
.rsplit_once(SPLIT_HERE)
.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,
},
};
@@ -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,12 @@ 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
let legacy_off = self.legacy_protocols_off().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
let legacy_off = self.legacy_protocols_off().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]),
+44 -9
View File
@@ -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::{
@@ -33,6 +39,8 @@ impl Server {
let mut records = Vec::new();
let network = &self.core.network;
let default_host = network.server_name.as_str();
// inbuxa: legacy-protocols LP-7
let legacy_off = self.legacy_protocols_off().await?;
let domain_name = domain.name.as_str();
let domain_name_suffix = format!(".{domain_name}");
@@ -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
let pacc = if self.legacy_protocols_off().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))
})
}
}
+83
View File
@@ -21,6 +21,10 @@
//! 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.
//!
//! 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.
@@ -31,6 +35,7 @@ use inbuxa_features::security::{
listeners,
protocol_policy::{self, ProtocolPolicy, SavedListener},
};
use registry::schema::enums::ServiceProtocol;
use registry::types::{error::Error, id::ObjectId};
use store::registry::bootstrap::Bootstrap;
@@ -102,6 +107,37 @@ impl Server {
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)
}
@@ -215,6 +251,12 @@ impl Server {
}
}
/// 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 {
@@ -302,6 +344,27 @@ impl Server {
}
}
/// 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 the server-wide switch is off, for the answers that must stop
/// offering legacy services (LP-7). Read per answer, as sign-in reads it.
pub async fn legacy_protocols_off(&self) -> trc::Result<bool> {
Ok(self.protocol_policy().await?.legacy_protocols.is_disabled())
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -349,6 +412,26 @@ mod tests {
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()));
+6 -1
View File
@@ -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()
}
},
+5 -2
View File
@@ -9,8 +9,9 @@
// This file is auto-generated. Do not edit directly.
// inbuxa: 637 to 641 are the fork's SCIM events (SCIM-54); 642 is
// auth.legacy-protocol-refused (legacy-protocols LP-6)
pub const TOTAL_EVENT_COUNT: usize = 643;
// 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)]
@@ -655,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)]
+28 -2
View File
@@ -446,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),
@@ -1211,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",
@@ -1883,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,
@@ -2571,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)),
@@ -2990,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,
@@ -3198,7 +3210,9 @@ impl EventType {
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::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",
@@ -3711,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",
@@ -3964,7 +3982,9 @@ 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::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",
@@ -4102,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",
@@ -4663,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),
+1 -1
View File
@@ -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://git.coffeylabs.org/inbuxa/inbuxa-server/releases" >&2
echo "Releases: https://github.com/inbuxa/inbuxa-server/releases" >&2
echo "Docs: https://docs.inbuxa.org/install/fresh/" >&2
exit 1
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
q-OZe-InKnF24mlL56Vvt3m_IQNRybiN61MFxBSo0WY
rLRbZKj15KvcPMVmnisfCDsXXZEKks6BXpMkMpS0mlI
+104 -1
View File
@@ -17,7 +17,11 @@ 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).
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).
Passwords are generated into files under target/e2e and never printed.
Everything is removed afterwards unless KEEP=1.
@@ -189,6 +193,47 @@ def smtp_auths(port, user, passwords):
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 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):
@@ -232,6 +277,15 @@ def main():
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}
@@ -243,6 +297,15 @@ def main():
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")
@@ -295,6 +358,19 @@ def main():
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": {
@@ -322,6 +398,25 @@ def main():
"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()
@@ -340,6 +435,14 @@ def main():
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")
# And sign-in works again, with no restart.
check(imap_login(PORTS["imap"], admin, admin_pw).startswith("OK"),