From 7f8d9b7ff1d5db971d752b39c4a13c634f585c87 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Mon, 24 Aug 2026 11:59:11 -0300 Subject: [PATCH] fix(tls): sem watcher nao ha veredito, e o README passa a explicar os modos Com TLS_MODE=acme o certificado e emitido por este servico, nao vigiado em disco, portanto nao ha watcher para estar saudavel. O painel reportava false e mostrava "indisponivel" em permanencia, ao lado de um certificado perfeitamente valido. Passa a ser null, e a pagina nao mostra veredito nenhum. O README ganha uma seccao sobre os dois modos de certificado. O modo traefik falha de duas maneiras silenciosas -- sem router para o PRIMARY_MAIL_HOST o nome nunca chega ao acme.json, e o acme.json fica em 600 do root, ilegivel para o utilizador nao-root do contentor. Nos dois casos o HTTPS continua a funcionar porque quem o serve e o proxy, enquanto as portas de correio caem no autoassinado e sao recusadas por qualquer cliente que verifique. Foi exactamente o que aconteceu nesta instalacao. Actualizado tambem o que ja nao esta por acabar: a resposta de ausencia, a assinatura e as regras Sieve sao consumidas, e a reconciliacao de DNS conduz o estado em vez de o relatar. Co-Authored-By: Claude Opus 5 --- README.md | 54 +++++++++++++------ .../presentation/http/admin_tls_api.go | 17 +++--- .../http/admin_tls_status_test.go | 53 ++++++++++++++++++ services/webmail/messages/en.json | 3 +- services/webmail/messages/es.json | 3 +- services/webmail/messages/pt-BR.json | 3 +- .../src/app/(admin)/admin/security/page.tsx | 15 +++++- 7 files changed, 119 insertions(+), 29 deletions(-) create mode 100644 services/protocols/internal/presentation/http/admin_tls_status_test.go diff --git a/README.md b/README.md index d904bb2..cbaf5ea 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,8 @@ paid tiers and no artificial limits on domains or mailboxes. > **Status: pre-release.** The mail path, the security posture and the web > interfaces work end to end, and there is an automated test suite behind them. -> Several console panels are still read-only, and some features are recorded but -> not yet acted on — see [What is not finished](#what-is-not-finished) before -> putting real mail through it. +> See [What is not finished](#what-is-not-finished) before putting real mail +> through it. --- @@ -23,8 +22,10 @@ paid tiers and no artificial limits on domains or mailboxes. | **Authentication** | SPF, DKIM (Ed25519 + RSA dual signature), DMARC, ARC sealing, DANE/TLSA and MTA-STS on delivery, TLS-RPT ingestion | | **Filtering** | Rspamd scoring with greylisting, RBLs and rate limiting; ClamAV; Sieve | | **Delivery** | Durable outbound queue in Postgres with the retry ladder and RFC 3464 bounces; smarthost fallback for hosts with port 25 blocked | -| **DNS** | Cloudflare reconciliation of the 13 records, idempotent, with drift detection | +| **DNS** | Cloudflare reconciliation of the expected records for every active domain, idempotent, with per-record verification against public resolvers | +| **Filing** | Sieve rules built in the UI, custom folders, and an RFC 5230 vacation responder that replies in thread and once per sender per week | | **Web** | Webmail at `/user/*` and an admin console at `/admin/*`, isolated by session audience, with TOTP two-factor, recovery codes, app passwords, and English, Portuguese and Spanish | +| **Console** | Server-side paging and filtering, user editing (role, quota, language, password, second factor), per-user aliases, and a diagnostics page at `/admin/tests` | ## Architecture @@ -109,13 +110,8 @@ more thing to keep patched. - **Ports below 1024** are bound by Docker's proxy on the host, so the daemon needs the privilege — the default on a normal install. The containers themselves run unprivileged. -- **The proxy's certificate directory** must be readable by the `protocols` - container. Under `TLS_MODE=traefik` it reads the certificate the proxy - obtained, from the directory given by `COOLIFY_PROXY_DIR`, mounted read-only. - The images run as a non-root user, so an `acme.json` left at mode `600` and - owned by root cannot be read, and the mail listeners fall back to a - self-signed certificate while HTTPS keeps working — a confusing failure that - looks like a certificate problem and is a permissions problem. +- **The proxy's certificate directory**, under `TLS_MODE=traefik` only — see + [Certificates](#certificates) below, and prefer `TLS_MODE=acme` if you can. - **Disk** for the mail spool (`protocols_spool`) and Postgres. Mail is stored once and reference-counted, but plan for growth. - **A firewall that does not rate-limit port 25 into uselessness.** Some @@ -159,6 +155,34 @@ default: | `POSTGRES_PASSWORD`, `REDIS_PASSWORD` | No defaults; compose fails without them. | | `MAIL_DOMAIN`, `PRIMARY_MAIL_HOST`, `PUBLIC_IPV4` | Identity. `PRIMARY_MAIL_HOST` must match the PTR of `PUBLIC_IPV4`. | +### Certificates + +Two modes, and the choice matters more than it looks. + +**`TLS_MODE=acme` (recommended).** LambdaMail obtains its own certificate for +`PRIMARY_MAIL_HOST`, `mta-sts.` and `autoconfig.` over DNS-01, +using the Cloudflare token already configured for DNS. It needs +`LAMBDAMAIL_MASTER_KEY` to seal the account and certificate keys, and +`ACME_EMAIL`. It is the only mode DANE can run in, because it is the only one +where the key for the next certificate exists before that certificate does. + +**`TLS_MODE=traefik`.** The mail listeners read the certificate the reverse +proxy already obtained, from `COOLIFY_PROXY_DIR`, mounted read-only. Two things +have to be true, and neither is obvious when they are not: + +- the proxy must have a router for `PRIMARY_MAIL_HOST`, or that name never + appears in `acme.json` and no certificate exists to read; +- `acme.json` must be readable by the container's non-root user. Left at mode + `600` owned by root — which is how the proxy writes it — it cannot be. + +Either failure is silent in the same way: HTTPS keeps working, because the +proxy is serving it, while the mail listeners fall back to a **self-signed +certificate** and every client that verifies refuses SMTP, IMAP and POP3. It +reads as a certificate problem and is usually a permissions problem. + +`/admin/security` names the issuer and says outright when the certificate is +self-signed, which is the fastest way to tell the two apart. + ## Operating | | | @@ -176,12 +200,8 @@ what the alerts mean and how to read a delivery failure. Stated plainly, because a mail server that half works loses mail: -- **Recorded but not acted on.** Vacation responder, signature and the Sieve - rules built in the UI are stored, but nothing consumes them yet — enabling a - vacation reply does not send one. -- **Read-only console panels.** The guided domain onboarding checklist, the DNS - desired-vs-actual diff and the CSV import report on state rather than driving - it. +- **Read-only console panels.** The guided domain onboarding checklist reports + on state rather than driving it. - **Not implemented.** JMAP, BIMI, CalDAV/CardDAV, multi-node. Backups are documented but not automated. - **DANE** is off unless `TLS_MODE=acme`, and deliberately so: under a diff --git a/services/protocols/internal/presentation/http/admin_tls_api.go b/services/protocols/internal/presentation/http/admin_tls_api.go index 9b2b299..b071e20 100644 --- a/services/protocols/internal/presentation/http/admin_tls_api.go +++ b/services/protocols/internal/presentation/http/admin_tls_api.go @@ -43,13 +43,16 @@ func (a *adminTlsAPI) handleStatus(w http.ResponseWriter, r *http.Request) { } status := map[string]any{ - "tls_mode": a.tlsMode, - "mail_host": a.mailHost, - "has_certificate": a.source.HasCertificateFor(a.mailHost), - "unknown_sni_total": a.source.UnknownSNICount(), - "last_reload": nil, - "last_change": nil, - "watcher_healthy": false, + "tls_mode": a.tlsMode, + "mail_host": a.mailHost, + "has_certificate": a.source.HasCertificateFor(a.mailHost), + "unknown_sni_total": a.source.UnknownSNICount(), + "last_reload": nil, + "last_change": nil, + // Null, not false. A source that reloads from disk reports a time and + // gets a verdict; one that issues its own certificate has no watcher to + // be unhealthy, and saying "unavailable" there is a false alarm. + "watcher_healthy": nil, "certificate_expires": nil, "expires_in_days": nil, "state": "UNKNOWN", diff --git a/services/protocols/internal/presentation/http/admin_tls_status_test.go b/services/protocols/internal/presentation/http/admin_tls_status_test.go new file mode 100644 index 0000000..9a1c935 --- /dev/null +++ b/services/protocols/internal/presentation/http/admin_tls_status_test.go @@ -0,0 +1,53 @@ +package httppresentation + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Under TLS_MODE=acme the certificate is issued by this service, not watched +// on disk, so there is no watcher to be healthy or unhealthy. Reporting false +// there put a standing "unavailable" warning on a panel whose certificate was +// perfectly fine. +func TestTlsStatus_NoWatcherVerdictWithoutAReloadTime(t *testing.T) { + api := &adminTlsAPI{ + source: stillSource{}, + mailHost: "mail.example.test", + tlsMode: "acme", + sessions: testVerifier(interopSecret), + pollPeriod: time.Minute, + } + + rec := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/tls", nil) + req.Header.Set("Authorization", "Bearer "+adminSurfaceToken2()) + api.sessions.now = func() time.Time { return time.Unix(interopIssuedAt+60, 0) } + api.handleStatus(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("answered %d: %s", rec.Code, rec.Body) + } + + var body map[string]any + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if v, present := body["watcher_healthy"]; !present || v != nil { + t.Errorf("watcher_healthy = %v, want null: there is no watcher to judge", v) + } +} + +// stillSource holds a certificate but never reloads: exactly what a +// self-issuing provider looks like. +type stillSource struct{} + +func (stillSource) EarliestExpiry() (string, time.Time, bool) { + return "mail.example.test", time.Now().Add(60 * 24 * time.Hour), true +} +func (stillSource) LastReload() time.Time { return time.Time{} } +func (stillSource) LastChange() time.Time { return time.Time{} } +func (stillSource) UnknownSNICount() int64 { return 0 } +func (stillSource) HasCertificateFor(string) bool { return true } diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index dfe9639..dccc7d7 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -161,7 +161,8 @@ "search": "Search", "status": "Status", "unavailable": "Unavailable", - "userPortal": "Webmail" + "userPortal": "Webmail", + "notApplicable": "Not applicable" }, "errors": { "forbidden": "You do not have access to this", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index 369a27a..2d847f7 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -161,7 +161,8 @@ "search": "Buscar", "status": "Estado", "unavailable": "No disponible", - "userPortal": "Webmail" + "userPortal": "Webmail", + "notApplicable": "No aplicable" }, "errors": { "forbidden": "No tiene acceso a esto", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 69c958b..8cc5a66 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -161,7 +161,8 @@ "search": "Buscar", "status": "Status", "unavailable": "Indisponível", - "userPortal": "Webmail" + "userPortal": "Webmail", + "notApplicable": "Nao aplicavel" }, "errors": { "forbidden": "Você não tem acesso a isto", diff --git a/services/webmail/src/app/(admin)/admin/security/page.tsx b/services/webmail/src/app/(admin)/admin/security/page.tsx index edf386c..7e5932a 100644 --- a/services/webmail/src/app/(admin)/admin/security/page.tsx +++ b/services/webmail/src/app/(admin)/admin/security/page.tsx @@ -318,12 +318,23 @@ export default function AdminSecurityPage() {
{t("admin.watcherHealthy")}
+ {/* Null means there is no watcher to judge - the certificate + is issued by this server rather than read from disk - so no + verdict is shown instead of a standing false alarm. */}
- {tlsStatus.watcher_healthy ? t("ui.healthy") : t("common.unavailable")} + {tlsStatus.watcher_healthy === null || tlsStatus.watcher_healthy === undefined + ? t("common.notApplicable") + : tlsStatus.watcher_healthy + ? t("ui.healthy") + : t("common.unavailable")}
{t("admin.lastReload")}:{" "}