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 <noreply@anthropic.com>
This commit is contained in:
1 parent
1e7c6a118c
commit
7f8d9b7ff1
7 files changed
+119
-29
No files matched your search
@@ -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
|
> **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.
|
> 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
|
> See [What is not finished](#what-is-not-finished) before putting real mail
|
||||||
> not yet acted on — see [What is not finished](#what-is-not-finished) before
|
> through it.
|
||||||
> 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 |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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
|
## 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
|
- **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
|
needs the privilege — the default on a normal install. The containers
|
||||||
themselves run unprivileged.
|
themselves run unprivileged.
|
||||||
- **The proxy's certificate directory** must be readable by the `protocols`
|
- **The proxy's certificate directory**, under `TLS_MODE=traefik` only — see
|
||||||
container. Under `TLS_MODE=traefik` it reads the certificate the proxy
|
[Certificates](#certificates) below, and prefer `TLS_MODE=acme` if you can.
|
||||||
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.
|
|
||||||
- **Disk** for the mail spool (`protocols_spool`) and Postgres. Mail is stored
|
- **Disk** for the mail spool (`protocols_spool`) and Postgres. Mail is stored
|
||||||
once and reference-counted, but plan for growth.
|
once and reference-counted, but plan for growth.
|
||||||
- **A firewall that does not rate-limit port 25 into uselessness.** Some
|
- **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. |
|
| `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`. |
|
| `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.<domain>` and `autoconfig.<domain>` 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
|
## 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:
|
Stated plainly, because a mail server that half works loses mail:
|
||||||
|
|
||||||
- **Recorded but not acted on.** Vacation responder, signature and the Sieve
|
- **Read-only console panels.** The guided domain onboarding checklist reports
|
||||||
rules built in the UI are stored, but nothing consumes them yet — enabling a
|
on state rather than driving it.
|
||||||
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.
|
|
||||||
- **Not implemented.** JMAP, BIMI, CalDAV/CardDAV, multi-node. Backups are
|
- **Not implemented.** JMAP, BIMI, CalDAV/CardDAV, multi-node. Backups are
|
||||||
documented but not automated.
|
documented but not automated.
|
||||||
- **DANE** is off unless `TLS_MODE=acme`, and deliberately so: under a
|
- **DANE** is off unless `TLS_MODE=acme`, and deliberately so: under a
|
||||||
|
|||||||
@@ -43,13 +43,16 @@ func (a *adminTlsAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status := map[string]any{
|
status := map[string]any{
|
||||||
"tls_mode": a.tlsMode,
|
"tls_mode": a.tlsMode,
|
||||||
"mail_host": a.mailHost,
|
"mail_host": a.mailHost,
|
||||||
"has_certificate": a.source.HasCertificateFor(a.mailHost),
|
"has_certificate": a.source.HasCertificateFor(a.mailHost),
|
||||||
"unknown_sni_total": a.source.UnknownSNICount(),
|
"unknown_sni_total": a.source.UnknownSNICount(),
|
||||||
"last_reload": nil,
|
"last_reload": nil,
|
||||||
"last_change": nil,
|
"last_change": nil,
|
||||||
"watcher_healthy": false,
|
// 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,
|
"certificate_expires": nil,
|
||||||
"expires_in_days": nil,
|
"expires_in_days": nil,
|
||||||
"state": "UNKNOWN",
|
"state": "UNKNOWN",
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -161,7 +161,8 @@
|
|||||||
"search": "Search",
|
"search": "Search",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"unavailable": "Unavailable",
|
"unavailable": "Unavailable",
|
||||||
"userPortal": "Webmail"
|
"userPortal": "Webmail",
|
||||||
|
"notApplicable": "Not applicable"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"forbidden": "You do not have access to this",
|
"forbidden": "You do not have access to this",
|
||||||
|
|||||||
@@ -161,7 +161,8 @@
|
|||||||
"search": "Buscar",
|
"search": "Buscar",
|
||||||
"status": "Estado",
|
"status": "Estado",
|
||||||
"unavailable": "No disponible",
|
"unavailable": "No disponible",
|
||||||
"userPortal": "Webmail"
|
"userPortal": "Webmail",
|
||||||
|
"notApplicable": "No aplicable"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"forbidden": "No tiene acceso a esto",
|
"forbidden": "No tiene acceso a esto",
|
||||||
|
|||||||
@@ -161,7 +161,8 @@
|
|||||||
"search": "Buscar",
|
"search": "Buscar",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"unavailable": "Indisponível",
|
"unavailable": "Indisponível",
|
||||||
"userPortal": "Webmail"
|
"userPortal": "Webmail",
|
||||||
|
"notApplicable": "Nao aplicavel"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"forbidden": "Você não tem acesso a isto",
|
"forbidden": "Você não tem acesso a isto",
|
||||||
|
|||||||
@@ -318,12 +318,23 @@ export default function AdminSecurityPage() {
|
|||||||
|
|
||||||
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||||
<div className="text-xs text-slate-400">{t("admin.watcherHealthy")}</div>
|
<div className="text-xs text-slate-400">{t("admin.watcherHealthy")}</div>
|
||||||
|
{/* 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. */}
|
||||||
<div
|
<div
|
||||||
className={`text-[15px] font-medium ${
|
className={`text-[15px] font-medium ${
|
||||||
tlsStatus.watcher_healthy ? "text-indigo-400" : "text-amber-400"
|
tlsStatus.watcher_healthy === null || tlsStatus.watcher_healthy === undefined
|
||||||
|
? "text-slate-400"
|
||||||
|
: tlsStatus.watcher_healthy
|
||||||
|
? "text-indigo-400"
|
||||||
|
: "text-amber-400"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{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")}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11.5px] text-slate-500">
|
<div className="text-[11.5px] text-slate-500">
|
||||||
{t("admin.lastReload")}:{" "}
|
{t("admin.lastReload")}:{" "}
|
||||||
|
|||||||
Reference in new issue
Block a user