From 059fa0ade30b399a455f075c8670c08a167621c5 Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Mon, 21 Sep 2026 22:17:29 +0200 Subject: [PATCH] Antispam: the app could never authenticate to the learning engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rspamd answers on two ports and only one of them asks for a password: the scan worker takes /checkv2 anonymously, the controller demands `Password:` for /stat and /learnspam. `webmail.sh init` generates RSPAMD_CONTROLLER_PASSWORD and docker-compose hands it to the rspamd container — but the shared x-backend-env block that feeds api and worker-* never carried it. The plugin reads it from the environment, found "", and sent no header. So classification worked and learning never did. Every learn returned 401 and the plugin swallowed it, as it is built to: an engine problem must not lose mail. Bootstrap logged "engine unavailable", passive learning from Inbox->Spam moves taught nothing, and the single visible trace was `"bayes": null` on GET /api/plugins/antispam/stats, on an install whose verdict counts were ticking up normally. Verified against a live engine: with no password /stat and /learnspam both 401 and rspamd.learn() returns False; with it, stats returns and learn returns True. - pass RSPAMD_CONTROLLER_PASSWORD to the app containers, in both the production and dev compose files - doctor now makes the real call from inside the api container, with the password that container actually holds, and says plainly that nothing will be learned if it fails - a test reads both compose files, because this is a missing line that review already let through once Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018SqJYt7jD7oUEkoY68sBZo --- .../tests/plugins/test_antispam_learning.py | 31 +++++++++++++++++++ deploy/webmail.sh | 31 +++++++++++++++++++ docker-compose.yml | 6 ++++ docker/docker-compose.yml | 2 ++ 4 files changed, 70 insertions(+) diff --git a/backend/tests/plugins/test_antispam_learning.py b/backend/tests/plugins/test_antispam_learning.py index c14fde4..e89bef2 100644 --- a/backend/tests/plugins/test_antispam_learning.py +++ b/backend/tests/plugins/test_antispam_learning.py @@ -328,3 +328,34 @@ async def test_one_failing_account_does_not_stop_the_sweep( monkeypatch.setattr(jobs, "bootstrap_account", _one) await jobs.bootstrap_sweep({}) assert done == [str(accounts[0][1]), str(accounts[2][1])] + + +def test_compose_gives_the_app_the_controller_password() -> None: + """The learning loop needs the controller password; classification does not. + + Rspamd splits over two ports: the scan worker takes /checkv2 anonymously, + the controller demands a password for /stat and /learnspam. Ship the password + to rspamd but not to the api/worker containers and the result is a filter + that looks healthy — mail is still classified — while every learn is refused + 401 and swallowed by the plugin's fail-open path. The only visible trace is + `"bayes": null` on the stats endpoint. + + That shipped, so it is worth a test that reads the compose files rather than + trusting review to catch the missing line again. + """ + import pathlib + + root = pathlib.Path(__file__).resolve().parents[3] + for rel, anchor in ( + ("docker-compose.yml", "x-backend-env:"), + ("docker/docker-compose.yml", "environment: &worker-env"), + ): + text = (root / rel).read_text() + assert anchor in text, f"{rel}: shared env block moved; update this test" + start = text.index(anchor) + # The shared block runs to the next top-level key. + block = text[start : text.index("\nservices:", start) if "\nservices:" in text[start:] else len(text)] + assert "RSPAMD_CONTROLLER_PASSWORD" in block, ( + f"{rel}: the app containers do not receive RSPAMD_CONTROLLER_PASSWORD — " + "antispam will classify but never learn" + ) diff --git a/deploy/webmail.sh b/deploy/webmail.sh index e6a09f8..3dfaddd 100755 --- a/deploy/webmail.sh +++ b/deploy/webmail.sh @@ -364,6 +364,37 @@ cmd_doctor() { fi fi + # Antispam splits over two ports and only one of them needs a password, so a + # missing password looks like a working filter: mail still gets classified by + # the scan worker while every learn is refused by the controller. Prove the + # app containers can actually reach it rather than trusting that the variable + # is set somewhere. + case " ${ENABLED_PLUGINS:-} " in + *" antispam "*|" ") + if [ -z "${RSPAMD_CONTROLLER_PASSWORD:-}" ]; then + red " RSPAMD_CONTROLLER_PASSWORD is empty — antispam will classify but never learn" + echo " fix: webmail.sh init regenerates it, then: webmail.sh update"; ok=1 + elif compose ps --status running --services 2>/dev/null | grep -qx rspamd; then + # From inside the api container, with the password it really has: + # that is the call the learning loop makes. + if compose exec -T api python -c " +import os, sys, urllib.request +req = urllib.request.Request( + os.environ.get('RSPAMD_CONTROLLER_URL', 'http://rspamd:11334') + '/stat', + headers={'Password': os.environ.get('RSPAMD_CONTROLLER_PASSWORD', ''), + 'Deliver-To': 'doctor'}) +urllib.request.urlopen(req, timeout=5).read() +" >/dev/null 2>&1; then + grn " antispam engine reachable and learning is authorised" + else + red " the api container cannot authenticate to the antispam controller." + red " Mail will still be classified, but NOTHING will be learned." + echo " usually RSPAMD_CONTROLLER_PASSWORD reaching rspamd but not api/worker-*." + echo " fix: webmail.sh update (v1.3.1+ passes it to both)"; ok=1 + fi + fi ;; + esac + if command -v ss >/dev/null 2>&1; then local p for p in "WEB_PORT:${WEB_PORT:-8088}" "ADMIN_PORT:${ADMIN_PORT:-8089}"; do diff --git a/docker-compose.yml b/docker-compose.yml index 7da75c3..ff839c4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -63,6 +63,12 @@ x-backend-env: &backend-env SEARCH_BACKEND: ${SEARCH_BACKEND:-pg} MEILI_URL: ${MEILI_URL:-http://meilisearch:7700} MEILI_KEY: ${MEILI_MASTER_KEY:-} + # The antispam engine splits across two ports: the scan worker (:11333) takes + # anonymous /checkv2, the controller (:11334) demands this password for /stat + # and /learnspam. Without it here, classification works and LEARNING SILENTLY + # DOES NOT — the controller answers 401, the plugin fails open, and the only + # visible symptom is "bayes": null on the stats endpoint. + RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD} LOG_LEVEL: ${LOG_LEVEL:-INFO} UNDO_SECONDS: ${UNDO_SECONDS:-10} SESSION_TTL_DAYS: ${SESSION_TTL_DAYS:-30} diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 1b482e3..7c3f170 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -48,6 +48,8 @@ services: REDIS_URL: redis://redis:6379/0 MASTER_KEY: CHANGE_ME_base64_32_bytes BLOB_ROOT: /var/blobs + # Must match the rspamd service below, or the controller 401s every learn. + RSPAMD_CONTROLLER_PASSWORD: ${RSPAMD_CONTROLLER_PASSWORD:-devpass} volumes: [blobs:/var/blobs] ulimits: { nofile: 65536 } depends_on: