Compare commits
230
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
697f647f8b | ||
|
|
14250cee03 | ||
|
|
cea3d53eb0 | ||
|
|
335281f1de | ||
|
|
7f14992e81 | ||
|
|
1f963a9a1c | ||
|
|
b353f4ad2a | ||
|
|
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 | ||
|
|
604d889220 | ||
|
|
d264429298 | ||
|
|
80972695b9 | ||
|
|
7c4add8425 | ||
|
|
92d2b07c2e | ||
|
|
987ed55d06 | ||
|
|
a63839f6b0 | ||
|
|
6a53d47106 | ||
|
|
77fce247e2 | ||
|
|
0117500d85 | ||
|
|
8c3450dffa | ||
|
|
7196f9dda2 | ||
|
|
efe0b01bac | ||
|
|
04a5a8bdc2 | ||
|
|
848bfeda4e | ||
|
|
4ce5fc6f68 | ||
|
|
3e69b6139f | ||
|
|
7c27bae4f3 | ||
|
|
bfda639d73 | ||
|
|
a6c7152c52 | ||
|
|
daaa06a5fb | ||
|
|
67619f64e9 | ||
|
|
f338ddf57d | ||
|
|
7d468ad22e | ||
|
|
92aed3df21 | ||
|
|
63fe315457 | ||
|
|
26759a1847 | ||
|
|
f646f2ec3a | ||
|
|
bfd2784819 | ||
|
|
7714bca8d3 | ||
|
|
01c5503a19 | ||
|
|
1da75e986e | ||
|
|
d407509f59 | ||
|
|
3d28f6bff2 | ||
|
|
045e9f6234 | ||
|
|
7816f38c29 | ||
|
|
fd7747ef21 | ||
|
|
2a127d6b9a | ||
|
|
261175aae5 | ||
|
|
4336251cea | ||
|
|
8839078a2b | ||
|
|
29d9263071 | ||
|
|
dac1808bbd | ||
|
|
08b3210cff | ||
|
|
9f444d2458 | ||
|
|
adfa22c817 | ||
|
|
309835be1d | ||
|
|
ce12659138 | ||
|
|
e492b7875a | ||
|
|
02ed679890 | ||
|
|
5cc28784df | ||
|
|
2f1e4bd2c7 | ||
|
|
cee4fd6bc6 | ||
|
|
c0377df942 | ||
|
|
0755fad51e | ||
|
|
e913a22b66 | ||
|
|
216bb5b711 | ||
|
|
1518c69033 | ||
|
|
a635b490ec | ||
|
|
0237d6fa92 | ||
|
|
3158277b04 | ||
|
|
f72e3bb85c | ||
|
|
b080fa0736 | ||
|
|
fdfa2bc259 | ||
|
|
e5e326de6b | ||
|
|
e7efa91cbc | ||
|
|
00b2eb6f4b | ||
|
|
3df72b355a | ||
|
|
a18e209015 | ||
|
|
3ffaee0695 | ||
|
|
940b42f3b4 | ||
|
|
0ca26070d7 | ||
|
|
776d18d06e | ||
|
|
2d3f8251c5 | ||
|
|
4b389b6b4e | ||
|
|
f107443b29 | ||
|
|
d9b36a3806 | ||
|
|
2edb552b6b | ||
|
|
49e3733428 | ||
|
|
7b6959155b | ||
|
|
143a10fcf0 | ||
|
|
fc2d4f237f | ||
|
|
9f7035588f | ||
|
|
715f219528 | ||
|
|
3ea242f7fb | ||
|
|
88090b3ea6 | ||
|
|
2e6c234152 | ||
|
|
554cc4fcd2 | ||
|
|
9490fc4677 | ||
|
|
cba48cf03b | ||
|
|
e844878ba7 | ||
|
|
725fbe7ec7 | ||
|
|
0bc6b03dcd | ||
|
|
ecbdfd533b | ||
|
|
0a2c29e8fd | ||
|
|
5b963b06c6 | ||
|
|
4a631bd0b5 | ||
|
|
a1ce14b76b | ||
|
|
ec4d668bc4 | ||
|
|
ac2232c98d | ||
|
|
4a9aa9c548 | ||
|
|
f58aea000f | ||
|
|
31b8ca8ed5 | ||
|
|
d319f013ec | ||
|
|
f07c00fffb | ||
|
|
d04aafd3d7 | ||
|
|
7080028437 | ||
|
|
53ccc8f4de | ||
|
|
60d1d8b84a | ||
|
|
3b052a57da | ||
|
|
aaca8fe537 | ||
|
|
7ef2cdc273 | ||
|
|
edfb357cb1 | ||
|
|
e109cf86ae | ||
|
|
2e43f5fc0d | ||
|
|
dd584dc9ce | ||
|
|
11d780b3d3 | ||
|
|
05ee6ac2be | ||
|
|
2cbecac415 | ||
|
|
bcf4a49325 | ||
|
|
cea8b1593b | ||
|
|
406aa05dc0 | ||
|
|
3896f720a1 | ||
|
|
5a22e79992 | ||
|
|
ad0db8b2b0 | ||
|
|
b6b345a129 | ||
|
|
d0d4ac4317 | ||
|
|
a1254cd3c4 | ||
|
|
edc6a8ccbd | ||
|
|
cf59d5183a | ||
|
|
8c1879e853 | ||
|
|
a45e0ef8b1 | ||
|
|
cd7c1a6d4c | ||
|
|
31e64f5a4d | ||
|
|
05d220ae4f | ||
|
|
c2be7e2956 | ||
|
|
d6fc4600cd | ||
|
|
f82f15d863 | ||
|
|
ad3322183a | ||
|
|
2e2eb76301 | ||
|
|
559bb3d1a6 | ||
|
|
0ed540f43a | ||
|
|
b2de803680 | ||
|
|
faedf7a1da | ||
|
|
bde11d54c3 | ||
|
|
c9c761fab5 | ||
|
|
d3f0b36dd2 | ||
|
|
5ce033e10c | ||
|
|
189f688768 | ||
|
|
68523374d3 | ||
|
|
8ca487ed36 | ||
|
|
1c6640e4b3 | ||
|
|
89665decfa | ||
|
|
6e73d284d8 | ||
|
|
0db5ab9155 | ||
|
|
0c6b3dffc0 | ||
|
|
ace3064fbb | ||
|
|
dedcc0fe94 | ||
|
|
826e8bc97f | ||
|
|
e79036d097 | ||
|
|
81db1433d2 | ||
|
|
e73744e30b | ||
|
|
f9c19a153b | ||
|
|
4e3a147de1 | ||
|
|
057491e33c | ||
|
|
dc1ddb2893 | ||
|
|
0fbf6b7d47 | ||
|
|
c984cb7afa | ||
|
|
490d11b04a | ||
|
|
c6b3674ccd | ||
|
|
68163e9dbd | ||
|
|
6b069f740a |
@@ -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
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# To get started with Dependabot version updates, you'll need to specify which
|
||||||
|
# package ecosystems to update and where the package manifests are located.
|
||||||
|
# Please see the documentation for all configuration options:
|
||||||
|
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||||
|
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "cargo" # See documentation for possible values
|
||||||
|
directory: "/" # Location of package manifests
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
# Enable version updates for GitHub Actions
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
# Workflow files stored in the default location of `.github/workflows`
|
||||||
|
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
@@ -0,0 +1,567 @@
|
|||||||
|
name: "CI"
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
Docker:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
Release:
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
type: boolean
|
||||||
|
push:
|
||||||
|
tags: ["v*.*.*"]
|
||||||
|
|
||||||
|
env:
|
||||||
|
SCCACHE_GHA_ENABLED: true
|
||||||
|
RUSTC_WRAPPER: sccache
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
CARGO_NET_RETRY: 10
|
||||||
|
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
||||||
|
AWS_LC_SYS_PREBUILT_NASM: 1
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
multiarch:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- variant: gnu
|
||||||
|
- variant: musl
|
||||||
|
name: Merge image / ${{matrix.variant}}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: read
|
||||||
|
attestations: write
|
||||||
|
packages: write
|
||||||
|
needs: [linux]
|
||||||
|
if: github.event_name == 'push' || inputs.Docker
|
||||||
|
steps:
|
||||||
|
- name: Install Cosign
|
||||||
|
uses: sigstore/[email protected]
|
||||||
|
- name: Log In to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{github.repository_owner}}
|
||||||
|
password: ${{github.token}}
|
||||||
|
|
||||||
|
- name: Log In to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||||
|
password: ${{secrets.DOCKERHUB_TOKEN}}
|
||||||
|
|
||||||
|
- name: Download ${{matrix.variant}} meta bake definition
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
name: bake-meta-${{matrix.variant}}
|
||||||
|
path: ${{ runner.temp }}/${{matrix.variant}}
|
||||||
|
|
||||||
|
- name: Download ${{matrix.variant}} digests
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: ${{ runner.temp }}/${{matrix.variant}}/digests
|
||||||
|
pattern: digests-${{matrix.variant}}-*
|
||||||
|
merge-multiple: true
|
||||||
|
|
||||||
|
- name: Create ${{matrix.variant}} manifest list and push
|
||||||
|
working-directory: ${{ runner.temp }}/${{matrix.variant}}/digests
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
|
||||||
|
$(printf 'ghcr.io/${{github.repository}}@sha256:%s ' *)
|
||||||
|
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
|
||||||
|
$(printf 'index.docker.io/${{github.repository}}@sha256:%s ' *)
|
||||||
|
|
||||||
|
- name: Inspect ${{matrix.variant}} image
|
||||||
|
id: manifest-digest
|
||||||
|
run: |
|
||||||
|
docker buildx imagetools inspect --format '{{json .Manifest}}' ghcr.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > GHCR_DIGEST_SHA
|
||||||
|
echo "GHCR_DIGEST_SHA=$(cat GHCR_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
|
||||||
|
docker buildx imagetools inspect --format '{{json .Manifest}}' index.docker.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > DOCKERHUB_DIGEST_SHA
|
||||||
|
echo "DOCKERHUB_DIGEST_SHA=$(cat DOCKERHUB_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
|
||||||
|
cosign sign --yes $(jq --arg GHCR_DIGEST_SHA "$(cat GHCR_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | . + "@" + $GHCR_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
|
||||||
|
cosign sign --yes $(jq --arg DOCKERHUB_DIGEST_SHA "$(cat DOCKERHUB_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | . + "@" + $DOCKERHUB_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
|
||||||
|
|
||||||
|
- name: Attest GHCR
|
||||||
|
uses: actions/attest-build-provenance@v4
|
||||||
|
with:
|
||||||
|
subject-name: ghcr.io/${{github.repository}}
|
||||||
|
subject-digest: ${{ env.GHCR_DIGEST_SHA }}
|
||||||
|
push-to-registry: true
|
||||||
|
|
||||||
|
- name: Attest Dockerhub
|
||||||
|
uses: actions/attest-build-provenance@v4
|
||||||
|
with:
|
||||||
|
subject-name: index.docker.io/${{github.repository}}
|
||||||
|
subject-digest: ${{ env.DOCKERHUB_DIGEST_SHA }}
|
||||||
|
push-to-registry: true
|
||||||
|
|
||||||
|
linux:
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
attestations: write
|
||||||
|
packages: write
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: x86_64-unknown-linux-gnu
|
||||||
|
platform: linux/amd64
|
||||||
|
suffix: ""
|
||||||
|
build_env: ""
|
||||||
|
- target: x86_64-unknown-linux-musl
|
||||||
|
platform: linux/amd64
|
||||||
|
suffix: "-alpine"
|
||||||
|
build_env: ""
|
||||||
|
- target: aarch64-unknown-linux-gnu
|
||||||
|
platform: linux/arm64
|
||||||
|
suffix: ""
|
||||||
|
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
||||||
|
- target: aarch64-unknown-linux-musl
|
||||||
|
platform: linux/arm64
|
||||||
|
suffix: "-alpine"
|
||||||
|
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
||||||
|
- target: armv7-unknown-linux-gnueabihf
|
||||||
|
platform: linux/arm/v7
|
||||||
|
suffix: ""
|
||||||
|
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
||||||
|
- target: armv7-unknown-linux-musleabihf
|
||||||
|
platform: linux/arm/v7
|
||||||
|
suffix: "-alpine"
|
||||||
|
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
||||||
|
- target: arm-unknown-linux-gnueabihf
|
||||||
|
platform: linux/arm/v6
|
||||||
|
suffix: ""
|
||||||
|
build_env: ""
|
||||||
|
- target: arm-unknown-linux-musleabihf
|
||||||
|
platform: linux/arm/v6
|
||||||
|
suffix: "-alpine"
|
||||||
|
build_env: ""
|
||||||
|
name: Build / ${{matrix.target}}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Free disk space (heavy ARM targets)
|
||||||
|
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
|
||||||
|
run: |
|
||||||
|
df -h /mnt /
|
||||||
|
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /usr/local/share/powershell /usr/share/swift /opt/hostedtoolcache/CodeQL
|
||||||
|
sudo docker image prune --all --force || true
|
||||||
|
df -h /mnt /
|
||||||
|
|
||||||
|
- name: Add swap (heavy ARM targets)
|
||||||
|
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
|
||||||
|
run: |
|
||||||
|
mnt_avail=$(df --output=avail -k /mnt | tail -1)
|
||||||
|
if [ "$mnt_avail" -lt 18874368 ]; then
|
||||||
|
echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sudo fallocate -l 16G /mnt/swapfile
|
||||||
|
sudo chmod 600 /mnt/swapfile
|
||||||
|
sudo mkswap /mnt/swapfile
|
||||||
|
sudo swapon /mnt/swapfile
|
||||||
|
sudo sysctl vm.swappiness=80
|
||||||
|
free -h
|
||||||
|
swapon --show
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v4
|
||||||
|
with:
|
||||||
|
platforms: "arm64,arm"
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v4
|
||||||
|
with:
|
||||||
|
buildkitd-config-inline: |
|
||||||
|
[registry."docker.io"]
|
||||||
|
mirrors = ["https://mirror.gcr.io"]
|
||||||
|
driver-opts: |
|
||||||
|
network=host
|
||||||
|
|
||||||
|
- name: Log In to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{github.repository_owner}}
|
||||||
|
password: ${{github.token}}
|
||||||
|
|
||||||
|
- name: Log In to DockerHub
|
||||||
|
uses: docker/login-action@v4
|
||||||
|
with:
|
||||||
|
username: ${{secrets.DOCKERHUB_USERNAME}}
|
||||||
|
password: ${{secrets.DOCKERHUB_TOKEN}}
|
||||||
|
|
||||||
|
- name: Calculate shasum of external deps
|
||||||
|
id: cal-dep-shasum
|
||||||
|
run: |
|
||||||
|
echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Cache apt
|
||||||
|
uses: actions/[email protected]
|
||||||
|
id: apt-cache
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
var-cache-apt
|
||||||
|
var-lib-apt
|
||||||
|
key: apt-cache-${{ hashFiles('Dockerfile.build') }}
|
||||||
|
|
||||||
|
- name: Cache Cargo
|
||||||
|
uses: actions/[email protected]
|
||||||
|
id: cargo-cache
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
usr-local-cargo-registry
|
||||||
|
usr-local-cargo-git
|
||||||
|
key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }}
|
||||||
|
|
||||||
|
- name: Inject cache into docker
|
||||||
|
uses: reproducible-containers/[email protected]
|
||||||
|
with:
|
||||||
|
cache-map: |
|
||||||
|
{
|
||||||
|
"var-cache-apt": "/var/cache/apt",
|
||||||
|
"var-lib-apt": "/var/lib/apt",
|
||||||
|
"usr-local-cargo-registry": "/usr/local/cargo/registry",
|
||||||
|
"usr-local-cargo-git": "/usr/local/cargo/git"
|
||||||
|
}
|
||||||
|
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }} && ${{ steps.apt-cache.outputs.cache-hit }}
|
||||||
|
|
||||||
|
- name: Extract Metadata for Docker
|
||||||
|
uses: docker/metadata-action@v6
|
||||||
|
id: meta
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
index.docker.io/${{github.repository}}
|
||||||
|
ghcr.io/${{github.repository}}
|
||||||
|
flavor: |
|
||||||
|
suffix=${{matrix.suffix}},onlatest=true
|
||||||
|
tags: |
|
||||||
|
type=ref,event=tag
|
||||||
|
type=ref,event=branch,prefix=branch-
|
||||||
|
type=edge,branch=main
|
||||||
|
type=semver,pattern=v{{major}}.{{minor}}
|
||||||
|
|
||||||
|
- name: Build Artifact
|
||||||
|
id: bake
|
||||||
|
uses: docker/bake-action@v7
|
||||||
|
env:
|
||||||
|
DOCKER_BUILD_RECORD_UPLOAD: false
|
||||||
|
TARGET: ${{matrix.target}}
|
||||||
|
GHCR_REPO: ghcr.io/${{github.repository}}
|
||||||
|
BUILD_ENV: ${{matrix.build_env}}
|
||||||
|
DOCKER_PLATFORM: ${{matrix.platform}}
|
||||||
|
SUFFIX: ${{matrix.suffix}}
|
||||||
|
with:
|
||||||
|
source: .
|
||||||
|
set: |
|
||||||
|
*.tags=
|
||||||
|
image.output=type=image,"name=ghcr.io/${{github.repository}},index.docker.io/${{github.repository}}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||||
|
files: |
|
||||||
|
docker-bake.hcl
|
||||||
|
${{ steps.meta.outputs.bake-file }}
|
||||||
|
targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}}
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/[email protected]
|
||||||
|
with:
|
||||||
|
name: artifact-${{matrix.target}}
|
||||||
|
path: |
|
||||||
|
artifact
|
||||||
|
!artifact/*.json
|
||||||
|
|
||||||
|
- name: Export digest & Rename meta bake definition file
|
||||||
|
if: github.event_name == 'push' || inputs.Docker
|
||||||
|
run: |
|
||||||
|
mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json"
|
||||||
|
mkdir -p ${{ runner.temp }}/digests
|
||||||
|
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
|
||||||
|
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
||||||
|
|
||||||
|
- name: Upload digest
|
||||||
|
if: github.event_name == 'push' || inputs.Docker
|
||||||
|
uses: actions/[email protected]
|
||||||
|
with:
|
||||||
|
name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }}
|
||||||
|
path: ${{ runner.temp }}/digests/*
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload GNU meta bake definition
|
||||||
|
uses: actions/[email protected]
|
||||||
|
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86')
|
||||||
|
with:
|
||||||
|
name: bake-meta-gnu
|
||||||
|
path: ${{ runner.temp }}/bake-meta.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
- name: Upload musl meta bake definition
|
||||||
|
uses: actions/[email protected]
|
||||||
|
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86')
|
||||||
|
with:
|
||||||
|
name: bake-meta-musl
|
||||||
|
path: ${{ runner.temp }}/bake-meta.json
|
||||||
|
if-no-files-found: error
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
|
windows:
|
||||||
|
name: Build / ${{matrix.target}}
|
||||||
|
runs-on: windows-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
# - target: aarch64-pc-windows-msvc
|
||||||
|
- target: x86_64-pc-windows-msvc
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Run sccache-cache
|
||||||
|
uses: mozilla-actions/[email protected]
|
||||||
|
with:
|
||||||
|
disable_annotations: true
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
rustup target add ${{matrix.target}}
|
||||||
|
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
||||||
|
mkdir -p artifacts
|
||||||
|
mv ./target/${{matrix.target}}/release/stalwart.exe ./artifacts/stalwart.exe
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/[email protected]
|
||||||
|
with:
|
||||||
|
name: artifact-${{matrix.target}}
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
macos:
|
||||||
|
name: Build / ${{matrix.target}}
|
||||||
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: aarch64-apple-darwin
|
||||||
|
- target: x86_64-apple-darwin
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Run sccache-cache
|
||||||
|
uses: mozilla-actions/[email protected]
|
||||||
|
with:
|
||||||
|
disable_annotations: true
|
||||||
|
|
||||||
|
#- name: Build FoundationDB Edition
|
||||||
|
# env:
|
||||||
|
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# run: |
|
||||||
|
# rustup target add ${{matrix.target}}
|
||||||
|
# # Pin FoundationDB 7.4.x (Apple publishes these as prereleases)
|
||||||
|
# curl --retry 5 -Lso foundationdb.pkg "$(gh api -X GET /repos/apple/foundationdb/releases --jq '[.[] | select(.tag_name | startswith("7.4."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("${{startsWith(matrix.target, 'x86') && 'x86_64' || 'arm64'}}" + ".pkg$")) | .browser_download_url')"
|
||||||
|
# echo "=== Package contents ==="
|
||||||
|
# pkgutil --payload-files foundationdb.pkg || true
|
||||||
|
# sudo installer -allowUntrusted -verbose -dumplog -pkg foundationdb.pkg -target /
|
||||||
|
# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats"
|
||||||
|
# mkdir -p artifacts
|
||||||
|
# mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart-foundationdb
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
run: |
|
||||||
|
rustup target add ${{matrix.target}}
|
||||||
|
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
||||||
|
mkdir -p artifacts
|
||||||
|
mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/[email protected]
|
||||||
|
with:
|
||||||
|
name: artifact-${{matrix.target}}
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
freebsd:
|
||||||
|
name: Build / ${{matrix.target}}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 360
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: x86_64-unknown-freebsd
|
||||||
|
arch: x86_64
|
||||||
|
# - target: aarch64-unknown-freebsd
|
||||||
|
# arch: aarch64
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Build in FreeBSD VM
|
||||||
|
uses: vmactions/freebsd-vm@v1
|
||||||
|
with:
|
||||||
|
release: "15.1"
|
||||||
|
arch: ${{matrix.arch}}
|
||||||
|
usesh: true
|
||||||
|
mem: 14336
|
||||||
|
cpu: 4
|
||||||
|
sync: rsync
|
||||||
|
copyback: true
|
||||||
|
# gmake: required by jemalloc-sys on BSD hosts
|
||||||
|
# llvm: provides libclang for bindgen (librocksdb-sys)
|
||||||
|
# rust: libsqlite3-sys 0.38 uses cfg_select!, stabilized in Rust
|
||||||
|
# 1.95. The default 'quarterly' pkg repo still ships rust 1.94, so
|
||||||
|
# switch to the 'latest' repo (currently 1.96.1). rustup is not an
|
||||||
|
# option here: aarch64-unknown-freebsd has no rustup toolchains yet.
|
||||||
|
prepare: |
|
||||||
|
set -e
|
||||||
|
mkdir -p /usr/local/etc/pkg/repos
|
||||||
|
echo 'FreeBSD: { url: "pkg+https://pkg.freebsd.org/${ABI}/latest", mirror_type: "srv" }' > /usr/local/etc/pkg/repos/FreeBSD.conf
|
||||||
|
pkg update -f
|
||||||
|
env ASSUME_ALWAYS_YES=yes pkg bootstrap -f
|
||||||
|
pkg update -f
|
||||||
|
pkg install -y rust gmake llvm rocksdb
|
||||||
|
rustc --version
|
||||||
|
run: |
|
||||||
|
set -e
|
||||||
|
export CARGO_TARGET_DIR=/tmp/target
|
||||||
|
export CARGO_TERM_COLOR=always
|
||||||
|
export CARGO_NET_RETRY=10
|
||||||
|
cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
||||||
|
mkdir -p artifacts
|
||||||
|
cp /tmp/target/release/stalwart artifacts/stalwart
|
||||||
|
|
||||||
|
- name: Upload Artifacts
|
||||||
|
uses: actions/[email protected]
|
||||||
|
with:
|
||||||
|
name: artifact-${{matrix.target}}
|
||||||
|
path: artifacts
|
||||||
|
|
||||||
|
release:
|
||||||
|
name: Release
|
||||||
|
permissions:
|
||||||
|
id-token: write
|
||||||
|
contents: write
|
||||||
|
attestations: write
|
||||||
|
if: github.event_name == 'push' || inputs.Release
|
||||||
|
needs: [linux, windows, macos, freebsd]
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Must run before artifacts are downloaded — checkout cleans the workspace.
|
||||||
|
- name: Checkout (for CHANGELOG)
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
|
||||||
|
- name: Download Artifacts
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
path: archive
|
||||||
|
pattern: artifact-*
|
||||||
|
|
||||||
|
- name: Compress
|
||||||
|
run: |
|
||||||
|
set -eux
|
||||||
|
BASE_DIR="$(pwd)/archive"
|
||||||
|
compress_files() {
|
||||||
|
local dir="$1"
|
||||||
|
local archive_dir_name="${dir#artifact-}"
|
||||||
|
cd "$dir"
|
||||||
|
# Process each file in the directory
|
||||||
|
for file in `ls`; do
|
||||||
|
filename="${file%.*}"
|
||||||
|
extension="${file##*.}"
|
||||||
|
if [ "$extension" = "exe" ]; then
|
||||||
|
7z a -tzip "${filename}-${archive_dir_name}.zip" "$file" > /dev/null
|
||||||
|
else
|
||||||
|
tar -czf "${filename}-${archive_dir_name}.tar.gz" "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
cd $BASE_DIR
|
||||||
|
}
|
||||||
|
cd $BASE_DIR
|
||||||
|
for arch_dir in `ls`; do
|
||||||
|
dir_name=$(basename "$arch_dir")
|
||||||
|
compress_files "$dir_name"
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Attest binary
|
||||||
|
id: attest
|
||||||
|
uses: actions/attest-build-provenance@v4
|
||||||
|
with:
|
||||||
|
subject-path: |
|
||||||
|
archive/**/*.tar.gz
|
||||||
|
archive/**/*.zip
|
||||||
|
|
||||||
|
- name: Use cosign to sign existing artifacts
|
||||||
|
uses: sigstore/[email protected]
|
||||||
|
with:
|
||||||
|
inputs: |
|
||||||
|
archive/**/*.tar.gz
|
||||||
|
archive/**/*.zip
|
||||||
|
|
||||||
|
- name: Build release body
|
||||||
|
run: |
|
||||||
|
if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
|
||||||
|
awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md
|
||||||
|
echo "" >> release_body.md
|
||||||
|
else
|
||||||
|
: > release_body.md
|
||||||
|
fi
|
||||||
|
cat >> release_body.md <<EOF
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }})
|
||||||
|
EOF
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
files: |
|
||||||
|
archive/**/*.tar.gz
|
||||||
|
archive/**/*.zip
|
||||||
|
archive/**/*.sigstore.json
|
||||||
|
prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}}
|
||||||
|
tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}}
|
||||||
|
# Tag-push releases are created as drafts; the `publish` job un-drafts
|
||||||
|
# them only after all build jobs succeed, so watcher notifications
|
||||||
|
# don't fire on broken builds.
|
||||||
|
draft: ${{ startsWith(github.ref, 'refs/tags/') || null }}
|
||||||
|
body_path: release_body.md
|
||||||
|
|
||||||
|
publish:
|
||||||
|
name: Publish release
|
||||||
|
needs: [linux, windows, macos, freebsd, multiarch, release]
|
||||||
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Un-draft release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}"
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
name: Cleanup failed release
|
||||||
|
needs: [linux, windows, macos, freebsd, multiarch, release]
|
||||||
|
if: failure() && startsWith(github.ref, 'refs/tags/') && github.run_attempt >= 3
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
steps:
|
||||||
|
- name: Delete draft release and tag
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
# Funding platforms shown behind the repository's Sponsor button.
|
||||||
|
# https://docs.github.com/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
|
||||||
|
|
||||||
|
github: jcoffey-dev
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<!--
|
||||||
|
Thanks for contributing to INBUXA. CONTRIBUTING.md has the full guide; this
|
||||||
|
is the short version. Delete any section that does not apply.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- What changes, and why. The why is the part that is hard to recover later. -->
|
||||||
|
|
||||||
|
## Related issues
|
||||||
|
|
||||||
|
<!-- e.g. Closes #123. Leave blank if there are none. -->
|
||||||
|
|
||||||
|
## Upstream files
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Does this touch files that came from Stalwart? If so: is the change as small
|
||||||
|
as it can be, and is it marked with an `inbuxa:` comment saying which
|
||||||
|
requirement it serves? Every edit to an upstream file is a conflict waiting
|
||||||
|
at the next import, so it should be worth one.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Clean room
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Only for changes to the rebuilt features in `crates/features`, or to the
|
||||||
|
hooks that serve them.
|
||||||
|
|
||||||
|
Confirm one:
|
||||||
|
- [ ] I have not read Stalwart's Enterprise-licensed source, and worked from
|
||||||
|
the specification in `docs/spec/features/`.
|
||||||
|
- [ ] I have read it. (Say so -- the change will be reviewed with that in
|
||||||
|
mind, or declined for the parts it touches. The project's claim of
|
||||||
|
independent creation is a record, and the record has to be true.)
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What you ran. `cargo test -p tests` covers what needs nothing but a store;
|
||||||
|
say so if you ran any of the `#[ignore]`d suites from
|
||||||
|
docs/spec/container-tests.md, and which.
|
||||||
|
-->
|
||||||
+38
-15
@@ -1,19 +1,42 @@
|
|||||||
# To get started with Dependabot version updates, you'll need to specify which
|
|
||||||
# package ecosystems to update and where the package manifests are located.
|
|
||||||
# Please see the documentation for all configuration options:
|
|
||||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
updates:
|
updates:
|
||||||
- package-ecosystem: "cargo" # See documentation for possible values
|
# Cargo. One entry: the workspace has a single lockfile at the root, and
|
||||||
directory: "/" # Location of package manifests
|
# ~30 manifests that upstream bumps on every release -- pointing entries at
|
||||||
schedule:
|
# individual crates would find manifests with no lockfile beside them.
|
||||||
interval: "weekly"
|
#
|
||||||
|
# Minor and patch arrive as one pull request a week. Majors are left out of
|
||||||
# Enable version updates for GitHub Actions
|
# the group on purpose: they are migrations rather than bumps, and each one
|
||||||
- package-ecosystem: "github-actions"
|
# deserves its own pull request and its own CI run.
|
||||||
# Workflow files stored in the default location of `.github/workflows`
|
- package-ecosystem: cargo
|
||||||
# You don't need to specify `/.github/workflows` for `directory`. You can use `directory: "/"`.
|
|
||||||
directory: "/"
|
directory: "/"
|
||||||
schedule:
|
schedule:
|
||||||
interval: "weekly"
|
interval: weekly
|
||||||
|
day: tuesday
|
||||||
|
time: "09:00"
|
||||||
|
timezone: Etc/UTC
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
groups:
|
||||||
|
minor-and-patch:
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: tuesday
|
||||||
|
time: "09:00"
|
||||||
|
timezone: Etc/UTC
|
||||||
|
groups:
|
||||||
|
actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
# The Dockerfiles pin their base images, so this is what keeps a published
|
||||||
|
# image off a stale base between releases.
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
day: tuesday
|
||||||
|
time: "09:00"
|
||||||
|
timezone: Etc/UTC
|
||||||
|
|||||||
+42
-558
@@ -1,567 +1,51 @@
|
|||||||
name: "CI"
|
# What CI can check without a mail server's worth of infrastructure.
|
||||||
|
#
|
||||||
|
# The build, and that every test target compiles. It deliberately does not
|
||||||
|
# *run* the test suites: the unit tests only build with the integration crate
|
||||||
|
# in the graph, because that is what switches on the `test_mode` features they
|
||||||
|
# rely on (docs/spec/SPEC.md 2.2b), and the integration suites need a `STORE`,
|
||||||
|
# fixed ports, and in most cases a container apiece (docs/spec/
|
||||||
|
# container-tests.md). Running them here would mean either a green tick that
|
||||||
|
# skipped everything, or a red one that means "the runner has no Redis".
|
||||||
|
#
|
||||||
|
# So this catches what it can honestly catch -- code that does not compile,
|
||||||
|
# including test code -- and the suites are run by hand, one at a time, as
|
||||||
|
# that page describes. If that changes, it changes because someone made the
|
||||||
|
# suites runnable unattended, not because CI started ignoring failures.
|
||||||
|
name: CI
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
Docker:
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
Release:
|
|
||||||
required: false
|
|
||||||
default: false
|
|
||||||
type: boolean
|
|
||||||
push:
|
push:
|
||||||
tags: ["v*.*.*"]
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
env:
|
# Lets CI be run by hand against any ref, including one that predates a CI
|
||||||
SCCACHE_GHA_ENABLED: true
|
# change, without pushing an empty commit to move it.
|
||||||
RUSTC_WRAPPER: sccache
|
workflow_dispatch:
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
CARGO_NET_RETRY: 10
|
|
||||||
CARGO_NET_GIT_FETCH_WITH_CLI: true
|
|
||||||
AWS_LC_SYS_PREBUILT_NASM: 1
|
|
||||||
|
|
||||||
|
# A second push to a branch cancels the run still going for the first: the
|
||||||
|
# older run's answer is about code nobody is looking at any more.
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
multiarch:
|
build:
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- variant: gnu
|
|
||||||
- variant: musl
|
|
||||||
name: Merge image / ${{matrix.variant}}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: read
|
|
||||||
attestations: write
|
|
||||||
packages: write
|
|
||||||
needs: [linux]
|
|
||||||
if: github.event_name == 'push' || inputs.Docker
|
|
||||||
steps:
|
|
||||||
- name: Install Cosign
|
|
||||||
uses: sigstore/[email protected]
|
|
||||||
- name: Log In to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{github.repository_owner}}
|
|
||||||
password: ${{github.token}}
|
|
||||||
|
|
||||||
- name: Log In to DockerHub
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
|
||||||
password: ${{secrets.DOCKERHUB_TOKEN}}
|
|
||||||
|
|
||||||
- name: Download ${{matrix.variant}} meta bake definition
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
name: bake-meta-${{matrix.variant}}
|
|
||||||
path: ${{ runner.temp }}/${{matrix.variant}}
|
|
||||||
|
|
||||||
- name: Download ${{matrix.variant}} digests
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
path: ${{ runner.temp }}/${{matrix.variant}}/digests
|
|
||||||
pattern: digests-${{matrix.variant}}-*
|
|
||||||
merge-multiple: true
|
|
||||||
|
|
||||||
- name: Create ${{matrix.variant}} manifest list and push
|
|
||||||
working-directory: ${{ runner.temp }}/${{matrix.variant}}/digests
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
|
|
||||||
$(printf 'ghcr.io/${{github.repository}}@sha256:%s ' *)
|
|
||||||
docker buildx imagetools create $(jq -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | "-t " + .) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) \
|
|
||||||
$(printf 'index.docker.io/${{github.repository}}@sha256:%s ' *)
|
|
||||||
|
|
||||||
- name: Inspect ${{matrix.variant}} image
|
|
||||||
id: manifest-digest
|
|
||||||
run: |
|
|
||||||
docker buildx imagetools inspect --format '{{json .Manifest}}' ghcr.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > GHCR_DIGEST_SHA
|
|
||||||
echo "GHCR_DIGEST_SHA=$(cat GHCR_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
|
|
||||||
docker buildx imagetools inspect --format '{{json .Manifest}}' index.docker.io/${{github.repository}}:$(jq -r '.target."docker-metadata-action".args.DOCKER_META_VERSION' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json) | jq -r '.digest' > DOCKERHUB_DIGEST_SHA
|
|
||||||
echo "DOCKERHUB_DIGEST_SHA=$(cat DOCKERHUB_DIGEST_SHA)" | tee -a "${GITHUB_ENV}"
|
|
||||||
cosign sign --yes $(jq --arg GHCR_DIGEST_SHA "$(cat GHCR_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("ghcr.io/${{github.repository}}")) | . + "@" + $GHCR_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
|
|
||||||
cosign sign --yes $(jq --arg DOCKERHUB_DIGEST_SHA "$(cat DOCKERHUB_DIGEST_SHA)" -cr '.target."docker-metadata-action".tags | map(select(startswith("index.docker.io/${{github.repository}}")) | . + "@" + $DOCKERHUB_DIGEST_SHA) | join(" ")' ${{ runner.temp }}/${{matrix.variant}}/bake-meta.json)
|
|
||||||
|
|
||||||
- name: Attest GHCR
|
|
||||||
uses: actions/attest-build-provenance@v4
|
|
||||||
with:
|
|
||||||
subject-name: ghcr.io/${{github.repository}}
|
|
||||||
subject-digest: ${{ env.GHCR_DIGEST_SHA }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
- name: Attest Dockerhub
|
|
||||||
uses: actions/attest-build-provenance@v4
|
|
||||||
with:
|
|
||||||
subject-name: index.docker.io/${{github.repository}}
|
|
||||||
subject-digest: ${{ env.DOCKERHUB_DIGEST_SHA }}
|
|
||||||
push-to-registry: true
|
|
||||||
|
|
||||||
linux:
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
attestations: write
|
|
||||||
packages: write
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: x86_64-unknown-linux-gnu
|
|
||||||
platform: linux/amd64
|
|
||||||
suffix: ""
|
|
||||||
build_env: ""
|
|
||||||
- target: x86_64-unknown-linux-musl
|
|
||||||
platform: linux/amd64
|
|
||||||
suffix: "-alpine"
|
|
||||||
build_env: ""
|
|
||||||
- target: aarch64-unknown-linux-gnu
|
|
||||||
platform: linux/arm64
|
|
||||||
suffix: ""
|
|
||||||
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
|
||||||
- target: aarch64-unknown-linux-musl
|
|
||||||
platform: linux/arm64
|
|
||||||
suffix: "-alpine"
|
|
||||||
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
|
||||||
- target: armv7-unknown-linux-gnueabihf
|
|
||||||
platform: linux/arm/v7
|
|
||||||
suffix: ""
|
|
||||||
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
|
||||||
- target: armv7-unknown-linux-musleabihf
|
|
||||||
platform: linux/arm/v7
|
|
||||||
suffix: "-alpine"
|
|
||||||
build_env: "JEMALLOC_SYS_WITH_LG_PAGE=16 "
|
|
||||||
- target: arm-unknown-linux-gnueabihf
|
|
||||||
platform: linux/arm/v6
|
|
||||||
suffix: ""
|
|
||||||
build_env: ""
|
|
||||||
- target: arm-unknown-linux-musleabihf
|
|
||||||
platform: linux/arm/v6
|
|
||||||
suffix: "-alpine"
|
|
||||||
build_env: ""
|
|
||||||
name: Build / ${{matrix.target}}
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
# Every `uses:` here is pinned to a full commit SHA, with the release it
|
||||||
uses: actions/checkout@v7
|
# belongs to in the trailing comment. A tag is a mutable pointer, so
|
||||||
|
# trusting `@v7` is trusting every future version of that action,
|
||||||
- name: Free disk space (heavy ARM targets)
|
# including one pushed by whoever compromises the account. Dependabot
|
||||||
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
|
# updates both halves together -- do not "simplify" a pin back to a tag.
|
||||||
run: |
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
df -h /mnt /
|
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||||
sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/lib/android /usr/local/.ghcup /usr/local/share/powershell /usr/share/swift /opt/hostedtoolcache/CodeQL
|
- name: System dependencies
|
||||||
sudo docker image prune --all --force || true
|
# foundationdb and the search backends are off by default, but the
|
||||||
df -h /mnt /
|
# default feature set still links against the system's C libraries.
|
||||||
|
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends clang
|
||||||
- name: Add swap (heavy ARM targets)
|
- name: Build the server
|
||||||
if: contains(matrix.target, 'arm') || contains(matrix.target, 'aarch64')
|
run: cargo build -p inbuxa --locked
|
||||||
run: |
|
- name: Compile every test target
|
||||||
mnt_avail=$(df --output=avail -k /mnt | tail -1)
|
# `--no-run` is the point: it builds the unit tests and the integration
|
||||||
if [ "$mnt_avail" -lt 18874368 ]; then
|
# crate together, which is the combination that resolves the test
|
||||||
echo "Insufficient space on /mnt (${mnt_avail}K available), aborting swap setup"
|
# features, and stops short of running anything that wants a store.
|
||||||
exit 1
|
run: cargo test --workspace --locked --no-run
|
||||||
fi
|
|
||||||
sudo fallocate -l 16G /mnt/swapfile
|
|
||||||
sudo chmod 600 /mnt/swapfile
|
|
||||||
sudo mkswap /mnt/swapfile
|
|
||||||
sudo swapon /mnt/swapfile
|
|
||||||
sudo sysctl vm.swappiness=80
|
|
||||||
free -h
|
|
||||||
swapon --show
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v4
|
|
||||||
with:
|
|
||||||
platforms: "arm64,arm"
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v4
|
|
||||||
with:
|
|
||||||
buildkitd-config-inline: |
|
|
||||||
[registry."docker.io"]
|
|
||||||
mirrors = ["https://mirror.gcr.io"]
|
|
||||||
driver-opts: |
|
|
||||||
network=host
|
|
||||||
|
|
||||||
- name: Log In to GitHub Container Registry
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
registry: ghcr.io
|
|
||||||
username: ${{github.repository_owner}}
|
|
||||||
password: ${{github.token}}
|
|
||||||
|
|
||||||
- name: Log In to DockerHub
|
|
||||||
uses: docker/login-action@v4
|
|
||||||
with:
|
|
||||||
username: ${{secrets.DOCKERHUB_USERNAME}}
|
|
||||||
password: ${{secrets.DOCKERHUB_TOKEN}}
|
|
||||||
|
|
||||||
- name: Calculate shasum of external deps
|
|
||||||
id: cal-dep-shasum
|
|
||||||
run: |
|
|
||||||
echo "checksum=$(yq -p toml -oy '.package[] | select((.source | contains("")) or (.checksum | contains("")))' Cargo.lock | sha256sum | awk '{print $1}')" >> "$GITHUB_OUTPUT"
|
|
||||||
|
|
||||||
- name: Cache apt
|
|
||||||
uses: actions/[email protected]
|
|
||||||
id: apt-cache
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
var-cache-apt
|
|
||||||
var-lib-apt
|
|
||||||
key: apt-cache-${{ hashFiles('Dockerfile.build') }}
|
|
||||||
|
|
||||||
- name: Cache Cargo
|
|
||||||
uses: actions/[email protected]
|
|
||||||
id: cargo-cache
|
|
||||||
with:
|
|
||||||
path: |
|
|
||||||
usr-local-cargo-registry
|
|
||||||
usr-local-cargo-git
|
|
||||||
key: cargo-cache-${{ steps.cal-dep-shasum.outputs.checksum }}
|
|
||||||
|
|
||||||
- name: Inject cache into docker
|
|
||||||
uses: reproducible-containers/[email protected]
|
|
||||||
with:
|
|
||||||
cache-map: |
|
|
||||||
{
|
|
||||||
"var-cache-apt": "/var/cache/apt",
|
|
||||||
"var-lib-apt": "/var/lib/apt",
|
|
||||||
"usr-local-cargo-registry": "/usr/local/cargo/registry",
|
|
||||||
"usr-local-cargo-git": "/usr/local/cargo/git"
|
|
||||||
}
|
|
||||||
skip-extraction: ${{ steps.cargo-cache.outputs.cache-hit }} && ${{ steps.apt-cache.outputs.cache-hit }}
|
|
||||||
|
|
||||||
- name: Extract Metadata for Docker
|
|
||||||
uses: docker/metadata-action@v6
|
|
||||||
id: meta
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
index.docker.io/${{github.repository}}
|
|
||||||
ghcr.io/${{github.repository}}
|
|
||||||
flavor: |
|
|
||||||
suffix=${{matrix.suffix}},onlatest=true
|
|
||||||
tags: |
|
|
||||||
type=ref,event=tag
|
|
||||||
type=ref,event=branch,prefix=branch-
|
|
||||||
type=edge,branch=main
|
|
||||||
type=semver,pattern=v{{major}}.{{minor}}
|
|
||||||
|
|
||||||
- name: Build Artifact
|
|
||||||
id: bake
|
|
||||||
uses: docker/bake-action@v7
|
|
||||||
env:
|
|
||||||
DOCKER_BUILD_RECORD_UPLOAD: false
|
|
||||||
TARGET: ${{matrix.target}}
|
|
||||||
GHCR_REPO: ghcr.io/${{github.repository}}
|
|
||||||
BUILD_ENV: ${{matrix.build_env}}
|
|
||||||
DOCKER_PLATFORM: ${{matrix.platform}}
|
|
||||||
SUFFIX: ${{matrix.suffix}}
|
|
||||||
with:
|
|
||||||
source: .
|
|
||||||
set: |
|
|
||||||
*.tags=
|
|
||||||
image.output=type=image,"name=ghcr.io/${{github.repository}},index.docker.io/${{github.repository}}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
|
||||||
files: |
|
|
||||||
docker-bake.hcl
|
|
||||||
${{ steps.meta.outputs.bake-file }}
|
|
||||||
targets: ${{(github.event_name == 'push' || inputs.Docker) && 'build,image' || 'build'}}
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
|
||||||
uses: actions/[email protected]
|
|
||||||
with:
|
|
||||||
name: artifact-${{matrix.target}}
|
|
||||||
path: |
|
|
||||||
artifact
|
|
||||||
!artifact/*.json
|
|
||||||
|
|
||||||
- name: Export digest & Rename meta bake definition file
|
|
||||||
if: github.event_name == 'push' || inputs.Docker
|
|
||||||
run: |
|
|
||||||
mv "${{ steps.meta.outputs.bake-file }}" "${{ runner.temp }}/bake-meta.json"
|
|
||||||
mkdir -p ${{ runner.temp }}/digests
|
|
||||||
digest="${{ fromJSON(steps.bake.outputs.metadata).image['containerimage.digest'] }}"
|
|
||||||
touch "${{ runner.temp }}/digests/${digest#sha256:}"
|
|
||||||
|
|
||||||
- name: Upload digest
|
|
||||||
if: github.event_name == 'push' || inputs.Docker
|
|
||||||
uses: actions/[email protected]
|
|
||||||
with:
|
|
||||||
name: digests-${{matrix.suffix == '' && 'gnu' || 'musl'}}-${{ matrix.target }}
|
|
||||||
path: ${{ runner.temp }}/digests/*
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Upload GNU meta bake definition
|
|
||||||
uses: actions/[email protected]
|
|
||||||
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'gnu') && startsWith(matrix.target,'x86')
|
|
||||||
with:
|
|
||||||
name: bake-meta-gnu
|
|
||||||
path: ${{ runner.temp }}/bake-meta.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
- name: Upload musl meta bake definition
|
|
||||||
uses: actions/[email protected]
|
|
||||||
if: (github.event_name == 'push' || inputs.Docker) && endsWith(matrix.target,'musl') && startsWith(matrix.target,'x86')
|
|
||||||
with:
|
|
||||||
name: bake-meta-musl
|
|
||||||
path: ${{ runner.temp }}/bake-meta.json
|
|
||||||
if-no-files-found: error
|
|
||||||
retention-days: 1
|
|
||||||
|
|
||||||
windows:
|
|
||||||
name: Build / ${{matrix.target}}
|
|
||||||
runs-on: windows-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
# - target: aarch64-pc-windows-msvc
|
|
||||||
- target: x86_64-pc-windows-msvc
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Run sccache-cache
|
|
||||||
uses: mozilla-actions/[email protected]
|
|
||||||
with:
|
|
||||||
disable_annotations: true
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
rustup target add ${{matrix.target}}
|
|
||||||
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
|
||||||
mkdir -p artifacts
|
|
||||||
mv ./target/${{matrix.target}}/release/stalwart.exe ./artifacts/stalwart.exe
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
|
||||||
uses: actions/[email protected]
|
|
||||||
with:
|
|
||||||
name: artifact-${{matrix.target}}
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
macos:
|
|
||||||
name: Build / ${{matrix.target}}
|
|
||||||
runs-on: macos-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: aarch64-apple-darwin
|
|
||||||
- target: x86_64-apple-darwin
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Run sccache-cache
|
|
||||||
uses: mozilla-actions/[email protected]
|
|
||||||
with:
|
|
||||||
disable_annotations: true
|
|
||||||
|
|
||||||
#- name: Build FoundationDB Edition
|
|
||||||
# env:
|
|
||||||
# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
# run: |
|
|
||||||
# rustup target add ${{matrix.target}}
|
|
||||||
# # Pin FoundationDB 7.4.x (Apple publishes these as prereleases)
|
|
||||||
# curl --retry 5 -Lso foundationdb.pkg "$(gh api -X GET /repos/apple/foundationdb/releases --jq '[.[] | select(.tag_name | startswith("7.4."))] | sort_by(.tag_name | split(".") | map(tonumber)) | reverse | .[0].assets[] | select(.name | test("${{startsWith(matrix.target, 'x86') && 'x86_64' || 'arm64'}}" + ".pkg$")) | .browser_download_url')"
|
|
||||||
# echo "=== Package contents ==="
|
|
||||||
# pkgutil --payload-files foundationdb.pkg || true
|
|
||||||
# sudo installer -allowUntrusted -verbose -dumplog -pkg foundationdb.pkg -target /
|
|
||||||
# cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "foundationdb s3 redis nats"
|
|
||||||
# mkdir -p artifacts
|
|
||||||
# mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart-foundationdb
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
run: |
|
|
||||||
rustup target add ${{matrix.target}}
|
|
||||||
cargo build --release --target ${{matrix.target}} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
|
||||||
mkdir -p artifacts
|
|
||||||
mv ./target/${{matrix.target}}/release/stalwart ./artifacts/stalwart
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
|
||||||
uses: actions/[email protected]
|
|
||||||
with:
|
|
||||||
name: artifact-${{matrix.target}}
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
freebsd:
|
|
||||||
name: Build / ${{matrix.target}}
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 360
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- target: x86_64-unknown-freebsd
|
|
||||||
arch: x86_64
|
|
||||||
# - target: aarch64-unknown-freebsd
|
|
||||||
# arch: aarch64
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Build in FreeBSD VM
|
|
||||||
uses: vmactions/freebsd-vm@v1
|
|
||||||
with:
|
|
||||||
release: "15.1"
|
|
||||||
arch: ${{matrix.arch}}
|
|
||||||
usesh: true
|
|
||||||
mem: 14336
|
|
||||||
cpu: 4
|
|
||||||
sync: rsync
|
|
||||||
copyback: true
|
|
||||||
# gmake: required by jemalloc-sys on BSD hosts
|
|
||||||
# llvm: provides libclang for bindgen (librocksdb-sys)
|
|
||||||
# rust: libsqlite3-sys 0.38 uses cfg_select!, stabilized in Rust
|
|
||||||
# 1.95. The default 'quarterly' pkg repo still ships rust 1.94, so
|
|
||||||
# switch to the 'latest' repo (currently 1.96.1). rustup is not an
|
|
||||||
# option here: aarch64-unknown-freebsd has no rustup toolchains yet.
|
|
||||||
prepare: |
|
|
||||||
set -e
|
|
||||||
mkdir -p /usr/local/etc/pkg/repos
|
|
||||||
echo 'FreeBSD: { url: "pkg+https://pkg.freebsd.org/${ABI}/latest", mirror_type: "srv" }' > /usr/local/etc/pkg/repos/FreeBSD.conf
|
|
||||||
pkg update -f
|
|
||||||
env ASSUME_ALWAYS_YES=yes pkg bootstrap -f
|
|
||||||
pkg update -f
|
|
||||||
pkg install -y rust gmake llvm rocksdb
|
|
||||||
rustc --version
|
|
||||||
run: |
|
|
||||||
set -e
|
|
||||||
export CARGO_TARGET_DIR=/tmp/target
|
|
||||||
export CARGO_TERM_COLOR=always
|
|
||||||
export CARGO_NET_RETRY=10
|
|
||||||
cargo build --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
|
||||||
mkdir -p artifacts
|
|
||||||
cp /tmp/target/release/stalwart artifacts/stalwart
|
|
||||||
|
|
||||||
- name: Upload Artifacts
|
|
||||||
uses: actions/[email protected]
|
|
||||||
with:
|
|
||||||
name: artifact-${{matrix.target}}
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
release:
|
|
||||||
name: Release
|
|
||||||
permissions:
|
|
||||||
id-token: write
|
|
||||||
contents: write
|
|
||||||
attestations: write
|
|
||||||
if: github.event_name == 'push' || inputs.Release
|
|
||||||
needs: [linux, windows, macos, freebsd]
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
# Must run before artifacts are downloaded — checkout cleans the workspace.
|
|
||||||
- name: Checkout (for CHANGELOG)
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
uses: actions/checkout@v7
|
|
||||||
|
|
||||||
- name: Download Artifacts
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
path: archive
|
|
||||||
pattern: artifact-*
|
|
||||||
|
|
||||||
- name: Compress
|
|
||||||
run: |
|
|
||||||
set -eux
|
|
||||||
BASE_DIR="$(pwd)/archive"
|
|
||||||
compress_files() {
|
|
||||||
local dir="$1"
|
|
||||||
local archive_dir_name="${dir#artifact-}"
|
|
||||||
cd "$dir"
|
|
||||||
# Process each file in the directory
|
|
||||||
for file in `ls`; do
|
|
||||||
filename="${file%.*}"
|
|
||||||
extension="${file##*.}"
|
|
||||||
if [ "$extension" = "exe" ]; then
|
|
||||||
7z a -tzip "${filename}-${archive_dir_name}.zip" "$file" > /dev/null
|
|
||||||
else
|
|
||||||
tar -czf "${filename}-${archive_dir_name}.tar.gz" "$file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
cd $BASE_DIR
|
|
||||||
}
|
|
||||||
cd $BASE_DIR
|
|
||||||
for arch_dir in `ls`; do
|
|
||||||
dir_name=$(basename "$arch_dir")
|
|
||||||
compress_files "$dir_name"
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Attest binary
|
|
||||||
id: attest
|
|
||||||
uses: actions/attest-build-provenance@v4
|
|
||||||
with:
|
|
||||||
subject-path: |
|
|
||||||
archive/**/*.tar.gz
|
|
||||||
archive/**/*.zip
|
|
||||||
|
|
||||||
- name: Use cosign to sign existing artifacts
|
|
||||||
uses: sigstore/[email protected]
|
|
||||||
with:
|
|
||||||
inputs: |
|
|
||||||
archive/**/*.tar.gz
|
|
||||||
archive/**/*.zip
|
|
||||||
|
|
||||||
- name: Build release body
|
|
||||||
run: |
|
|
||||||
if [ "${{ startsWith(github.ref, 'refs/tags/') }}" = "true" ]; then
|
|
||||||
awk '/^## \[/{c++} c==1' CHANGELOG.md > release_body.md
|
|
||||||
echo "" >> release_body.md
|
|
||||||
else
|
|
||||||
: > release_body.md
|
|
||||||
fi
|
|
||||||
cat >> release_body.md <<EOF
|
|
||||||
<hr />
|
|
||||||
|
|
||||||
### Check binary attestation [here](${{ steps.attest.outputs.attestation-url }})
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v3
|
|
||||||
with:
|
|
||||||
files: |
|
|
||||||
archive/**/*.tar.gz
|
|
||||||
archive/**/*.zip
|
|
||||||
archive/**/*.sigstore.json
|
|
||||||
prerelease: ${{!startsWith(github.ref, 'refs/tags/') || null}}
|
|
||||||
tag_name: ${{!startsWith(github.ref, 'refs/tags/') && 'nightly' || null}}
|
|
||||||
# Tag-push releases are created as drafts; the `publish` job un-drafts
|
|
||||||
# them only after all build jobs succeed, so watcher notifications
|
|
||||||
# don't fire on broken builds.
|
|
||||||
draft: ${{ startsWith(github.ref, 'refs/tags/') || null }}
|
|
||||||
body_path: release_body.md
|
|
||||||
|
|
||||||
publish:
|
|
||||||
name: Publish release
|
|
||||||
needs: [linux, windows, macos, freebsd, multiarch, release]
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Un-draft release
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: gh release edit "${{ github.ref_name }}" --draft=false --latest --repo "${{ github.repository }}"
|
|
||||||
|
|
||||||
cleanup:
|
|
||||||
name: Cleanup failed release
|
|
||||||
needs: [linux, windows, macos, freebsd, multiarch, release]
|
|
||||||
if: failure() && startsWith(github.ref, 'refs/tags/') && github.run_attempt >= 3
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Delete draft release and tag
|
|
||||||
env:
|
|
||||||
GH_TOKEN: ${{ github.token }}
|
|
||||||
run: gh release delete "${{ github.ref_name }}" --yes --cleanup-tag --repo "${{ github.repository }}" || true
|
|
||||||
|
|||||||
@@ -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
|
||||||
+11
@@ -1,4 +1,7 @@
|
|||||||
/target
|
/target
|
||||||
|
# Release binaries built by hand: ~100 MB each, and a public repository is
|
||||||
|
# the wrong place for them.
|
||||||
|
/artifact
|
||||||
*.failed
|
*.failed
|
||||||
*_failed
|
*_failed
|
||||||
run.sh
|
run.sh
|
||||||
@@ -6,4 +9,12 @@ run.sh
|
|||||||
!.gitignore
|
!.gitignore
|
||||||
!.gitattributes
|
!.gitattributes
|
||||||
!.github
|
!.github
|
||||||
|
!.gitlab-ci.yml
|
||||||
|
!.gitea
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
|
||||||
|
# The cutover rehearsal writes its fixture and state here.
|
||||||
|
tools/fork/cutover-rehearsal/__pycache__/
|
||||||
|
tools/fork/cutover-rehearsal/before.json
|
||||||
|
tools/fork/cutover-rehearsal/phase2_notes.json
|
||||||
|
tools/fork/__pycache__/
|
||||||
|
|||||||
@@ -2,39 +2,6 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## [0.16.23] - 2026-09-21
|
|
||||||
|
|
||||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
|
||||||
|
|
||||||
## Added
|
|
||||||
- Expressions: `bit_and` function.
|
|
||||||
|
|
||||||
## Changed
|
|
||||||
|
|
||||||
## Fixed
|
|
||||||
- MTA:
|
|
||||||
- A mailing list whose recipients include another mailing list is accepted at `RCPT TO` and then rejected at local delivery with `550 5.5.0 Mailbox not found`.
|
|
||||||
- DMARC aggregate reports carry two `spf` elements per record and the `version` element of a DMARC aggregate report is written as `1` instead of `1.0`.
|
|
||||||
- DSNs generated for an alias rewrite or a list expansion emit a doubled `addr-type` in `Original-Recipient` (`rfc822;rfc822;[email protected]`).
|
|
||||||
- DSNs that cannot be written to the store are discarded, the recipients are flagged as notified and the original message is removed from the queue, losing both the bounce and the message.
|
|
||||||
- POP3:
|
|
||||||
- `TOP msg n` counts the `n` lines from the first byte of the message instead of from the first byte of the body.
|
|
||||||
- A message whose very first line begins with `.` is not byte-stuffed.
|
|
||||||
- Spam filter: Moving or copying a message from one account into another creates no training sample, so the classifier never learns from it.
|
|
||||||
- Sieve: `envelope "orcpt"` yields the bare address for an `ORCPT` supplied over SMTP. It now carries the `addr-type` prefix in every case, as required by RFC 6009.
|
|
||||||
- ACME: The `_acme-challenge` TXT records published for a DNS-01 authorization are never removed.
|
|
||||||
- DNS: The DNSSEC resolver queries a single nameserver at a time, working around a `hickory-resolver` race that cancels the TCP retry when two nameservers return a truncated response in parallel.
|
|
||||||
- Troubleshoot tool:
|
|
||||||
- MX records are resolved through the DNSSEC-validating resolver, matching the resolver used by the delivery path.
|
|
||||||
- A TLSA lookup that fails or returns bogus records stops the delivery attempt for that host, instead of continuing without DANE.
|
|
||||||
- OIDC: Bearer tokens that carry no `email`, `preferred_username` or `upn` claim are always authenticated against the default directory.
|
|
||||||
- Meilisearch: A confirmation timeout is treated as a failed write even when `failOnTimeout` is disabled, so an index whose batches take longer than `pollInterval` x `maxRetries` never completes an indexing task and resubmits the same batch indefinitely.
|
|
||||||
- WebUI: A failed update no longer takes an `Application` offline.
|
|
||||||
- FoundationDB: The cached read version is invalidated when any broadcast is received from another node.
|
|
||||||
- Redis:
|
|
||||||
- On a cluster, the rate limiter and the blob upload quota issue `INCR` and `EXPIRE` as a `MULTI`/`EXEC` transaction, whose `MOVED` redirects collapse into a single `EXECABORT` that never refreshes the slot map.
|
|
||||||
- A connection that fails because it is addressing the wrong server is returned to the pool and reused, since the recycle check only issues `PING`.
|
|
||||||
|
|
||||||
## [0.16.22] - 2026-09-13
|
## [0.16.22] - 2026-09-13
|
||||||
|
|
||||||
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions.
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# Contributor Covenant Code of Conduct
|
||||||
|
|
||||||
|
## Our Pledge
|
||||||
|
|
||||||
|
We as members, contributors, and leaders pledge to make participation in our
|
||||||
|
community a harassment-free experience for everyone, regardless of age, body
|
||||||
|
size, visible or invisible disability, ethnicity, sex characteristics, gender
|
||||||
|
identity and expression, level of experience, education, socio-economic status,
|
||||||
|
nationality, personal appearance, race, religion, or sexual identity
|
||||||
|
and orientation.
|
||||||
|
|
||||||
|
We pledge to act and interact in ways that contribute to an open, welcoming,
|
||||||
|
diverse, inclusive, and healthy community.
|
||||||
|
|
||||||
|
## Our Standards
|
||||||
|
|
||||||
|
Examples of behavior that contributes to a positive environment for our
|
||||||
|
community include:
|
||||||
|
|
||||||
|
* Demonstrating empathy and kindness toward other people
|
||||||
|
* Being respectful of differing opinions, viewpoints, and experiences
|
||||||
|
* Giving and gracefully accepting constructive feedback
|
||||||
|
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||||
|
and learning from the experience
|
||||||
|
* Focusing on what is best not just for us as individuals, but for the
|
||||||
|
overall community
|
||||||
|
|
||||||
|
Examples of unacceptable behavior include:
|
||||||
|
|
||||||
|
* The use of sexualized language or imagery, and sexual attention or
|
||||||
|
advances of any kind
|
||||||
|
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||||
|
* Public or private harassment
|
||||||
|
* Publishing others' private information, such as a physical or email
|
||||||
|
address, without their explicit permission
|
||||||
|
* Other conduct which could reasonably be considered inappropriate in a
|
||||||
|
professional setting
|
||||||
|
|
||||||
|
## Enforcement Responsibilities
|
||||||
|
|
||||||
|
Community leaders are responsible for clarifying and enforcing our standards of
|
||||||
|
acceptable behavior and will take appropriate and fair corrective action in
|
||||||
|
response to any behavior that they deem inappropriate, threatening, offensive,
|
||||||
|
or harmful.
|
||||||
|
|
||||||
|
Community leaders have the right and responsibility to remove, edit, or reject
|
||||||
|
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||||
|
not aligned to this Code of Conduct, and will communicate reasons for moderation
|
||||||
|
decisions when appropriate.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
This Code of Conduct applies within all community spaces, and also applies when
|
||||||
|
an individual is officially representing the community in public spaces.
|
||||||
|
Examples of representing our community include using an official e-mail address,
|
||||||
|
posting via an official social media account, or acting as an appointed
|
||||||
|
representative at an online or offline event.
|
||||||
|
|
||||||
|
## Enforcement
|
||||||
|
|
||||||
|
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||||
|
reported to the community leaders responsible for enforcement at
|
||||||
|
**johnellisATlinuxDOTcom**.
|
||||||
|
All complaints will be reviewed and investigated promptly and fairly.
|
||||||
|
|
||||||
|
All community leaders are obligated to respect the privacy and security of the
|
||||||
|
reporter of any incident.
|
||||||
|
|
||||||
|
## Enforcement Guidelines
|
||||||
|
|
||||||
|
Community leaders will follow these Community Impact Guidelines in determining
|
||||||
|
the consequences for any action they deem in violation of this Code of Conduct:
|
||||||
|
|
||||||
|
### 1. Correction
|
||||||
|
|
||||||
|
**Community Impact**: Use of inappropriate language or other behavior deemed
|
||||||
|
unprofessional or unwelcome in the community.
|
||||||
|
|
||||||
|
**Consequence**: A private, written warning from community leaders, providing
|
||||||
|
clarity around the nature of the violation and an explanation of why the
|
||||||
|
behavior was inappropriate. A public apology may be requested.
|
||||||
|
|
||||||
|
### 2. Warning
|
||||||
|
|
||||||
|
**Community Impact**: A violation through a single incident or series
|
||||||
|
of actions.
|
||||||
|
|
||||||
|
**Consequence**: A warning with consequences for continued behavior. No
|
||||||
|
interaction with the people involved, including unsolicited interaction with
|
||||||
|
those enforcing the Code of Conduct, for a specified period of time. This
|
||||||
|
includes avoiding interactions in community spaces as well as external channels
|
||||||
|
like social media. Violating these terms may lead to a temporary or
|
||||||
|
permanent ban.
|
||||||
|
|
||||||
|
### 3. Temporary Ban
|
||||||
|
|
||||||
|
**Community Impact**: A serious violation of community standards, including
|
||||||
|
sustained inappropriate behavior.
|
||||||
|
|
||||||
|
**Consequence**: A temporary ban from any sort of interaction or public
|
||||||
|
communication with the community for a specified period of time. No public or
|
||||||
|
private interaction with the people involved, including unsolicited interaction
|
||||||
|
with those enforcing the Code of Conduct, is allowed during this period.
|
||||||
|
Violating these terms may lead to a permanent ban.
|
||||||
|
|
||||||
|
### 4. Permanent Ban
|
||||||
|
|
||||||
|
**Community Impact**: Demonstrating a pattern of violation of community
|
||||||
|
standards, including sustained inappropriate behavior, harassment of an
|
||||||
|
individual, or aggression toward or disparagement of classes of individuals.
|
||||||
|
|
||||||
|
**Consequence**: A permanent ban from any sort of public interaction within
|
||||||
|
the community.
|
||||||
|
|
||||||
|
## Attribution
|
||||||
|
|
||||||
|
This Code of Conduct is adapted from the [Contributor Covenant][homepage],
|
||||||
|
version 2.0, available at
|
||||||
|
https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
|
||||||
|
|
||||||
|
Community Impact Guidelines were inspired by [Mozilla's code of conduct
|
||||||
|
enforcement ladder](https://github.com/mozilla/diversity).
|
||||||
|
|
||||||
|
[homepage]: https://www.contributor-covenant.org
|
||||||
|
|
||||||
|
For answers to common questions about this code of conduct, see the FAQ at
|
||||||
|
https://www.contributor-covenant.org/faq. Translations are available at
|
||||||
|
https://www.contributor-covenant.org/translations.
|
||||||
+62
-39
@@ -1,61 +1,84 @@
|
|||||||
# Contributing
|
# Contributing
|
||||||
|
|
||||||
Thank you for your interest in contributing to Stalwart. We appreciate the support and enthusiasm of the open-source community. To keep the project maintainable and the review process sustainable, contributions are subject to the policies described below. Please read them in full before opening a pull request.
|
Patches, bug reports and questions are welcome.
|
||||||
|
|
||||||
## Vouched Contributors Only
|
## Before a pull request
|
||||||
|
|
||||||
Due to the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors. Pull requests opened by anyone who is not on this list are closed automatically.
|
**Open an issue first for anything substantial.** A feature or a refactor is
|
||||||
|
worth agreeing on before it is written, because this is a fork that tracks
|
||||||
|
upstream: a change that moves code around costs a conflict on every import,
|
||||||
|
and it should be worth that.
|
||||||
|
|
||||||
To be added as a vouched contributor, post a message at [support.stalw.art](https://support.stalw.art) explaining the code changes you would like to submit, and include a link to the proposed change (a branch, diff, or draft). Once a maintainer has reviewed your request and vouched for you, you will be able to open pull requests directly.
|
Small fixes — a bug, a typo, a test — need no ceremony. Send them.
|
||||||
|
|
||||||
This policy lets us focus limited review capacity on contributions from people who have taken the time to understand the codebase and discuss their changes first.
|
## How a change lands
|
||||||
|
|
||||||
## What Contributions Are Accepted
|
`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.
|
||||||
|
|
||||||
At this stage of the project we accept a narrow set of contributions:
|
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.
|
||||||
|
|
||||||
- **Bug fixes.** Corrections to existing, incorrect behavior are welcome. Please include steps to reproduce the bug and describe the fix.
|
Releases are cut weekly from `main` by `.github/workflows/release.yml`, on
|
||||||
- **Translations.** Additions and corrections to existing translations are welcome.
|
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.
|
||||||
|
|
||||||
New features are generally **not** accepted, unless they involve only a few lines of code. Larger features fall outside the scope of what we can review and integrate while the architecture is still evolving.
|
## What this repository is
|
||||||
|
|
||||||
If you would like to see a new feature, please request it at [support.stalw.art](https://support.stalw.art) under the **Feature Ideas** category rather than opening a pull request. This lets the community discuss and prioritize ideas before any code is written.
|
INBUXA is a fork of Stalwart, taken under the AGPL-3.0-only half of its dual
|
||||||
|
licence, with nine features rebuilt independently. Two things follow:
|
||||||
|
|
||||||
## No AI-Generated Code
|
- **The clean room is real.** The rebuilt features in `crates/features` were
|
||||||
|
written from specifications in `docs/spec/features/`, by people who had not
|
||||||
|
read Stalwart's Enterprise source. If you have read it, say so in the pull
|
||||||
|
request and it will be reviewed with that in mind, or declined for the parts
|
||||||
|
it touches. Nothing about this is personal: the project's defence of
|
||||||
|
independent creation is a record, and the record has to be true.
|
||||||
|
- **Upstream files stay recognisable.** Changes to files that came from
|
||||||
|
upstream are kept small and marked with an `inbuxa:` comment saying which
|
||||||
|
requirement they serve, so the next import merges cleanly and a reader can
|
||||||
|
tell fork from base. New work belongs in the fork's own crates where it can.
|
||||||
|
|
||||||
AI-generated code is not accepted in this project.
|
## Licence and provenance
|
||||||
|
|
||||||
Even the most advanced models write inefficient Rust code. Beyond raw performance, AI creates technical debt by generating large amounts of code that not even the authors who submitted it can fully understand or maintain. Reviewing and untangling such contributions costs the maintainers far more time than it saves.
|
Contributions are under AGPL-3.0-only. Keep upstream's copyright headers where
|
||||||
|
they are; if you change a file that came from upstream, leave its "Modified by
|
||||||
|
Coffey Labs" line in place. New files carry:
|
||||||
|
|
||||||
Using AI as a fancy autocomplete is perfectly fine. What matters is that every line generated by a model is read, understood, and reviewed by a human before it is submitted. You are responsible for every line in your pull request, regardless of how it was produced. If you cannot explain why a change is written the way it is, it is not ready to be submitted.
|
```
|
||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
```
|
||||||
|
|
||||||
## Pull Request Process
|
If you bring in code from another project, it stays under its own licence and
|
||||||
|
its notice goes in `THIRD-PARTY.md`. `tools/fork/strip.py` reports any file
|
||||||
|
that is missing from there on every import.
|
||||||
|
|
||||||
Once you are a vouched contributor:
|
## Running the tests
|
||||||
|
|
||||||
1. Keep each pull request small and focused on a single logical change.
|
`cargo test -p tests` runs what needs nothing but a store on disk. The rest
|
||||||
2. Match the style and conventions of the surrounding code.
|
need containers, a particular backend, or a copy of real data, and are
|
||||||
3. Make sure the project builds and the test suite passes before opening the pull request.
|
`#[ignore]`d:
|
||||||
4. In the pull request description, explain what the change does and why, and link to the [support.stalw.art](https://support.stalw.art) discussion where the change was vouched.
|
|
||||||
|
|
||||||
## Code of Conduct
|
- `docs/spec/container-tests.md` — the suites that need containers, with the
|
||||||
|
`STORE` each one wants and what a plain regression leaves failing.
|
||||||
|
- `docs/spec/compat-tests.md` — the compatibility set, which needs a copy of a
|
||||||
|
real server's data.
|
||||||
|
|
||||||
We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community.
|
Run one suite at a time. They bind fixed ports, and the timing checks flake if
|
||||||
|
two run at once.
|
||||||
|
|
||||||
You can read the full Code of Conduct [here](https://github.com/stalwartlabs/.github/blob/main/CODE_OF_CONDUCT.md).
|
## Commit messages
|
||||||
|
|
||||||
## Licensing
|
Say what changed and why, in prose, wrapped at 72 characters or so. The why is
|
||||||
|
the part that is hard to recover later. No tool trailers.
|
||||||
This project is licensed under the Affero General Public License (AGPL) version 3.0. By contributing to this project, you agree that your contributions will be licensed under the AGPL-3.0 license.
|
|
||||||
|
|
||||||
## Fiduciary Contributor License Agreement
|
|
||||||
|
|
||||||
Before making any contributions, all contributors are required to sign the Fiduciary Contributor License Agreement (FLA). The FLA is a legal agreement that assigns the copyright of contributions to a designated fiduciary, who manages these rights on behalf of the project. This arrangement ensures that the software remains free and open, even as contributors come and go.
|
|
||||||
|
|
||||||
Key points of the FLA:
|
|
||||||
|
|
||||||
- Ensures the software remains free and open source
|
|
||||||
- Protects the project from potential copyright issues
|
|
||||||
- Includes a reversion clause: if the fiduciary violates Free Software principles, rights revert to the original contributors
|
|
||||||
|
|
||||||
For more details about the FLA, please refer to the [FLA FAQ](https://fsfe.org/activities/fla/fla.en.html).
|
|
||||||
|
|||||||
Generated
+206
-176
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ members = [
|
|||||||
"crates/common",
|
"crates/common",
|
||||||
"crates/trc",
|
"crates/trc",
|
||||||
"crates/migration",
|
"crates/migration",
|
||||||
|
"crates/features",
|
||||||
"tests",
|
"tests",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -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
|
WORKDIR /build
|
||||||
|
|
||||||
FROM --platform=$BUILDPLATFORM chef AS planner
|
FROM --platform=$BUILDPLATFORM chef AS planner
|
||||||
@@ -21,7 +21,7 @@ RUN rustup target add "$(cat /target.txt)"
|
|||||||
COPY --from=planner /recipe.json /recipe.json
|
COPY --from=planner /recipe.json /recipe.json
|
||||||
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
|
RUN RUSTFLAGS="$(cat /flags.txt)" cargo chef cook --target "$(cat /target.txt)" --release --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" --recipe-path /recipe.json
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
RUN RUSTFLAGS="$(cat /flags.txt)" cargo build --target "$(cat /target.txt)" --release -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
||||||
RUN mv "/build/target/$(cat /target.txt)/release" "/output"
|
RUN mv "/build/target/$(cat /target.txt)/release" "/output"
|
||||||
|
|
||||||
FROM docker.io/debian:trixie-slim
|
FROM docker.io/debian:trixie-slim
|
||||||
@@ -29,18 +29,18 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
|
|||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \
|
apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
groupadd -r -g 2000 stalwart && \
|
groupadd -r -g 2000 inbuxa && \
|
||||||
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \
|
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M inbuxa && \
|
||||||
mkdir -p /etc/stalwart /var/lib/stalwart && \
|
mkdir -p /etc/inbuxa /var/lib/inbuxa && \
|
||||||
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart
|
chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa
|
||||||
COPY --from=builder --chmod=0755 /output/stalwart /usr/local/bin/stalwart
|
COPY --from=builder --chmod=0755 /output/inbuxa /usr/local/bin/inbuxa
|
||||||
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart
|
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
|
||||||
USER stalwart
|
USER inbuxa
|
||||||
WORKDIR /var/lib/stalwart
|
WORKDIR /var/lib/inbuxa
|
||||||
VOLUME ["/etc/stalwart", "/var/lib/stalwart"]
|
VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
|
||||||
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
||||||
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/stalwart"]
|
ENTRYPOINT ["/usr/local/bin/inbuxa"]
|
||||||
CMD ["--config", "/etc/stalwart/config.json"]
|
CMD ["--config", "/etc/inbuxa/config.json"]
|
||||||
|
|||||||
+32
-32
@@ -108,7 +108,7 @@ RUN \
|
|||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
source /env-cargo && \
|
source /env-cargo && \
|
||||||
if [ ! -z "${FDB_ARCH}" ]; then \
|
if [ ! -z "${FDB_ARCH}" ]; then \
|
||||||
RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats"; \
|
RUSTFLAGS="-L /usr/lib" cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "foundationdb s3 redis nats"; \
|
||||||
fi
|
fi
|
||||||
RUN \
|
RUN \
|
||||||
--mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \
|
--mount=type=secret,id=ACTIONS_RESULTS_URL,env=ACTIONS_RESULTS_URL \
|
||||||
@@ -116,7 +116,7 @@ RUN \
|
|||||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
source /env-cargo && \
|
source /env-cargo && \
|
||||||
cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
cargo chef cook --recipe-path recipe.json --zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats"
|
||||||
# Copy the source code
|
# Copy the source code
|
||||||
COPY . .
|
COPY . .
|
||||||
ENV RUSTC_WRAPPER="sccache" \
|
ENV RUSTC_WRAPPER="sccache" \
|
||||||
@@ -129,8 +129,8 @@ RUN \
|
|||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
source /env-cargo && \
|
source /env-cargo && \
|
||||||
if [ ! -z "${FDB_ARCH}" ]; then \
|
if [ ! -z "${FDB_ARCH}" ]; then \
|
||||||
RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "foundationdb s3 redis nats" && \
|
RUSTFLAGS="-L /usr/lib" cargo zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "foundationdb s3 redis nats" && \
|
||||||
mv /app/target/${TARGET}/release/stalwart /app/artifact/stalwart-foundationdb; \
|
mv /app/target/${TARGET}/release/inbuxa /app/artifact/inbuxa-foundationdb; \
|
||||||
fi
|
fi
|
||||||
# Build generic version
|
# Build generic version
|
||||||
RUN \
|
RUN \
|
||||||
@@ -139,8 +139,8 @@ RUN \
|
|||||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||||
--mount=type=cache,target=/usr/local/cargo/git \
|
--mount=type=cache,target=/usr/local/cargo/git \
|
||||||
source /env-cargo && \
|
source /env-cargo && \
|
||||||
cargo zigbuild --release --target ${TARGET} -p stalwart --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" && \
|
cargo zigbuild --release --target ${TARGET} -p inbuxa --no-default-features --features "sqlite postgres mysql rocks s3 redis azure nats" && \
|
||||||
mv /app/target/${TARGET}/release/stalwart /app/artifact/stalwart
|
mv /app/target/${TARGET}/release/inbuxa /app/artifact/inbuxa
|
||||||
|
|
||||||
# *****************
|
# *****************
|
||||||
# Binary stage
|
# Binary stage
|
||||||
@@ -156,21 +156,21 @@ RUN export DEBIAN_FRONTEND=noninteractive && \
|
|||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -yq --no-install-recommends ca-certificates curl tzdata libcap2-bin && \
|
apt-get install -yq --no-install-recommends ca-certificates curl tzdata libcap2-bin && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
groupadd -r -g 2000 stalwart && \
|
groupadd -r -g 2000 inbuxa && \
|
||||||
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \
|
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M inbuxa && \
|
||||||
mkdir -p /etc/stalwart /var/lib/stalwart && \
|
mkdir -p /etc/inbuxa /var/lib/inbuxa && \
|
||||||
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart
|
chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa
|
||||||
COPY --from=builder --chmod=0755 /app/artifact/stalwart /usr/local/bin/stalwart
|
COPY --from=builder --chmod=0755 /app/artifact/inbuxa /usr/local/bin/inbuxa
|
||||||
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart
|
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
|
||||||
USER stalwart
|
USER inbuxa
|
||||||
WORKDIR /var/lib/stalwart
|
WORKDIR /var/lib/inbuxa
|
||||||
VOLUME ["/etc/stalwart", "/var/lib/stalwart"]
|
VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
|
||||||
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
||||||
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/stalwart"]
|
ENTRYPOINT ["/usr/local/bin/inbuxa"]
|
||||||
CMD ["--config", "/etc/stalwart/config.json"]
|
CMD ["--config", "/etc/inbuxa/config.json"]
|
||||||
|
|
||||||
# *****************
|
# *****************
|
||||||
# Runtime image for musl targets
|
# Runtime image for musl targets
|
||||||
@@ -178,18 +178,18 @@ CMD ["--config", "/etc/stalwart/config.json"]
|
|||||||
FROM --platform=$TARGETPLATFORM alpine AS musl
|
FROM --platform=$TARGETPLATFORM alpine AS musl
|
||||||
RUN apk add --update --no-cache ca-certificates curl tzdata libcap && \
|
RUN apk add --update --no-cache ca-certificates curl tzdata libcap && \
|
||||||
rm -rf /var/cache/apk/* && \
|
rm -rf /var/cache/apk/* && \
|
||||||
addgroup -S -g 2000 stalwart && \
|
addgroup -S -g 2000 inbuxa && \
|
||||||
adduser -S -D -H -u 2000 -G stalwart -s /sbin/nologin stalwart && \
|
adduser -S -D -H -u 2000 -G inbuxa -s /sbin/nologin inbuxa && \
|
||||||
mkdir -p /etc/stalwart /var/lib/stalwart && \
|
mkdir -p /etc/inbuxa /var/lib/inbuxa && \
|
||||||
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart
|
chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa
|
||||||
COPY --from=builder --chmod=0755 /app/artifact/stalwart /usr/local/bin/stalwart
|
COPY --from=builder --chmod=0755 /app/artifact/inbuxa /usr/local/bin/inbuxa
|
||||||
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart
|
RUN setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
|
||||||
USER stalwart
|
USER inbuxa
|
||||||
WORKDIR /var/lib/stalwart
|
WORKDIR /var/lib/inbuxa
|
||||||
VOLUME ["/etc/stalwart", "/var/lib/stalwart"]
|
VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
|
||||||
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
||||||
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/stalwart"]
|
ENTRYPOINT ["/usr/local/bin/inbuxa"]
|
||||||
CMD ["--config", "/etc/stalwart/config.json"]
|
CMD ["--config", "/etc/inbuxa/config.json"]
|
||||||
|
|||||||
+14
-14
@@ -53,28 +53,28 @@ COPY Cargo.lock .
|
|||||||
COPY crates/ crates/
|
COPY crates/ crates/
|
||||||
COPY resources/ resources/
|
COPY resources/ resources/
|
||||||
COPY tests/ tests/
|
COPY tests/ tests/
|
||||||
RUN cargo build -p stalwart --no-default-features --features "foundationdb s3 redis azure nats" --release
|
RUN cargo build -p inbuxa --no-default-features --features "foundationdb s3 redis azure nats" --release
|
||||||
|
|
||||||
FROM debian:trixie-slim AS runtime
|
FROM debian:trixie-slim AS runtime
|
||||||
|
|
||||||
COPY --from=builder --chmod=0755 /app/target/release/stalwart /usr/local/bin/stalwart
|
COPY --from=builder --chmod=0755 /app/target/release/inbuxa /usr/local/bin/inbuxa
|
||||||
COPY --from=builder /usr/lib/libfdb_c.so /usr/lib/libfdb_c.so
|
COPY --from=builder /usr/lib/libfdb_c.so /usr/lib/libfdb_c.so
|
||||||
RUN export DEBIAN_FRONTEND=noninteractive && \
|
RUN export DEBIAN_FRONTEND=noninteractive && \
|
||||||
apt-get update && \
|
apt-get update && \
|
||||||
apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \
|
apt-get install -yq --no-install-recommends ca-certificates curl libcap2-bin && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
groupadd -r -g 2000 stalwart && \
|
groupadd -r -g 2000 inbuxa && \
|
||||||
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M stalwart && \
|
useradd -r -u 2000 -g 2000 -s /usr/sbin/nologin -M inbuxa && \
|
||||||
mkdir -p /etc/stalwart /var/lib/stalwart && \
|
mkdir -p /etc/inbuxa /var/lib/inbuxa && \
|
||||||
chown stalwart:stalwart /etc/stalwart /var/lib/stalwart && \
|
chown inbuxa:inbuxa /etc/inbuxa /var/lib/inbuxa && \
|
||||||
setcap 'cap_net_bind_service=+ep' /usr/local/bin/stalwart
|
setcap 'cap_net_bind_service=+ep' /usr/local/bin/inbuxa
|
||||||
|
|
||||||
USER stalwart
|
USER inbuxa
|
||||||
WORKDIR /var/lib/stalwart
|
WORKDIR /var/lib/inbuxa
|
||||||
VOLUME ["/etc/stalwart", "/var/lib/stalwart"]
|
VOLUME ["/etc/inbuxa", "/var/lib/inbuxa"]
|
||||||
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
EXPOSE 443 25 110 587 465 143 993 995 4190 8080
|
||||||
ENV STALWART_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
ENV INBUXA_HEALTHCHECK_URL=https://127.0.0.1:443/healthz/live
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \
|
||||||
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$STALWART_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
CMD curl -fsSk -H "X-Forwarded-For: 127.0.0.1" "$INBUXA_HEALTHCHECK_URL" || curl -fsS -H "X-Forwarded-For: 127.0.0.1" http://127.0.0.1:8080/healthz/live || exit 1
|
||||||
ENTRYPOINT ["/usr/local/bin/stalwart"]
|
ENTRYPOINT ["/usr/local/bin/inbuxa"]
|
||||||
CMD ["--config", "/etc/stalwart/config.json"]
|
CMD ["--config", "/etc/inbuxa/config.json"]
|
||||||
|
|||||||
@@ -1,186 +1,70 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://stalw.art">
|
<img src="./img/brand/inbuxa-lockup-light.svg" alt="inbuxa" height="140">
|
||||||
<img src="./img/logo-red.svg" height="150">
|
|
||||||
</a>
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h3 align="center">
|
<h3 align="center">
|
||||||
Secure, scalable mail & collaboration server with comprehensive protocol support 🛡️ <br/>(IMAP, JMAP, SMTP, CalDAV, CardDAV, WebDAV)
|
A complete mail and collaboration server, every feature included, under the AGPL
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<br>
|
---
|
||||||
|
|
||||||
<p align="center">
|
**INBUXA** is a mail and collaboration server: JMAP, IMAP, POP3, SMTP,
|
||||||
<a href="https://github.com/stalwartlabs/stalwart/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/stalwartlabs/stalwart/ci.yml?style=flat-square" alt="continuous integration"></a>
|
CalDAV, CardDAV and WebDAV, in one Rust binary, with ihasmail as its web front
|
||||||
|
end. It is a fork of [Stalwart](https://github.com/stalwartlabs/stalwart).
|
||||||
<a href="https://www.gnu.org/licenses/agpl-3.0"><img src="https://img.shields.io/badge/License-AGPL_v3-blue.svg?label=license&style=flat-square" alt="License: AGPL v3"></a>
|
Project site: [inbuxa.org](https://inbuxa.org). Documentation: [docs.inbuxa.org](https://docs.inbuxa.org).
|
||||||
|
|
||||||
<a href="https://stalw.art/docs/install/get-started"><img src="https://img.shields.io/badge/read_the-docs-red?style=flat-square" alt="Documentation"></a>
|
|
||||||
|
|
||||||
<a href="https://github.com/stalwartlabs/stalwart/releases"><img src="https://img.shields.io/github/downloads/stalwartlabs/stalwart/total?style=flat-square" alt="downloads"></a
|
|
||||||
</p>
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://mastodon.social/@stalwartlabs"><img src="https://img.shields.io/mastodon/follow/109929667531941122?style=flat-square&logo=mastodon&color=%236364ff&label=Mastodon" alt="Mastodon"></a>
|
|
||||||
|
|
||||||
<a href="https://twitter.com/stalwartlabs"><img src="https://img.shields.io/twitter/follow/stalwartlabs?style=flat-square&logo=x&label=Twitter" alt="Twitter"></a>
|
|
||||||
<a href="https://discord.gg/vhqRgdhguq"><img src="https://img.shields.io/discord/923615863037390889?label=Discord&logo=discord&style=flat-square" alt="Discord"></a>
|
|
||||||
|
|
||||||
<a href="https://www.reddit.com/r/stalwartlabs/"><img src="https://img.shields.io/reddit/subreddit-subscribers/stalwartlabs?label=%2Fr%2Fstalwartlabs&logo=reddit&style=flat-square" alt="Reddit"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
## Features
|
Stalwart ships some features only in a paid Enterprise Edition: multi-tenancy,
|
||||||
|
masked email, undelete and others. INBUXA ships everything to everybody under
|
||||||
|
the AGPL-3.0, rebuilding those features independently and without using any
|
||||||
|
of Stalwart's Enterprise code.
|
||||||
|
|
||||||
**Stalwart** is an open-source mail & collaboration server with JMAP, IMAP4, POP3, SMTP, CalDAV, CardDAV and WebDAV support and a wide range of modern features. It is written in Rust and designed to be secure, fast, robust and scalable.
|
## What's different from Stalwart
|
||||||
|
|
||||||
Key features:
|
- **Every feature, one edition.** No license key, no edition checks, no
|
||||||
|
upsell. See `docs/spec/SPEC.md` §4 for the features being rebuilt, and
|
||||||
|
`docs/spec/features/` for each one's specification.
|
||||||
|
- **Webmail and administration by ihasmail,** as a separate service that can
|
||||||
|
run beside the server or elsewhere. Stalwart's own web interface is removed,
|
||||||
|
so there's no web front end on the mail host.
|
||||||
|
- **Clean-room rebuilds.** Enterprise-only code is stripped from every
|
||||||
|
upstream release before it's imported. The rebuilt features are written
|
||||||
|
from specifications that use only public sources (`docs/spec/SPEC.md` §3).
|
||||||
|
|
||||||
- **Email** server with complete protocol support:
|
## How the fork is kept
|
||||||
- JMAP:
|
|
||||||
* [JMAP for Mail](https://datatracker.ietf.org/doc/html/rfc8621) server.
|
|
||||||
* [JMAP for Sieve Scripts](https://www.ietf.org/archive/id/draft-ietf-jmap-sieve-22.html).
|
|
||||||
* [WebSocket](https://datatracker.ietf.org/doc/html/rfc8887), [Blob Management](https://www.rfc-editor.org/rfc/rfc9404.html) and [Quotas](https://www.rfc-editor.org/rfc/rfc9425.html) extensions.
|
|
||||||
- IMAP:
|
|
||||||
* [IMAP4rev2](https://datatracker.ietf.org/doc/html/rfc9051) and [IMAP4rev1](https://datatracker.ietf.org/doc/html/rfc3501) server.
|
|
||||||
* [ManageSieve](https://datatracker.ietf.org/doc/html/rfc5804) server.
|
|
||||||
* Numerous [extensions](https://stalw.art/docs/development/rfcs#imap4-and-extensions) supported.
|
|
||||||
- POP3:
|
|
||||||
- [POP3](https://datatracker.ietf.org/doc/html/rfc1939) server.
|
|
||||||
- [STLS](https://datatracker.ietf.org/doc/html/rfc2595) and [SASL](https://datatracker.ietf.org/doc/html/rfc5034) support as well as other [extensions](https://datatracker.ietf.org/doc/html/rfc2449).
|
|
||||||
- SMTP:
|
|
||||||
* SMTP server with built-in [DMARC](https://datatracker.ietf.org/doc/html/rfc7489), [DKIMv2](https://datatracker.ietf.org/doc/draft-ietf-dkim-dkim2-spec/), [DKIMv1](https://datatracker.ietf.org/doc/html/rfc6376), [SPF](https://datatracker.ietf.org/doc/html/rfc7208) and [ARC](https://datatracker.ietf.org/doc/html/rfc8617) support for message authentication.
|
|
||||||
* Strong transport security through [DANE](https://datatracker.ietf.org/doc/html/rfc6698), [MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) and [SMTP TLS](https://datatracker.ietf.org/doc/html/rfc8460) reporting.
|
|
||||||
* Automated DKIM key rotation and management.
|
|
||||||
* Inbound throttling and filtering with granular configuration rules, sieve scripting, MTA hooks and milter integration.
|
|
||||||
* Distributed virtual queues with delayed delivery, priority delivery, quotas, routing rules and throttling support.
|
|
||||||
* Envelope rewriting and message modification.
|
|
||||||
- **Collaboration** server:
|
|
||||||
- Calendaring and scheduling:
|
|
||||||
- [CalDAV](https://datatracker.ietf.org/doc/html/rfc4791) and [CalDAV Scheduling](https://datatracker.ietf.org/doc/html/rfc6638) support.
|
|
||||||
- [JMAP for Calendars](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-calendars-24) support.
|
|
||||||
- Contact management:
|
|
||||||
- [CardDAV](https://datatracker.ietf.org/doc/html/rfc6352) support.
|
|
||||||
- [JMAP for Contacts](https://datatracker.ietf.org/doc/html/rfc9610) support.
|
|
||||||
- File storage:
|
|
||||||
- [WebDAV](https://datatracker.ietf.org/doc/html/rfc4918) support.
|
|
||||||
- [JMAP for File Storage](https://datatracker.ietf.org/doc/html/draft-ietf-jmap-filenode-03) support.
|
|
||||||
- Sharing with fine-grained access controls:
|
|
||||||
- [WebDAV ACL](https://datatracker.ietf.org/doc/html/rfc3744) support.
|
|
||||||
- [JMAP Sharing](https://datatracker.ietf.org/doc/html/rfc9670) support.
|
|
||||||
- **Spam** and **Phishing** built-in filter:
|
|
||||||
- Comprehensive set of filtering **rules** on par with popular solutions.
|
|
||||||
- LLM-driven spam filtering and message analysis.
|
|
||||||
- Statistical **spam classifier** with collaborative filtering, automatic training capabilities and address book integration.
|
|
||||||
- DNS Blocklists (**DNSBLs**) checking of IP addresses, domains, and hashes.
|
|
||||||
- Collaborative digest-based spam filtering with **Pyzor**.
|
|
||||||
- **Phishing** protection against homographic URL attacks, sender spoofing and other techniques.
|
|
||||||
- Trusted **reply** tracking to recognize and prioritize genuine e-mail replies.
|
|
||||||
- Sender **reputation** monitoring by IP address, ASN, domain and email address.
|
|
||||||
- **Greylisting** to temporarily defer unknown senders.
|
|
||||||
- **Spam traps** to set up decoy email addresses that catch and analyze spam.
|
|
||||||
- **Flexible**:
|
|
||||||
- Pluggable storage backends with **RocksDB**, **FoundationDB**, **PostgreSQL**, **mySQL**, **SQLite**, **S3-Compatible**, **Azure** and **Redis** support.
|
|
||||||
- Full-text search available in 17 languages using the built-in search engine or via **Meilisearch**, **ElasticSearch**, **OpenSearch**, **PostgreSQL** or **mySQL** backends.
|
|
||||||
- Sieve scripting language with support for all [registered extensions](https://www.iana.org/assignments/sieve-extensions/sieve-extensions.xhtml).
|
|
||||||
- Email aliases, mailing lists, subaddressing and catch-all addresses support.
|
|
||||||
- Automated DNS management.
|
|
||||||
- Automatic account configuration and discovery with [PACC](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-pacc/), [autoconfig](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-autoconfig/) and [autodiscover](https://learn.microsoft.com/en-us/exchange/architecture/client-access/autodiscover?view=exchserver-2019).
|
|
||||||
- Multi-tenancy support with domain and tenant isolation.
|
|
||||||
- Disk quotas per user and tenant.
|
|
||||||
- **Secure and robust**:
|
|
||||||
- Encryption at rest with **S/MIME** or **OpenPGP**.
|
|
||||||
- Automatic TLS certificate provisioning with [ACME](https://datatracker.ietf.org/doc/html/rfc8555) using `TLS-ALPN-01`, `DNS-01`, `DNS-PERSIST-01` or `HTTP-01` challenges.
|
|
||||||
- Automated blocking of IP addresses that attack, abuse or scan the server for exploits.
|
|
||||||
- Rate limiting.
|
|
||||||
- Security audited (read the [report](https://stalw.art/blog/security-audit)).
|
|
||||||
- Memory safe (thanks to Rust).
|
|
||||||
- **Scalable and fault-tolerant**:
|
|
||||||
- Designed to handle growth seamlessly, from small setups to large-scale deployments of thousands of nodes.
|
|
||||||
- Built with **fault tolerance** and **high availability** in mind, recovers from hardware or software failures with minimal operational impact.
|
|
||||||
- Peer-to-peer cluster coordination or with **Kafka**, **Redpanda**, **NATS** or **Redis**.
|
|
||||||
- **Kubernetes**, **Apache Mesos** and **Docker Swarm** support for automated scaling and container orchestration.
|
|
||||||
- Read replicas, sharded blob storage and in-memory data stores for high performance and low latency.
|
|
||||||
- **Authentication and Authorization**:
|
|
||||||
- **OpenID Connect** authentication.
|
|
||||||
- OAuth 2.0 authorization with [authorization code](https://www.rfc-editor.org/rfc/rfc8628) and [device authorization](https://www.rfc-editor.org/rfc/rfc8628) flows.
|
|
||||||
- **LDAP**, **OIDC**, **SQL** or built-in authentication backend support.
|
|
||||||
- System for Cross-domain Identity Management ([SCIM](https://www.rfc-editor.org/info/rfc7643/)) v2 for automated provisioning.
|
|
||||||
- Two-factor authentication with Time-based One-Time Passwords (`2FA-TOTP`)
|
|
||||||
- Application passwords (App Passwords).
|
|
||||||
- Roles and permissions.
|
|
||||||
- Access Control Lists (ACLs).
|
|
||||||
- **Observability**:
|
|
||||||
- Logging and tracing with **OpenTelemetry**, journald, log files and console support.
|
|
||||||
- Metrics with **OpenTelemetry** and **Prometheus** integration.
|
|
||||||
- Webhooks for event-driven automation.
|
|
||||||
- Alerts with email and webhook notifications.
|
|
||||||
- Live tracing and metrics.
|
|
||||||
- **Web-based administration**:
|
|
||||||
- Dashboard with real-time statistics and monitoring.
|
|
||||||
- Account, domain, group and mailing list management.
|
|
||||||
- SMTP queue management for messages and outbound DMARC and TLS reports.
|
|
||||||
- Report visualization interface for received DMARC, TLS-RPT and Failure (ARF) reports.
|
|
||||||
- Configuration of every aspect of the mail server.
|
|
||||||
- Log viewer with search and filtering capabilities.
|
|
||||||
- Self-service portal for password reset and encryption-at-rest key management.
|
|
||||||
|
|
||||||
## Screenshots
|
Upstream releases arrive as stripped snapshots, never with upstream's git
|
||||||
|
history, which contains Enterprise code. `tools/fork/strip.py` builds each
|
||||||
|
snapshot on top of upstream's own `ossify.py`, then verifies it independently.
|
||||||
|
The report for every import is in `docs/fork/strip-reports/`. See
|
||||||
|
`docs/spec/SPEC.md` §2.
|
||||||
|
|
||||||
<img src="./img/demo.gif">
|
## Building
|
||||||
|
|
||||||
## Presentation
|
```bash
|
||||||
|
cargo build --release -p inbuxa # the binary is target/release/inbuxa
|
||||||
|
docker build -t inbuxa . # or the container image
|
||||||
|
```
|
||||||
|
|
||||||
**Want a deeper dive?** Need to explain to your boss why Stalwart is the perfect fit? Whether you're evaluating options, making a case to your team, or simply curious about how it all works under the hood, these slides walk you through the key features, architecture, and benefits of Stalwart. Browse the [slides](https://stalw.art/slides) to see what makes it stand out.
|
Settings are read from `INBUXA_*` environment variables. An existing Stalwart
|
||||||
|
install's `STALWART_*` variables still work, with a warning to rename them.
|
||||||
|
New installs keep their data in `/var/lib/inbuxa` and logs in
|
||||||
|
`/var/log/inbuxa`. Existing installs keep the paths their configuration
|
||||||
|
already names, so none of their data moves.
|
||||||
|
|
||||||
## Get Started
|
## License and credits
|
||||||
|
|
||||||
Install Stalwart on your server by following the instructions for your platform:
|
INBUXA is free software under the [GNU Affero General Public License,
|
||||||
|
version 3](./LICENSES/AGPL-3.0-only.txt).
|
||||||
|
|
||||||
- [Linux / MacOS / FreeBSD](https://stalw.art/docs/install/platform/linux)
|
It is a fork of Stalwart, copyright © Stalwart Labs LLC, **modified by
|
||||||
- [Windows](https://stalw.art/docs/install/platform/windows)
|
Coffey Labs in 2026**. Upstream's copyright notices are kept on every file
|
||||||
- [Docker](https://stalw.art/docs/install/platform/docker)
|
they cover, and every upstream file this fork changed says so in its header,
|
||||||
|
under the notice it came with. Stalwart's files are dual-licensed
|
||||||
|
AGPL-3.0-only or Stalwart's Enterprise License, and INBUXA takes them under
|
||||||
|
the AGPL-3.0 only. A few of those files also carry code from other projects
|
||||||
|
under MIT or BSD licenses, which stays under those licenses;
|
||||||
|
[THIRD-PARTY.md](./THIRD-PARTY.md) lists it with its notices. "Stalwart" is
|
||||||
|
Stalwart Labs' name. INBUXA isn't affiliated with or endorsed by Stalwart
|
||||||
|
Labs.
|
||||||
|
|
||||||
All documentation is available at [stalw.art/docs](https://stalw.art/docs/install/get-started).
|
The INBUXA mark reuses ihasmail's cat-and-envelope artwork.
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
If you are having problems running Stalwart, found a bug, or just have a question, please head to the [Stalwart Support Portal](https://support.stalw.art) at [support.stalw.art](https://support.stalw.art).
|
|
||||||
Additionally, you may purchase an [Enterprise License](https://stalw.art/enterprise) to obtain priority support from Stalwart Labs LLC, including response-time commitments and a private Priority Support area on the portal.
|
|
||||||
|
|
||||||
## Contributing
|
|
||||||
|
|
||||||
We welcome contributions, but to keep the project maintainable there are a few things to know before opening a pull request. Because of the high volume of low-quality, AI-generated submissions, pull requests are limited to a list of vouched contributors; to be added, post at [support.stalw.art](https://support.stalw.art) describing the change you would like to submit, together with a link to the proposed change. At this stage only bug fixes and translations are accepted, and new features are not, unless they involve just a few lines of code.
|
|
||||||
For the full guidelines, please read [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
||||||
|
|
||||||
## Roadmap
|
|
||||||
|
|
||||||
Stalwart has reached an exciting point in its journey, it’s now **feature complete**. All the core functionality and open standard email and collaboration protocols that we set out to support are in place. In other words, Stalwart already does everything you’d expect from a modern, standards-compliant mail and collaboration platform.
|
|
||||||
|
|
||||||
The next major milestone is all about refinement: finalizing the database schema and focusing on performance optimizations to ensure everything runs as efficiently and reliably as possible. Once that’s done, we’ll be ready to roll out version **1.0**.
|
|
||||||
|
|
||||||
Of course, development doesn’t stop there. The community has contributed hundreds of great ideas for improvements and new features, everything from subtle usability tweaks to entirely new integrations. You can see the full list of proposals over on our [GitHub issues](https://github.com/stalwartlabs/stalwart/issues?q=is%3Aissue+is%3Aopen+sort%3Areactions-%2B1-desc+label%3Aenhancement). If there’s something you’d like to see prioritized, just give it a thumbs up as we plan to implement enhancements based on the community’s votes.
|
|
||||||
|
|
||||||
## Sponsorship
|
|
||||||
|
|
||||||
Your support is crucial in helping us continue to improve the project, add new features, and maintain the highest level of quality. By [becoming a sponsor](https://opencollective.com/stalwart), you help fund the development and future of Stalwart. As a thank-you, sponsors who contribute $5 per month or more will automatically receive a [Enterprise edition](https://stalw.art/enterprise/) license. And, sponsors who contribute $30 per month or more, also have access to [Premium Support](https://stalw.art/support) from Stalwart Labs.
|
|
||||||
|
|
||||||
## Funding
|
|
||||||
|
|
||||||
Part of the development of this project was funded through:
|
|
||||||
|
|
||||||
- [NGI0 Entrust Fund](https://nlnet.nl/entrust), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's [Next Generation Internet](https://ngi.eu/) programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101069594.
|
|
||||||
- [NGI Zero Core](https://nlnet.nl/NGI0/), a fund established by [NLnet](https://nlnet.nl/) with financial support from the European Commission's programme, under the aegis of DG Communications Networks, Content and Technology under grant agreement No 101092990.
|
|
||||||
|
|
||||||
If you find the project useful you can help by [becoming a sponsor](https://opencollective.com/stalwart). Thank you!
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
This project is dual-licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0; as published by the Free Software Foundation) and the **Stalwart Enterprise License v2 (SELv2)**:
|
|
||||||
|
|
||||||
- The [GNU Affero General Public License v3.0](./LICENSES/AGPL-3.0-only.txt) is a free software license that ensures your freedom to use, modify, and distribute the software, with the condition that any modified versions of the software must also be distributed under the same license.
|
|
||||||
- The [Stalwart Enterprise License v2 (SELv2)](./LICENSES/LicenseRef-SEL.txt) is a proprietary license designed for commercial use. It offers additional features and greater flexibility for businesses that do not wish to comply with the AGPL-3.0 license requirements.
|
|
||||||
|
|
||||||
Each file in this project contains a license notice at the top, indicating the applicable license(s). The license notice follows the [REUSE guidelines](https://reuse.software/) to ensure clarity and consistency. The full text of each license is available in the [LICENSES](./LICENSES/) directory.
|
|
||||||
|
|
||||||
## Copyright
|
|
||||||
|
|
||||||
Copyright (C) 2020, Stalwart Labs LLC
|
|
||||||
|
|||||||
+27
-139
@@ -1,154 +1,42 @@
|
|||||||
# Security Policy for Stalwart
|
# Security policy
|
||||||
|
|
||||||
## Supported Versions
|
## Supported versions
|
||||||
|
|
||||||
We provide security updates for the following versions of Stalwart:
|
INBUXA is developed on `main`, and security fixes are applied there and in
|
||||||
|
the latest release. Older tags are not backported.
|
||||||
|
|
||||||
| Version | Supported | End of Support |
|
| Version | Supported |
|
||||||
| ------- | ------------------ | -------------- |
|
| --- | --- |
|
||||||
| 0.16.x | :white_check_mark: | TBD |
|
| `main` and the latest release | :white_check_mark: |
|
||||||
| 0.15.x | :white_check_mark: | 2026-12-01 |
|
| Older releases | :x: |
|
||||||
| < 0.14 | :x: | Ended |
|
|
||||||
|
|
||||||
**Note**: We typically support the current major version and one previous major version. Users are strongly encouraged to upgrade to the latest version for the best security posture.
|
## Reporting a vulnerability
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
**Please don't open a public issue for a security problem.** An issue is
|
||||||
|
visible to everyone, including whoever would use it, before there is a fix.
|
||||||
|
|
||||||
We take the security of Stalwart very seriously. If you believe you've found a security vulnerability, we encourage you to inform us responsibly through coordinated disclosure.
|
Report it privately by email to:
|
||||||
|
|
||||||
### How to Report
|
**johnellisATlinuxDOTcom**
|
||||||
|
|
||||||
**Do not report security vulnerabilities through public GitHub issues, discussions, or social media.**
|
Include as much as you can of:
|
||||||
|
|
||||||
Instead, please use one of these secure channels:
|
- what the vulnerability is, and what it lets someone do;
|
||||||
|
- how to reproduce it, or a proof of concept;
|
||||||
|
- the version or commit affected;
|
||||||
|
- anything about the deployment that matters — backend, front ends, whether
|
||||||
|
it needs an authenticated account.
|
||||||
|
|
||||||
1. **Email** (preferred): Send details to `[email protected]`
|
You'll get an acknowledgement within a few days. If a report turns out to
|
||||||
2. **GitHub Security Advisories**: Use the "Report a vulnerability" button in the Security tab
|
affect upstream Stalwart rather than this fork's own code, it will be passed
|
||||||
3. **Backup contact**: If no response within 48 hours, email `[email protected]`
|
to Stalwart Labs with credit to you, and you'll be told that has happened.
|
||||||
|
|
||||||
### What to Include
|
|
||||||
|
|
||||||
To help us understand and address the issue quickly, please include:
|
|
||||||
|
|
||||||
**Required Information:**
|
|
||||||
- Brief description of the vulnerability type
|
|
||||||
- Affected version(s) and components
|
|
||||||
- Steps to reproduce the issue
|
|
||||||
- Impact assessment (what could an attacker achieve?)
|
|
||||||
|
|
||||||
**Helpful Additional Details:**
|
|
||||||
- Full paths of affected source files
|
|
||||||
- Specific commit/branch where the issue exists
|
|
||||||
- Required configuration to reproduce
|
|
||||||
- Proof-of-concept code (if available)
|
|
||||||
- Suggested mitigation or fix (if you have ideas)
|
|
||||||
|
|
||||||
### Our Response Process
|
|
||||||
|
|
||||||
**Timeline Commitments:**
|
|
||||||
- **Initial acknowledgment**: Within 24 hours
|
|
||||||
- **Detailed response**: Within 72 hours
|
|
||||||
- **Status updates**: Every 7 days until resolved
|
|
||||||
- **Resolution target**: 90 days for most issues
|
|
||||||
|
|
||||||
**What We'll Do:**
|
|
||||||
1. Acknowledge your report and assign a tracking ID
|
|
||||||
2. Assess the vulnerability and determine severity
|
|
||||||
3. Develop and test a fix
|
|
||||||
4. Coordinate disclosure timeline with you
|
|
||||||
5. Release security update and publish advisory
|
|
||||||
6. Credit you in our security advisory (if desired)
|
|
||||||
|
|
||||||
## Disclosure Policy
|
|
||||||
|
|
||||||
We follow responsible disclosure principles:
|
|
||||||
|
|
||||||
- **Coordinated disclosure**: We'll work with you to determine appropriate disclosure timing
|
|
||||||
- **Typical timeline**: 90 days from report to public disclosure
|
|
||||||
- **Early disclosure**: May occur if issue is being actively exploited
|
|
||||||
- **Delayed disclosure**: May be necessary for complex issues requiring significant changes
|
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
This security policy applies to:
|
This repository is the mail server. The web front ends have their own:
|
||||||
|
|
||||||
**In Scope:**
|
- [inbuxa-admin](https://git.coffeylabs.org/inbuxa/inbuxa-admin)
|
||||||
- Stalwart (all supported versions)
|
- [ihasmail-inbuxa](https://git.coffeylabs.org/inbuxa/ihasmail-inbuxa)
|
||||||
- Official Docker images
|
|
||||||
- Documentation that could lead to insecure configurations
|
|
||||||
- Dependencies with security implications
|
|
||||||
|
|
||||||
**Out of Scope:**
|
|
||||||
- Third-party integrations or plugins
|
|
||||||
- Issues requiring physical access to the server
|
|
||||||
- Social engineering attacks
|
|
||||||
- Attacks requiring compromised credentials (unless the vulnerability enables credential compromise)
|
|
||||||
- Theoretical vulnerabilities without practical exploitation
|
|
||||||
|
|
||||||
## Security Measures
|
|
||||||
|
|
||||||
**Our Commitments:**
|
|
||||||
- Regular security audits of dependencies using `cargo audit`
|
|
||||||
- Automated security scanning in CI/CD pipeline
|
|
||||||
- Following Rust security best practices
|
|
||||||
- Prompt security updates for critical dependencies
|
|
||||||
- Security-focused code review process
|
|
||||||
|
|
||||||
**User Responsibilities:**
|
|
||||||
- Keep Stalwart updated to supported versions
|
|
||||||
- Follow security configuration guidelines
|
|
||||||
- Implement proper network security (firewalls, TLS, etc.)
|
|
||||||
- Regular security monitoring and logging
|
|
||||||
- Secure credential management
|
|
||||||
|
|
||||||
## Legal Safe Harbor
|
|
||||||
|
|
||||||
We support security research conducted in good faith. If you follow these guidelines:
|
|
||||||
|
|
||||||
**We will NOT:**
|
|
||||||
- Initiate legal action against you
|
|
||||||
- Contact law enforcement about your research
|
|
||||||
- Suspend or terminate your access to Stalwart services
|
|
||||||
|
|
||||||
**You must:**
|
|
||||||
- Only test against your own Stalwart installations
|
|
||||||
- Not access, modify, or delete user data
|
|
||||||
- Not perform testing that could degrade service availability
|
|
||||||
- Not publicly disclose the issue before coordinated disclosure
|
|
||||||
- Act in good faith and not for malicious purposes
|
|
||||||
|
|
||||||
## Recognition
|
|
||||||
|
|
||||||
We believe in recognizing security researchers who help keep Stalwart secure:
|
|
||||||
|
|
||||||
- **Security Advisory Credits**: We'll credit you in our GitHub Security Advisories (unless you prefer to remain anonymous)
|
|
||||||
- **Hall of Fame**: Significant contributors may be listed in our security acknowledgments
|
|
||||||
- **Swag**: We may send Stalwart merchandise for notable contributions
|
|
||||||
|
|
||||||
## Security Updates
|
|
||||||
|
|
||||||
**Stay Informed:**
|
|
||||||
- Subscribe to our [GitHub releases](https://github.com/stalwartlabs/stalwart/releases) for security updates
|
|
||||||
- Join our community channels for security announcements
|
|
||||||
- Enable GitHub notifications for security advisories
|
|
||||||
|
|
||||||
**Update Process:**
|
|
||||||
- Security updates are published as patch releases (e.g., 0.12.1 → 0.12.2)
|
|
||||||
- Critical vulnerabilities may receive out-of-band releases
|
|
||||||
- Docker images are updated simultaneously with releases
|
|
||||||
- Security advisories are published through GitHub Security Advisories
|
|
||||||
|
|
||||||
## Contact Information
|
|
||||||
|
|
||||||
- **Security reports**: security@stalw.art
|
|
||||||
- **General inquiries**: hello@stalw.art
|
|
||||||
- **PGP Key**: Available upon request for sensitive communications
|
|
||||||
|
|
||||||
## Additional Resources
|
|
||||||
|
|
||||||
- [Stalwart Security Incident Response Process](SECURITY_PROCESS.md)
|
|
||||||
- [Security Configuration Guide](https://stalw.art/docs/install/security)
|
|
||||||
- [Rust Security Advisory Database](https://rustsec.org/)
|
|
||||||
|
|
||||||
*This security policy is effective as of June 20, 2025 and may be updated periodically. Check back regularly for updates.*
|
|
||||||
|
|
||||||
|
Upstream's own security documents are kept in `.github-upstream/` for
|
||||||
|
reference. They describe Stalwart Labs' process, not this project's.
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Third-party code
|
||||||
|
|
||||||
|
INBUXA is a fork of Stalwart. Stalwart is original work by Stalwart Labs LLC,
|
||||||
|
not a fork of anything, but a few of its files carry code, adapted or ported,
|
||||||
|
from other projects under permissive licenses. Those parts stay under their own
|
||||||
|
licenses, not the AGPL, and their notices are reproduced here as the licenses
|
||||||
|
require. Where a project offers MIT or Apache-2.0, INBUXA takes it under MIT.
|
||||||
|
|
||||||
|
`tools/fork/strip.py` lists every such file on each upstream import and names
|
||||||
|
any this page doesn't cover yet (docs/spec/SPEC.md §2.2). The fork's own code,
|
||||||
|
and Rust crates pulled in as dependencies, aren't listed here: dependencies
|
||||||
|
carry their own license files.
|
||||||
|
|
||||||
|
## Under the MIT license
|
||||||
|
|
||||||
|
| Where | From | Notice |
|
||||||
|
|---|---|---|
|
||||||
|
| `crates/common/src/scripts/functions/text.rs` | [levenshtein-rs](https://github.com/wooorm/levenshtein-rs) | Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com> |
|
||||||
|
| `crates/common/src/telemetry/tracers/journald.rs` | the journald snippet | Copyright (c) 2018 Benjamin Saunders <ben.e.saunders@gmail.com> |
|
||||||
|
| `crates/jmap/src/registry/mapping/log.rs` | [rev_lines](https://github.com/mikeycgto/rev_lines) | Copyright (c) 2017 Michael Coyne <mjc@hey.com> |
|
||||||
|
| `crates/imap-proto/src/utf7.rs` | [MailKit](https://github.com/jstedfast/MailKit), by Jeffrey Stedfast | Copyright (C) 2013-2026 .NET Foundation and Contributors |
|
||||||
|
| `crates/nlp/src/tokenizers/japanese.rs` | [rust-tinysegmenter](https://github.com/woxtu/rust-tinysegmenter) | Copyright (c) 2015 woxtu |
|
||||||
|
| `crates/store/src/backend/postgres/tls.rs` | [tokio-postgres-rustls](https://github.com/jbg/tokio-postgres-rustls) | Copyright (c) 2019 Jasper Hugo |
|
||||||
|
| `crates/common/src/network/acme/directory.rs`, `crates/common/src/network/acme/jose.rs`, `crates/common/src/network/acme/order.rs` | [rustls-acme](https://github.com/FlorianUekermann/rustls-acme) (MIT or Apache-2.0) | Copyright (c) Florian Uekermann |
|
||||||
|
| `crates/types/src/id.rs` | [crockford](https://github.com/archer884/crockford) (MIT or Apache-2.0) | Copyright (c) 2017 J/A <archer884@gmail.com> |
|
||||||
|
| `crates/nlp/src/tokenizers/types.rs` | test cases from [linkify](https://github.com/robinst/linkify) (MIT or Apache-2.0) | Copyright (c) 2017 Robin Stocker |
|
||||||
|
|
||||||
|
Each notice above applies with this permission notice:
|
||||||
|
|
||||||
|
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
> of this software and associated documentation files (the "Software"), to deal
|
||||||
|
> in the Software without restriction, including without limitation the rights
|
||||||
|
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
> copies of the Software, and to permit persons to whom the Software is
|
||||||
|
> furnished to do so, subject to the following conditions:
|
||||||
|
>
|
||||||
|
> The above copyright notice and this permission notice shall be included in
|
||||||
|
> all copies or substantial portions of the Software.
|
||||||
|
>
|
||||||
|
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
> SOFTWARE.
|
||||||
|
|
||||||
|
## Under the BSD 3-Clause license
|
||||||
|
|
||||||
|
| Where | From | Notice |
|
||||||
|
|---|---|---|
|
||||||
|
| `crates/jmap-proto/src/types/date.rs`, `crates/registry/src/types/datetime.rs` | [upb](https://github.com/protocolbuffers/upb/blob/22182e6e/upb/json_decode.c), the date parsing marked in each file | Copyright (c) 2009-2011, Google Inc. All rights reserved. |
|
||||||
|
|
||||||
|
```text
|
||||||
|
Copyright (c) 2009-2011, Google Inc.
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of Google Inc. nor the names of any other
|
||||||
|
contributors may be used to endorse or promote products
|
||||||
|
derived from this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY GOOGLE INC. ``AS IS'' AND ANY EXPRESS OR IMPLIED
|
||||||
|
WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
|
||||||
|
EVENT SHALL GOOGLE INC. BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
|
||||||
|
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
|
||||||
|
IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||||
|
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Credited algorithms
|
||||||
|
|
||||||
|
These files implement published algorithms and credit their source. No code
|
||||||
|
is copied, so there's no notice to carry. They're listed so the strip report
|
||||||
|
doesn't flag them as new.
|
||||||
|
|
||||||
|
- `crates/jmap-proto/src/types/date.rs`, `crates/registry/src/types/datetime.rs`:
|
||||||
|
`civil_from_days`, from Howard Hinnant's
|
||||||
|
[date algorithms](http://howardhinnant.github.io/date_algorithms.html)
|
||||||
|
- `crates/utils/src/glob.rs`: Russ Cox's
|
||||||
|
[glob matching](https://research.swtch.com/glob)
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "common"
|
name = "common"
|
||||||
version = "0.16.23"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
build = "build.rs"
|
build = "build.rs"
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ directory = { path = "../directory" }
|
|||||||
coordinator = { path = "../coordinator" }
|
coordinator = { path = "../coordinator" }
|
||||||
types = { path = "../types" }
|
types = { path = "../types" }
|
||||||
registry = { path = "../registry" }
|
registry = { path = "../registry" }
|
||||||
|
inbuxa-features = { path = "../features" }
|
||||||
jmap_proto = { path = "../jmap-proto" }
|
jmap_proto = { path = "../jmap-proto" }
|
||||||
sieve-rs = { version = "0.7", features = ["rkyv", "serde"] }
|
sieve-rs = { version = "0.7", features = ["rkyv", "serde"] }
|
||||||
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
mail-parser = { version = "0.11", features = ["full_encoding"] }
|
||||||
@@ -53,7 +54,7 @@ sha2 = "0.11"
|
|||||||
md5 = "0.8.1"
|
md5 = "0.8.1"
|
||||||
whatlang = "0.18"
|
whatlang = "0.18"
|
||||||
idna = "1.1"
|
idna = "1.1"
|
||||||
decancer = "3.3.3"
|
decancer = "4.0.0"
|
||||||
unicode-security = "0.1.2"
|
unicode-security = "0.1.2"
|
||||||
infer = "0.22"
|
infer = "0.22"
|
||||||
bincode = { version = "2.0.1", features = ["serde"] }
|
bincode = { version = "2.0.1", features = ["serde"] }
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::AccessToken;
|
use super::AccessToken;
|
||||||
@@ -796,6 +798,14 @@ impl AccessToken {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AccessTokenInner {
|
impl AccessTokenInner {
|
||||||
|
/// inbuxa: SCIM-27: the account's own effective permission, from its
|
||||||
|
/// roles, its own settings and its tenant, before a credential narrows it
|
||||||
|
pub fn account_has_permission(&self, permission: Permission) -> bool {
|
||||||
|
self.scopes
|
||||||
|
.first()
|
||||||
|
.is_some_and(|scope| scope.permissions.get(permission as usize))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn from_id(account_id: u32) -> Self {
|
pub fn from_id(account_id: u32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
account_id,
|
account_id,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -9,7 +11,7 @@ use crate::{
|
|||||||
auth::{
|
auth::{
|
||||||
AccessToken, AuthRequest, DomainCache,
|
AccessToken, AuthRequest, DomainCache,
|
||||||
credential::{ApiKey, AppPassword},
|
credential::{ApiKey, AppPassword},
|
||||||
oauth::{GrantType, token::TOKEN_HEADER},
|
oauth::GrantType,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use base64::{Engine, engine::general_purpose};
|
use base64::{Engine, engine::general_purpose};
|
||||||
@@ -21,8 +23,7 @@ use registry::schema::{
|
|||||||
enums::Permission,
|
enums::Permission,
|
||||||
structs::{self, Credential},
|
structs::{self, Credential},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use std::{net::IpAddr, sync::Arc};
|
||||||
use std::{borrow::Cow, net::IpAddr, sync::Arc};
|
|
||||||
use store::write::now;
|
use store::write::now;
|
||||||
use trc::AddContext;
|
use trc::AddContext;
|
||||||
|
|
||||||
@@ -185,7 +186,7 @@ impl Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
is_alias_login = directory_account.email != auth_as_address;
|
is_alias_login = directory_account.email != auth_as_address;
|
||||||
self.build_directory_token(directory_account, req.remote_ip)
|
self.build_directory_token(directory, directory_account, req.remote_ip)
|
||||||
.await
|
.await
|
||||||
} else if let Some(account_id) =
|
} else if let Some(account_id) =
|
||||||
self.account_id_from_parts(auth_as_local, domain.id).await?
|
self.account_id_from_parts(auth_as_local, domain.id).await?
|
||||||
@@ -320,12 +321,19 @@ impl Server {
|
|||||||
// Obtain external directory, if any. When no username is supplied
|
// Obtain external directory, if any. When no username is supplied
|
||||||
// (e.g. HTTP bearer auth), peek at the JWT claims to find the
|
// (e.g. HTTP bearer auth), peek at the JWT claims to find the
|
||||||
// user's domain so per-domain OIDC directories are reachable.
|
// user's domain so per-domain OIDC directories are reachable.
|
||||||
let directory = match username.as_deref().map(UsernameParts::new) {
|
let directory = if let Some(username) = username.as_deref().map(UsernameParts::new)
|
||||||
Some(username) => match username.auth_as().domain() {
|
{
|
||||||
Some(domain_name) => self.get_directory_for_domain(domain_name).await?,
|
if let Some(domain_name) = username.auth_as().domain() {
|
||||||
None => self.get_directory_for_token(token).await?,
|
self.get_directory_for_domain(domain_name).await?
|
||||||
},
|
} else if let Some(domain_name) = extract_jwt_domain(token) {
|
||||||
None => self.get_directory_for_token(token).await?,
|
self.get_directory_for_domain(&domain_name).await?
|
||||||
|
} else {
|
||||||
|
self.get_default_directory()
|
||||||
|
}
|
||||||
|
} else if let Some(domain_name) = extract_jwt_domain(token) {
|
||||||
|
self.get_directory_for_domain(&domain_name).await?
|
||||||
|
} else {
|
||||||
|
self.get_default_directory()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try external directory authentication first if supported, then fallback to internal OAuth.
|
// Try external directory authentication first if supported, then fallback to internal OAuth.
|
||||||
@@ -335,7 +343,38 @@ impl Server {
|
|||||||
{
|
{
|
||||||
match directory.authenticate(&req.credentials).await {
|
match directory.authenticate(&req.credentials).await {
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
return self.build_directory_token(result, req.remote_ip).await;
|
// inbuxa: DIR-7: the token must be the named user's, or
|
||||||
|
// the named address an alias it may sign in with
|
||||||
|
let named = username
|
||||||
|
.as_deref()
|
||||||
|
.map(|name| UsernameParts::new(name).auth_as().address().to_lowercase());
|
||||||
|
let is_alias = match &named {
|
||||||
|
Some(named) if !named.eq_ignore_ascii_case(&result.email) => {
|
||||||
|
if !result
|
||||||
|
.email_aliases
|
||||||
|
.iter()
|
||||||
|
.any(|alias| alias.eq_ignore_ascii_case(named))
|
||||||
|
{
|
||||||
|
return Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::AccountName, named.clone())
|
||||||
|
.details(result.email.clone())
|
||||||
|
.reason("The token belongs to a different user"));
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
let token = self
|
||||||
|
.build_directory_token(directory, result, req.remote_ip)
|
||||||
|
.await?;
|
||||||
|
if is_alias && !token.has_permission(Permission::AuthenticateWithAlias) {
|
||||||
|
return Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::AccountId, token.account_id())
|
||||||
|
.reason("Authenticated using an email alias but account does not have AuthenticateAlias permission"));
|
||||||
|
}
|
||||||
|
return Ok(token);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
external_error = Some(err);
|
external_error = Some(err);
|
||||||
@@ -376,6 +415,8 @@ impl Server {
|
|||||||
&& let Some(directory) = self.get_directory_for_cached_domain(&domain_cache)
|
&& let Some(directory) = self.get_directory_for_cached_domain(&domain_cache)
|
||||||
&& let Recipient::Account(account) = directory.recipient(address).await?
|
&& let Recipient::Account(account) = directory.recipient(address).await?
|
||||||
{
|
{
|
||||||
|
// inbuxa: DIR-6
|
||||||
|
self.assert_directory_serves(directory, &account.email).await?;
|
||||||
return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id));
|
return Ok(Some(Box::pin(self.synchronize_account(account)).await?.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,69 +538,91 @@ impl Server {
|
|||||||
|
|
||||||
async fn build_directory_token(
|
async fn build_directory_token(
|
||||||
&self,
|
&self,
|
||||||
|
directory: &Arc<Directory>,
|
||||||
account: directory::Account,
|
account: directory::Account,
|
||||||
remote_ip: IpAddr,
|
remote_ip: IpAddr,
|
||||||
) -> trc::Result<AccessToken> {
|
) -> trc::Result<AccessToken> {
|
||||||
|
// inbuxa: DIR-6
|
||||||
|
self.assert_directory_serves(directory, &account.email).await?;
|
||||||
let account = Box::pin(self.synchronize_account(account)).await?;
|
let account = Box::pin(self.synchronize_account(account)).await?;
|
||||||
self.access_token_from_account(account.id, account.account)
|
self.access_token_from_account(account.id, account.account)
|
||||||
.await
|
.await
|
||||||
.and_then(|token| AccessToken::new(token, remote_ip))
|
.and_then(|token| AccessToken::new(token, remote_ip))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: DIR-1, DIR-5: the directory a domain signs in against: its
|
||||||
|
/// own, else the server default, else the internal one (`None`). An
|
||||||
|
/// unknown domain gets the server default.
|
||||||
pub async fn get_directory_for_domain(
|
pub async fn get_directory_for_domain(
|
||||||
&self,
|
&self,
|
||||||
domain_name: &str,
|
domain_name: &str,
|
||||||
) -> trc::Result<Option<&Arc<Directory>>> {
|
) -> trc::Result<Option<&Arc<Directory>>> {
|
||||||
|
Ok(match self.domain(domain_name).await? {
|
||||||
Ok(self.get_default_directory())
|
Some(domain) => self.get_directory_for_cached_domain(&domain),
|
||||||
}
|
None => self.get_default_directory(),
|
||||||
|
})
|
||||||
async fn get_directory_for_token(&self, token: &str) -> trc::Result<Option<&Arc<Directory>>> {
|
|
||||||
let Some(payload) = JwtClaims::decode_payload(token) else {
|
|
||||||
return Ok(self.get_default_directory());
|
|
||||||
};
|
|
||||||
let Some(claims) = JwtClaims::parse(&payload) else {
|
|
||||||
return Ok(self.get_default_directory());
|
|
||||||
};
|
|
||||||
|
|
||||||
match (claims.domain(), claims.iss.as_deref()) {
|
|
||||||
(Some(domain_name), _) => self.get_directory_for_domain(domain_name).await,
|
|
||||||
(None, Some(issuer)) => Ok(self
|
|
||||||
.get_directory_for_issuer(issuer)
|
|
||||||
.or_else(|| self.get_default_directory())),
|
|
||||||
(None, None) => Ok(self.get_default_directory()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn get_directory_for_issuer(&self, issuer: &str) -> Option<&Arc<Directory>> {
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: DIR-1, DIR-5: as above, for a domain already read. A
|
||||||
|
/// `directoryId` naming no directory the server built is unavailable,
|
||||||
|
/// never the internal directory.
|
||||||
pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc<Directory>> {
|
pub fn get_directory_for_cached_domain(&self, domain: &DomainCache) -> Option<&Arc<Directory>> {
|
||||||
|
match domain.id_directory {
|
||||||
|
Some(directory_id) => Some(
|
||||||
|
self.core
|
||||||
|
.storage
|
||||||
|
.directories
|
||||||
|
.get(&directory_id)
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
trc::event!(
|
||||||
|
Auth(trc::AuthEvent::Warning),
|
||||||
|
Domain = domain.name().to_string(),
|
||||||
|
Id = directory_id,
|
||||||
|
Reason = "The domain's directory doesn't exist; sign-in fails",
|
||||||
|
);
|
||||||
|
unavailable_directory()
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
None => self.get_default_directory(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
self.get_default_directory()
|
/// inbuxa: DIR-6: a directory speaks only for the domains it serves.
|
||||||
|
pub async fn assert_directory_serves(
|
||||||
|
&self,
|
||||||
|
directory: &Arc<Directory>,
|
||||||
|
address: &str,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
let serves = match address.rsplit_once('@') {
|
||||||
|
Some((_, domain)) => self
|
||||||
|
.get_directory_for_domain(domain)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|effective| Arc::ptr_eq(effective, directory)),
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
if serves {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::AccountName, address.to_string())
|
||||||
|
.reason("The directory returned an account on a domain it doesn't serve"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
/// inbuxa: DIR-5: what a dangling `directoryId` resolves to.
|
||||||
struct JwtClaims<'x> {
|
pub fn unavailable_directory() -> &'static Arc<Directory> {
|
||||||
#[serde(borrow, default)]
|
static UNAVAILABLE: std::sync::OnceLock<Arc<Directory>> = std::sync::OnceLock::new();
|
||||||
iss: Option<Cow<'x, str>>,
|
UNAVAILABLE.get_or_init(|| {
|
||||||
#[serde(borrow, default)]
|
Arc::new(Directory::Unavailable(directory::UnavailableDirectory::new(
|
||||||
email: Option<Cow<'x, str>>,
|
registry::schema::enums::DirectoryType::Ldap,
|
||||||
#[serde(borrow, default)]
|
"The directory named by the domain doesn't exist",
|
||||||
preferred_username: Option<Cow<'x, str>>,
|
)))
|
||||||
#[serde(borrow, default)]
|
})
|
||||||
upn: Option<Cow<'x, str>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<'x> JwtClaims<'x> {
|
fn extract_jwt_domain(token: &str) -> Option<String> {
|
||||||
fn decode_payload(token: &str) -> Option<Vec<u8>> {
|
|
||||||
if token.starts_with(TOKEN_HEADER) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = token.split('.');
|
let mut parts = token.split('.');
|
||||||
let _header = parts.next()?;
|
let _header = parts.next()?;
|
||||||
let payload = parts.next()?;
|
let payload = parts.next()?;
|
||||||
@@ -567,25 +630,17 @@ impl<'x> JwtClaims<'x> {
|
|||||||
if parts.next().is_some() {
|
if parts.next().is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
let payload_bytes = general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()?;
|
||||||
general_purpose::URL_SAFE_NO_PAD.decode(payload).ok()
|
let claims: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?;
|
||||||
|
for claim in ["email", "preferred_username", "upn"] {
|
||||||
|
if let Some(val) = claims.get(claim).and_then(|v| v.as_str())
|
||||||
|
&& let Some((_, domain)) = val.rsplit_once('@')
|
||||||
|
&& !domain.is_empty()
|
||||||
|
{
|
||||||
|
return Some(domain.to_ascii_lowercase());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse(payload: &'x [u8]) -> Option<Self> {
|
|
||||||
serde_json::from_slice(payload).ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn domain(&self) -> Option<&str> {
|
|
||||||
[&self.email, &self.preferred_username, &self.upn]
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.find_map(|claim| {
|
|
||||||
claim
|
|
||||||
.rsplit_once('@')
|
|
||||||
.map(|(_, domain)| domain)
|
|
||||||
.filter(|domain| !domain.is_empty())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
impl UsernameParts {
|
impl UsernameParts {
|
||||||
@@ -683,76 +738,3 @@ impl AuthRequest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
fn jwt(payload: &str) -> String {
|
|
||||||
format!(
|
|
||||||
"eyJhbGciOiJSUzI1NiJ9.{}.c2lnbmF0dXJl",
|
|
||||||
general_purpose::URL_SAFE_NO_PAD.encode(payload)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hints(token: &str) -> Option<(Option<String>, Option<String>)> {
|
|
||||||
let payload = JwtClaims::decode_payload(token)?;
|
|
||||||
let claims = JwtClaims::parse(&payload)?;
|
|
||||||
|
|
||||||
Some((
|
|
||||||
claims.domain().map(str::to_string),
|
|
||||||
claims.iss.as_deref().map(str::to_string),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn jwt_claims_are_extracted() {
|
|
||||||
for (payload, domain, issuer) in [
|
|
||||||
(
|
|
||||||
r#"{"iss":"https://idp.example.org","email":"[email protected]"}"#,
|
|
||||||
Some("Example.ORG"),
|
|
||||||
Some("https://idp.example.org"),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r#"{"preferred_username":"[email protected]","upn":"[email protected]"}"#,
|
|
||||||
Some("example.net"),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r#"{"email":"broken@","upn":"[email protected]"}"#,
|
|
||||||
Some("example.com"),
|
|
||||||
None,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
r#"{"iss":"https://idp.example.org","sub":"5db2d1b6","aud":["a","b"],"scope":"openid"}"#,
|
|
||||||
None,
|
|
||||||
Some("https://idp.example.org"),
|
|
||||||
),
|
|
||||||
(r#"{"sub":"5db2d1b6"}"#, None, None),
|
|
||||||
(r#"{"email":"[email protected]"}"#, Some("example.net"), None),
|
|
||||||
] {
|
|
||||||
assert_eq!(
|
|
||||||
hints(&jwt(payload)),
|
|
||||||
Some((domain.map(str::to_string), issuer.map(str::to_string))),
|
|
||||||
"Unexpected claims for {payload}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn non_jwt_tokens_are_ignored() {
|
|
||||||
for token in [
|
|
||||||
"sw1.eyJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLm9yZyJ9",
|
|
||||||
"sw1.eyJhbGciOiJSUzI1NiJ9",
|
|
||||||
"opaque-token",
|
|
||||||
"one.two",
|
|
||||||
"one.two.three.four",
|
|
||||||
"",
|
|
||||||
] {
|
|
||||||
assert!(
|
|
||||||
JwtClaims::decode_payload(token).is_none(),
|
|
||||||
"Token {token:?} was parsed as a JWT"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -69,7 +71,8 @@ pub struct DomainCache {
|
|||||||
|
|
||||||
pub const DOMAIN_FLAG_RELAY: u8 = 1;
|
pub const DOMAIN_FLAG_RELAY: u8 = 1;
|
||||||
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
|
pub const DOMAIN_FLAG_SUB_ADDRESSING: u8 = 1 << 1;
|
||||||
|
// inbuxa: SCIM-15, SCIM-58
|
||||||
|
pub const DOMAIN_FLAG_SCIM: u8 = 1 << 2;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AccountCache {
|
pub struct AccountCache {
|
||||||
@@ -329,4 +332,8 @@ impl DomainCache {
|
|||||||
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
|
self.names.first().map(|s| s.as_ref()).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: SCIM-15, SCIM-58
|
||||||
|
pub fn allows_scim(&self) -> bool {
|
||||||
|
self.flags & DOMAIN_FLAG_SCIM != 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ pub const FAILED_TO_DECODE_TOKEN: &str = concat!(
|
|||||||
"the Authentication object."
|
"the Authentication object."
|
||||||
);
|
);
|
||||||
|
|
||||||
pub(crate) const TOKEN_HEADER: &str = "sw1.";
|
const TOKEN_HEADER: &str = "sw1.";
|
||||||
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
|
const TOKEN_KEY_CONTEXT: &str = "stalwart-oauth-token-sw1";
|
||||||
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
|
const OAUTH_EPOCH: u64 = 946684800; // Jan 1, 2000
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -64,10 +66,48 @@ impl Server {
|
|||||||
.caused_by(trc::location!())?
|
.caused_by(trc::location!())?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MT-13, MT-14, MT-15: cut down to what the tenant allows
|
||||||
|
if let Some(tenant_id) = tenant_id {
|
||||||
|
self.apply_tenant_ceiling(&mut permissions, tenant_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(permissions)
|
Ok(permissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: MT-13. The tenant's roles give the base; its own permission
|
||||||
|
/// lists adjust it (`inbuxa_features::tenancy::ceiling`).
|
||||||
|
async fn apply_tenant_ceiling(
|
||||||
|
&self,
|
||||||
|
permissions: &mut PermissionsGroup,
|
||||||
|
tenant_id: u32,
|
||||||
|
) -> trc::Result<()> {
|
||||||
|
use inbuxa_features::tenancy::ceiling::{Policy, ceiling};
|
||||||
|
|
||||||
|
let tenant = self.tenant(tenant_id).await?;
|
||||||
|
let base = self
|
||||||
|
.add_role_permissions(PermissionsGroup::default(), tenant.id_roles.iter().copied())
|
||||||
|
.await?
|
||||||
|
.finalize();
|
||||||
|
let policy = match tenant.permissions.as_deref() {
|
||||||
|
None => Policy::Inherit,
|
||||||
|
Some(list) if list.merge => Policy::Merge {
|
||||||
|
enabled: &list.enabled,
|
||||||
|
disabled: &list.disabled,
|
||||||
|
},
|
||||||
|
Some(list) => Policy::Replace {
|
||||||
|
enabled: &list.enabled,
|
||||||
|
disabled: &list.disabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ceiling(base, policy).apply(&mut permissions.enabled, &mut permissions.disabled);
|
||||||
|
// inbuxa: MT-1, MT-15: impersonation would reach beyond the tenant
|
||||||
|
permissions.disabled.set(Permission::Impersonate as usize);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn can_set_permissions(
|
pub async fn can_set_permissions(
|
||||||
&self,
|
&self,
|
||||||
access_token: &AccessToken,
|
access_token: &AccessToken,
|
||||||
@@ -224,6 +264,11 @@ impl Default for DefaultPermissions {
|
|||||||
default.superuser.push(permission);
|
default.superuser.push(permission);
|
||||||
default.tenant.push(permission);
|
default.tenant.push(permission);
|
||||||
}
|
}
|
||||||
|
// inbuxa: MT-12: a tenant administrator reads its own tenant
|
||||||
|
Permission::SysTenantGet | Permission::SysTenantQuery => {
|
||||||
|
default.superuser.push(permission);
|
||||||
|
default.tenant.push(permission);
|
||||||
|
}
|
||||||
permission => {
|
permission => {
|
||||||
let name = permission.as_str();
|
let name = permission.as_str();
|
||||||
if name.starts_with("jmap")
|
if name.starts_with("jmap")
|
||||||
|
|||||||
Vendored
+100
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder};
|
use crate::{Server, auth::DomainCache, cache::invalidate::CacheInvalidationBuilder};
|
||||||
@@ -51,6 +53,14 @@ impl Server {
|
|||||||
.ctx(trc::Key::AccountId, account_id)
|
.ctx(trc::Key::AccountId, account_id)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// inbuxa: SCIM-58: SCIM is authoritative; sign-in changes nothing
|
||||||
|
if domain.allows_scim() {
|
||||||
|
return Ok(AccountWithId {
|
||||||
|
id: account_id,
|
||||||
|
account: Account::from(current_account),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let mut updated_account = Account::from(current_account.clone())
|
let mut updated_account = Account::from(current_account.clone())
|
||||||
.into_user()
|
.into_user()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -79,6 +89,7 @@ impl Server {
|
|||||||
for alias in account.email_aliases {
|
for alias in account.email_aliases {
|
||||||
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
||||||
&& alias_domain.id_tenant == domain.id_tenant
|
&& alias_domain.id_tenant == domain.id_tenant
|
||||||
|
&& self.same_directory(&domain, &alias).await?
|
||||||
&& self
|
&& self
|
||||||
.rcpt_id_from_parts(local, alias_domain.id)
|
.rcpt_id_from_parts(local, alias_domain.id)
|
||||||
.await?
|
.await?
|
||||||
@@ -96,6 +107,12 @@ impl Server {
|
|||||||
if let Some(groups) = account.groups {
|
if let Some(groups) = account.groups {
|
||||||
let mut member_group_ids = Vec::with_capacity(groups.len());
|
let mut member_group_ids = Vec::with_capacity(groups.len());
|
||||||
for email in groups {
|
for email in groups {
|
||||||
|
// inbuxa: SCIM-58: no group comes from a claim on a SCIM domain
|
||||||
|
if self.is_scim_address(&email).await?
|
||||||
|
|| !self.same_directory(&domain, &email).await?
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
member_group_ids.push(
|
member_group_ids.push(
|
||||||
self.synchronize_group(directory::Group {
|
self.synchronize_group(directory::Group {
|
||||||
email,
|
email,
|
||||||
@@ -155,11 +172,19 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
// inbuxa: SCIM-58: accounts on this domain come from SCIM only
|
||||||
|
if domain.allows_scim() {
|
||||||
|
return Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.details("The account isn't provisioned: its domain is managed by SCIM")
|
||||||
|
.ctx(trc::Key::AccountName, account.email));
|
||||||
|
}
|
||||||
|
|
||||||
let mut aliases = Vec::with_capacity(account.email_aliases.len());
|
let mut aliases = Vec::with_capacity(account.email_aliases.len());
|
||||||
for alias in account.email_aliases {
|
for alias in account.email_aliases {
|
||||||
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
||||||
&& alias_domain.id_tenant == domain.id_tenant
|
&& alias_domain.id_tenant == domain.id_tenant
|
||||||
|
&& self.same_directory(&domain, &alias).await?
|
||||||
&& self
|
&& self
|
||||||
.rcpt_id_from_parts(local, alias_domain.id)
|
.rcpt_id_from_parts(local, alias_domain.id)
|
||||||
.await?
|
.await?
|
||||||
@@ -175,6 +200,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
let mut member_group_ids = Vec::new();
|
let mut member_group_ids = Vec::new();
|
||||||
for email in account.groups.unwrap_or_default() {
|
for email in account.groups.unwrap_or_default() {
|
||||||
|
// inbuxa: SCIM-58: no group comes from a claim on a SCIM domain
|
||||||
|
if self.is_scim_address(&email).await?
|
||||||
|
|| !self.same_directory(&domain, &email).await?
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
member_group_ids.push(
|
member_group_ids.push(
|
||||||
self.synchronize_group(directory::Group {
|
self.synchronize_group(directory::Group {
|
||||||
email,
|
email,
|
||||||
@@ -205,6 +236,8 @@ impl Server {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
// inbuxa: DIR-15
|
||||||
|
self.check_tenant_limits(&account).await?;
|
||||||
match self
|
match self
|
||||||
.registry()
|
.registry()
|
||||||
.write(RegistryWrite::insert(&account))
|
.write(RegistryWrite::insert(&account))
|
||||||
@@ -255,6 +288,11 @@ impl Server {
|
|||||||
.ctx(trc::Key::AccountId, account_id)
|
.ctx(trc::Key::AccountId, account_id)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// inbuxa: SCIM-58: SCIM is authoritative; sign-in changes nothing
|
||||||
|
if domain.allows_scim() {
|
||||||
|
return Ok(account_id);
|
||||||
|
}
|
||||||
|
|
||||||
let mut updated_account = Account::from(current_account.clone())
|
let mut updated_account = Account::from(current_account.clone())
|
||||||
.into_group()
|
.into_group()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
@@ -275,6 +313,7 @@ impl Server {
|
|||||||
for alias in group.email_aliases {
|
for alias in group.email_aliases {
|
||||||
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
||||||
&& alias_domain.id_tenant == domain.id_tenant
|
&& alias_domain.id_tenant == domain.id_tenant
|
||||||
|
&& self.same_directory(&domain, &alias).await?
|
||||||
&& self
|
&& self
|
||||||
.rcpt_id_from_parts(local, alias_domain.id)
|
.rcpt_id_from_parts(local, alias_domain.id)
|
||||||
.await?
|
.await?
|
||||||
@@ -322,11 +361,19 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
|
// inbuxa: SCIM-58: groups on this domain come from SCIM only
|
||||||
|
if domain.allows_scim() {
|
||||||
|
return Err(trc::AuthEvent::Error
|
||||||
|
.into_err()
|
||||||
|
.details("The group isn't provisioned: its domain is managed by SCIM")
|
||||||
|
.ctx(trc::Key::AccountName, group.email));
|
||||||
|
}
|
||||||
|
|
||||||
let mut aliases = Vec::with_capacity(group.email_aliases.len());
|
let mut aliases = Vec::with_capacity(group.email_aliases.len());
|
||||||
for alias in group.email_aliases {
|
for alias in group.email_aliases {
|
||||||
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
if let Some((local, alias_domain)) = self.validate_alias(&alias).await?
|
||||||
&& alias_domain.id_tenant == domain.id_tenant
|
&& alias_domain.id_tenant == domain.id_tenant
|
||||||
|
&& self.same_directory(&domain, &alias).await?
|
||||||
&& self
|
&& self
|
||||||
.rcpt_id_from_parts(local, alias_domain.id)
|
.rcpt_id_from_parts(local, alias_domain.id)
|
||||||
.await?
|
.await?
|
||||||
@@ -353,6 +400,8 @@ impl Server {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
// inbuxa: DIR-15
|
||||||
|
self.check_tenant_limits(&account).await?;
|
||||||
match self
|
match self
|
||||||
.registry()
|
.registry()
|
||||||
.write(RegistryWrite::insert(&account))
|
.write(RegistryWrite::insert(&account))
|
||||||
@@ -378,6 +427,57 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// inbuxa: DIR-6: whether an address is on a domain served by the same
|
||||||
|
/// directory as `domain`; a warning when it isn't.
|
||||||
|
async fn same_directory(&self, domain: &DomainCache, address: &str) -> trc::Result<bool> {
|
||||||
|
let Some((_, other)) = address.rsplit_once('@') else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
let Some(other) = self.domain(other).await? else {
|
||||||
|
return Ok(true);
|
||||||
|
};
|
||||||
|
let same = match (
|
||||||
|
self.get_directory_for_cached_domain(domain),
|
||||||
|
self.get_directory_for_cached_domain(&other),
|
||||||
|
) {
|
||||||
|
(None, None) => true,
|
||||||
|
(Some(a), Some(b)) => Arc::ptr_eq(a, b),
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !same {
|
||||||
|
trc::event!(
|
||||||
|
Auth(trc::AuthEvent::Warning),
|
||||||
|
AccountName = address.to_string(),
|
||||||
|
Domain = other.name().to_string(),
|
||||||
|
Reason = "Dropped: the address is on a domain served by another directory",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(same)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: DIR-15, MT-3, MT-17: an object created from a directory
|
||||||
|
/// passes the same tenant checks as one created over JMAP.
|
||||||
|
async fn check_tenant_limits(&self, object: &Object) -> trc::Result<()> {
|
||||||
|
match inbuxa_features::tenancy::writes::check(self.registry(), None, None, object).await? {
|
||||||
|
Ok(_) => Ok(()),
|
||||||
|
Err(err) => Err(trc::AuthEvent::Failed
|
||||||
|
.into_err()
|
||||||
|
.details(err.description().unwrap_or("A tenant limit is reached").to_string())
|
||||||
|
.reason("The directory's account can't be created")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// inbuxa: SCIM-58: whether an address is on a domain SCIM manages.
|
||||||
|
async fn is_scim_address(&self, address: &str) -> trc::Result<bool> {
|
||||||
|
Ok(match address.rsplit_once('@') {
|
||||||
|
Some((_, domain)) => self
|
||||||
|
.domain(domain)
|
||||||
|
.await?
|
||||||
|
.is_some_and(|domain| domain.allows_scim()),
|
||||||
|
None => false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate_address<'x>(
|
async fn validate_address<'x>(
|
||||||
&self,
|
&self,
|
||||||
email: &'x str,
|
email: &'x str,
|
||||||
|
|||||||
+31
-1
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -17,7 +19,6 @@ use registry::{
|
|||||||
},
|
},
|
||||||
types::id::ObjectId,
|
types::id::ObjectId,
|
||||||
};
|
};
|
||||||
use store::{registry::RegistryQuery, roaring::RoaringBitmap};
|
|
||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
|
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
@@ -120,6 +121,10 @@ impl CacheInvalidationBuilder {
|
|||||||
|| (current.sub_addressing != new.sub_addressing)
|
|| (current.sub_addressing != new.sub_addressing)
|
||||||
|| (current.allow_relaying != new.allow_relaying)
|
|| (current.allow_relaying != new.allow_relaying)
|
||||||
|| (current.is_enabled != new.is_enabled)
|
|| (current.is_enabled != new.is_enabled)
|
||||||
|
// inbuxa: SCIM-60, the flag is cached as DOMAIN_FLAG_SCIM,
|
||||||
|
// so turning SCIM's authority on or off has to take effect
|
||||||
|
// without a restart
|
||||||
|
|| (current.allow_scim_provisioning != new.allow_scim_provisioning)
|
||||||
{
|
{
|
||||||
self.invalidate(CacheInvalidation::Domain(id));
|
self.invalidate(CacheInvalidation::Domain(id));
|
||||||
}
|
}
|
||||||
@@ -281,6 +286,15 @@ impl Server {
|
|||||||
.registry()
|
.registry()
|
||||||
.linked_objects(ObjectId::new(ObjectType::Role, role_id.into()))
|
.linked_objects(ObjectId::new(ObjectType::Role, role_id.into()))
|
||||||
.await?;
|
.await?;
|
||||||
|
// inbuxa: MT-16: a role a tenant holds sets its ceiling
|
||||||
|
for tenant_id in inbuxa_features::tenancy::members::tenants_using_role(
|
||||||
|
self.registry(),
|
||||||
|
&linked_objects,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
changes.insert(CacheInvalidation::Tenant(tenant_id));
|
||||||
|
}
|
||||||
for linked_object in linked_objects {
|
for linked_object in linked_objects {
|
||||||
match linked_object.object() {
|
match linked_object.object() {
|
||||||
ObjectType::Account => {
|
ObjectType::Account => {
|
||||||
@@ -298,6 +312,22 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MT-16: a tenant's change reaches its people on their next request
|
||||||
|
let tenant_ids = changes
|
||||||
|
.iter()
|
||||||
|
.filter_map(|change| match change {
|
||||||
|
CacheInvalidation::Tenant(tenant_id) => Some(*tenant_id),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
for tenant_id in tenant_ids {
|
||||||
|
for account_id in
|
||||||
|
inbuxa_features::tenancy::members::accounts(self.registry(), tenant_id).await?
|
||||||
|
{
|
||||||
|
changes.insert(CacheInvalidation::AccessToken(account_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let changes = changes.into_iter().collect::<Vec<_>>();
|
let changes = changes.into_iter().collect::<Vec<_>>();
|
||||||
self.invalidate_local_caches(&changes).await;
|
self.invalidate_local_caches(&changes).await;
|
||||||
self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes))
|
self.cluster_broadcast(BroadcastEvent::CacheInvalidate(changes))
|
||||||
|
|||||||
+32
-3
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -28,7 +30,7 @@ use registry::{
|
|||||||
enums::{DkimRotationStage, Locale, StorageQuota, TenantStorageQuota},
|
enums::{DkimRotationStage, Locale, StorageQuota, TenantStorageQuota},
|
||||||
prelude::{ObjectType, Property},
|
prelude::{ObjectType, Property},
|
||||||
structs::{
|
structs::{
|
||||||
Account, DkimSignature, Domain, EncryptionAtRest, MailingList, MaskedEmail,
|
Account, DkimSignature, Domain, EncryptionAtRest, MailingList,
|
||||||
Permissions, PublicKey, Role, SubAddressing, Tenant,
|
Permissions, PublicKey, Role, SubAddressing, Tenant,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -38,7 +40,7 @@ use std::{borrow::Cow, sync::Arc};
|
|||||||
use store::{
|
use store::{
|
||||||
U64_LEN,
|
U64_LEN,
|
||||||
registry::{RegistryQuery, bootstrap::Bootstrap},
|
registry::{RegistryQuery, bootstrap::Bootstrap},
|
||||||
write::{key::KeySerializer, now},
|
write::key::KeySerializer,
|
||||||
};
|
};
|
||||||
use trc::{AddContext, StoreEvent};
|
use trc::{AddContext, StoreEvent};
|
||||||
use types::id::Id;
|
use types::id::Id;
|
||||||
@@ -158,7 +160,11 @@ impl Server {
|
|||||||
if domain.allow_relaying {
|
if domain.allow_relaying {
|
||||||
flags |= DOMAIN_FLAG_RELAY;
|
flags |= DOMAIN_FLAG_RELAY;
|
||||||
}
|
}
|
||||||
|
// inbuxa: SCIM-15, SCIM-58: the domain is open to SCIM, and SCIM is
|
||||||
|
// authoritative for its accounts
|
||||||
|
if domain.allow_scim_provisioning {
|
||||||
|
flags |= crate::auth::DOMAIN_FLAG_SCIM;
|
||||||
|
}
|
||||||
|
|
||||||
let sub_addressing_custom = match domain.sub_addressing {
|
let sub_addressing_custom = match domain.sub_addressing {
|
||||||
SubAddressing::Enabled => {
|
SubAddressing::Enabled => {
|
||||||
@@ -293,6 +299,29 @@ impl Server {
|
|||||||
|
|
||||||
Ok(Some(result))
|
Ok(Some(result))
|
||||||
} else {
|
} else {
|
||||||
|
// inbuxa: ME-4, ME-6: a live masked address reaches its
|
||||||
|
// owner; a refused one isn't cached, as it can come back
|
||||||
|
if let Some(domain) = self.domain_by_id(domain_id).await?
|
||||||
|
&& let Some(name) = domain.names.first()
|
||||||
|
{
|
||||||
|
use inbuxa_features::masked_email::ops::{Lookup, lookup};
|
||||||
|
match lookup(
|
||||||
|
&self.core.storage.data,
|
||||||
|
self.registry(),
|
||||||
|
&format!("{local_part}@{name}"),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Lookup::Accepts(mask) => {
|
||||||
|
return Ok(Some(EmailCache::Account(
|
||||||
|
mask.object.account_id.document_id(),
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Lookup::Refuses => return Ok(None),
|
||||||
|
Lookup::Unknown => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Cache negative result
|
// Cache negative result
|
||||||
emails_negative.insert(
|
emails_negative.insert(
|
||||||
EmailAddress::new(local_part, domain_id),
|
EmailAddress::new(local_part, domain_id),
|
||||||
|
|||||||
Vendored
+3
-2
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -130,8 +132,7 @@ impl Server {
|
|||||||
|
|
||||||
// Update tracers
|
// Update tracers
|
||||||
|
|
||||||
#[cfg(not(feature = "enterprise"))]
|
tracers.update();
|
||||||
tracers.update(false);
|
|
||||||
|
|
||||||
// Reload queue settings
|
// Reload queue settings
|
||||||
self.inner
|
self.inner
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use calcard::vcard::VCardVersion;
|
use calcard::vcard::VCardVersion;
|
||||||
@@ -100,6 +102,17 @@ impl GroupwareConfig {
|
|||||||
let dr = bp.setting_infallible::<DataRetention>().await;
|
let dr = bp.setting_infallible::<DataRetention>().await;
|
||||||
let system = bp.setting_infallible::<SystemSettings>().await;
|
let system = bp.setting_infallible::<SystemSettings>().await;
|
||||||
|
|
||||||
|
// inbuxa: BT-19: a stored template that doesn't parse is reported at
|
||||||
|
// start and on each reload; the built-in is used meanwhile
|
||||||
|
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
|
||||||
|
"CalendarAlarm.template",
|
||||||
|
alarm.template.as_deref(),
|
||||||
|
);
|
||||||
|
inbuxa_features::branding::templates::warn_unusable::<CalendarTemplateVariable>(
|
||||||
|
"CalendarScheduling.emailTemplate",
|
||||||
|
sched.email_template.as_deref(),
|
||||||
|
);
|
||||||
|
|
||||||
GroupwareConfig {
|
GroupwareConfig {
|
||||||
max_request_size: dav.request_max_size as usize,
|
max_request_size: dav.request_max_size as usize,
|
||||||
dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
|
dead_property_size: dav.dead_property_max_size.map(|v| v as usize),
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ impl Data {
|
|||||||
|
|
||||||
Data {
|
Data {
|
||||||
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
|
spam_classifier: ArcSwap::from_pointee(SpamClassifier::default()),
|
||||||
|
listener_control: Default::default(),
|
||||||
tls_certificates: ArcSwap::from_pointee(certificates),
|
tls_certificates: ArcSwap::from_pointee(certificates),
|
||||||
tls_self_signed_cert: build_self_signed_cert(
|
tls_self_signed_cert: build_self_signed_cert(
|
||||||
subject_names
|
subject_names
|
||||||
@@ -222,6 +223,7 @@ impl Default for Data {
|
|||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
spam_classifier: Default::default(),
|
spam_classifier: Default::default(),
|
||||||
|
listener_control: Default::default(),
|
||||||
tls_certificates: Default::default(),
|
tls_certificates: Default::default(),
|
||||||
tls_self_signed_cert: Default::default(),
|
tls_self_signed_cert: Default::default(),
|
||||||
blocked_ips: Default::default(),
|
blocked_ips: Default::default(),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -90,7 +92,7 @@ impl Scripting {
|
|||||||
.with_protected_headers(untrusted.protected_headers)
|
.with_protected_headers(untrusted.protected_headers)
|
||||||
.with_vacation_default_subject(untrusted.default_subject)
|
.with_vacation_default_subject(untrusted.default_subject)
|
||||||
.with_vacation_subject_prefix(untrusted.default_subject_prefix)
|
.with_vacation_subject_prefix(untrusted.default_subject_prefix)
|
||||||
.with_env_variable("name", "Stalwart Server")
|
.with_env_variable("name", types::brand_server!())
|
||||||
.with_env_variable("version", VERSION_PUBLIC)
|
.with_env_variable("version", VERSION_PUBLIC)
|
||||||
.with_env_variable("location", "MS")
|
.with_env_variable("location", "MS")
|
||||||
.with_env_variable("phase", "during");
|
.with_env_variable("phase", "during");
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::expr::{
|
use crate::expr::{
|
||||||
@@ -426,13 +428,24 @@ impl SpamFilterLists {
|
|||||||
&tag.tag,
|
&tag.tag,
|
||||||
SpamFilterAction::Allow(tag.score.into_inner() as f32),
|
SpamFilterAction::Allow(tag.score.into_inner() as f32),
|
||||||
),
|
),
|
||||||
SpamTag::Discard(tag) => lists
|
SpamTag::Discard(tag) => {
|
||||||
|
warn_llm_refusal(&tag.tag);
|
||||||
|
lists
|
||||||
.scores
|
.scores
|
||||||
.insert_pattern(&tag.tag, SpamFilterAction::Discard),
|
.insert_pattern(&tag.tag, SpamFilterAction::Discard)
|
||||||
SpamTag::Reject(tag) => lists
|
|
||||||
.scores
|
|
||||||
.insert_pattern(&tag.tag, SpamFilterAction::Reject),
|
|
||||||
}
|
}
|
||||||
|
SpamTag::Reject(tag) => {
|
||||||
|
warn_llm_refusal(&tag.tag);
|
||||||
|
lists
|
||||||
|
.scores
|
||||||
|
.insert_pattern(&tag.tag, SpamFilterAction::Reject)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// inbuxa: AI-2: at start and each reload, models off this network are flagged
|
||||||
|
for model in bp.list_infallible::<registry::schema::structs::AiModel>().await {
|
||||||
|
crate::enterprise::llm::warn_if_remote(&model.object).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
for ext in bp.list_infallible::<SpamFileExtension>().await {
|
for ext in bp.list_infallible::<SpamFileExtension>().await {
|
||||||
@@ -740,3 +753,16 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: AI-13: a Discard or Reject on the model's tag counts as no entry
|
||||||
|
fn warn_llm_refusal(tag: &str) {
|
||||||
|
if inbuxa_features::ai::answer::is_llm_tag(tag) {
|
||||||
|
trc::event!(
|
||||||
|
Registry(trc::RegistryEvent::BuildWarning),
|
||||||
|
Details = format!(
|
||||||
|
"Spam tag {tag} discards or rejects, which the language model's opinion alone \
|
||||||
|
may not do: it scores 0 (AI-13)"
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use std::io::Cursor;
|
use std::io::Cursor;
|
||||||
@@ -38,7 +40,7 @@ pub mod storage;
|
|||||||
pub mod telemetry;
|
pub mod telemetry;
|
||||||
|
|
||||||
impl Core {
|
impl Core {
|
||||||
pub async fn parse(bp: &mut Bootstrap, mut storage: Storage) -> Self {
|
pub async fn parse(bp: &mut Bootstrap, storage: Storage) -> Self {
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
sieve: Scripting::parse(bp).await,
|
sieve: Scripting::parse(bp).await,
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -45,6 +47,9 @@ pub struct Network {
|
|||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct NetworkInfo {
|
pub struct NetworkInfo {
|
||||||
pub pacc: Pacc,
|
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 mxs: Vec<MailExchanger>,
|
||||||
pub services: VecMap<ServiceProtocol, Service>,
|
pub services: VecMap<ServiceProtocol, Service>,
|
||||||
}
|
}
|
||||||
@@ -62,6 +67,9 @@ pub struct Http {
|
|||||||
pub url_https: String,
|
pub url_https: String,
|
||||||
pub allowed_endpoint: IfBlock,
|
pub allowed_endpoint: IfBlock,
|
||||||
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
|
pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>,
|
||||||
|
/// inbuxa: origins allowed cross-origin access (contract C-14). Empty when
|
||||||
|
/// CORS is permissive (bootstrap, recovery, or `usePermissiveCors`).
|
||||||
|
pub cors_origins: Vec<hyper::header::HeaderValue>,
|
||||||
pub use_forwarded: bool,
|
pub use_forwarded: bool,
|
||||||
pub redirect_root: Option<String>,
|
pub redirect_root: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -190,7 +198,7 @@ impl Network {
|
|||||||
}),
|
}),
|
||||||
info: Info {
|
info: Info {
|
||||||
provider: Provider {
|
provider: Provider {
|
||||||
name: "Stalwart".into(),
|
name: types::brand!().into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
..Default::default()
|
..Default::default()
|
||||||
@@ -315,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()
|
.unwrap_or_default()
|
||||||
.rsplit_once(SPLIT_HERE)
|
.rsplit_once(SPLIT_HERE)
|
||||||
.map(|(prefix, suffix)| (prefix.to_string(), suffix.to_string()))
|
.map(|(prefix, suffix)| Pacc {
|
||||||
.unwrap();
|
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 {
|
let mut network = Network {
|
||||||
node_id: bp.node_id() as u64,
|
node_id: bp.node_id() as u64,
|
||||||
server_name: default_hostname.to_string(),
|
server_name: default_hostname.to_string(),
|
||||||
@@ -334,7 +357,8 @@ impl Network {
|
|||||||
info: NetworkInfo {
|
info: NetworkInfo {
|
||||||
mxs: system.mail_exchangers.into_iter().collect(),
|
mxs: system.mail_exchangers.into_iter().collect(),
|
||||||
services: system.services,
|
services: system.services,
|
||||||
pacc: Pacc { prefix, suffix },
|
pacc,
|
||||||
|
pacc_jmap_only,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -408,6 +432,17 @@ impl Http {
|
|||||||
#[cfg(not(feature = "dev_mode"))]
|
#[cfg(not(feature = "dev_mode"))]
|
||||||
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
|
let use_permissive_cors = http.use_permissive_cors || bp.registry.is_recovery_mode();
|
||||||
|
|
||||||
|
// inbuxa: otherwise only the front ends' origins get cross-origin
|
||||||
|
// access, echoed per request (contract C-14)
|
||||||
|
let cors_origins = if use_permissive_cors {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
crate::manager::first_party::front_end_origins()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|origin| hyper::header::HeaderValue::from_str(&origin).ok())
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
if use_permissive_cors {
|
if use_permissive_cors {
|
||||||
http_headers.push((
|
http_headers.push((
|
||||||
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
hyper::header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||||
@@ -464,6 +499,7 @@ impl Http {
|
|||||||
http.rate_limit_anonymous
|
http.rate_limit_anonymous
|
||||||
},
|
},
|
||||||
response_headers: http_headers,
|
response_headers: http_headers,
|
||||||
|
cors_origins,
|
||||||
use_forwarded: http.use_x_forwarded,
|
use_forwarded: http.use_x_forwarded,
|
||||||
redirect_root: http.redirect_root,
|
redirect_root: http.redirect_root,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -69,7 +71,7 @@ impl Listeners {
|
|||||||
bind: Map::new(vec![
|
bind: Map::new(vec![
|
||||||
SocketAddr::from_str(&format!(
|
SocketAddr::from_str(&format!(
|
||||||
"[::]:{}",
|
"[::]:{}",
|
||||||
std::env::var("STALWART_RECOVERY_MODE_PORT")
|
types::branding::env_var("RECOVERY_MODE_PORT")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|p| p.parse::<u16>().ok())
|
.and_then(|p| p.parse::<u16>().ok())
|
||||||
.unwrap_or(8080)
|
.unwrap_or(8080)
|
||||||
|
|||||||
@@ -214,7 +214,6 @@ impl Resolvers {
|
|||||||
let config_dnssec = resolver_config.clone();
|
let config_dnssec = resolver_config.clone();
|
||||||
let mut opts_dnssec = opts.clone();
|
let mut opts_dnssec = opts.clone();
|
||||||
opts_dnssec.validate = true;
|
opts_dnssec.validate = true;
|
||||||
opts_dnssec.num_concurrent_reqs = 1;
|
|
||||||
|
|
||||||
let dnssec = DnssecResolver {
|
let dnssec = DnssecResolver {
|
||||||
resolver: TokioResolver::builder_with_config(
|
resolver: TokioResolver::builder_with_config(
|
||||||
@@ -344,7 +343,6 @@ impl Default for Resolvers {
|
|||||||
let config_dnssec = config.clone();
|
let config_dnssec = config.clone();
|
||||||
let mut opts_dnssec = opts.clone();
|
let mut opts_dnssec = opts.clone();
|
||||||
opts_dnssec.validate = true;
|
opts_dnssec.validate = true;
|
||||||
opts_dnssec.num_concurrent_reqs = 1;
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
|
dns: MessageAuthenticator::new(config, opts).expect("Failed to build DNS resolver"),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use coordinator::Coordinator;
|
use coordinator::Coordinator;
|
||||||
@@ -41,12 +43,19 @@ impl Storage {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let coordinator = Coordinator::build(bp, &memory).await.unwrap_or_default();
|
||||||
|
// inbuxa: ST-7: with more than one node, read replicas share
|
||||||
|
// high-water marks through the in-memory store
|
||||||
|
if !matches!(coordinator, Coordinator::None) {
|
||||||
|
bp.data_store.share_marks(&memory);
|
||||||
|
}
|
||||||
|
|
||||||
Storage {
|
Storage {
|
||||||
registry: bp.registry.clone(),
|
registry: bp.registry.clone(),
|
||||||
data: bp.data_store.clone(),
|
data: bp.data_store.clone(),
|
||||||
blob: BlobStore::build(bp).await.unwrap_or_default(),
|
blob: BlobStore::build(bp).await.unwrap_or_default(),
|
||||||
search,
|
search,
|
||||||
coordinator: Coordinator::build(bp, &memory).await.unwrap_or_default(),
|
coordinator,
|
||||||
memory,
|
memory,
|
||||||
tracing: Store::build_tracing(bp).await.unwrap_or_default(),
|
tracing: Store::build_tracing(bp).await.unwrap_or_default(),
|
||||||
metrics: Store::build_metrics(bp).await.unwrap_or_default(),
|
metrics: Store::build_metrics(bp).await.unwrap_or_default(),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::config::storage::Storage;
|
use crate::config::storage::Storage;
|
||||||
@@ -40,6 +42,15 @@ pub enum TelemetrySubscriberType {
|
|||||||
Webhook(WebhookTracer),
|
Webhook(WebhookTracer),
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
JournalTracer(crate::telemetry::tracers::journald::Subscriber),
|
JournalTracer(crate::telemetry::tracers::journald::Subscriber),
|
||||||
|
// inbuxa: MON-10: trace history
|
||||||
|
StoreTracer(StoreTracer),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where trace history goes: traces to `tracing`, index tasks to `data`.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StoreTracer {
|
||||||
|
pub tracing: store::Store,
|
||||||
|
pub data: store::Store,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
@@ -386,6 +397,7 @@ impl Tracers {
|
|||||||
TelemetrySubscriberType::JournalTracer(_) => {
|
TelemetrySubscriberType::JournalTracer(_) => {
|
||||||
EventType::Telemetry(TelemetryEvent::JournalError).into()
|
EventType::Telemetry(TelemetryEvent::JournalError).into()
|
||||||
}
|
}
|
||||||
|
TelemetrySubscriberType::StoreTracer(_) => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Parse disabled events
|
// Parse disabled events
|
||||||
@@ -477,6 +489,36 @@ impl Tracers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MON-10 to MON-12: trace history, when a tracing store is set:
|
||||||
|
// info and above, the span edges and MAIL FROM, never raw I/O
|
||||||
|
if !storage.tracing.is_none() {
|
||||||
|
let mut interests = Interests::default();
|
||||||
|
for event_type in EventType::variants() {
|
||||||
|
let event_level = custom_levels
|
||||||
|
.get(event_type)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(event_type.level());
|
||||||
|
if !event_type.is_raw_io()
|
||||||
|
&& (Level::Info.is_contained(event_level)
|
||||||
|
|| event_type.is_span_start()
|
||||||
|
|| event_type.is_span_end()
|
||||||
|
|| event_type.as_str().starts_with("smtp.mail-from"))
|
||||||
|
{
|
||||||
|
interests.set(event_type.to_id() as usize);
|
||||||
|
global_interests.set(event_type.to_id() as usize);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tracers.push(TelemetrySubscriber {
|
||||||
|
id: "trace-history".to_string(),
|
||||||
|
interests,
|
||||||
|
typ: TelemetrySubscriberType::StoreTracer(StoreTracer {
|
||||||
|
tracing: storage.tracing.clone(),
|
||||||
|
data: storage.data.clone(),
|
||||||
|
}),
|
||||||
|
lossy: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "dev_mode")]
|
#[cfg(feature = "dev_mode")]
|
||||||
if let Ok(level) = std::env::var("LOG") {
|
if let Ok(level) = std::env::var("LOG") {
|
||||||
let level = Level::from_str(&level).expect("Invalid LOG level");
|
let level = Level::from_str(&level).expect("Invalid LOG level");
|
||||||
@@ -503,7 +545,7 @@ impl Tracers {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Add default tracer if none were found
|
// Add default tracer if none were found
|
||||||
let level = std::env::var("STALWART_RECOVERY_MODE_LOG_LEVEL")
|
let level = types::branding::env_var("RECOVERY_MODE_LOG_LEVEL")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|level| Level::from_str(&level).ok())
|
.and_then(|level| Level::from_str(&level).ok())
|
||||||
.unwrap_or(Level::Info);
|
.unwrap_or(Level::Info);
|
||||||
@@ -541,11 +583,11 @@ impl Metrics {
|
|||||||
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
pub async fn parse(bp: &mut Bootstrap) -> Self {
|
||||||
let metrics = bp.setting_infallible::<structs::Metrics>().await;
|
let metrics = bp.setting_infallible::<structs::Metrics>().await;
|
||||||
let resource = Resource::builder()
|
let resource = Resource::builder()
|
||||||
.with_service_name("stalwart")
|
.with_service_name("inbuxa")
|
||||||
.with_attribute(KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION")))
|
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
|
||||||
.build();
|
.build();
|
||||||
let instrumentation = InstrumentationScope::builder("stalwart")
|
let instrumentation = InstrumentationScope::builder("inbuxa")
|
||||||
.with_version(env!("CARGO_PKG_VERSION"))
|
.with_version(types::brand_version_full!())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
Metrics {
|
Metrics {
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Calling the operator's model (AI spam classification spec, AI-5 to AI-11,
|
||||||
|
//! AI-21 to AI-25). The rules live in `inbuxa_features::ai`; this makes the
|
||||||
|
//! HTTP request. The wire types are the OpenAI-compatible chat completions
|
||||||
|
//! shapes local model servers speak.
|
||||||
|
|
||||||
|
use crate::Server;
|
||||||
|
use inbuxa_features::ai::{
|
||||||
|
gate::{Gate, Refused, Transition},
|
||||||
|
limits::{self, AiLimits},
|
||||||
|
locality,
|
||||||
|
request::{self, Kind, MAX_RESPONSE_BYTES},
|
||||||
|
};
|
||||||
|
use registry::schema::{
|
||||||
|
enums::AiModelType,
|
||||||
|
prelude::ObjectType,
|
||||||
|
structs::AiModel,
|
||||||
|
};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use store::registry::RegistryQuery;
|
||||||
|
use trc::AiEvent;
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
/// A chat completions request.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ChatCompletionRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub temperature: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_tokens: Option<u32>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One chat message.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A chat completions response.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ChatCompletionResponse {
|
||||||
|
pub created: i64,
|
||||||
|
pub object: String,
|
||||||
|
pub id: String,
|
||||||
|
pub model: String,
|
||||||
|
pub choices: Vec<ChatCompletionChoice>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One choice in a response.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct ChatCompletionChoice {
|
||||||
|
pub index: u32,
|
||||||
|
pub finish_reason: String,
|
||||||
|
pub message: Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a call produced no answer. Every one leaves mail flowing (AI-9).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub enum Failure {
|
||||||
|
Refused(Refused),
|
||||||
|
Timeout,
|
||||||
|
Http(String),
|
||||||
|
Status(u16),
|
||||||
|
BadAnswer,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One call to make.
|
||||||
|
pub struct Call<'x> {
|
||||||
|
pub model_id: Id,
|
||||||
|
pub model: &'x AiModel,
|
||||||
|
/// Set for an account's own script (AI-24, AI-25).
|
||||||
|
pub account_id: Option<u32>,
|
||||||
|
pub system: Option<&'x str>,
|
||||||
|
pub user: &'x str,
|
||||||
|
pub temperature: f64,
|
||||||
|
pub max_tokens: u32,
|
||||||
|
pub timeout: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(model: &AiModel) -> Kind {
|
||||||
|
match model.model_type {
|
||||||
|
AiModelType::Chat => Kind::Chat,
|
||||||
|
AiModelType::Text => Kind::Text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// The fork's limits, as stored now.
|
||||||
|
pub async fn ai_limits(&self) -> AiLimits {
|
||||||
|
limits::get(&self.core.storage.data)
|
||||||
|
.await
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A model by its id.
|
||||||
|
pub async fn ai_model_by_id(&self, id: Id) -> Option<AiModel> {
|
||||||
|
self.registry().object::<AiModel>(id).await.ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A model by name, or failing that by id (AI-20).
|
||||||
|
pub async fn ai_model_by_name(&self, name: &str) -> Option<(Id, AiModel)> {
|
||||||
|
let ids = self
|
||||||
|
.registry()
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::AiModel))
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
let mut by_id = None;
|
||||||
|
for id in ids {
|
||||||
|
if let Some(model) = self.ai_model_by_id(id).await {
|
||||||
|
if model.name == name {
|
||||||
|
return Some((id, model));
|
||||||
|
}
|
||||||
|
if id.to_string() == name {
|
||||||
|
by_id = Some((id, model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
by_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Makes one call. The answer, or why there is none; either way the
|
||||||
|
/// outcome is logged, with no message content and no secret (AI-5).
|
||||||
|
pub async fn ai_call(&self, call: Call<'_>) -> Result<String, Failure> {
|
||||||
|
let limits = self.ai_limits().await;
|
||||||
|
let gate = Gate::global();
|
||||||
|
let permit = match gate.try_start(call.model_id.id(), call.account_id, limits.gate()) {
|
||||||
|
Ok(permit) => permit,
|
||||||
|
Err(refused) => {
|
||||||
|
trc::event!(
|
||||||
|
Ai(AiEvent::ApiError),
|
||||||
|
Details = call.model.name.clone(),
|
||||||
|
AccountId = call.account_id,
|
||||||
|
Reason = format!("{refused:?}"),
|
||||||
|
);
|
||||||
|
return Err(Failure::Refused(refused));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = tokio::time::timeout(call.timeout, self.ai_request(&call)).await;
|
||||||
|
let result = match result {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => Err(Failure::Timeout),
|
||||||
|
};
|
||||||
|
let transition = permit.finish(result.is_ok(), limits.failure_backoff.into_inner());
|
||||||
|
match &transition {
|
||||||
|
Some(Transition::Paused) => trc::event!(
|
||||||
|
Ai(AiEvent::ApiError),
|
||||||
|
Details = call.model.name.clone(),
|
||||||
|
Reason = format!(
|
||||||
|
"Paused for {}s after repeated failures",
|
||||||
|
limits.failure_backoff.into_inner().as_secs()
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Some(Transition::Resumed) => trc::event!(
|
||||||
|
Ai(AiEvent::LlmResponse),
|
||||||
|
Details = call.model.name.clone(),
|
||||||
|
Reason = "Resumed after a pause",
|
||||||
|
),
|
||||||
|
None => {}
|
||||||
|
}
|
||||||
|
match &result {
|
||||||
|
Ok(answer) => trc::event!(
|
||||||
|
Ai(AiEvent::LlmResponse),
|
||||||
|
Details = call.model.name.clone(),
|
||||||
|
AccountId = call.account_id,
|
||||||
|
Elapsed = started.elapsed(),
|
||||||
|
Result = request::cut(answer, 1024),
|
||||||
|
),
|
||||||
|
Err(failure) => trc::event!(
|
||||||
|
Ai(AiEvent::ApiError),
|
||||||
|
Details = call.model.name.clone(),
|
||||||
|
AccountId = call.account_id,
|
||||||
|
Elapsed = started.elapsed(),
|
||||||
|
Code = match failure {
|
||||||
|
Failure::Status(code) => *code as u64,
|
||||||
|
_ => 0,
|
||||||
|
},
|
||||||
|
Reason = format!("{failure:?}"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ai_request(&self, call: &Call<'_>) -> Result<String, Failure> {
|
||||||
|
let model = call.model;
|
||||||
|
let kind = kind(model);
|
||||||
|
let body = request::body(
|
||||||
|
kind,
|
||||||
|
&model.model,
|
||||||
|
call.system,
|
||||||
|
call.user,
|
||||||
|
call.temperature,
|
||||||
|
call.max_tokens,
|
||||||
|
);
|
||||||
|
// Secrets are read now, from their source (AI-8)
|
||||||
|
let headers = model
|
||||||
|
.http_auth
|
||||||
|
.build_headers(model.http_headers.clone(), Some("application/json"))
|
||||||
|
.await
|
||||||
|
.map_err(Failure::Http)?;
|
||||||
|
let client = utils::http::http_client_builder(model.allow_invalid_certs)
|
||||||
|
// A redirect would send content to a host nobody named (AI-8)
|
||||||
|
.redirect(reqwest::redirect::Policy::none())
|
||||||
|
.connect_timeout(call.timeout)
|
||||||
|
.timeout(call.timeout)
|
||||||
|
.default_headers(headers)
|
||||||
|
.build()
|
||||||
|
.map_err(|err| Failure::Http(err.to_string()))?;
|
||||||
|
let mut response = client
|
||||||
|
.post(&model.url)
|
||||||
|
.body(body.to_string())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
if err.is_timeout() {
|
||||||
|
Failure::Timeout
|
||||||
|
} else {
|
||||||
|
Failure::Http(err.without_url().to_string())
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
let status = response.status().as_u16();
|
||||||
|
if status != 200 {
|
||||||
|
return Err(Failure::Status(status));
|
||||||
|
}
|
||||||
|
let mut bytes = Vec::new();
|
||||||
|
while let Some(chunk) = response
|
||||||
|
.chunk()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Failure::Http(err.without_url().to_string()))?
|
||||||
|
{
|
||||||
|
bytes.extend_from_slice(&chunk);
|
||||||
|
if bytes.len() > MAX_RESPONSE_BYTES {
|
||||||
|
return Err(Failure::BadAnswer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
request::answer(kind, &bytes).ok_or(Failure::BadAnswer)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI-2: warns when a model's endpoint isn't on this network.
|
||||||
|
pub async fn ai_warn_if_remote(&self, model: &AiModel) {
|
||||||
|
warn_if_remote(model).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AI-2: warns when a model's endpoint isn't on this network. Names are
|
||||||
|
/// resolved; any address outside the local ranges counts.
|
||||||
|
pub async fn warn_if_remote(model: &AiModel) {
|
||||||
|
let local = match locality::classify(&model.url) {
|
||||||
|
Some(local) => local,
|
||||||
|
None => {
|
||||||
|
let host = locality::host(&model.url).unwrap_or_default().to_string();
|
||||||
|
match tokio::net::lookup_host((host.as_str(), 443)).await {
|
||||||
|
Ok(addrs) => {
|
||||||
|
let addrs = addrs.map(|a| a.ip()).collect::<Vec<_>>();
|
||||||
|
!addrs.is_empty()
|
||||||
|
&& addrs.into_iter().all(locality::is_local_ip)
|
||||||
|
}
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !local {
|
||||||
|
trc::event!(
|
||||||
|
Registry(trc::RegistryEvent::BuildWarning),
|
||||||
|
Details = locality::warning(&model.name, &model.url),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The most of a script's prompt sent (AI-23).
|
||||||
|
const MAX_PROMPT_BYTES: usize = 32 * 1024;
|
||||||
|
|
||||||
|
/// The most of an answer a script gets back (AI-22).
|
||||||
|
const MAX_SCRIPT_ANSWER_BYTES: usize = 8 * 1024;
|
||||||
|
|
||||||
|
/// The longest an account's own script waits (AI-23).
|
||||||
|
const ACCOUNT_SCRIPT_CEILING: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
|
/// `llm_prompt(model, prompt, temperature)` (AI-20 to AI-25). The answer as
|
||||||
|
/// plain text, or `None`, which the script sees as `false`.
|
||||||
|
pub async fn sieve_prompt(
|
||||||
|
ctx: crate::scripts::plugins::PluginContext<'_>,
|
||||||
|
) -> Option<String> {
|
||||||
|
use registry::schema::enums::Permission;
|
||||||
|
use sieve::runtime::Variable;
|
||||||
|
|
||||||
|
let server = ctx.server;
|
||||||
|
let name = ctx.arguments.first()?.to_string();
|
||||||
|
let prompt = ctx.arguments.get(1)?.to_string();
|
||||||
|
let temperature = match ctx.arguments.get(2) {
|
||||||
|
Some(Variable::Float(t)) => Some(*t),
|
||||||
|
Some(Variable::Integer(t)) => Some(*t as f64),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
// AI-23: trusted system scripts always; an account's own with interactAi
|
||||||
|
let account_id = match ctx.access_token {
|
||||||
|
Some(token) if !token.has_permission(Permission::InteractAi) => {
|
||||||
|
trc::event!(
|
||||||
|
Ai(AiEvent::ApiError),
|
||||||
|
SpanId = ctx.session_id,
|
||||||
|
AccountId = token.account_id(),
|
||||||
|
Reason = "The account may not call AI models",
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token) => Some(token.account_id()),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some((model_id, model)) = server.ai_model_by_name(name.as_ref()).await else {
|
||||||
|
trc::event!(
|
||||||
|
Ai(AiEvent::ApiError),
|
||||||
|
SpanId = ctx.session_id,
|
||||||
|
AccountId = account_id,
|
||||||
|
Reason = format!("No AI model named {name:?}"),
|
||||||
|
);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let limits = server.ai_limits().await;
|
||||||
|
let timeout = match account_id {
|
||||||
|
Some(_) => model.timeout.into_inner().min(ACCOUNT_SCRIPT_CEILING),
|
||||||
|
None => model
|
||||||
|
.timeout
|
||||||
|
.into_inner()
|
||||||
|
.min(limits.spam_call_ceiling.into_inner()),
|
||||||
|
};
|
||||||
|
let prompt = request::cut(&prompt, MAX_PROMPT_BYTES);
|
||||||
|
let answer = server
|
||||||
|
.ai_call(Call {
|
||||||
|
model_id,
|
||||||
|
model: &model,
|
||||||
|
account_id,
|
||||||
|
system: None,
|
||||||
|
user: &prompt,
|
||||||
|
temperature: temperature.unwrap_or_else(|| model.temperature.into_inner()),
|
||||||
|
max_tokens: request::PROMPT_MAX_TOKENS,
|
||||||
|
timeout,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.ok()?;
|
||||||
|
// Plain data: never evaluated (AI-22)
|
||||||
|
Some(request::cut(answer.trim(), MAX_SCRIPT_ANSWER_BYTES))
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Rebuilt features that sit on the server itself, at the paths the shared
|
||||||
|
//! tests name. The rules live in `inbuxa_features`.
|
||||||
|
|
||||||
|
pub mod llm;
|
||||||
@@ -23,13 +23,6 @@ pub(crate) fn fn_is_number(v: Vec<Variable>) -> Variable {
|
|||||||
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
|
matches!(&v[0], Variable::Integer(_) | Variable::Float(_)).into()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn fn_bit_and(v: Vec<Variable>) -> Variable {
|
|
||||||
match (v[0].to_integer(), v[1].to_integer()) {
|
|
||||||
(Some(lhs), Some(rhs)) => Variable::Integer(lhs & rhs),
|
|
||||||
_ => Variable::Integer(0),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
|
pub(crate) fn fn_is_ip_addr(v: Vec<Variable>) -> Variable {
|
||||||
v[0].to_string()
|
v[0].to_string()
|
||||||
.as_str()
|
.as_str()
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ pub(crate) const FUNCTIONS: &[(&str, fn(Vec<Variable>) -> Variable, u32)] = &[
|
|||||||
("email_part", email::fn_email_part, 2),
|
("email_part", email::fn_email_part, 2),
|
||||||
("is_empty", misc::fn_is_empty, 1),
|
("is_empty", misc::fn_is_empty, 1),
|
||||||
("is_number", misc::fn_is_number, 1),
|
("is_number", misc::fn_is_number, 1),
|
||||||
("bit_and", misc::fn_bit_and, 2),
|
|
||||||
("is_ip_addr", misc::fn_is_ip_addr, 1),
|
("is_ip_addr", misc::fn_is_ip_addr, 1),
|
||||||
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
|
("is_ipv4_addr", misc::fn_is_ipv4_addr, 1),
|
||||||
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
|
("is_ipv6_addr", misc::fn_is_ipv6_addr, 1),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::config::smtp::{
|
use crate::config::smtp::{
|
||||||
@@ -43,6 +45,11 @@ pub enum PushEvent {
|
|||||||
account_id: u32,
|
account_id: u32,
|
||||||
broadcast: bool,
|
broadcast: bool,
|
||||||
},
|
},
|
||||||
|
// inbuxa: SCIM-52: ends the push subscriptions the account itself holds
|
||||||
|
// (IMAP IDLE, JMAP event streams and WebSockets) on this node
|
||||||
|
Revoke {
|
||||||
|
account_id: u32,
|
||||||
|
},
|
||||||
Stop,
|
Stop,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+24
-59
@@ -2,8 +2,14 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// inbuxa: composite stores (sharded members, read replicas) nest store
|
||||||
|
// futures deeply enough to pass rustc's default query depth
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
|
||||||
#![warn(clippy::large_futures)]
|
#![warn(clippy::large_futures)]
|
||||||
|
|
||||||
use crate::auth::{AccessTokenInner, EmailAddress};
|
use crate::auth::{AccessTokenInner, EmailAddress};
|
||||||
@@ -67,6 +73,7 @@ pub mod i18n;
|
|||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
pub mod enterprise; // inbuxa: rebuilt features (AI spam classification)
|
||||||
pub mod scripts;
|
pub mod scripts;
|
||||||
pub mod sharing;
|
pub mod sharing;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
@@ -78,9 +85,9 @@ pub use psl;
|
|||||||
pub static VERSION_PRIVATE: &str = env!("CARGO_PKG_VERSION");
|
pub static VERSION_PRIVATE: &str = env!("CARGO_PKG_VERSION");
|
||||||
pub static VERSION_PUBLIC: &str = "1.0.0";
|
pub static VERSION_PUBLIC: &str = "1.0.0";
|
||||||
|
|
||||||
pub static USER_AGENT: &str = "Stalwart/1.0.0";
|
pub static USER_AGENT: &str = concat!(types::brand!(), "/1.0.0");
|
||||||
pub static DAEMON_NAME: &str = concat!("Stalwart v", env!("CARGO_PKG_VERSION"),);
|
pub static DAEMON_NAME: &str = concat!(types::brand!(), " v", types::brand_version!(),);
|
||||||
pub static PROD_ID: &str = "-//Stalwart Labs LLC//Stalwart Server//EN";
|
pub static PROD_ID: &str = types::brand_prodid!();
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
||||||
@@ -143,6 +150,10 @@ pub struct Data {
|
|||||||
pub blocked_ips: RwLock<BlockedIps>,
|
pub blocked_ips: RwLock<BlockedIps>,
|
||||||
pub lookup_stores: ArcSwap<AHashMap<Box<str>, InMemoryStore>>,
|
pub lookup_stores: ArcSwap<AHashMap<Box<str>, InMemoryStore>>,
|
||||||
|
|
||||||
|
// inbuxa: the running listeners and their shutdown switches, so one
|
||||||
|
// protocol's ports can close while the rest keep accepting (LP-2)
|
||||||
|
pub listener_control: crate::network::control::ListenerControl,
|
||||||
|
|
||||||
pub asn_geo_data: AsnGeoLookupData,
|
pub asn_geo_data: AsnGeoLookupData,
|
||||||
|
|
||||||
pub jmap_id_gen: SnowflakeIdGenerator,
|
pub jmap_id_gen: SnowflakeIdGenerator,
|
||||||
@@ -161,6 +172,9 @@ pub struct Data {
|
|||||||
pub struct LogoCache {
|
pub struct LogoCache {
|
||||||
domain_id: u32,
|
domain_id: u32,
|
||||||
tenant_id: Option<u32>,
|
tenant_id: Option<u32>,
|
||||||
|
// inbuxa: read again when the /logo endpoint (per-tenant and per-domain
|
||||||
|
// branding) is rebuilt; docs/spec/features/multi-tenancy.md MT-22.
|
||||||
|
#[allow(dead_code)]
|
||||||
data: Option<Resource<Vec<u8>>>,
|
data: Option<Resource<Vec<u8>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,59 +429,10 @@ pub struct ThrottleKeyHasher {
|
|||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
pub struct ThrottleKeyHasherBuilder {}
|
pub struct ThrottleKeyHasherBuilder {}
|
||||||
|
|
||||||
pub const DEFAULT_LOGO_BASE64: &str = "iVBORw0KGgoAAAANSUhEUgAAAMgAAAAnCAMAAAB9lPf7AAABOFBMVEUAAAAAADoAAEkPDkIPDkIQ\r\n\
|
/// The logo embedded in calendar emails when no custom logo is set: INBUXA's
|
||||||
DkIQDkLcLVTYMVTbLVTbLVQPDkLWM2YQDkIPD0IODEHaJlPbLVQWDT8MC0IQDkIQDkIPDkIODkEQ\r\n\
|
/// compact lockup at 380x80, twice its 180-pixel display width. Base64 with
|
||||||
D0ITDEAQDkIPDkIODkIPDkIPDkIRDUIQDkIQDUIPDkLcLVQQDkIQDkIQDkIQDULbLVQQDUIPDkIQ\r\n\
|
/// CRLF line breaks, ready for a base64 MIME part.
|
||||||
EEIPDkIPD0EPDkHcLlUPDkLbLFQPDULbLFPbLVQQDkLbLFPcK1TbLVTbLVQQDUIPDkIPDkIPDULc\r\n\
|
pub const DEFAULT_LOGO_BASE64: &str = include_str!(concat!(
|
||||||
LVTcLVQQDkLbLFTcLFTbLFQPDkIQDkIQDULbLVUSEkPcLVTbLVQQDULbLFTcLVTbLVTbLVPbLFQP\r\n\
|
env!("CARGO_MANIFEST_DIR"),
|
||||||
DUHbLVMPDkPbLVTdLFXbLFQNDULcLVTcLlMRD0cQDkIQDkXpMFnrMFrtMFvlL1jjLlfdLVThLlYS\r\n\
|
"/../../resources/branding/email-logo.png.b64"
|
||||||
EErnL1gRD0n0Ml2YjG1wAAAAWnRSTlMABAb59/379wr7/lMF9HgoBvQJF/BtZSQwGu7JNulAO95x\r\n\
|
));
|
||||||
WD3TsF1M3b6kH5MRiRe5saujyH9oHezk4tjGmn9fwkc1vrVqYA8N19DPqnFQkYh1V0a4LSITmSiJ\r\n\
|
|
||||||
LN30AAAKZUlEQVRYw91ZCVvbRhCVrcuWD7AxYLANtrEx5opDIBCOQBLO5qTQ0uyubyD//x/0za4O\r\n\
|
|
||||||
RIG0/dLvazu0tbTaWc3bOd6sqv2PJen9/LcF9o++fnf58r8OBcb/vNBttzvrc0Ck/VcFMN6+a/fs\r\n\
|
|
||||||
uG23+x9+fcopkUiE/ks/35ew2j8ucMCz3wbteNwZtIcZp2O/jj3mlWjo+t8nHzNdx3ac/sLrTDee\r\n\
|
|
||||||
GXRf7cMpD+OYnF9dO6zvnc/gOuLv93dBRScTkz/KWrwu+mBUfTrrwhHtod1b0J696N/E7T5S5UEo\r\n\
|
|
||||||
xbrBlIidyp90CvCO1cu56you/jFBAP1y2evZdq/z4suwv3CiaXPr7X7Gbg9fbP4xvtKccSNlGEYq\r\n\
|
|
||||||
ZQpWG3GRTM2ki49biUkjgpks8aOAZCcT6ZX7ME4+Izkyw85Pn7T9m+FCjMb2X3UHSJXM62TIKxFt\r\n\
|
|
||||||
lXHdEPAG/jUNnelFQjJVM8T1BK4e98hIiqd+DBCskcgZjE/dW+zlFiVHd+sjXfcICEnsS7wTtwfd\r\n\
|
|
||||||
hZ8DJFBsMNPirLxabSQqTSFMQ5RWyErBDTH/NBCdGz8MyPm1wUtjocXe/NQZZuLtwednMN4FkpSO\r\n\
|
|
||||||
+vqhh1Tp3bx7qwXyXuhcLLsLNHJAwvZg/6TBLRYGEoE8AOTplAqrPx6nR8wQ01N3Fht90evF4+2b\r\n\
|
|
||||||
y1+k7QEQLTkqQfbt+M3wi29NUZgWO8RSJLg1ucVTY6hj5j0geCh//pZHAvX7s+nNtOr4fSCbw0Hc\r\n\
|
|
||||||
cdbd8ElKIIESMf3AsftbSW8nJphusqqWdTMOGQNgVRdINUQvkZWrLP24Ix6Q74qn46kHEtwqIKda\r\n\
|
|
||||||
IJtbDgQ4YpoLxMk89+Xy+W/rAyc+OPOBpBki6wIX7v2s4CZblUBMXjtstgoNLYK/6PxarVQqt8Zh\r\n\
|
|
||||||
zEThEKMKSCPaah4WKporK/Vm6yCP+SqDC61ma0paW10k9SYVpirUq3Jqcw23F8tr0K8wg+sHrSbN\r\n\
|
|
||||||
l/LMadvxwfDyKyKL/vn4DQSipNdG19XtO3G7+8rf6Ap5pKJlPZ9H8/l8ccoFQrXsukIoG2Vcco6R\r\n\
|
|
||||||
UkNrXfPrI83zSA135YhXOm6hseHt8yEe1Wh4puapT89rixjdQF3kGFvKHtA7FskOeow/RbGKCG2n\r\n\
|
|
||||||
7Xw5kdG1+bzXaUvp9uIkjiRGH0iCWSafzitmDUhdATFNpP4RZm0wYRm6EFTKxHxBpMSEC6QKE1I6\r\n\
|
|
||||||
B+NItWWWMsSupmRsmudok7QjqoaGVOdsoiVS7Fw+tfhx4dpI5cSyAoL3cWPyTmvScWx0JC9din+h\r\n\
|
|
||||||
5POH+MBx4rJVCWTKgLrQV4tZt7QADWxSQCAWgCCATcsSorxbKDGMUUELgOQt/KSBltR3hMW5vuRG\r\n\
|
|
||||||
Fowz84glMJUl2PbuLtRNqOssDSA5WJ7CdeAREssDEqNmcQg+7Ldp42PJoCwPHLgq/iWG0WDrl29T\r\n\
|
|
||||||
WJzx7cONxEjUTz9KdpFeQqCNaXm80WKFGWBdmS8JHdABxE/2XWGwA68GcuBgx4CFu0WWYvDOEqw1\r\n\
|
|
||||||
We0C6qeJMtR9ILRVopmeqMxQsvPczCTeB9UACrXvGftGNu8xd6SD0PJHAokc3ILPLcoHxsuL1RUM\r\n\
|
|
||||||
uUC8klRnusXqXryUsekhIGkGnyxBi0JQJycuyiWy20JavMcMizW9TgS8FQJSfaRqPfuEFB+VB6qB\r\n\
|
|
||||||
HVfNu+sjGz3LG8DA0093XBJdRIaZOgmhKW1g0OeRLHEjDBZlTJSGSP+EgOTJdtXN7FJkmWI7S+l3\r\n\
|
|
||||||
IWBoXpvKcZ26haxSh39MH4iOZEFqRh7ikcE7UGGMjrh2x3Gb949bXVx2tl5iGI/mzohHgkI+u1gS\r\n\
|
|
||||||
sqpYhoFYLqxo0YAQI0Q1iJagQi8y/S6QCJoDg61pEQlZ7BSELmaI5pZhW0GT5QSGZz31VWZ5QHRA\r\n\
|
|
||||||
VlXCAxIJPJLpqOYEmF4M2zZS5d16Fz1Lx/l8Iod//TDsnmlhOb3YqO+kBMBYpkFxfRfIHkuJ3NSd\r\n\
|
|
||||||
7iwMJIuAMtSWTyApNiZuU2yV7N4RBhm8qlI+YKogRwwZhA8D2cygXfS3fl12JNTQ954rR8Veo3V0\r\n\
|
|
||||||
FkbvdkBu2V1qrL5HncTy46EW5RDm1QLQ8lEABFPyVPgasL3ODFacFJbYpcTnkBGNHCi2Tz0TI1R2\r\n\
|
|
||||||
fY/IaveYR7po4LuUDKNu827b6j6WdJv5+Dd4JCwemEZJwKhCCMgBgBz4M5UFodBCbKXYsqZdlbjY\r\n\
|
|
||||||
jmo1IclgA2oEqA4gO8GLqAYEQMYfBYIjleuBrzBdNu/tNjX0SSpdOF5RMcPxypVssUgFNjhrFok/\r\n\
|
|
||||||
SlfaiOUDacKiwl3qSYWBRMhmUctqDYHOmWLJYMdIfKhRaqwBSPlOr3la+nNAvENuB9wuoXzd3/dy\r\n\
|
|
||||||
BgfeTMDrpL9kCo5AjfqOgdk6zy3dBbIGM8ungQ5VqRAQFUVFmd3owWYo92kB1CwA2aMFx4LQWjL4\r\n\
|
|
||||||
nwIympSfHRS3K0dIGMnX7uA+BZ3vEbCCKAdNaJQ2kE+PBUAitMMwM+h6j8LJTuM12XXsCF46pTW5\r\n\
|
|
||||||
KMnEL+BGcrZoBMleFeb3gYS5XW1+LBaTvNIhN4V5XUWwicYp6582doCshhxxgeCv4R22ICojrDCQ\r\n\
|
|
||||||
qIymwxGhUxWKkg9EHrROqeyeeOq4UrUFLrceBxINkv1jUkv63N6TTC7v+rbP9JgUC5pGHXVqlq6j\r\n\
|
|
||||||
tEqF9mtZJrvOqpIQT7cFtfowEUJJbPH7QGaJBfdAIChiWFMYYrEMXlJFt0bq85qm1I+Y+Vho5a5k\r\n\
|
|
||||||
BCjZ7C/c5/ZRfKgL8fp+JqP5sssMU5jpK1WQVgU3qWhSJFOkS0lLsG6/soGyFgaimkWTQ6ZX5KFk\r\n\
|
|
||||||
mq5l8dMUoeLhsVI/4vxBIJjExXHoYNXuXXqUobh94VWY19fbnbNk0K8bwtA5m24try63cozrqrhH\r\n\
|
|
||||||
y7SP7+v11h7CDWC5aI03EukaQ3m4D4RYXPaCa3QXkXkWkIR2INUPKonEeUFA/SEgM7Q/VrNeb07g\r\n\
|
|
||||||
joDEbfrwcBJwu90P83q/ZzuvRoMzbDHHcFjgjISncIHAIj6/zYFRmLgFM0xOM90wGROMERuDskNA\r\n\
|
|
||||||
lB2cYlG1vccEhCIroihwG+oW1AWDZr0mjHtAVIeZMjm19Mtq6GSBPgXJ7deSHrcrXh9VTsInoW/v\r\n\
|
|
||||||
7n7AWGoJOjfRNzpYnhpXWzRVuuVwj8Fr5Lb3DCwH0QXbI4aUQAw/tKQDLaqyEBmXpi4K/hvGcAz0\r\n\
|
|
||||||
1NdQX1J+izLu15AqY1DSU2LVHXr22ekPB+1vZ59wI7m815M8j7uXW996g2Evg5Y4EKhdrJWE8sjO\r\n\
|
|
||||||
xpRf79emucAmluWU8RqXE94nNDqr3tJRlwt+W/WOhtecX9e9NZu3uEsH3KEdF6S62DnWaOo1AdGh\r\n\
|
|
||||||
XgnqVKNg4H3c8wjk69zc3Nu3b+ZG3c+Oc3SVJG+9efP2LR5u/vFDxkqxOn5+lMhTwPujK/nZ2dmZ\r\n\
|
|
||||||
vDslX61U5vMS4szsDPY+S0+vvL7lAoNeZ4kZeHQaesNIYvz8uCg7AzUzWpwNNJRWkVZceur/vSUf\r\n\
|
|
||||||
GAtDCZrI0KAvoeG/Lt9Xx5MfIhFiicj9QSmhGd649zg098nPik+rB+/T/k/yO9A7bEvKcQkCAAAA\r\n\
|
|
||||||
AElFTkSuQmCC";
|
|
||||||
|
|||||||
@@ -11,11 +11,8 @@ use registry::schema::{enums::CompressionAlgo, structs::Application};
|
|||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
io::{self, Cursor, Read},
|
io::{self, Cursor, Read},
|
||||||
path::{Path, PathBuf},
|
path::PathBuf,
|
||||||
sync::{
|
sync::Arc,
|
||||||
Arc,
|
|
||||||
atomic::{AtomicU64, Ordering},
|
|
||||||
},
|
|
||||||
time::Duration,
|
time::Duration,
|
||||||
};
|
};
|
||||||
use store::{
|
use store::{
|
||||||
@@ -39,18 +36,16 @@ enum IndexEdit<'x> {
|
|||||||
pub struct WebApplications {
|
pub struct WebApplications {
|
||||||
applications: ArcSwap<Vec<WebApplicationManager>>,
|
applications: ArcSwap<Vec<WebApplicationManager>>,
|
||||||
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
|
routes: ArcSwap<AHashMap<String, Arc<AppRoutes>>>,
|
||||||
generation: AtomicU64,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct AppRoutes {
|
pub struct AppRoutes {
|
||||||
resources: AHashMap<String, Resource<PathBuf>>,
|
resources: AHashMap<String, Resource<PathBuf>>,
|
||||||
oauth_client_id_meta: Option<String>,
|
oauth_client_id_meta: Option<String>,
|
||||||
_bundle_dir: TempDir,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct WebApplicationManager {
|
pub struct WebApplicationManager {
|
||||||
base_path: PathBuf,
|
bundle_path: TempDir,
|
||||||
prefixes: Vec<String>,
|
prefixes: Vec<String>,
|
||||||
description: String,
|
description: String,
|
||||||
url: String,
|
url: String,
|
||||||
@@ -84,7 +79,6 @@ impl WebApplications {
|
|||||||
Self {
|
Self {
|
||||||
applications: ArcSwap::new(Arc::new(Vec::new())),
|
applications: ArcSwap::new(Arc::new(Vec::new())),
|
||||||
routes: ArcSwap::new(Arc::new(AHashMap::new())),
|
routes: ArcSwap::new(Arc::new(AHashMap::new())),
|
||||||
generation: AtomicU64::new(0),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,55 +128,48 @@ impl WebApplications {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn unpack_all(&self, server: &Server, update: bool) {
|
pub async fn unpack_all(&self, server: &Server, update: bool) {
|
||||||
let previous = self.routes.load_full();
|
let mut routes = AHashMap::new();
|
||||||
let sweep_orphans = previous.is_empty();
|
|
||||||
let mut routes = AHashMap::with_capacity(previous.len());
|
|
||||||
|
|
||||||
for app in self.applications.load().as_ref() {
|
for app in self.applications.load().as_ref() {
|
||||||
match app
|
if update && let Err(err) = app.delete(server).await {
|
||||||
.unpack(server, self.next_generation(), update, sweep_orphans)
|
trc::event!(
|
||||||
.await
|
Resource(trc::ResourceEvent::Error),
|
||||||
{
|
Reason = err,
|
||||||
Ok(app_routes) => {
|
Url = app.url.clone(),
|
||||||
let app_routes = Arc::new(app_routes);
|
Details = format!(
|
||||||
|
"Failed to delete application bundle for prefixes: {}",
|
||||||
|
app.prefixes.join(", ")
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
match app.unpack(server).await {
|
||||||
|
Ok(resources) => {
|
||||||
|
let app_routes = Arc::new(AppRoutes {
|
||||||
|
resources,
|
||||||
|
oauth_client_id_meta: app
|
||||||
|
.oauth_client_id
|
||||||
|
.as_deref()
|
||||||
|
.map(oauth_client_id_meta),
|
||||||
|
});
|
||||||
|
|
||||||
for prefix in &app.prefixes {
|
for prefix in &app.prefixes {
|
||||||
routes.insert(prefix.clone(), app_routes.clone());
|
routes.insert(prefix.clone(), app_routes.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
let mut is_retained = false;
|
|
||||||
for prefix in &app.prefixes {
|
|
||||||
if let Some(app_routes) = previous.get(prefix) {
|
|
||||||
routes.insert(prefix.clone(), app_routes.clone());
|
|
||||||
is_retained = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Resource(trc::ResourceEvent::Error),
|
Resource(trc::ResourceEvent::Error),
|
||||||
Reason = err,
|
Reason = err,
|
||||||
Url = app.url.clone(),
|
Url = app.url.clone(),
|
||||||
Details = format!(
|
Details = format!(
|
||||||
"Failed to unpack application for prefixes: {}, {}",
|
"Failed to unpack application for prefixes: {}",
|
||||||
app.prefixes.join(", "),
|
app.prefixes.join(", ")
|
||||||
if is_retained {
|
|
||||||
"the previously unpacked bundle remains in service"
|
|
||||||
} else {
|
|
||||||
"no bundle is available to serve"
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.routes.store(Arc::new(routes));
|
self.routes.store(Arc::new(routes));
|
||||||
}
|
}
|
||||||
|
|
||||||
fn next_generation(&self) -> u64 {
|
|
||||||
self.generation.fetch_add(1, Ordering::Relaxed)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebApplicationManager {
|
impl WebApplicationManager {
|
||||||
@@ -195,7 +182,7 @@ impl WebApplicationManager {
|
|||||||
.join(app.id.id().to_string());
|
.join(app.id.id().to_string());
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
base_path,
|
bundle_path: TempDir::new(base_path),
|
||||||
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
|
blob_key: BlobHash::generate(format!("{}{}", APP_BLOB_PREFIX, app.id.id()).as_bytes()),
|
||||||
url: app.object.resource_url,
|
url: app.object.resource_url,
|
||||||
description: app.object.description,
|
description: app.object.description,
|
||||||
@@ -215,43 +202,82 @@ impl WebApplicationManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn unpack(
|
async fn unpack(&self, server: &Server) -> trc::Result<AHashMap<String, Resource<PathBuf>>> {
|
||||||
&self,
|
// Delete any existing bundles
|
||||||
server: &Server,
|
self.bundle_path.clean().await.map_err(unpack_error)?;
|
||||||
generation: u64,
|
|
||||||
force_refresh: bool,
|
// Obtain application bundle
|
||||||
sweep_orphans: bool,
|
let bundle = if let Some(bundle) = server
|
||||||
) -> trc::Result<AppRoutes> {
|
|
||||||
let cached = if force_refresh {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
server
|
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
.get_blob(self.blob_key.as_slice(), 0..usize::MAX)
|
||||||
.await?
|
.await?
|
||||||
};
|
{
|
||||||
let is_cached = cached.is_some();
|
bundle
|
||||||
let bundle = match cached {
|
} else {
|
||||||
Some(bundle) => bundle,
|
// Fetch app bundle
|
||||||
None => self.fetch().await?,
|
let resource = fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
||||||
};
|
.await
|
||||||
|
.map_err(|err| {
|
||||||
|
trc::ResourceEvent::Error
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
.ctx(Key::Url, self.url.clone())
|
||||||
|
.reason(err)
|
||||||
|
.details("Failed to fetch application bundle")
|
||||||
|
})?;
|
||||||
|
|
||||||
let staging = TempDir::new(self.base_path.join(format!("{:x}-{generation:x}", now())));
|
// Store in blob store for future use
|
||||||
staging.create().await.map_err(unpack_error)?;
|
server
|
||||||
|
.blob_store()
|
||||||
|
.put_blob(self.blob_key.as_slice(), &resource, CompressionAlgo::None)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
// Schedule expiration
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
batch
|
||||||
|
.set(
|
||||||
|
BlobOp::Link {
|
||||||
|
hash: self.blob_key.clone(),
|
||||||
|
to: BlobLink::Temporary {
|
||||||
|
until: now() + self.expiry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
vec![],
|
||||||
|
)
|
||||||
|
.set(
|
||||||
|
BlobOp::Commit {
|
||||||
|
hash: self.blob_key.clone(),
|
||||||
|
},
|
||||||
|
Vec::new(),
|
||||||
|
);
|
||||||
|
server
|
||||||
|
.store()
|
||||||
|
.write(batch.build_all())
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
|
||||||
|
trc::event!(
|
||||||
|
Resource(trc::ResourceEvent::ApplicationUpdated),
|
||||||
|
Url = self.url.clone(),
|
||||||
|
Details = self.description.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
resource
|
||||||
|
};
|
||||||
|
|
||||||
let url = self.url.clone();
|
let url = self.url.clone();
|
||||||
let bundle_path = staging.path.clone();
|
let bundle_path = self.bundle_path.path.clone();
|
||||||
let (resources, bundle) = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
let routes = tokio::task::spawn_blocking(move || -> trc::Result<_> {
|
||||||
let mut archive = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
let mut bundle = zip::ZipArchive::new(Cursor::new(bundle)).map_err(|err| {
|
||||||
trc::ResourceEvent::Error
|
trc::ResourceEvent::Error
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.reason(err)
|
.reason(err)
|
||||||
.ctx(Key::Url, url.clone())
|
.ctx(Key::Url, url.clone())
|
||||||
.details("Failed to decompress application bundle")
|
.details("Failed to decompress application bundle")
|
||||||
})?;
|
})?;
|
||||||
let mut resources = AHashMap::with_capacity(archive.len());
|
let mut routes = AHashMap::new();
|
||||||
for i in 0..archive.len() {
|
for i in 0..bundle.len() {
|
||||||
let mut file = archive.by_index(i).map_err(|err| {
|
let mut file = bundle.by_index(i).map_err(|err| {
|
||||||
trc::ResourceEvent::Error
|
trc::ResourceEvent::Error
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
.reason(err)
|
.reason(err)
|
||||||
@@ -289,9 +315,9 @@ impl WebApplicationManager {
|
|||||||
contents: path,
|
contents: path,
|
||||||
};
|
};
|
||||||
|
|
||||||
resources.insert(file_name, resource);
|
routes.insert(file_name, resource);
|
||||||
}
|
}
|
||||||
Ok((resources, archive.into_inner().into_inner()))
|
Ok(routes)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|err| {
|
.map_err(|err| {
|
||||||
@@ -301,81 +327,21 @@ impl WebApplicationManager {
|
|||||||
.details("Bundle unpack task panicked")
|
.details("Bundle unpack task panicked")
|
||||||
})??;
|
})??;
|
||||||
|
|
||||||
if !is_cached && let Err(err) = self.cache(server, &bundle).await {
|
|
||||||
trc::event!(
|
|
||||||
Resource(trc::ResourceEvent::Error),
|
|
||||||
Reason = err,
|
|
||||||
Url = self.url.clone(),
|
|
||||||
Details = "Failed to cache application bundle, it will be downloaded again"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if sweep_orphans {
|
|
||||||
remove_siblings(&self.base_path, &staging.path).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
Resource(trc::ResourceEvent::ApplicationUnpacked),
|
||||||
Url = self.url.clone(),
|
Url = self.url.clone(),
|
||||||
Path = staging.path.to_string_lossy().into_owned(),
|
Path = self.bundle_path.path.to_string_lossy().into_owned(),
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(AppRoutes {
|
Ok(routes)
|
||||||
resources,
|
|
||||||
oauth_client_id_meta: self.oauth_client_id.as_deref().map(oauth_client_id_meta),
|
|
||||||
_bundle_dir: staging,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fetch(&self) -> trc::Result<Vec<u8>> {
|
async fn delete(&self, server: &Server) -> trc::Result<()> {
|
||||||
fetch_resource(&self.url, None, Duration::from_secs(60), MAX_APP_SIZE)
|
|
||||||
.await
|
|
||||||
.map_err(|err| {
|
|
||||||
trc::ResourceEvent::Error
|
|
||||||
.caused_by(trc::location!())
|
|
||||||
.ctx(Key::Url, self.url.clone())
|
|
||||||
.reason(err)
|
|
||||||
.details("Failed to fetch application bundle")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn cache(&self, server: &Server, bundle: &[u8]) -> trc::Result<()> {
|
|
||||||
server
|
server
|
||||||
.blob_store()
|
.blob_store()
|
||||||
.put_blob(self.blob_key.as_slice(), bundle, CompressionAlgo::None)
|
.delete_blob(self.blob_key.as_slice())
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())?;
|
.map(|_| ())
|
||||||
|
|
||||||
let mut batch = BatchBuilder::new();
|
|
||||||
batch
|
|
||||||
.set(
|
|
||||||
BlobOp::Link {
|
|
||||||
hash: self.blob_key.clone(),
|
|
||||||
to: BlobLink::Temporary {
|
|
||||||
until: now() + self.expiry,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
vec![],
|
|
||||||
)
|
|
||||||
.set(
|
|
||||||
BlobOp::Commit {
|
|
||||||
hash: self.blob_key.clone(),
|
|
||||||
},
|
|
||||||
Vec::new(),
|
|
||||||
);
|
|
||||||
server
|
|
||||||
.store()
|
|
||||||
.write(batch.build_all())
|
|
||||||
.await
|
|
||||||
.caused_by(trc::location!())?;
|
|
||||||
|
|
||||||
trc::event!(
|
|
||||||
Resource(trc::ResourceEvent::ApplicationUpdated),
|
|
||||||
Url = self.url.clone(),
|
|
||||||
Details = self.description.clone(),
|
|
||||||
);
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
pub async fn delete_bundle(server: &Server, app_id: Id) -> trc::Result<()> {
|
||||||
@@ -395,6 +361,7 @@ impl Resource<Vec<u8>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
pub struct TempDir {
|
pub struct TempDir {
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
}
|
}
|
||||||
@@ -404,36 +371,11 @@ impl TempDir {
|
|||||||
TempDir { path }
|
TempDir { path }
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn create(&self) -> io::Result<()> {
|
pub async fn clean(&self) -> io::Result<()> {
|
||||||
if tokio::fs::metadata(&self.path).await.is_ok() {
|
if tokio::fs::metadata(&self.path).await.is_ok() {
|
||||||
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
let _ = tokio::fs::remove_dir_all(&self.path).await;
|
||||||
}
|
}
|
||||||
tokio::fs::create_dir_all(&self.path).await
|
tokio::fs::create_dir(&self.path).await
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for TempDir {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = std::fs::remove_dir_all(&self.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn remove_siblings(base_path: &Path, keep: &Path) {
|
|
||||||
let Ok(mut entries) = tokio::fs::read_dir(base_path).await else {
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
while let Ok(Some(entry)) = entries.next_entry().await {
|
|
||||||
let path = entry.path();
|
|
||||||
if path == keep {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(entry.file_type().await, Ok(file_type) if file_type.is_dir()) {
|
|
||||||
let _ = tokio::fs::remove_dir_all(&path).await;
|
|
||||||
} else {
|
|
||||||
let _ = tokio::fs::remove_file(&path).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +385,12 @@ fn unpack_error(err: std::io::Error) -> trc::Error {
|
|||||||
.details("Failed to unpack application bundle")
|
.details("Failed to unpack application bundle")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Drop for TempDir {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_dir_all(&self.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Default for WebApplications {
|
impl Default for WebApplications {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::new()
|
Self::new()
|
||||||
@@ -573,9 +521,9 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn fixture(name: &str, client_id: Option<&str>) -> WebApplications {
|
async fn fixture(name: &str, client_id: Option<&str>) -> (WebApplications, TempDir) {
|
||||||
let dir = TempDir::new(std::env::temp_dir().join(format!("stalwart-app-{name}")));
|
let dir = TempDir::new(std::env::temp_dir().join(format!("inbuxa-app-{name}")));
|
||||||
dir.create().await.unwrap();
|
dir.clean().await.unwrap();
|
||||||
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
tokio::fs::write(dir.path.join("index.html"), INDEX)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -596,7 +544,6 @@ mod tests {
|
|||||||
let routes = Arc::new(AppRoutes {
|
let routes = Arc::new(AppRoutes {
|
||||||
resources,
|
resources,
|
||||||
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
oauth_client_id_meta: client_id.map(oauth_client_id_meta),
|
||||||
_bundle_dir: dir,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut map = AHashMap::new();
|
let mut map = AHashMap::new();
|
||||||
@@ -606,7 +553,7 @@ mod tests {
|
|||||||
let apps = WebApplications::new();
|
let apps = WebApplications::new();
|
||||||
apps.routes.store(Arc::new(map));
|
apps.routes.store(Arc::new(map));
|
||||||
|
|
||||||
apps
|
(apps, dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
async fn serve_html(apps: &WebApplications, prefix: &str, path: &str) -> String {
|
||||||
@@ -618,7 +565,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serving_index_injects_the_prefix_and_client_id() {
|
async fn serving_index_injects_the_prefix_and_client_id() {
|
||||||
let apps = fixture("serve-configured", Some("pocket-id-client")).await;
|
let (apps, _dir) = fixture("serve-configured", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "index.html").await;
|
let html = serve_html(&apps, "admin", "index.html").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -637,7 +584,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
async fn unknown_paths_fall_back_to_a_rewritten_index() {
|
||||||
let apps = fixture("serve-fallback", Some("pocket-id-client")).await;
|
let (apps, _dir) = fixture("serve-fallback", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "settings/directory").await;
|
let html = serve_html(&apps, "admin", "settings/directory").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -649,7 +596,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn assets_and_unknown_prefixes_are_untouched() {
|
async fn assets_and_unknown_prefixes_are_untouched() {
|
||||||
let apps = fixture("serve-assets", Some("pocket-id-client")).await;
|
let (apps, _dir) = fixture("serve-assets", Some("pocket-id-client")).await;
|
||||||
|
|
||||||
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
let served = apps.serve("admin", "app.js").await.unwrap().unwrap();
|
||||||
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
assert_eq!(served.resource.contents, b"export const x = 1;\n");
|
||||||
@@ -661,7 +608,7 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
async fn serving_index_without_a_client_id_keeps_the_placeholder() {
|
||||||
let apps = fixture("serve-unconfigured", None).await;
|
let (apps, _dir) = fixture("serve-unconfigured", None).await;
|
||||||
|
|
||||||
let html = serve_html(&apps, "admin", "index.html").await;
|
let html = serve_html(&apps, "admin", "index.html").await;
|
||||||
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
assert!(html.contains("<base href=\"/admin/\" />"), "{html}");
|
||||||
@@ -677,65 +624,4 @@ mod tests {
|
|||||||
|
|
||||||
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
assert_eq!(rewrite_index(bundle, "admin", None), bundle.as_bytes());
|
||||||
}
|
}
|
||||||
#[tokio::test]
|
|
||||||
async fn missing_parent_directories_are_created() {
|
|
||||||
let base = std::env::temp_dir().join("stalwart-app-nested");
|
|
||||||
let _ = tokio::fs::remove_dir_all(&base).await;
|
|
||||||
|
|
||||||
let dir = TempDir::new(base.join("webui").join("0"));
|
|
||||||
dir.create().await.unwrap();
|
|
||||||
|
|
||||||
assert!(tokio::fs::metadata(&dir.path).await.is_ok());
|
|
||||||
|
|
||||||
drop(dir);
|
|
||||||
let _ = tokio::fs::remove_dir_all(&base).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn dropping_the_routes_removes_the_bundle_directory() {
|
|
||||||
let apps = fixture("drop-guard", None).await;
|
|
||||||
let path = apps
|
|
||||||
.routes
|
|
||||||
.load()
|
|
||||||
.get("admin")
|
|
||||||
.unwrap()
|
|
||||||
._bundle_dir
|
|
||||||
.path
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
assert!(tokio::fs::metadata(&path).await.is_ok());
|
|
||||||
|
|
||||||
apps.routes.store(Arc::new(AHashMap::new()));
|
|
||||||
|
|
||||||
assert!(tokio::fs::metadata(&path).await.is_err());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn sweeping_orphans_spares_the_current_generation() {
|
|
||||||
let base = std::env::temp_dir().join("stalwart-app-sweep");
|
|
||||||
let _ = tokio::fs::remove_dir_all(&base).await;
|
|
||||||
|
|
||||||
let current = TempDir::new(base.join("1"));
|
|
||||||
current.create().await.unwrap();
|
|
||||||
let orphan = base.join("0");
|
|
||||||
tokio::fs::create_dir_all(&orphan).await.unwrap();
|
|
||||||
let stray = base.join("webui.zip");
|
|
||||||
tokio::fs::write(&stray, b"not a bundle").await.unwrap();
|
|
||||||
|
|
||||||
remove_siblings(&base, ¤t.path).await;
|
|
||||||
|
|
||||||
assert!(tokio::fs::metadata(¤t.path).await.is_ok());
|
|
||||||
assert!(tokio::fs::metadata(&orphan).await.is_err());
|
|
||||||
assert!(tokio::fs::metadata(&stray).await.is_err());
|
|
||||||
|
|
||||||
drop(current);
|
|
||||||
let _ = tokio::fs::remove_dir_all(&base).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn generations_never_repeat() {
|
|
||||||
let apps = WebApplications::new();
|
|
||||||
|
|
||||||
assert_ne!(apps.next_generation(), apps.next_generation());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::Core;
|
use crate::Core;
|
||||||
@@ -321,6 +323,7 @@ impl Family {
|
|||||||
SUBSPACE_REGISTRY_IDX,
|
SUBSPACE_REGISTRY_IDX,
|
||||||
SUBSPACE_REGISTRY_PK,
|
SUBSPACE_REGISTRY_PK,
|
||||||
SUBSPACE_DIRECTORY,
|
SUBSPACE_DIRECTORY,
|
||||||
|
store::SUBSPACE_INBUXA, // inbuxa: masked email
|
||||||
],
|
],
|
||||||
Family::Changelog => &[SUBSPACE_LOGS],
|
Family::Changelog => &[SUBSPACE_LOGS],
|
||||||
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
Family::Queue => &[SUBSPACE_QUEUE_MESSAGE, SUBSPACE_QUEUE_EVENT],
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::{backup::BackupParams, console::store_console};
|
use super::{backup::BackupParams, console::store_console};
|
||||||
@@ -38,11 +40,12 @@ pub struct IpcReceivers {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const HELP: &str = concat!(
|
const HELP: &str = concat!(
|
||||||
"Stalwart Server v",
|
types::brand_server!(),
|
||||||
env!("CARGO_PKG_VERSION"),
|
" ",
|
||||||
|
types::brand_version_full!(),
|
||||||
r#"
|
r#"
|
||||||
|
|
||||||
Usage: stalwart [OPTIONS]
|
Usage: inbuxa [OPTIONS]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c, --config <PATH> Start server with the specified configuration file
|
-c, --config <PATH> Start server with the specified configuration file
|
||||||
@@ -87,7 +90,7 @@ impl BootManager {
|
|||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
("version" | "V", _) => {
|
("version" | "V", _) => {
|
||||||
println!("{}", env!("CARGO_PKG_VERSION"));
|
println!("{}", types::brand_version_full!());
|
||||||
std::process::exit(0);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
("config" | "c", Some(value)) => {
|
("config" | "c", Some(value)) => {
|
||||||
@@ -156,8 +159,7 @@ impl BootManager {
|
|||||||
// Enable telemetry
|
// Enable telemetry
|
||||||
|
|
||||||
|
|
||||||
#[cfg(not(feature = "enterprise"))]
|
telemetry.enable();
|
||||||
telemetry.enable(false);
|
|
||||||
|
|
||||||
if bootstrap.registry.is_bootstrap_mode() {
|
if bootstrap.registry.is_bootstrap_mode() {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
@@ -165,20 +167,20 @@ impl BootManager {
|
|||||||
Hostname = bootstrap.registry.local_hostname().to_string(),
|
Hostname = bootstrap.registry.local_hostname().to_string(),
|
||||||
Details =
|
Details =
|
||||||
"No configuration file was found. Port 8080 is open for initial setup.",
|
"No configuration file was found. Port 8080 is open for initial setup.",
|
||||||
Version = env!("CARGO_PKG_VERSION"),
|
Version = types::brand_version_full!(),
|
||||||
);
|
);
|
||||||
} else if bootstrap.registry.is_recovery_mode() {
|
} else if bootstrap.registry.is_recovery_mode() {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Server(trc::ServerEvent::RecoveryMode),
|
Server(trc::ServerEvent::RecoveryMode),
|
||||||
Details = "Port 8080 is open for troubleshooting and recovery.",
|
Details = "Port 8080 is open for troubleshooting and recovery.",
|
||||||
Hostname = bootstrap.registry.local_hostname().to_string(),
|
Hostname = bootstrap.registry.local_hostname().to_string(),
|
||||||
Version = env!("CARGO_PKG_VERSION"),
|
Version = types::brand_version_full!(),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Server(trc::ServerEvent::Startup),
|
Server(trc::ServerEvent::Startup),
|
||||||
Hostname = bootstrap.registry.local_hostname().to_string(),
|
Hostname = bootstrap.registry.local_hostname().to_string(),
|
||||||
Version = env!("CARGO_PKG_VERSION"),
|
Version = types::brand_version_full!(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -240,7 +242,7 @@ impl BootManager {
|
|||||||
}
|
}
|
||||||
StoreOp::Export(path) => {
|
StoreOp::Export(path) => {
|
||||||
// Enable telemetry
|
// Enable telemetry
|
||||||
telemetry.enable(false);
|
telemetry.enable();
|
||||||
|
|
||||||
// Parse settings and backup
|
// Parse settings and backup
|
||||||
Box::pin(Core::parse(&mut bootstrap, storage))
|
Box::pin(Core::parse(&mut bootstrap, storage))
|
||||||
@@ -251,7 +253,7 @@ impl BootManager {
|
|||||||
}
|
}
|
||||||
StoreOp::Import(path) => {
|
StoreOp::Import(path) => {
|
||||||
// Enable telemetry
|
// Enable telemetry
|
||||||
telemetry.enable(false);
|
telemetry.enable();
|
||||||
|
|
||||||
// Parse settings and restore
|
// Parse settings and restore
|
||||||
Box::pin(Core::parse(&mut bootstrap, storage))
|
Box::pin(Core::parse(&mut bootstrap, storage))
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use base64::Engine;
|
use base64::Engine;
|
||||||
@@ -12,8 +14,9 @@ use store::write::{AnyClass, AnyKey, BatchBuilder, ValueClass};
|
|||||||
use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store};
|
use store::{Deserialize, IterateParams, SUBSPACE_INDEXES, SUBSPACE_REGISTRY_IDX, Store};
|
||||||
|
|
||||||
const HELP: &str = concat!(
|
const HELP: &str = concat!(
|
||||||
"Stalwart Server v",
|
types::brand_server!(),
|
||||||
env!("CARGO_PKG_VERSION"),
|
" ",
|
||||||
|
types::brand_version_full!(),
|
||||||
r#" Data Store CLI
|
r#" Data Store CLI
|
||||||
|
|
||||||
Enter commands (type 'help' for available commands).
|
Enter commands (type 'help' for available commands).
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::auth::permissions::DefaultPermissions;
|
use crate::auth::permissions::DefaultPermissions;
|
||||||
@@ -54,28 +56,10 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
let is_recovery_mode = bp.registry.is_recovery_mode();
|
let is_recovery_mode = bp.registry.is_recovery_mode();
|
||||||
let is_bootstrap_mode = bp.registry.is_bootstrap_mode();
|
let is_bootstrap_mode = bp.registry.is_bootstrap_mode();
|
||||||
|
|
||||||
#[cfg(not(feature = "test_mode"))]
|
// inbuxa: no web interface is installed on the mail host, and nothing is
|
||||||
if bp.registry.count_object(ObjectType::Application).await? == 0 {
|
// downloaded for one (docs/spec/SPEC.md §5.3). Administration is INBUXA
|
||||||
bp.registry
|
// Admin and webmail is ihasmail, both deployed separately. An install
|
||||||
.write(RegistryWrite::insert(
|
// upgraded from Stalwart keeps any web application it already has.
|
||||||
&Application {
|
|
||||||
auto_update_frequency: Duration::from_millis(30 * 24 * 60 * 60 * 1000),
|
|
||||||
description: "Stalwart Web Interface".to_string(),
|
|
||||||
enabled: true,
|
|
||||||
#[cfg(not(feature = "dev_mode"))]
|
|
||||||
resource_url:
|
|
||||||
"https://github.com/stalwartlabs/webui/releases/latest/download/webui.zip"
|
|
||||||
.into(),
|
|
||||||
#[cfg(feature = "dev_mode")]
|
|
||||||
resource_url: "file:///Users/me/code/webui/.ignore/webui.zip".into(),
|
|
||||||
unpack_directory: None,
|
|
||||||
oauth_client_id: None,
|
|
||||||
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
|
||||||
}
|
|
||||||
.into(),
|
|
||||||
))
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
if is_bootstrap_mode {
|
if is_bootstrap_mode {
|
||||||
#[cfg(not(any(feature = "dev_mode", feature = "test_mode")))]
|
#[cfg(not(any(feature = "dev_mode", feature = "test_mode")))]
|
||||||
@@ -98,6 +82,10 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: registration is required (contract C-5), so the first-party
|
||||||
|
// front ends are registered on every start (C-6)
|
||||||
|
super::first_party::ensure_first_party_clients(bp).await?;
|
||||||
|
|
||||||
if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 {
|
if bp.registry.count_object(ObjectType::MtaQueueQuota).await? == 0 {
|
||||||
bp.registry
|
bp.registry
|
||||||
.write(RegistryWrite::insert(
|
.write(RegistryWrite::insert(
|
||||||
@@ -527,9 +515,9 @@ async fn insert_safe_defaults(bp: &mut Bootstrap) -> trc::Result<()> {
|
|||||||
&Tracer::Log(TracerLog {
|
&Tracer::Log(TracerLog {
|
||||||
enable: true,
|
enable: true,
|
||||||
ansi: false,
|
ansi: false,
|
||||||
prefix: "stalwart.log".into(),
|
prefix: "inbuxa.log".into(),
|
||||||
rotate: LogRotateFrequency::Daily,
|
rotate: LogRotateFrequency::Daily,
|
||||||
path: "/var/log/stalwart".into(),
|
path: "/var/log/inbuxa".into(),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
})
|
})
|
||||||
.into(),
|
.into(),
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! First-party OAuth clients (docs/spec/contract.md C-6).
|
||||||
|
//!
|
||||||
|
//! INBUXA requires OAuth clients to be registered (C-5), so the front ends
|
||||||
|
//! that ship with it are registered for it, on every start:
|
||||||
|
//!
|
||||||
|
//! - the web interface the server serves itself (`Application`, `/admin` and
|
||||||
|
//! `/account`), as its OAuth client id, `stalwart-webui` unless the
|
||||||
|
//! application names another;
|
||||||
|
//! - INBUXA Admin hosted elsewhere, as `inbuxa-admin`, when `INBUXA_ADMIN_URL`
|
||||||
|
//! is set;
|
||||||
|
//! - ihasmail-inbuxa, as the confidential client `ihasmail-inbuxa`, when
|
||||||
|
//! `INBUXA_WEBMAIL_URL` and `INBUXA_WEBMAIL_CLIENT_SECRET` are set.
|
||||||
|
//!
|
||||||
|
//! inbuxa: the environment variables stand in for `x:FrontEnds` (C-4) until
|
||||||
|
//! that object exists; the installer and INBUXA Admin's setup wizard will set
|
||||||
|
//! it instead.
|
||||||
|
//!
|
||||||
|
//! A missing client is created. An existing one gains any redirect URI it
|
||||||
|
//! lacks and, for ihasmail-inbuxa, the configured secret; nothing an operator
|
||||||
|
//! added is removed.
|
||||||
|
|
||||||
|
use directory::core::secret::{hash_secret, verify_secret_hash};
|
||||||
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
enums::{PasswordHashAlgorithm, ServiceProtocol},
|
||||||
|
prelude::{ObjectType, Property, UTCDateTime},
|
||||||
|
structs::{Application, OAuthClient, SystemSettings},
|
||||||
|
},
|
||||||
|
types::map::Map,
|
||||||
|
};
|
||||||
|
use store::registry::{
|
||||||
|
bootstrap::Bootstrap,
|
||||||
|
write::{RegistryWrite, RegistryWriteResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The client id the upstream web interface uses when its application names none.
|
||||||
|
pub const WEB_INTERFACE_CLIENT_ID: &str = "stalwart-webui";
|
||||||
|
pub const ADMIN_CLIENT_ID: &str = "inbuxa-admin";
|
||||||
|
pub const WEBMAIL_CLIENT_ID: &str = "ihasmail-inbuxa";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct FirstPartyClient {
|
||||||
|
pub client_id: String,
|
||||||
|
pub description: String,
|
||||||
|
pub redirect_uris: Vec<String>,
|
||||||
|
pub secret: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The first-party clients this server should have, from its applications and
|
||||||
|
/// the front-end addresses it was given.
|
||||||
|
pub fn first_party_clients(
|
||||||
|
base_url: &str,
|
||||||
|
applications: &[Application],
|
||||||
|
admin_url: Option<&str>,
|
||||||
|
webmail: Option<(&str, &str)>,
|
||||||
|
) -> Vec<FirstPartyClient> {
|
||||||
|
let base_url = base_url.trim_end_matches('/');
|
||||||
|
let mut clients: Vec<FirstPartyClient> = Vec::new();
|
||||||
|
|
||||||
|
for app in applications.iter().filter(|app| app.enabled) {
|
||||||
|
let client_id = app
|
||||||
|
.oauth_client_id
|
||||||
|
.as_deref()
|
||||||
|
.filter(|id| !id.is_empty())
|
||||||
|
.unwrap_or(WEB_INTERFACE_CLIENT_ID);
|
||||||
|
let redirect_uris = app
|
||||||
|
.url_prefix
|
||||||
|
.iter()
|
||||||
|
.map(|prefix| {
|
||||||
|
format!(
|
||||||
|
"{base_url}/{}/oauth/callback",
|
||||||
|
prefix.trim_matches('/')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if redirect_uris.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(client) = clients.iter_mut().find(|c| c.client_id == client_id) {
|
||||||
|
for uri in redirect_uris {
|
||||||
|
if !client.redirect_uris.contains(&uri) {
|
||||||
|
client.redirect_uris.push(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clients.push(FirstPartyClient {
|
||||||
|
client_id: client_id.to_string(),
|
||||||
|
description: format!("{} (served by this server)", app.description),
|
||||||
|
redirect_uris,
|
||||||
|
secret: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(url) = admin_url.map(|url| url.trim().trim_end_matches('/')).filter(|url| !url.is_empty()) {
|
||||||
|
clients.push(FirstPartyClient {
|
||||||
|
client_id: ADMIN_CLIENT_ID.to_string(),
|
||||||
|
description: "INBUXA Admin".to_string(),
|
||||||
|
redirect_uris: vec![format!("{url}/oauth/callback")],
|
||||||
|
secret: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((url, secret)) = webmail {
|
||||||
|
let url = url.trim().trim_end_matches('/');
|
||||||
|
if !url.is_empty() && !secret.is_empty() {
|
||||||
|
clients.push(FirstPartyClient {
|
||||||
|
client_id: WEBMAIL_CLIENT_ID.to_string(),
|
||||||
|
description: "ihasmail webmail".to_string(),
|
||||||
|
redirect_uris: vec![format!("{url}/api/auth/callback")],
|
||||||
|
secret: Some(secret.to_string()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clients
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The address the server's own pages are served from, as `Http` works it out.
|
||||||
|
fn base_url(bp: &Bootstrap, system: &SystemSettings) -> String {
|
||||||
|
if let Some(url) = bp.registry.public_url() {
|
||||||
|
return url.to_string();
|
||||||
|
}
|
||||||
|
let default_hostname = if !system.default_hostname.is_empty() {
|
||||||
|
system.default_hostname.as_str()
|
||||||
|
} else {
|
||||||
|
bp.registry.local_hostname()
|
||||||
|
};
|
||||||
|
let host = system
|
||||||
|
.services
|
||||||
|
.iter()
|
||||||
|
.find(|(service, _)| matches!(service, ServiceProtocol::Jmap))
|
||||||
|
.and_then(|(_, details)| details.hostname.as_deref())
|
||||||
|
.unwrap_or(default_hostname);
|
||||||
|
format!("https://{host}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The origin (`scheme://host[:port]`) of a front end's address, lowercased,
|
||||||
|
/// without a default port. `None` if it isn't an `http` or `https` URL.
|
||||||
|
pub fn origin_of(url: &str) -> Option<String> {
|
||||||
|
let uri = url.trim().parse::<hyper::Uri>().ok()?;
|
||||||
|
let scheme = uri.scheme_str()?.to_ascii_lowercase();
|
||||||
|
let default_port = match scheme.as_str() {
|
||||||
|
"https" => 443,
|
||||||
|
"http" => 80,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
let host = uri.host()?.to_ascii_lowercase();
|
||||||
|
if host.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(match uri.port_u16() {
|
||||||
|
Some(port) if port != default_port => format!("{scheme}://{host}:{port}"),
|
||||||
|
_ => format!("{scheme}://{host}"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The origins allowed to make cross-origin requests (contract C-14): INBUXA
|
||||||
|
/// Admin's, the webmail's, and `INBUXA_CORS_EXTRA_ORIGINS` (comma-separated).
|
||||||
|
///
|
||||||
|
/// inbuxa: read from the environment until `x:FrontEnds` exists (C-4).
|
||||||
|
pub fn front_end_origins() -> Vec<String> {
|
||||||
|
let mut origins = Vec::new();
|
||||||
|
for url in [env("ADMIN_URL"), env("WEBMAIL_URL")].into_iter().flatten() {
|
||||||
|
origins.extend(origin_of(&url));
|
||||||
|
}
|
||||||
|
if let Some(extra) = env("CORS_EXTRA_ORIGINS") {
|
||||||
|
origins.extend(extra.split(',').filter_map(origin_of));
|
||||||
|
}
|
||||||
|
origins.sort();
|
||||||
|
origins.dedup();
|
||||||
|
origins
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env(name: &str) -> Option<String> {
|
||||||
|
types::branding::env_var(name)
|
||||||
|
.ok()
|
||||||
|
.map(|value| value.trim().to_string())
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn ensure_first_party_clients(bp: &mut Bootstrap) -> trc::Result<()> {
|
||||||
|
let system = bp.setting_infallible::<SystemSettings>().await;
|
||||||
|
let base_url = base_url(bp, &system);
|
||||||
|
let applications = bp
|
||||||
|
.list_infallible::<Application>()
|
||||||
|
.await
|
||||||
|
.into_iter()
|
||||||
|
.map(|app| app.object)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let admin_url = env("ADMIN_URL");
|
||||||
|
let webmail_url = env("WEBMAIL_URL");
|
||||||
|
let webmail_secret = env("WEBMAIL_CLIENT_SECRET");
|
||||||
|
if webmail_url.is_some() && webmail_secret.is_none() {
|
||||||
|
trc::event!(
|
||||||
|
Auth(trc::AuthEvent::Error),
|
||||||
|
Details = "INBUXA_WEBMAIL_URL is set without INBUXA_WEBMAIL_CLIENT_SECRET; the webmail client was not registered."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let webmail = webmail_url.as_deref().zip(webmail_secret.as_deref());
|
||||||
|
|
||||||
|
for client in first_party_clients(&base_url, &applications, admin_url.as_deref(), webmail) {
|
||||||
|
ensure_client(bp, client).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ensure_client(bp: &mut Bootstrap, client: FirstPartyClient) -> trc::Result<()> {
|
||||||
|
let existing = match bp
|
||||||
|
.registry
|
||||||
|
.primary_key(
|
||||||
|
ObjectType::OAuthClient.into(),
|
||||||
|
Property::ClientId,
|
||||||
|
client.client_id.as_bytes().to_vec(),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(object_id) => bp
|
||||||
|
.registry
|
||||||
|
.object::<OAuthClient>(object_id.id())
|
||||||
|
.await?
|
||||||
|
.map(|object| (object_id.id(), object)),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = if let Some((id, current)) = existing {
|
||||||
|
let mut updated = current.clone();
|
||||||
|
for uri in &client.redirect_uris {
|
||||||
|
if !updated.redirect_uris.contains(uri) {
|
||||||
|
updated.redirect_uris.push(uri.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(secret) = &client.secret {
|
||||||
|
let matches = match updated.secret.as_deref() {
|
||||||
|
Some(hash) if !hash.is_empty() => {
|
||||||
|
verify_secret_hash(hash, secret.as_bytes()).await?
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
if !matches {
|
||||||
|
updated.secret = Some(
|
||||||
|
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec())
|
||||||
|
.await?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if updated == current {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
bp.registry
|
||||||
|
.write(RegistryWrite::update(id, &updated.into(), ¤t.into()))
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
let secret = match &client.secret {
|
||||||
|
Some(secret) => Some(
|
||||||
|
hash_secret(PasswordHashAlgorithm::Argon2id, secret.as_bytes().to_vec()).await?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
bp.registry
|
||||||
|
.write(RegistryWrite::insert(
|
||||||
|
&OAuthClient {
|
||||||
|
client_id: client.client_id.clone(),
|
||||||
|
description: Some(client.description),
|
||||||
|
redirect_uris: Map::new(client.redirect_uris),
|
||||||
|
secret,
|
||||||
|
created_at: UTCDateTime::now(),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
))
|
||||||
|
.await?
|
||||||
|
};
|
||||||
|
|
||||||
|
if !matches!(result, RegistryWriteResult::Success(_)) {
|
||||||
|
return Err(trc::StoreEvent::UnexpectedError
|
||||||
|
.into_err()
|
||||||
|
.details("Failed to register a first-party OAuth client.")
|
||||||
|
.ctx(trc::Key::Id, client.client_id)
|
||||||
|
.reason(result.to_string())
|
||||||
|
.caused_by(trc::location!()));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn web_interface() -> Application {
|
||||||
|
Application {
|
||||||
|
description: "INBUXA Web Interface".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
url_prefix: Map::new(vec!["/admin".into(), "/account".into()]),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn web_interface_gets_one_uri_per_prefix() {
|
||||||
|
let clients = first_party_clients("https://mail.example.org/", &[web_interface()], None, None);
|
||||||
|
assert_eq!(
|
||||||
|
clients,
|
||||||
|
vec![FirstPartyClient {
|
||||||
|
client_id: WEB_INTERFACE_CLIENT_ID.to_string(),
|
||||||
|
description: "INBUXA Web Interface (served by this server)".to_string(),
|
||||||
|
redirect_uris: vec![
|
||||||
|
"https://mail.example.org/admin/oauth/callback".to_string(),
|
||||||
|
"https://mail.example.org/account/oauth/callback".to_string(),
|
||||||
|
],
|
||||||
|
secret: None,
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_applications_and_named_clients() {
|
||||||
|
let mut disabled = web_interface();
|
||||||
|
disabled.enabled = false;
|
||||||
|
let mut named = web_interface();
|
||||||
|
named.oauth_client_id = Some("custom".to_string());
|
||||||
|
named.url_prefix = Map::new(vec!["portal".into()]);
|
||||||
|
let clients = first_party_clients("https://h", &[disabled, named], None, None);
|
||||||
|
assert_eq!(clients.len(), 1);
|
||||||
|
assert_eq!(clients[0].client_id, "custom");
|
||||||
|
assert_eq!(clients[0].redirect_uris, vec!["https://h/portal/oauth/callback"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn front_ends_from_their_addresses() {
|
||||||
|
let clients = first_party_clients(
|
||||||
|
"https://h",
|
||||||
|
&[],
|
||||||
|
Some("https://admin.example.org/"),
|
||||||
|
Some(("https://webmail.example.org", "s3cret")),
|
||||||
|
);
|
||||||
|
assert_eq!(clients.len(), 2);
|
||||||
|
assert_eq!(clients[0].client_id, ADMIN_CLIENT_ID);
|
||||||
|
assert_eq!(clients[0].redirect_uris, vec!["https://admin.example.org/oauth/callback"]);
|
||||||
|
assert_eq!(clients[0].secret, None);
|
||||||
|
assert_eq!(clients[1].client_id, WEBMAIL_CLIENT_ID);
|
||||||
|
assert_eq!(clients[1].redirect_uris, vec!["https://webmail.example.org/api/auth/callback"]);
|
||||||
|
assert_eq!(clients[1].secret.as_deref(), Some("s3cret"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn origins() {
|
||||||
|
assert_eq!(origin_of("https://Admin.Example.org/"), Some("https://admin.example.org".into()));
|
||||||
|
assert_eq!(origin_of("https://admin.example.org:443/x"), Some("https://admin.example.org".into()));
|
||||||
|
assert_eq!(origin_of("http://localhost:5173"), Some("http://localhost:5173".into()));
|
||||||
|
assert_eq!(origin_of("https://h:8443/app"), Some("https://h:8443".into()));
|
||||||
|
assert_eq!(origin_of("ftp://h"), None);
|
||||||
|
assert_eq!(origin_of("not a url"), None);
|
||||||
|
assert_eq!(origin_of(""), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn webmail_needs_a_secret() {
|
||||||
|
let clients = first_party_clients("https://h", &[], Some(" "), Some(("https://w", "")));
|
||||||
|
assert!(clients.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::USER_AGENT;
|
use crate::USER_AGENT;
|
||||||
@@ -18,6 +20,7 @@ pub mod backup;
|
|||||||
pub mod boot;
|
pub mod boot;
|
||||||
pub mod console;
|
pub mod console;
|
||||||
pub mod defaults;
|
pub mod defaults;
|
||||||
|
pub mod first_party;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
|
|
||||||
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes();
|
pub const SPAM_TRAINER_KEY: &[u8] = "STALWART_SPAM_TRAIN_DATA.lz4".as_bytes();
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::backup::MAGIC_MARKER;
|
use super::backup::MAGIC_MARKER;
|
||||||
@@ -52,9 +54,9 @@ impl Core {
|
|||||||
if !conflicts.is_empty() {
|
if !conflicts.is_empty() {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"Cannot import: the target database already contains data in the key ranges being \
|
"Cannot import: the target database already contains data in the key ranges being \
|
||||||
imported. This usually means Stalwart was started before the import ran, which \
|
imported. This usually means the server was started before the import ran, which \
|
||||||
can create duplicate entries. Import into a fresh, empty database and do not \
|
can create duplicate entries. Import into a fresh, empty database and do not \
|
||||||
start Stalwart before importing. Conflicting dumps:"
|
start the server before importing. Conflicting dumps:"
|
||||||
);
|
);
|
||||||
for path in conflicts {
|
for path in conflicts {
|
||||||
eprintln!(" {}", path.display());
|
eprintln!(" {}", path.display());
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
// Adapted from rustls-acme (https://github.com/FlorianUekermann/rustls-acme), licensed under MIT/Apache-2.0.
|
||||||
@@ -96,38 +98,7 @@ impl AcmeRequestBuilder {
|
|||||||
reuse_key_pem: Option<String>,
|
reuse_key_pem: Option<String>,
|
||||||
dns_parameters: Option<AcmeDnsParameters>,
|
dns_parameters: Option<AcmeDnsParameters>,
|
||||||
) -> AcmeResult<PemCert> {
|
) -> AcmeResult<PemCert> {
|
||||||
let mut published = BTreeSet::new();
|
let mut params = CertificateParams::new(domains.clone()).map_err(|err| {
|
||||||
let result = self
|
|
||||||
.run_order(
|
|
||||||
server,
|
|
||||||
&domains,
|
|
||||||
reuse_key_pem,
|
|
||||||
dns_parameters.as_ref(),
|
|
||||||
&mut published,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
if let Some(dns_parameters) = &dns_parameters {
|
|
||||||
for (zone, challenge_name) in published {
|
|
||||||
let _ = dns_parameters
|
|
||||||
.updater
|
|
||||||
.delete_rrset(&zone, &challenge_name, dns_update::DnsRecordType::TXT)
|
|
||||||
.await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_order(
|
|
||||||
&self,
|
|
||||||
server: &Server,
|
|
||||||
domains: &[String],
|
|
||||||
reuse_key_pem: Option<String>,
|
|
||||||
dns_parameters: Option<&AcmeDnsParameters>,
|
|
||||||
published: &mut BTreeSet<(String, String)>,
|
|
||||||
) -> AcmeResult<PemCert> {
|
|
||||||
let mut params = CertificateParams::new(domains.to_vec()).map_err(|err| {
|
|
||||||
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
AcmeError::Crypto(format!("Failed to create certificate params: {}", err))
|
||||||
})?;
|
})?;
|
||||||
params.distinguished_name = DistinguishedName::new();
|
params.distinguished_name = DistinguishedName::new();
|
||||||
@@ -139,7 +110,7 @@ impl AcmeRequestBuilder {
|
|||||||
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
|
AcmeError::Crypto(format!("Failed to generate key pair: {}", err))
|
||||||
})?,
|
})?,
|
||||||
};
|
};
|
||||||
let response = self.new_order(domains.to_vec()).await?;
|
let response = self.new_order(domains.clone()).await?;
|
||||||
let order_url = response.location;
|
let order_url = response.location;
|
||||||
let mut order = response.body;
|
let mut order = response.body;
|
||||||
let mut retry_after = None;
|
let mut retry_after = None;
|
||||||
@@ -148,7 +119,7 @@ impl AcmeRequestBuilder {
|
|||||||
Acme(AcmeEvent::OrderStart),
|
Acme(AcmeEvent::OrderStart),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Details = order_url.to_string(),
|
Details = order_url.to_string(),
|
||||||
Hostname = domains,
|
Hostname = domains.as_slice(),
|
||||||
Type = self.challenge.as_str(),
|
Type = self.challenge.as_str(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,20 +128,19 @@ impl AcmeRequestBuilder {
|
|||||||
OrderStatus::Pending => {
|
OrderStatus::Pending => {
|
||||||
if matches!(self.challenge, ChallengeType::Dns01) {
|
if matches!(self.challenge, ChallengeType::Dns01) {
|
||||||
for url in &order.authorizations {
|
for url in &order.authorizations {
|
||||||
self.authorize(server, url, dns_parameters, Some(published))
|
self.authorize(server, url, dns_parameters.as_ref()).await?;
|
||||||
.await?;
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let auth_futures = order
|
let auth_futures = order
|
||||||
.authorizations
|
.authorizations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|url| self.authorize(server, url, dns_parameters, None));
|
.map(|url| self.authorize(server, url, dns_parameters.as_ref()));
|
||||||
try_join_all(auth_futures).await?;
|
try_join_all(auth_futures).await?;
|
||||||
}
|
}
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::AuthCompleted),
|
Acme(AcmeEvent::AuthCompleted),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains,
|
Hostname = domains.as_slice(),
|
||||||
);
|
);
|
||||||
let response = self.order(&order_url).await?;
|
let response = self.order(&order_url).await?;
|
||||||
order = response.body;
|
order = response.body;
|
||||||
@@ -181,7 +151,7 @@ impl AcmeRequestBuilder {
|
|||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::OrderProcessing),
|
Acme(AcmeEvent::OrderProcessing),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains,
|
Hostname = domains.as_slice(),
|
||||||
Total = i,
|
Total = i,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -209,7 +179,7 @@ impl AcmeRequestBuilder {
|
|||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::OrderReady),
|
Acme(AcmeEvent::OrderReady),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains,
|
Hostname = domains.as_slice(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let csr = params.serialize_request(&key_pair).map_err(|err| {
|
let csr = params.serialize_request(&key_pair).map_err(|err| {
|
||||||
@@ -222,10 +192,10 @@ impl AcmeRequestBuilder {
|
|||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::OrderValid),
|
Acme(AcmeEvent::OrderValid),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Hostname = domains,
|
Hostname = domains.as_slice(),
|
||||||
);
|
);
|
||||||
|
|
||||||
let certificate = self.select_certificate(domains, certificate).await?;
|
let certificate = self.select_certificate(&domains, certificate).await?;
|
||||||
|
|
||||||
return Ok(PemCert {
|
return Ok(PemCert {
|
||||||
certificate,
|
certificate,
|
||||||
@@ -243,7 +213,7 @@ impl AcmeRequestBuilder {
|
|||||||
Acme(AcmeEvent::OrderInvalid),
|
Acme(AcmeEvent::OrderInvalid),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Details = order_url.to_string(),
|
Details = order_url.to_string(),
|
||||||
Hostname = domains,
|
Hostname = domains.as_slice(),
|
||||||
Reason = reason.clone(),
|
Reason = reason.clone(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -258,7 +228,6 @@ impl AcmeRequestBuilder {
|
|||||||
server: &Server,
|
server: &Server,
|
||||||
url: &String,
|
url: &String,
|
||||||
dns_parameters: Option<&AcmeDnsParameters>,
|
dns_parameters: Option<&AcmeDnsParameters>,
|
||||||
published: Option<&mut BTreeSet<(String, String)>>,
|
|
||||||
) -> AcmeResult<()> {
|
) -> AcmeResult<()> {
|
||||||
let response = self
|
let response = self
|
||||||
.auth(url)
|
.auth(url)
|
||||||
@@ -267,7 +236,7 @@ impl AcmeRequestBuilder {
|
|||||||
let mut retry_after = response.retry_after;
|
let mut retry_after = response.retry_after;
|
||||||
let auth = response.body;
|
let auth = response.body;
|
||||||
|
|
||||||
let (domain, challenge_url) = match auth.status {
|
let domain = match auth.status {
|
||||||
AuthStatus::Pending => {
|
AuthStatus::Pending => {
|
||||||
let Identifier::Dns(domain) = auth.identifier;
|
let Identifier::Dns(domain) = auth.identifier;
|
||||||
|
|
||||||
@@ -320,12 +289,7 @@ impl AcmeRequestBuilder {
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
ChallengeType::Dns01 => {
|
ChallengeType::Dns01 => {
|
||||||
let Some(dns_parameters) = dns_parameters else {
|
let dns_parameters = dns_parameters.unwrap();
|
||||||
return Err(AcmeError::Invalid(
|
|
||||||
"DNS-01 challenge requested but a DNS provider was not configured"
|
|
||||||
.to_string(),
|
|
||||||
));
|
|
||||||
};
|
|
||||||
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
let domain = domain.strip_prefix("*.").unwrap_or(&domain);
|
||||||
|
|
||||||
let zone = dns_parameters
|
let zone = dns_parameters
|
||||||
@@ -346,11 +310,6 @@ impl AcmeRequestBuilder {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(AcmeError::Dns)?;
|
.map_err(AcmeError::Dns)?;
|
||||||
|
|
||||||
if let Some(published) = published {
|
|
||||||
published.insert((zone.to_string(), challenge_name.clone()));
|
|
||||||
}
|
|
||||||
|
|
||||||
dns_parameters
|
dns_parameters
|
||||||
.updater
|
.updater
|
||||||
.wait_for_txt_propagation(&challenge_name, zone, &proof)
|
.wait_for_txt_propagation(&challenge_name, zone, &proof)
|
||||||
@@ -361,7 +320,7 @@ impl AcmeRequestBuilder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.challenge(&challenge.url).await?;
|
self.challenge(&challenge.url).await?;
|
||||||
(domain, challenge.url.clone())
|
domain
|
||||||
}
|
}
|
||||||
AuthStatus::Valid => return Ok(()),
|
AuthStatus::Valid => return Ok(()),
|
||||||
_ => {
|
_ => {
|
||||||
@@ -388,14 +347,20 @@ impl AcmeRequestBuilder {
|
|||||||
|
|
||||||
match response.body.status {
|
match response.body.status {
|
||||||
AuthStatus::Pending => {
|
AuthStatus::Pending => {
|
||||||
|
// inbuxa: keep polling, don't post the challenge again.
|
||||||
|
// RFC 8555 section 7.5.1 has the client post a challenge
|
||||||
|
// once to say it's ready and then poll the authorization,
|
||||||
|
// which stays pending while validation runs. Posting it
|
||||||
|
// again is refused once the server has moved the
|
||||||
|
// challenge to "processing" (pebble answers 400
|
||||||
|
// malformed, "Cannot update challenge with status
|
||||||
|
// processing"), and that refusal failed the renewal.
|
||||||
trc::event!(
|
trc::event!(
|
||||||
Acme(AcmeEvent::AuthPending),
|
Acme(AcmeEvent::AuthPending),
|
||||||
Hostname = domain.to_string(),
|
Hostname = domain.to_string(),
|
||||||
Url = self.directory.new_order.to_string(),
|
Url = self.directory.new_order.to_string(),
|
||||||
Total = i,
|
Total = i,
|
||||||
);
|
);
|
||||||
|
|
||||||
self.challenge(&challenge_url).await?
|
|
||||||
}
|
}
|
||||||
AuthStatus::Valid => {
|
AuthStatus::Valid => {
|
||||||
trc::event!(
|
trc::event!(
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{Server, manager::application::Resource};
|
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||||
use quick_xml::Reader;
|
use quick_xml::Reader;
|
||||||
use quick_xml::XmlVersion;
|
use quick_xml::XmlVersion;
|
||||||
use quick_xml::events::Event;
|
use quick_xml::events::Event;
|
||||||
@@ -55,7 +57,15 @@ impl Server {
|
|||||||
let _ = writeln!(&mut config, "\t\t<Account>");
|
let _ = writeln!(&mut config, "\t\t<Account>");
|
||||||
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
let _ = writeln!(&mut config, "\t\t\t<AccountType>email</AccountType>");
|
||||||
let _ = writeln!(&mut config, "\t\t\t<Action>settings</Action>");
|
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 {
|
for (protocol, service) in &self.core.network.info.services {
|
||||||
|
if legacy_off && is_legacy_service(protocol) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (protocol, ports) = match protocol {
|
let (protocol, ports) = match protocol {
|
||||||
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
ServiceProtocol::Imap => ("IMAP", [143, 993]),
|
||||||
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
ServiceProtocol::Pop3 => ("POP3", [110, 995]),
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{Server, manager::application::Resource};
|
use crate::{Server, manager::application::Resource, network::legacy::is_legacy_service};
|
||||||
use registry::schema::enums::ServiceProtocol;
|
use registry::schema::enums::ServiceProtocol;
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
use utils::url_params::UrlParams;
|
use utils::url_params::UrlParams;
|
||||||
@@ -28,6 +30,9 @@ impl Server {
|
|||||||
("%EMAILADDRESS%", default_host.as_str())
|
("%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
|
// Build XML response
|
||||||
let mut config = String::with_capacity(1024);
|
let mut config = String::with_capacity(1024);
|
||||||
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
config.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||||
@@ -40,6 +45,9 @@ impl Server {
|
|||||||
"\t\t<displayShortName>{domain}</displayShortName>"
|
"\t\t<displayShortName>{domain}</displayShortName>"
|
||||||
);
|
);
|
||||||
for (protocol, service) in &self.core.network.info.services {
|
for (protocol, service) in &self.core.network.info.services {
|
||||||
|
if legacy_off && is_legacy_service(protocol) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let (protocol, tag, ports) = match protocol {
|
let (protocol, tag, ports) = match protocol {
|
||||||
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
ServiceProtocol::Smtp => ("smtp", "outgoingServer", [587, 465]),
|
||||||
ServiceProtocol::Imap => ("imap", "incomingServer", [143, 993]),
|
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 <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{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 ahash::{AHashMap, AHashSet};
|
||||||
use base64::{Engine, engine::general_purpose};
|
use base64::{Engine, engine::general_purpose};
|
||||||
use dns_update::{
|
use dns_update::{
|
||||||
@@ -34,6 +40,8 @@ impl Server {
|
|||||||
let network = &self.core.network;
|
let network = &self.core.network;
|
||||||
let default_host = network.server_name.as_str();
|
let default_host = network.server_name.as_str();
|
||||||
let domain_name = domain.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}");
|
let domain_name_suffix = format!(".{domain_name}");
|
||||||
|
|
||||||
for record_type in record_types {
|
for record_type in record_types {
|
||||||
@@ -193,6 +201,25 @@ impl Server {
|
|||||||
ServiceProtocol::Smtp => [("submission", 587), ("submissions", 465)],
|
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() {
|
for (is_tls, (service_name, port)) in services.into_iter().enumerate() {
|
||||||
if is_tls == 1 || service.cleartext {
|
if is_tls == 1 || service.cleartext {
|
||||||
records.push(NamedDnsRecord {
|
records.push(NamedDnsRecord {
|
||||||
@@ -277,6 +304,14 @@ impl Server {
|
|||||||
for (protocol, service) in &network.info.services {
|
for (protocol, service) in &network.info.services {
|
||||||
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
let hostname = service.hostname.as_deref().unwrap_or(default_host);
|
||||||
if hostname.ends_with(&domain_name_suffix) || hostname == domain_name {
|
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 {
|
let port = match protocol {
|
||||||
ServiceProtocol::Imap => 993,
|
ServiceProtocol::Imap => 993,
|
||||||
ServiceProtocol::Pop3 => 995,
|
ServiceProtocol::Pop3 => 995,
|
||||||
@@ -382,6 +417,12 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_pacc_for_domain(&self, domain_name: &str) -> trc::Result<String> {
|
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)
|
self.get_directory_for_domain(domain_name)
|
||||||
.await
|
.await
|
||||||
.caused_by(trc::location!())
|
.caused_by(trc::location!())
|
||||||
@@ -390,15 +431,9 @@ impl Server {
|
|||||||
.and_then(|directory| {
|
.and_then(|directory| {
|
||||||
directory
|
directory
|
||||||
.oidc_discovery_document()
|
.oidc_discovery_document()
|
||||||
.map(|doc| self.core.network.info.pacc.build(&doc.url))
|
.map(|doc| pacc.build(&doc.url))
|
||||||
})
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
self.core
|
|
||||||
.network
|
|
||||||
.info
|
|
||||||
.pacc
|
|
||||||
.build(&self.core.network.http.url_https)
|
|
||||||
})
|
})
|
||||||
|
.unwrap_or_else(|| pacc.build(&self.core.network.http.url_https))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1150,36 +1150,6 @@ impl DnsUpdater {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn delete_rrset(
|
|
||||||
&self,
|
|
||||||
origin: &str,
|
|
||||||
name: &str,
|
|
||||||
record_type: DnsRecordType,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if let Err(err) = self
|
|
||||||
.updater
|
|
||||||
.set_rrset(
|
|
||||||
name,
|
|
||||||
record_type,
|
|
||||||
self.ttl.as_secs() as u32,
|
|
||||||
Vec::new(),
|
|
||||||
origin,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
trc::event!(
|
|
||||||
Dns(DnsEvent::RecordDeletionFailed),
|
|
||||||
Hostname = name.to_string(),
|
|
||||||
Details = origin.to_string(),
|
|
||||||
Type = record_type.as_str(),
|
|
||||||
Reason = err.to_string(),
|
|
||||||
);
|
|
||||||
return Err(format!("Failed to delete DNS RRSet: {}", err));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn add_to_rrset(
|
pub async fn add_to_rrset(
|
||||||
&self,
|
&self,
|
||||||
origin: &str,
|
origin: &str,
|
||||||
|
|||||||
@@ -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 trc::{EventType, HttpEvent, ImapEvent, ManageSieveEvent, Pop3Event, SmtpEvent};
|
||||||
use utils::UnwrapFailure;
|
use utils::UnwrapFailure;
|
||||||
|
|
||||||
|
use super::control::ListenerControl;
|
||||||
|
|
||||||
impl Listener {
|
impl Listener {
|
||||||
pub fn spawn(
|
pub fn spawn(
|
||||||
self,
|
self,
|
||||||
@@ -324,8 +326,14 @@ impl SocketOpts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Listeners {
|
impl Listeners {
|
||||||
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
|
/// Binds every socket, reporting each failure against its listener.
|
||||||
// Bind as root
|
///
|
||||||
|
/// Split out of [`Listeners::bind_and_drop_priv`] so a listener can be
|
||||||
|
/// bound again at runtime, when the legacy-protocols switch puts one back
|
||||||
|
/// (LP-5), without dropping privileges a second time. A port below 1024
|
||||||
|
/// will fail here once privileges are gone, which is one of the cases
|
||||||
|
/// LP-5 expects and reports rather than hides.
|
||||||
|
pub fn bind(&self, bp: &mut Bootstrap) {
|
||||||
for server in &self.servers {
|
for server in &self.servers {
|
||||||
for listener in &server.listeners {
|
for listener in &server.listeners {
|
||||||
if let Err(err) = listener.socket.bind(listener.addr) {
|
if let Err(err) = listener.socket.bind(listener.addr) {
|
||||||
@@ -336,6 +344,11 @@ impl Listeners {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn bind_and_drop_priv(&self, bp: &mut Bootstrap) {
|
||||||
|
// Bind as root
|
||||||
|
self.bind(bp);
|
||||||
|
|
||||||
// Drop privileges
|
// Drop privileges
|
||||||
#[cfg(not(target_env = "msvc"))]
|
#[cfg(not(target_env = "msvc"))]
|
||||||
@@ -370,6 +383,38 @@ impl Listeners {
|
|||||||
}
|
}
|
||||||
(shutdown_tx, shutdown_rx)
|
(shutdown_tx, shutdown_rx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// As [`Listeners::spawn`], but each listener gets its own shutdown
|
||||||
|
/// channel, registered in `control` under the listener's id, so one can be
|
||||||
|
/// stopped without touching the others (legacy-protocols LP-2).
|
||||||
|
///
|
||||||
|
/// The returned sender no longer reaches the listeners: whole-server
|
||||||
|
/// shutdown must also call [`ListenerControl::stop_all`]. `control` has to
|
||||||
|
/// outlive the listeners, because it owns the sending ends — dropping it
|
||||||
|
/// would stop every listener at once.
|
||||||
|
pub fn spawn_with_control(
|
||||||
|
mut self,
|
||||||
|
control: &ListenerControl,
|
||||||
|
spawn: impl Fn(Listener, TcpAcceptor, watch::Receiver<bool>),
|
||||||
|
) -> (watch::Sender<bool>, watch::Receiver<bool>) {
|
||||||
|
let (shutdown_tx, shutdown_rx) = watch::channel(false);
|
||||||
|
for server in self.servers {
|
||||||
|
let acceptor = self
|
||||||
|
.tcp_acceptors
|
||||||
|
.remove(&server.id)
|
||||||
|
.unwrap_or(TcpAcceptor::Plain);
|
||||||
|
|
||||||
|
let ports = server
|
||||||
|
.listeners
|
||||||
|
.iter()
|
||||||
|
.map(|listener| listener.addr.port())
|
||||||
|
.collect();
|
||||||
|
let listener_rx = control.register(server.id.clone(), server.protocol, ports);
|
||||||
|
|
||||||
|
spawn(server, acceptor, listener_rx);
|
||||||
|
}
|
||||||
|
(shutdown_tx, shutdown_rx)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TcpListener {
|
impl TcpListener {
|
||||||
|
|||||||
@@ -33,8 +33,10 @@ use utils::snowflake::SnowflakeIdGenerator;
|
|||||||
pub mod acme;
|
pub mod acme;
|
||||||
pub mod asn;
|
pub mod asn;
|
||||||
pub mod autoconfig;
|
pub mod autoconfig;
|
||||||
|
pub mod control;
|
||||||
pub mod dkim;
|
pub mod dkim;
|
||||||
pub mod dns;
|
pub mod dns;
|
||||||
|
pub mod legacy;
|
||||||
pub mod limiter;
|
pub mod limiter;
|
||||||
pub mod listen;
|
pub mod listen;
|
||||||
pub mod mta;
|
pub mod mta;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -21,10 +23,9 @@ use crate::{
|
|||||||
manager::SPAM_CLASSIFIER_KEY,
|
manager::SPAM_CLASSIFIER_KEY,
|
||||||
network::RcptResolution,
|
network::RcptResolution,
|
||||||
};
|
};
|
||||||
use ahash::AHashSet;
|
|
||||||
use directory::Recipient;
|
use directory::Recipient;
|
||||||
use mail_auth::IpLookupStrategy;
|
use mail_auth::IpLookupStrategy;
|
||||||
use registry::schema::{enums::ExpressionVariable, structs::MaskedEmail};
|
use registry::schema::enums::ExpressionVariable;
|
||||||
use sieve::Sieve;
|
use sieve::Sieve;
|
||||||
use std::{
|
use std::{
|
||||||
borrow::Cow,
|
borrow::Cow,
|
||||||
@@ -33,11 +34,9 @@ use std::{
|
|||||||
};
|
};
|
||||||
use store::{
|
use store::{
|
||||||
Deserialize, IterateParams, ValueKey,
|
Deserialize, IterateParams, ValueKey,
|
||||||
write::{AlignedBytes, Archive, QueueClass, ValueClass, now},
|
write::{AlignedBytes, Archive, QueueClass, ValueClass},
|
||||||
};
|
};
|
||||||
use trc::{AddContext, SpamEvent};
|
use trc::{AddContext, SpamEvent};
|
||||||
use types::id::Id;
|
|
||||||
use utils::DomainPart;
|
|
||||||
|
|
||||||
impl Server {
|
impl Server {
|
||||||
pub async fn rcpt_resolve(
|
pub async fn rcpt_resolve(
|
||||||
@@ -75,6 +74,27 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: ME-4, ME-9: a live masked address is rewritten to its
|
||||||
|
// owner's, which keeps the mask as the original recipient
|
||||||
|
if let inbuxa_features::masked_email::ops::Lookup::Accepts(mask) =
|
||||||
|
inbuxa_features::masked_email::ops::lookup(
|
||||||
|
&self.core.storage.data,
|
||||||
|
self.registry(),
|
||||||
|
&format!("{local_part}@{domain_part}"),
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
let owner = self.account(mask.object.account_id.document_id()).await?;
|
||||||
|
if let Some(address) = owner.addresses.first()
|
||||||
|
&& let Some(owner_domain) = self.domain_by_id(address.domain_id).await?
|
||||||
|
&& let Some(owner_domain) = owner_domain.names.first()
|
||||||
|
{
|
||||||
|
return Ok(RcptResolution::Rewrite(format!(
|
||||||
|
"{}@{}",
|
||||||
|
address.local_part, owner_domain
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Obtain external directory, if configured
|
// Obtain external directory, if configured
|
||||||
let directory = self
|
let directory = self
|
||||||
@@ -88,6 +108,17 @@ impl Server {
|
|||||||
Cow::Borrowed(rcpt)
|
Cow::Borrowed(rcpt)
|
||||||
};
|
};
|
||||||
match directory.recipient(address.as_ref()).await? {
|
match directory.recipient(address.as_ref()).await? {
|
||||||
|
// inbuxa: DIR-6: an answer for another directory's domain is no answer
|
||||||
|
Recipient::Account(account)
|
||||||
|
if self
|
||||||
|
.assert_directory_serves(directory, &account.email)
|
||||||
|
.await
|
||||||
|
.is_err() => {}
|
||||||
|
Recipient::Group(group)
|
||||||
|
if self
|
||||||
|
.assert_directory_serves(directory, &group.email)
|
||||||
|
.await
|
||||||
|
.is_err() => {}
|
||||||
Recipient::Account(account) => {
|
Recipient::Account(account) => {
|
||||||
Box::pin(self.synchronize_account(account)).await?;
|
Box::pin(self.synchronize_account(account)).await?;
|
||||||
return Ok(if is_subaddressed {
|
return Ok(if is_subaddressed {
|
||||||
@@ -132,10 +163,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
EmailCache::MailingList(id) => {
|
EmailCache::MailingList(id) => {
|
||||||
if let Some(list) = self.try_list(id).await? {
|
if let Some(list) = self.try_list(id).await? {
|
||||||
return Ok(RcptResolution::Expand(
|
return Ok(RcptResolution::Expand(list.recipients.clone()));
|
||||||
self.expand_nested_lists(id, list.recipients.clone())
|
|
||||||
.await?,
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
self.inner
|
self.inner
|
||||||
.cache
|
.cache
|
||||||
@@ -167,56 +195,6 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn expand_nested_lists(
|
|
||||||
&self,
|
|
||||||
list_id: u32,
|
|
||||||
recipients: Arc<[Box<str>]>,
|
|
||||||
) -> trc::Result<Arc<[Box<str>]>> {
|
|
||||||
let mut has_nested = false;
|
|
||||||
for member in recipients.iter() {
|
|
||||||
if let Some(EmailCache::MailingList(_)) = self.rcpt_id_from_email(member).await? {
|
|
||||||
has_nested = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !has_nested {
|
|
||||||
return Ok(recipients);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut expanded = Vec::with_capacity(recipients.len());
|
|
||||||
let mut seen: AHashSet<Box<str>> = AHashSet::with_capacity(recipients.len());
|
|
||||||
let mut visited = AHashSet::from_iter([list_id]);
|
|
||||||
let mut pending: Vec<Arc<[Box<str>]>> = Vec::new();
|
|
||||||
let mut members = recipients;
|
|
||||||
|
|
||||||
loop {
|
|
||||||
for member in members.iter() {
|
|
||||||
if let Some(EmailCache::MailingList(nested_id)) =
|
|
||||||
self.rcpt_id_from_email(member).await?
|
|
||||||
{
|
|
||||||
if !visited.insert(nested_id) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Some(nested) = self.try_list(nested_id).await? {
|
|
||||||
pending.push(nested.recipients.clone());
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if seen.insert(member.to_canonical_address().into()) {
|
|
||||||
expanded.push(member.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some(next) = pending.pop() else {
|
|
||||||
break;
|
|
||||||
};
|
|
||||||
members = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(expanded.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn get_dkim_signers(
|
pub async fn get_dkim_signers(
|
||||||
&self,
|
&self,
|
||||||
domain: &str,
|
domain: &str,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use sieve::{FunctionMap, compiler::Number, runtime::Variable};
|
use sieve::{FunctionMap, runtime::Variable};
|
||||||
use std::time::Instant;
|
|
||||||
use trc::{AiEvent, SecurityEvent};
|
|
||||||
|
|
||||||
use super::PluginContext;
|
use super::PluginContext;
|
||||||
|
|
||||||
@@ -14,7 +14,9 @@ pub fn register(plugin_id: u32, fnc_map: &mut FunctionMap) {
|
|||||||
fnc_map.set_external_function("llm_prompt", plugin_id, 3);
|
fnc_map.set_external_function("llm_prompt", plugin_id, 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: AI-20 to AI-25, `llm_prompt(model, prompt, temperature)`
|
||||||
pub async fn exec(ctx: PluginContext<'_>) -> trc::Result<Variable> {
|
pub async fn exec(ctx: PluginContext<'_>) -> trc::Result<Variable> {
|
||||||
|
Ok(crate::enterprise::llm::sieve_prompt(ctx)
|
||||||
Ok(false.into())
|
.await
|
||||||
|
.map_or(Variable::from(false), Variable::from))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Which logo applies to a domain name (branding spec BT-1, BT-2). The rules
|
||||||
|
//! live in `inbuxa_features::branding::logo`; this finds the domain through
|
||||||
|
//! the server's domain cache and reads the three levels from the registry
|
||||||
|
//! each time, so a change shows at once on every node (BT-10).
|
||||||
|
|
||||||
|
use crate::Server;
|
||||||
|
use inbuxa_features::branding::logo::{self, Logo, Source};
|
||||||
|
use registry::schema::structs::{Domain, Enterprise, Tenant};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// The logos that apply to a domain name, most specific first. An unknown
|
||||||
|
/// name gets what a known domain with no logo of its own gets (BT-6).
|
||||||
|
pub async fn logos_for(&self, name: &str) -> trc::Result<Vec<Logo>> {
|
||||||
|
let mut domain = None;
|
||||||
|
for candidate in logo::lookup_names(name) {
|
||||||
|
if let Some(found) = self.domain(&candidate).await? {
|
||||||
|
domain = Some(found);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let registry = self.registry();
|
||||||
|
let domain_logo = match &domain {
|
||||||
|
Some(domain) => registry
|
||||||
|
.object::<Domain>(Id::from(domain.id))
|
||||||
|
.await?
|
||||||
|
.and_then(|d| d.logo),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let tenant_id = domain.as_ref().and_then(|d| d.id_tenant);
|
||||||
|
let tenant_logo = match tenant_id {
|
||||||
|
Some(tenant_id) => registry
|
||||||
|
.object::<Tenant>(Id::from(tenant_id))
|
||||||
|
.await?
|
||||||
|
.and_then(|t| t.logo),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let server_logo = registry
|
||||||
|
.object::<Enterprise>(Id::singleton())
|
||||||
|
.await?
|
||||||
|
.and_then(|e| e.logo_url);
|
||||||
|
Ok(logo::chain([
|
||||||
|
(
|
||||||
|
Source::Domain(domain.as_ref().map_or(u32::MAX, |d| d.id)),
|
||||||
|
domain_logo.as_deref(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
Source::Tenant(tenant_id.unwrap_or(u32::MAX)),
|
||||||
|
tenant_logo.as_deref(),
|
||||||
|
),
|
||||||
|
(Source::Server, server_logo.as_deref()),
|
||||||
|
]))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::Server;
|
use crate::Server;
|
||||||
@@ -18,6 +20,7 @@ use store::{BlobStore, InMemoryStore, RegistryStore, SearchStore, Store};
|
|||||||
|
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
pub mod blob;
|
pub mod blob;
|
||||||
|
pub mod branding; // inbuxa: branding BT-1, BT-2
|
||||||
pub mod dav;
|
pub mod dav;
|
||||||
pub mod document;
|
pub mod document;
|
||||||
pub mod encryption;
|
pub mod encryption;
|
||||||
@@ -95,11 +98,26 @@ impl Server {
|
|||||||
self.registry().count_object(ObjectType::Domain).await
|
self.registry().count_object(ObjectType::Domain).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "enterprise"))]
|
// inbuxa: BT-9: the first logo mail can carry inline; none leaves the
|
||||||
|
// built-in INBUXA logo
|
||||||
pub async fn logo_resource(
|
pub async fn logo_resource(
|
||||||
&self,
|
&self,
|
||||||
_: &str,
|
domain: &str,
|
||||||
) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> {
|
) -> trc::Result<Option<crate::manager::application::Resource<Vec<u8>>>> {
|
||||||
Ok(None)
|
Ok(self
|
||||||
|
.logos_for(domain)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.find(|logo| logo.is_embeddable())
|
||||||
|
.and_then(|logo| match logo {
|
||||||
|
inbuxa_features::branding::logo::Logo::Image {
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
} => Some(crate::manager::application::Resource::new(
|
||||||
|
content_type,
|
||||||
|
bytes,
|
||||||
|
)),
|
||||||
|
inbuxa_features::branding::logo::Logo::Url(_) => None,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -31,9 +33,9 @@ impl Server {
|
|||||||
.add_context(|err| err.caused_by(trc::location!()).account_id(account_id))
|
.add_context(|err| err.caused_by(trc::location!()).account_id(account_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "enterprise"))]
|
// inbuxa: MT-20: storage used by all a tenant's members together
|
||||||
pub async fn get_used_quota_tenant(&self, _tenant_id: u32) -> trc::Result<i64> {
|
pub async fn get_used_quota_tenant(&self, tenant_id: u32) -> trc::Result<i64> {
|
||||||
Ok(0)
|
inbuxa_features::tenancy::quota::used(&self.core.storage.data, tenant_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn has_available_quota(
|
pub async fn has_available_quota(
|
||||||
@@ -52,6 +54,21 @@ impl Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// inbuxa: MT-19: the tenant's limit applies too, whichever is reached first
|
||||||
|
if let Some(tenant_id) = account.id_tenant {
|
||||||
|
let tenant = self.tenant(tenant_id).await?;
|
||||||
|
if tenant.quota_disk != 0 {
|
||||||
|
let used_quota = self.get_used_quota_tenant(tenant_id).await?.max(0) as u64;
|
||||||
|
|
||||||
|
if used_quota + item_size > tenant.quota_disk {
|
||||||
|
return Err(trc::LimitEvent::TenantQuota
|
||||||
|
.into_err()
|
||||||
|
.ctx(trc::Key::Id, tenant_id)
|
||||||
|
.ctx(trc::Key::Limit, tenant.quota_disk)
|
||||||
|
.ctx(trc::Key::Size, used_quota));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,265 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Alerts (monitoring spec MON-25 to MON-30). Each enabled `x:Alert` is read
|
||||||
|
//! from the registry at evaluation, so a change needs no reload (MON-3), and
|
||||||
|
//! fires when its condition goes from false to true (MON-26).
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Server,
|
||||||
|
expr::{functions::EmptyResolver, if_block::BootstrapExprExt},
|
||||||
|
};
|
||||||
|
use ahash::AHashSet;
|
||||||
|
use mail_builder::{
|
||||||
|
MessageBuilder,
|
||||||
|
headers::{HeaderType, address::Address},
|
||||||
|
};
|
||||||
|
use registry::{
|
||||||
|
schema::{
|
||||||
|
prelude::{ExpressionContext, ObjectType},
|
||||||
|
structs::{Alert, AlertEmail, AlertEvent, Expression, ExpressionMatch},
|
||||||
|
},
|
||||||
|
types::{id::ObjectId, list::List},
|
||||||
|
};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use store::registry::{RegistryQuery, bootstrap::Bootstrap};
|
||||||
|
use trc::{Collector, MetricType, TelemetryEvent};
|
||||||
|
use types::id::Id;
|
||||||
|
|
||||||
|
/// An alert email, ready to queue.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AlertMessage {
|
||||||
|
pub from: String,
|
||||||
|
pub to: Vec<String>,
|
||||||
|
pub body: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alerts whose condition held at the last evaluation (MON-26). In
|
||||||
|
/// memory, so a restart while a condition holds fires once more.
|
||||||
|
static FIRING: Mutex<Option<AHashSet<u64>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
fn is_ident_char(c: char) -> bool {
|
||||||
|
c.is_ascii_alphanumeric() || c == '_'
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The metric an underscore name stands for (`queue_count`), MON-25.
|
||||||
|
fn underscore_metric(name: &str) -> Option<MetricType> {
|
||||||
|
static NAMES: std::sync::OnceLock<ahash::AHashMap<String, MetricType>> =
|
||||||
|
std::sync::OnceLock::new();
|
||||||
|
if !name.contains('_') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
NAMES
|
||||||
|
.get_or_init(|| {
|
||||||
|
(0..=u16::MAX)
|
||||||
|
.filter_map(MetricType::from_id)
|
||||||
|
.map(|metric| (metric.as_str().replace(['.', '-'], "_"), metric))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.get(name)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrites bare underscore metric names into `metric('dotted.name')`,
|
||||||
|
/// leaving quoted text and function names alone (MON-25).
|
||||||
|
pub fn rewrite(text: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(text.len());
|
||||||
|
let chars = text.chars().collect::<Vec<_>>();
|
||||||
|
let mut i = 0;
|
||||||
|
while i < chars.len() {
|
||||||
|
let c = chars[i];
|
||||||
|
if c == '"' || c == '\'' {
|
||||||
|
let quote = c;
|
||||||
|
out.push(c);
|
||||||
|
i += 1;
|
||||||
|
while i < chars.len() {
|
||||||
|
out.push(chars[i]);
|
||||||
|
if chars[i] == quote {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
i += 1;
|
||||||
|
} else if c.is_ascii_alphabetic() || c == '_' {
|
||||||
|
let start = i;
|
||||||
|
while i < chars.len() && is_ident_char(chars[i]) {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
let word = chars[start..i].iter().collect::<String>();
|
||||||
|
let is_call = chars[i..].iter().find(|c| !c.is_whitespace()) == Some(&'(');
|
||||||
|
match underscore_metric(&word) {
|
||||||
|
Some(metric) if !is_call => {
|
||||||
|
out.push_str(&format!("metric('{}')", metric.as_str()));
|
||||||
|
}
|
||||||
|
_ => out.push_str(&word),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
out.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An alert condition with underscore names rewritten.
|
||||||
|
pub fn rewrite_condition(condition: &Expression) -> Expression {
|
||||||
|
Expression {
|
||||||
|
match_: List::from_iter(condition.match_.iter().map(|m| ExpressionMatch {
|
||||||
|
if_: rewrite(&m.if_),
|
||||||
|
then: rewrite(&m.then),
|
||||||
|
})),
|
||||||
|
else_: rewrite(&condition.else_),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `%{metric.name}%` replaced by the metric's value: whole numbers without
|
||||||
|
/// decimals, others with at most two (MON-27). Unknown names stay.
|
||||||
|
pub fn render(template: &str) -> String {
|
||||||
|
let mut out = String::with_capacity(template.len());
|
||||||
|
let mut rest = template;
|
||||||
|
while let Some(start) = rest.find("%{") {
|
||||||
|
out.push_str(&rest[..start]);
|
||||||
|
let after = &rest[start + 2..];
|
||||||
|
match after.find("}%") {
|
||||||
|
Some(end) => {
|
||||||
|
let name = &after[..end];
|
||||||
|
match MetricType::parse(name) {
|
||||||
|
Some(metric) => {
|
||||||
|
let value = Collector::read_metric(metric);
|
||||||
|
if value.fract() == 0.0 {
|
||||||
|
out.push_str(&format!("{}", value as i64));
|
||||||
|
} else {
|
||||||
|
let text = format!("{value:.2}");
|
||||||
|
out.push_str(text.trim_end_matches('0').trim_end_matches('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => out.push_str(&rest[start..start + 2 + end + 2]),
|
||||||
|
}
|
||||||
|
rest = &after[end + 2..];
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
out.push_str(&rest[start..]);
|
||||||
|
rest = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push_str(rest);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_email(email: ®istry::schema::structs::AlertEmailProperties) -> AlertMessage {
|
||||||
|
let from = match &email.from_name {
|
||||||
|
Some(name) => Address::new_address(Some(name.clone()), email.from_address.clone()),
|
||||||
|
None => Address::new_address(None::<String>, email.from_address.clone()),
|
||||||
|
};
|
||||||
|
let to = email.to.iter().cloned().collect::<Vec<_>>();
|
||||||
|
let body = MessageBuilder::new()
|
||||||
|
.from(from)
|
||||||
|
.to(to
|
||||||
|
.iter()
|
||||||
|
.map(|addr| Address::new_address(None::<String>, addr.clone()))
|
||||||
|
.collect::<Vec<_>>())
|
||||||
|
.subject(render(&email.subject))
|
||||||
|
.header("Auto-Submitted", HeaderType::Text("auto-generated".into()))
|
||||||
|
.text_body(render(&email.body))
|
||||||
|
.write_to_vec()
|
||||||
|
.unwrap_or_default();
|
||||||
|
AlertMessage {
|
||||||
|
from: email.from_address.clone(),
|
||||||
|
to,
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Evaluates every enabled alert once (MON-25, MON-26), emits the event
|
||||||
|
/// of each that fires (MON-28), and returns the emails to queue
|
||||||
|
/// (MON-29). A failing alert is logged and skipped (MON-37).
|
||||||
|
pub async fn process_alerts(&self) -> trc::Result<Vec<AlertMessage>> {
|
||||||
|
let registry = self.registry();
|
||||||
|
let ids = registry
|
||||||
|
.query::<Vec<Id>>(RegistryQuery::new(ObjectType::Alert))
|
||||||
|
.await?;
|
||||||
|
let mut messages = Vec::new();
|
||||||
|
let mut holding = AHashSet::new();
|
||||||
|
for id in ids {
|
||||||
|
let Some(alert) = registry.object::<Alert>(id).await? else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if !alert.enable {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let condition = rewrite_condition(&alert.condition);
|
||||||
|
let mut bp = Bootstrap::new_uninitialized(registry.clone());
|
||||||
|
let if_block = bp.compile_expr(
|
||||||
|
ObjectId::new(ObjectType::Alert, id),
|
||||||
|
&ExpressionContext {
|
||||||
|
expr: &condition,
|
||||||
|
..alert.ctx_condition()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if !bp.errors.is_empty() || if_block.is_empty() {
|
||||||
|
trc::event!(
|
||||||
|
Registry(trc::RegistryEvent::BuildWarning),
|
||||||
|
Id = id.id(),
|
||||||
|
Details = "The alert's condition can't be evaluated",
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let holds = self
|
||||||
|
.eval_if::<bool, _>(&if_block, &EmptyResolver, 0)
|
||||||
|
.await
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !holds {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
holding.insert(id.id());
|
||||||
|
let was_firing = FIRING
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|firing| firing.contains(&id.id()));
|
||||||
|
if was_firing {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let AlertEvent::Enabled(event) = &alert.event_alert {
|
||||||
|
trc::event!(
|
||||||
|
Telemetry(TelemetryEvent::AlertEvent),
|
||||||
|
Id = id.id(),
|
||||||
|
Details = render(event.event_message.as_deref().unwrap_or("Alert triggered")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let AlertEmail::Enabled(email) = &alert.email_alert {
|
||||||
|
messages.push(build_email(email));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*FIRING.lock().unwrap() = Some(holding);
|
||||||
|
Ok(messages)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rewrites_underscore_names() {
|
||||||
|
assert_eq!(rewrite("domain_count > 1"), "metric('domain.count') > 1");
|
||||||
|
assert_eq!(
|
||||||
|
rewrite("metric('queue.count') > 5 && queue_count < 9"),
|
||||||
|
"metric('queue.count') > 5 && metric('queue.count') < 9"
|
||||||
|
);
|
||||||
|
assert_eq!(rewrite("'domain_count' == x"), "'domain_count' == x");
|
||||||
|
assert_eq!(rewrite("unknown_thing > 1"), "unknown_thing > 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_placeholders() {
|
||||||
|
assert_eq!(render("no placeholders"), "no placeholders");
|
||||||
|
assert_eq!(render("%{no.such-metric}% left"), "%{no.such-metric}% left");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,12 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
pub mod otel;
|
pub mod otel;
|
||||||
pub mod prometheus;
|
pub mod prometheus;
|
||||||
|
pub mod store; // inbuxa: monitoring history (MON-4 to MON-9)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::config::telemetry::OtelMetrics;
|
use crate::config::telemetry::OtelMetrics;
|
||||||
@@ -17,12 +19,12 @@ use std::time::SystemTime;
|
|||||||
use trc::{Collector, TelemetryEvent};
|
use trc::{Collector, TelemetryEvent};
|
||||||
|
|
||||||
impl OtelMetrics {
|
impl OtelMetrics {
|
||||||
pub async fn push_metrics(&self, is_enterprise: bool, start_time: SystemTime) {
|
pub async fn push_metrics(&self, start_time: SystemTime) {
|
||||||
let mut metrics = Vec::with_capacity(256);
|
let mut metrics = Vec::with_capacity(256);
|
||||||
let time = SystemTime::now();
|
let time = SystemTime::now();
|
||||||
|
|
||||||
// Add counters
|
// Add counters
|
||||||
for counter in Collector::collect_counters(is_enterprise) {
|
for counter in Collector::collect_counters() {
|
||||||
metrics.push(Metric::new(
|
metrics.push(Metric::new(
|
||||||
counter.id().as_str(),
|
counter.id().as_str(),
|
||||||
counter.id().description(),
|
counter.id().description(),
|
||||||
@@ -38,7 +40,7 @@ impl OtelMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add gauges
|
// Add gauges
|
||||||
for gauge in Collector::collect_gauges(is_enterprise) {
|
for gauge in Collector::collect_gauges() {
|
||||||
metrics.push(Metric::new(
|
metrics.push(Metric::new(
|
||||||
gauge.id().as_str(),
|
gauge.id().as_str(),
|
||||||
gauge.id().description(),
|
gauge.id().description(),
|
||||||
@@ -52,7 +54,7 @@ impl OtelMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add histograms
|
// Add histograms
|
||||||
for histogram in Collector::collect_histograms(is_enterprise) {
|
for histogram in Collector::collect_histograms() {
|
||||||
metrics.push(Metric::new(
|
metrics.push(Metric::new(
|
||||||
histogram.id().as_str(),
|
histogram.id().as_str(),
|
||||||
histogram.id().description(),
|
histogram.id().description(),
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use prometheus::{
|
use prometheus::{
|
||||||
@@ -16,12 +18,8 @@ impl Server {
|
|||||||
pub async fn export_prometheus_metrics(&self) -> trc::Result<String> {
|
pub async fn export_prometheus_metrics(&self) -> trc::Result<String> {
|
||||||
let mut metrics = Vec::new();
|
let mut metrics = Vec::new();
|
||||||
|
|
||||||
|
|
||||||
#[cfg(not(feature = "enterprise"))]
|
|
||||||
let is_enterprise = false;
|
|
||||||
|
|
||||||
// Add counters
|
// Add counters
|
||||||
for counter in Collector::collect_counters(is_enterprise) {
|
for counter in Collector::collect_counters() {
|
||||||
let mut metric = MetricFamily::default();
|
let mut metric = MetricFamily::default();
|
||||||
metric.set_name(metric_name(counter.id().as_str()));
|
metric.set_name(metric_name(counter.id().as_str()));
|
||||||
metric.set_help(counter.id().description().into());
|
metric.set_help(counter.id().description().into());
|
||||||
@@ -31,7 +29,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add gauges
|
// Add gauges
|
||||||
for gauge in Collector::collect_gauges(is_enterprise) {
|
for gauge in Collector::collect_gauges() {
|
||||||
let mut metric = MetricFamily::default();
|
let mut metric = MetricFamily::default();
|
||||||
metric.set_name(metric_name(gauge.id().as_str()));
|
metric.set_name(metric_name(gauge.id().as_str()));
|
||||||
metric.set_help(gauge.id().description().into());
|
metric.set_help(gauge.id().description().into());
|
||||||
@@ -41,7 +39,7 @@ impl Server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add histograms
|
// Add histograms
|
||||||
for histogram in Collector::collect_histograms(is_enterprise) {
|
for histogram in Collector::collect_histograms() {
|
||||||
let mut metric = MetricFamily::default();
|
let mut metric = MetricFamily::default();
|
||||||
metric.set_name(metric_name(histogram.id().as_str()));
|
metric.set_name(metric_name(histogram.id().as_str()));
|
||||||
metric.set_help(histogram.id().description().into());
|
metric.set_help(histogram.id().description().into());
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Metric history (monitoring spec MON-4 to MON-9, MON-17). Each sample is
|
||||||
|
//! stored under `TelemetryClass::Metric(id)`, as an `x:Metric` in the
|
||||||
|
//! registry's own encoding. The id is a snowflake of the tick's time, so key
|
||||||
|
//! order is time order and the timestamp is read from the id.
|
||||||
|
|
||||||
|
use crate::Server;
|
||||||
|
use ahash::AHashMap;
|
||||||
|
use registry::{
|
||||||
|
pickle::PickledStream,
|
||||||
|
schema::{
|
||||||
|
prelude::{ObjectInner, ObjectType},
|
||||||
|
structs::{DataRetention, Metric, MetricCount, MetricSum},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::{future::Future, sync::Mutex, time::Duration};
|
||||||
|
use store::{
|
||||||
|
IterateParams, Store, ValueKey,
|
||||||
|
write::{BatchBuilder, TelemetryClass, ValueClass, key::DeserializeBigEndian, now},
|
||||||
|
};
|
||||||
|
use trc::{AddContext, Collector, MetricType, TelemetryEvent};
|
||||||
|
use types::id::Id;
|
||||||
|
use utils::snowflake::SnowflakeIdGenerator;
|
||||||
|
|
||||||
|
pub trait MetricsStore: Sync + Send {
|
||||||
|
/// Writes one tick's samples, all at `timestamp`.
|
||||||
|
fn write_metrics(
|
||||||
|
&self,
|
||||||
|
samples: Vec<Metric>,
|
||||||
|
timestamp: u64,
|
||||||
|
) -> impl Future<Output = trc::Result<()>> + Send;
|
||||||
|
|
||||||
|
/// Deletes samples older than `keep` (MON-17).
|
||||||
|
fn purge_metrics(&self, keep: Duration) -> impl Future<Output = trc::Result<()>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MetricsStore for Store {
|
||||||
|
async fn write_metrics(&self, samples: Vec<Metric>, timestamp: u64) -> trc::Result<()> {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
for sample in samples {
|
||||||
|
let Some(id) = SnowflakeIdGenerator::global_id_from_timestamp(timestamp) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
batch.set(
|
||||||
|
ValueClass::Telemetry(TelemetryClass::Metric(id)),
|
||||||
|
ObjectInner::Metric(sample).to_pickled_vec(),
|
||||||
|
);
|
||||||
|
if batch.is_large_batch() {
|
||||||
|
self.write(batch.build_all()).await?;
|
||||||
|
batch = BatchBuilder::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !batch.is_empty() {
|
||||||
|
self.write(batch.build_all()).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn purge_metrics(&self, keep: Duration) -> trc::Result<()> {
|
||||||
|
let Some(until) = SnowflakeIdGenerator::from_duration(keep) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
self.delete_range(
|
||||||
|
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(0))),
|
||||||
|
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(until))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a stored sample. Records in any other encoding (INBUXA's history
|
||||||
|
/// from before the fork) read as `None` and are skipped.
|
||||||
|
pub fn decode_metric(bytes: &[u8]) -> Option<Metric> {
|
||||||
|
PickledStream::new(bytes)
|
||||||
|
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Metric, &mut stream))
|
||||||
|
.and_then(|inner| match inner {
|
||||||
|
ObjectInner::Metric(metric) => Some(metric),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored sample as read by key: `None` when it can't be decoded.
|
||||||
|
pub struct MaybeMetric(pub Option<Metric>);
|
||||||
|
|
||||||
|
impl store::Deserialize for MaybeMetric {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
Ok(MaybeMetric(decode_metric(bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored sample with its id.
|
||||||
|
pub struct StoredMetric {
|
||||||
|
pub id: u64,
|
||||||
|
pub metric: Metric,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl StoredMetric {
|
||||||
|
pub fn timestamp(&self) -> u64 {
|
||||||
|
SnowflakeIdGenerator::to_timestamp(self.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the node wrote last, so counters and histograms are written as
|
||||||
|
/// changes (MON-4). Per process: a restart counts from the start.
|
||||||
|
static LAST: Mutex<Option<AHashMap<MetricType, (u64, u64)>>> = Mutex::new(None);
|
||||||
|
|
||||||
|
/// One tick's samples (MON-4 to MON-6).
|
||||||
|
pub fn sample() -> Vec<Metric> {
|
||||||
|
let mut last_guard = LAST.lock().unwrap();
|
||||||
|
let last = last_guard.get_or_insert_with(AHashMap::new);
|
||||||
|
let mut samples = Vec::new();
|
||||||
|
|
||||||
|
// Counters: the increase since the previous sample; none if unchanged
|
||||||
|
for counter in Collector::collect_counters() {
|
||||||
|
let Some(metric) = MetricType::parse(counter.id().as_str()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let total = counter.value();
|
||||||
|
let previous = last.insert(metric, (total, 0)).map_or(0, |(count, _)| count);
|
||||||
|
let increase = total.saturating_sub(previous);
|
||||||
|
if increase > 0 {
|
||||||
|
samples.push(Metric::Counter(MetricCount {
|
||||||
|
count: increase,
|
||||||
|
metric,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gauges: the reading, always (MON-5)
|
||||||
|
for gauge in Collector::collect_gauges() {
|
||||||
|
samples.push(Metric::Gauge(MetricCount {
|
||||||
|
count: gauge.get(),
|
||||||
|
metric: gauge.id(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Histograms: totals, when changed (MON-4 Decision)
|
||||||
|
for histogram in Collector::collect_histograms() {
|
||||||
|
let metric = histogram.id();
|
||||||
|
let current = (histogram.count(), histogram.sum());
|
||||||
|
if last.insert(metric, current) != Some(current) {
|
||||||
|
samples.push(Metric::Histogram(MetricSum {
|
||||||
|
count: current.0,
|
||||||
|
sum: current.1,
|
||||||
|
metric,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
samples
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The retention settings in force now (MON-3 Decision: no reload needed).
|
||||||
|
pub async fn retention(server: &Server) -> DataRetention {
|
||||||
|
server
|
||||||
|
.registry()
|
||||||
|
.object::<DataRetention>(Id::singleton())
|
||||||
|
.await
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Writes one tick of metric history, if it's on (MON-4, MON-9). Never
|
||||||
|
/// fails loudly: history is lost, mail isn't (MON-35).
|
||||||
|
pub async fn store_metrics(&self) {
|
||||||
|
let store = self.metrics_store();
|
||||||
|
if store.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let samples = sample();
|
||||||
|
let count = samples.len();
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
match store.write_metrics(samples, now()).await {
|
||||||
|
Ok(()) => trc::event!(
|
||||||
|
Telemetry(TelemetryEvent::MetricsStored),
|
||||||
|
Total = count,
|
||||||
|
Elapsed = started.elapsed(),
|
||||||
|
),
|
||||||
|
Err(err) => {
|
||||||
|
trc::error!(err.details("Failed to store metric history"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The stored samples between two ids, in key order, skipping any that
|
||||||
|
/// can't be decoded or are past `holdMetricsFor` (MON-17).
|
||||||
|
pub async fn read_metrics(
|
||||||
|
&self,
|
||||||
|
from_id: u64,
|
||||||
|
to_id: u64,
|
||||||
|
ascending: bool,
|
||||||
|
mut accept: impl FnMut(&StoredMetric) -> bool + Send + Sync,
|
||||||
|
) -> trc::Result<Vec<StoredMetric>> {
|
||||||
|
let store = self.metrics_store();
|
||||||
|
let mut out = Vec::new();
|
||||||
|
if store.is_none() {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let floor = match retention(self).await.hold_metrics_for {
|
||||||
|
Some(keep) => SnowflakeIdGenerator::from_duration(keep.into_inner()).unwrap_or(0),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
let from_id = from_id.max(floor);
|
||||||
|
if from_id > to_id {
|
||||||
|
return Ok(out);
|
||||||
|
}
|
||||||
|
let params = IterateParams::new(
|
||||||
|
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(from_id))),
|
||||||
|
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Metric(to_id))),
|
||||||
|
);
|
||||||
|
let params = if ascending {
|
||||||
|
params.ascending()
|
||||||
|
} else {
|
||||||
|
params.descending()
|
||||||
|
};
|
||||||
|
store
|
||||||
|
.iterate(params, |key, value| {
|
||||||
|
let id = key.deserialize_be_u64(0)?;
|
||||||
|
if let Some(metric) = decode_metric(value) {
|
||||||
|
let sample = StoredMetric { id, metric };
|
||||||
|
if accept(&sample) {
|
||||||
|
out.push(sample);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(true)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Test data for the shared metrics suite: 90 days of hourly ticks, a
|
||||||
|
/// counter, a gauge and a histogram each.
|
||||||
|
#[cfg(feature = "test_mode")]
|
||||||
|
pub async fn insert_test_metrics(&self) {
|
||||||
|
let now = now();
|
||||||
|
for hour in (0..90 * 24u64).rev() {
|
||||||
|
let samples = vec![
|
||||||
|
Metric::Counter(MetricCount {
|
||||||
|
count: 1 + hour % 7,
|
||||||
|
metric: MetricType::AuthSuccess,
|
||||||
|
}),
|
||||||
|
Metric::Gauge(MetricCount {
|
||||||
|
count: 20 + hour % 11,
|
||||||
|
metric: MetricType::QueueCount,
|
||||||
|
}),
|
||||||
|
Metric::Histogram(MetricSum {
|
||||||
|
count: 100 + hour,
|
||||||
|
sum: 1000 + hour * 10,
|
||||||
|
metric: MetricType::DeliveryTotalTime,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
self.metrics_store()
|
||||||
|
.write_metrics(samples, now - hour * 3600)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,8 +2,11 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
pub mod alerts; // inbuxa: monitoring (MON-25 to MON-30)
|
||||||
pub mod metrics;
|
pub mod metrics;
|
||||||
pub mod tracers;
|
pub mod tracers;
|
||||||
pub mod webhooks;
|
pub mod webhooks;
|
||||||
@@ -17,14 +20,13 @@ use webhooks::spawn_webhook_tracer;
|
|||||||
use crate::config::telemetry::{Telemetry, TelemetrySubscriberType};
|
use crate::config::telemetry::{Telemetry, TelemetrySubscriberType};
|
||||||
|
|
||||||
impl Telemetry {
|
impl Telemetry {
|
||||||
pub fn enable(self, is_enterprise: bool) {
|
pub fn enable(self) {
|
||||||
// Spawn tracers
|
// Spawn tracers
|
||||||
for tracer in self.tracers.subscribers {
|
for tracer in self.tracers.subscribers {
|
||||||
tracer.typ.spawn(
|
tracer.typ.spawn(
|
||||||
SubscriberBuilder::new(tracer.id)
|
SubscriberBuilder::new(tracer.id)
|
||||||
.with_interests(tracer.interests)
|
.with_interests(tracer.interests)
|
||||||
.with_lossy(tracer.lossy),
|
.with_lossy(tracer.lossy),
|
||||||
is_enterprise,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,7 +37,7 @@ impl Telemetry {
|
|||||||
Collector::reload();
|
Collector::reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn update(self, is_enterprise: bool) {
|
pub fn update(self) {
|
||||||
// Remove tracers that are no longer active
|
// Remove tracers that are no longer active
|
||||||
let active_subscribers = Collector::get_subscribers();
|
let active_subscribers = Collector::get_subscribers();
|
||||||
for subscribed_id in &active_subscribers {
|
for subscribed_id in &active_subscribers {
|
||||||
@@ -58,7 +60,6 @@ impl Telemetry {
|
|||||||
SubscriberBuilder::new(tracer.id)
|
SubscriberBuilder::new(tracer.id)
|
||||||
.with_interests(tracer.interests)
|
.with_interests(tracer.interests)
|
||||||
.with_lossy(tracer.lossy),
|
.with_lossy(tracer.lossy),
|
||||||
is_enterprise,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ impl Telemetry {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TelemetrySubscriberType {
|
impl TelemetrySubscriberType {
|
||||||
pub fn spawn(self, builder: SubscriberBuilder, is_enterprise: bool) {
|
pub fn spawn(self, builder: SubscriberBuilder) {
|
||||||
match self {
|
match self {
|
||||||
TelemetrySubscriberType::ConsoleTracer(settings) => {
|
TelemetrySubscriberType::ConsoleTracer(settings) => {
|
||||||
spawn_console_tracer(builder, settings)
|
spawn_console_tracer(builder, settings)
|
||||||
@@ -104,6 +105,10 @@ impl TelemetrySubscriberType {
|
|||||||
TelemetrySubscriberType::LogTracer(settings) => spawn_log_tracer(builder, settings),
|
TelemetrySubscriberType::LogTracer(settings) => spawn_log_tracer(builder, settings),
|
||||||
TelemetrySubscriberType::Webhook(settings) => spawn_webhook_tracer(builder, settings),
|
TelemetrySubscriberType::Webhook(settings) => spawn_webhook_tracer(builder, settings),
|
||||||
TelemetrySubscriberType::OtelTracer(settings) => spawn_otel_tracer(builder, settings),
|
TelemetrySubscriberType::OtelTracer(settings) => spawn_otel_tracer(builder, settings),
|
||||||
|
// inbuxa: MON-10: trace history
|
||||||
|
TelemetrySubscriberType::StoreTracer(settings) => {
|
||||||
|
tracers::store::spawn_store_tracer(builder, settings.tracing, settings.data)
|
||||||
|
}
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
TelemetrySubscriberType::JournalTracer(subscriber) => {
|
TelemetrySubscriberType::JournalTracer(subscriber) => {
|
||||||
tracers::journald::spawn_journald_tracer(builder, subscriber)
|
tracers::journald::spawn_journald_tracer(builder, subscriber)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
@@ -9,6 +11,7 @@ pub mod journald;
|
|||||||
pub mod log;
|
pub mod log;
|
||||||
pub mod otel;
|
pub mod otel;
|
||||||
pub mod stdout;
|
pub mod stdout;
|
||||||
|
pub mod store; // inbuxa: monitoring history (MON-10 to MON-17)
|
||||||
|
|
||||||
|
|
||||||
use registry::{
|
use registry::{
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use crate::{LONG_1Y_SLUMBER, config::telemetry::OtelTracer};
|
use crate::{LONG_1Y_SLUMBER, config::telemetry::OtelTracer};
|
||||||
@@ -27,12 +29,12 @@ pub(crate) fn spawn_otel_tracer(builder: SubscriberBuilder, mut otel: OtelTracer
|
|||||||
let (_, mut rx) = builder.register();
|
let (_, mut rx) = builder.register();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let resource = Resource::builder()
|
let resource = Resource::builder()
|
||||||
.with_service_name("stalwart")
|
.with_service_name("inbuxa")
|
||||||
.with_attribute(KeyValue::new(SERVICE_VERSION, env!("CARGO_PKG_VERSION")))
|
.with_attribute(KeyValue::new(SERVICE_VERSION, types::brand_version_full!()))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let instrumentation = InstrumentationScope::builder("stalwart")
|
let instrumentation = InstrumentationScope::builder("inbuxa")
|
||||||
.with_version(env!("CARGO_PKG_VERSION"))
|
.with_version(types::brand_version_full!())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
otel.log_exporter.set_resource(&resource);
|
otel.log_exporter.set_resource(&resource);
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||||
|
*
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
//! Trace history (monitoring spec MON-10 to MON-17, MON-34). A lossy
|
||||||
|
//! collector subscriber gathers each inbound SMTP session and delivery
|
||||||
|
//! attempt, and writes it once, when the span closes, as an `x:Trace` in the
|
||||||
|
//! registry's own encoding under `TelemetryClass::Span(span_id)`.
|
||||||
|
|
||||||
|
use crate::telemetry::tracers::TraceEvents;
|
||||||
|
use ahash::AHashMap;
|
||||||
|
use registry::{
|
||||||
|
pickle::PickledStream,
|
||||||
|
schema::{
|
||||||
|
prelude::{ObjectInner, ObjectType},
|
||||||
|
structs::{
|
||||||
|
Task, TaskIndexTrace, TaskStatus, Trace, TraceKeyValue, TraceValue,
|
||||||
|
TraceValueString, TraceValueUnsignedInt,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::{future::Future, sync::Arc, time::Duration};
|
||||||
|
use store::{
|
||||||
|
SearchStore, Store, ValueKey,
|
||||||
|
search::{SearchFilter, SearchQuery},
|
||||||
|
write::{BatchBuilder, SearchIndex, TelemetryClass, ValueClass, now},
|
||||||
|
};
|
||||||
|
use trc::{
|
||||||
|
AddContext, DeliveryEvent, Event, EventDetails, EventType, Key, Level, SmtpEvent,
|
||||||
|
ipc::subscriber::SubscriberBuilder,
|
||||||
|
};
|
||||||
|
use utils::snowflake::SnowflakeIdGenerator;
|
||||||
|
|
||||||
|
/// Events kept per trace (MON-15).
|
||||||
|
pub const MAX_EVENTS: usize = 1000;
|
||||||
|
/// The longest string value kept (MON-15).
|
||||||
|
pub const MAX_STRING: usize = 4096;
|
||||||
|
/// A span still open after this is dropped (MON-13).
|
||||||
|
const SPAN_MAX_HOLD: u64 = 86_400;
|
||||||
|
|
||||||
|
pub trait TracingStore: Sync + Send {
|
||||||
|
/// Deletes traces older than `keep`, and their search documents
|
||||||
|
/// (MON-17).
|
||||||
|
fn purge_spans(
|
||||||
|
&self,
|
||||||
|
keep: Duration,
|
||||||
|
search: Option<&SearchStore>,
|
||||||
|
) -> impl Future<Output = trc::Result<()>> + Send;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TracingStore for Store {
|
||||||
|
async fn purge_spans(&self, keep: Duration, search: Option<&SearchStore>) -> trc::Result<()> {
|
||||||
|
let Some(until) = SnowflakeIdGenerator::from_duration(keep) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
self.delete_range(
|
||||||
|
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(0))),
|
||||||
|
ValueKey::from(ValueClass::Telemetry(TelemetryClass::Span(until))),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
if let Some(search) = search {
|
||||||
|
search
|
||||||
|
.unindex(
|
||||||
|
SearchQuery::new(SearchIndex::Tracing)
|
||||||
|
.with_filter(SearchFilter::lt(store::search::SearchField::Id, until)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a stored trace; `None` for records in any other encoding.
|
||||||
|
pub fn decode_trace(bytes: &[u8]) -> Option<Trace> {
|
||||||
|
PickledStream::new(bytes)
|
||||||
|
.and_then(|mut stream| ObjectInner::unpickle(ObjectType::Trace, &mut stream))
|
||||||
|
.and_then(|inner| match inner {
|
||||||
|
ObjectInner::Trace(trace) => Some(trace),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stored trace as read by key: `None` when it can't be decoded.
|
||||||
|
pub struct MaybeTrace(pub Option<Trace>);
|
||||||
|
|
||||||
|
impl store::Deserialize for MaybeTrace {
|
||||||
|
fn deserialize(bytes: &[u8]) -> trc::Result<Self> {
|
||||||
|
Ok(MaybeTrace(decode_trace(bytes)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_stored_span(event: EventType) -> bool {
|
||||||
|
matches!(
|
||||||
|
event,
|
||||||
|
EventType::Smtp(SmtpEvent::ConnectionStart) | EventType::Delivery(DeliveryEvent::AttemptStart)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_mail_from(event: EventType) -> bool {
|
||||||
|
event.as_str().starts_with("smtp.mail-from") || event == EventType::Smtp(SmtpEvent::MultipleMailFrom)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Span {
|
||||||
|
started: u64,
|
||||||
|
is_smtp: bool,
|
||||||
|
has_mail_from: bool,
|
||||||
|
events: Vec<Arc<Event<EventDetails>>>,
|
||||||
|
cut: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate_values_list(values: &mut registry::types::list::List<TraceKeyValue>) {
|
||||||
|
for kv in values.values_mut() {
|
||||||
|
match &mut kv.value {
|
||||||
|
TraceValue::String(TraceValueString { value }) if value.len() > MAX_STRING => {
|
||||||
|
let mut end = MAX_STRING;
|
||||||
|
while !value.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
value.truncate(end);
|
||||||
|
}
|
||||||
|
TraceValue::Event(event) => truncate_values_list(&mut event.value),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The trace a closed span leaves (MON-12, MON-15).
|
||||||
|
fn build_trace(span: &Span) -> Trace {
|
||||||
|
let mut trace = Trace::from_events(span.events.iter().map(|e| e.as_ref()), span.events.len());
|
||||||
|
for event in trace.events.values_mut() {
|
||||||
|
truncate_values_list(&mut event.key_values);
|
||||||
|
}
|
||||||
|
if span.cut > 0
|
||||||
|
&& let Some(last) = trace.events.values_mut().last()
|
||||||
|
{
|
||||||
|
// The count of events cut rides on the closing event
|
||||||
|
last.key_values.push(TraceKeyValue {
|
||||||
|
key: Key::Total,
|
||||||
|
value: TraceValue::UnsignedInt(TraceValueUnsignedInt {
|
||||||
|
value: span.cut as u64,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
trace
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts the subscriber that stores traces in `tracing`, scheduling their
|
||||||
|
/// indexing in `data` (MON-16). Lossy: a slow store loses history, never
|
||||||
|
/// delays mail (MON-34, MON-35).
|
||||||
|
pub(crate) fn spawn_store_tracer(builder: SubscriberBuilder, tracing: Store, data: Store) {
|
||||||
|
let (_, mut rx) = builder.register();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut spans: AHashMap<u64, Span> = AHashMap::new();
|
||||||
|
while let Some(events) = rx.recv().await {
|
||||||
|
let mut closed = Vec::new();
|
||||||
|
for event in events {
|
||||||
|
let typ = event.inner.typ;
|
||||||
|
let Some(span_id) = event.span_id() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_stored_span(typ) {
|
||||||
|
spans.insert(
|
||||||
|
span_id,
|
||||||
|
Span {
|
||||||
|
started: event.inner.timestamp,
|
||||||
|
is_smtp: matches!(typ, EventType::Smtp(_)),
|
||||||
|
has_mail_from: false,
|
||||||
|
events: vec![event],
|
||||||
|
cut: 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(span) = spans.get_mut(&span_id) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if is_mail_from(typ) {
|
||||||
|
span.has_mail_from = true;
|
||||||
|
}
|
||||||
|
let is_end = typ.is_span_end();
|
||||||
|
// MON-12: info and above, never raw I/O
|
||||||
|
if !typ.is_raw_io() && (is_end || event.inner.level as usize >= Level::Info as usize) {
|
||||||
|
if span.events.len() < MAX_EVENTS - 1 || is_end {
|
||||||
|
span.events.push(event);
|
||||||
|
} else {
|
||||||
|
span.cut += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if is_end && let Some(span) = spans.remove(&span_id) {
|
||||||
|
// MON-11: a session that never reached MAIL FROM isn't kept
|
||||||
|
if !span.is_smtp || span.has_mail_from {
|
||||||
|
closed.push((span_id, span));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !closed.is_empty() {
|
||||||
|
let mut batch = BatchBuilder::new();
|
||||||
|
let mut tasks = BatchBuilder::new();
|
||||||
|
for (span_id, span) in &closed {
|
||||||
|
batch.set(
|
||||||
|
ValueClass::Telemetry(TelemetryClass::Span(*span_id)),
|
||||||
|
ObjectInner::Trace(build_trace(span)).to_pickled_vec(),
|
||||||
|
);
|
||||||
|
tasks.schedule_task(Task::IndexTrace(TaskIndexTrace {
|
||||||
|
trace_id: (*span_id).into(),
|
||||||
|
status: TaskStatus::now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if let Err(err) = tracing.write(batch.build_all()).await {
|
||||||
|
trc::error!(err.details("Failed to store trace history"));
|
||||||
|
} else if let Err(err) = data.write(tasks.build_all()).await {
|
||||||
|
trc::error!(err.details("Failed to schedule trace indexing"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MON-13: spans open for over a day are dropped
|
||||||
|
if spans.len() > 1000 {
|
||||||
|
let now = now();
|
||||||
|
spans.retain(|_, span| now.saturating_sub(span.started) < SPAN_MAX_HOLD);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "coordinator"
|
name = "coordinator"
|
||||||
version = "0.16.23"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// inbuxa: composite stores (sharded members, read replicas) nest store
|
||||||
|
// futures deeply enough to pass rustc's default query depth
|
||||||
|
#![recursion_limit = "512"]
|
||||||
|
|
||||||
#![warn(clippy::large_futures)]
|
#![warn(clippy::large_futures)]
|
||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav-proto"
|
name = "dav-proto"
|
||||||
version = "0.16.23"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "dav"
|
name = "dav"
|
||||||
version = "0.16.23"
|
version = "0.16.22"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
use super::ArchivedResource;
|
use super::ArchivedResource;
|
||||||
@@ -137,6 +139,26 @@ impl DavAclHandler for Server {
|
|||||||
.validate_and_map_aces(access_token, request, collection)
|
.validate_and_map_aces(access_token, request, collection)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// inbuxa: MT-3: grants stay within the owner's tenant
|
||||||
|
let tenant_id = self
|
||||||
|
.try_account(account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.and_then(|owner| owner.id_tenant);
|
||||||
|
for grant in &grants {
|
||||||
|
if self
|
||||||
|
.try_account(grant.account_id)
|
||||||
|
.await
|
||||||
|
.caused_by(trc::location!())?
|
||||||
|
.is_none_or(|grantee| grantee.id_tenant != tenant_id)
|
||||||
|
{
|
||||||
|
return Err(DavError::Condition(DavErrorCondition::new(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
BaseCondition::AllowedPrincipal,
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) {
|
if grants.len() != acls.len() || acls.iter().zip(grants.iter()).any(|(a, b)| a != b) {
|
||||||
// Refresh ACLs
|
// Refresh ACLs
|
||||||
self.refresh_archived_acls(&grants, acls)
|
self.refresh_archived_acls(&grants, acls)
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||||
*
|
*
|
||||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||||
|
*
|
||||||
|
* Modified by Coffey Labs in 2026 for INBUXA.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// inbuxa: composite stores (sharded members, read replicas) nest store
|
||||||
|
// futures deeply enough to pass rustc's default query depth
|
||||||
|
#![recursion_limit = "512"]
|
||||||
#![warn(clippy::large_futures)]
|
#![warn(clippy::large_futures)]
|
||||||
|
|
||||||
pub mod calendar;
|
pub mod calendar;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user