diff --git a/services/protocols/cmd/lambdamail-protocols/main.go b/services/protocols/cmd/lambdamail-protocols/main.go index de4215f..b90bb80 100644 --- a/services/protocols/cmd/lambdamail-protocols/main.go +++ b/services/protocols/cmd/lambdamail-protocols/main.go @@ -238,9 +238,15 @@ func run(cfg config) { // The TLS panel reads the live watcher instead of the constants the admin // service used to return, which reported a healthy certificate whatever // the process actually held. - if source, ok := certProvider.(httppresentation.TlsStatusSource); ok { - router.SetAdminTlsAPI(source, cfg.PrimaryMailHost, cfg.TLSMode, cfg.JwtSecret, cfg.CertPollInterval) + // Wired for every certificate source, not just the Traefik watcher. Only + // the watcher carried status methods, so with TRAEFIK_ACME_DIR unset the + // panel answered 503 and showed "could not load the data" - in exactly the + // state an operator most needs to see, the degraded self-signed fallback. + tlsStatus, ok := certProvider.(httppresentation.TlsStatusSource) + if !ok { + tlsStatus = tlsprovider.NewProviderStatus(certProvider) } + router.SetAdminTlsAPI(tlsStatus, cfg.PrimaryMailHost, cfg.TLSMode, cfg.JwtSecret, cfg.CertPollInterval) // Real-time updates: the outbox relay reads the events delivery wrote in // its own transaction and hands them to the hub, which pushes them to the diff --git a/services/protocols/internal/infrastructure/tls/provider_status.go b/services/protocols/internal/infrastructure/tls/provider_status.go new file mode 100644 index 0000000..6ac7e2b --- /dev/null +++ b/services/protocols/internal/infrastructure/tls/provider_status.go @@ -0,0 +1,96 @@ +package tlsprovider + +import ( + "bytes" + "crypto/tls" + "crypto/x509" + "time" + + "lambdamail/protocols/internal/application/port" +) + +// ProviderStatus reports on whatever certificate a provider is actually +// serving, by asking it for one and reading the leaf. +// +// The TLS panel used to be wired only when the certificate came from the +// Traefik acme.json watcher, the single provider that carried status methods +// of its own. Every other mode answered 503 "the TLS panel needs a certificate +// watcher" - including the degraded fallback, where the server serves an +// ephemeral self-signed certificate and the operator most needs to be told. +type ProviderStatus struct { + provider port.CertProvider +} + +func NewProviderStatus(provider port.CertProvider) *ProviderStatus { + return &ProviderStatus{provider: provider} +} + +// leafFor returns the parsed leaf the provider would serve for a host. +func (s *ProviderStatus) leafFor(host string) *x509.Certificate { + if s == nil || s.provider == nil { + return nil + } + cert, err := s.provider.GetCertificate(&tls.ClientHelloInfo{ServerName: host}) + if err != nil || cert == nil { + return nil + } + if cert.Leaf != nil { + return cert.Leaf + } + if len(cert.Certificate) == 0 { + return nil + } + leaf, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil + } + return leaf +} + +func (s *ProviderStatus) HasCertificateFor(host string) bool { + return s.leafFor(host) != nil +} + +// EarliestExpiry reports the expiry of the certificate served for the host it +// was asked about. A single-certificate provider has only one to report. +func (s *ProviderStatus) EarliestExpiry() (string, time.Time, bool) { + leaf := s.leafFor("") + if leaf == nil { + return "", time.Time{}, false + } + host := leaf.Subject.CommonName + if host == "" && len(leaf.DNSNames) > 0 { + host = leaf.DNSNames[0] + } + return host, leaf.NotAfter, true +} + +// LastReload and LastChange are unknown for a provider that loads once. The +// panel already treats a zero time as "not reported" rather than as an error. +func (s *ProviderStatus) LastReload() time.Time { return time.Time{} } +func (s *ProviderStatus) LastChange() time.Time { return time.Time{} } + +// UnknownSNICount is a watcher-only measure; nothing here counts SNI misses. +func (s *ProviderStatus) UnknownSNICount() int64 { return 0 } + +// CertificateSummary names the issuer and says whether the certificate is +// self-signed, which is the difference between a working mail server and one +// every verifying client refuses. +func (s *ProviderStatus) CertificateSummary(host string) (string, bool, bool) { + leaf := s.leafFor(host) + if leaf == nil { + return "", false, false + } + issuer := leaf.Issuer.CommonName + if issuer == "" { + issuer = leaf.Issuer.String() + } + // Equal subject and issuer is what self-signed means on the wire, and + // verifying the signature with the certificate's own key confirms it. + // CheckSignatureFrom is the wrong tool: it also demands the issuer be a + // CA, which a throwaway leaf never claims to be, so it reported the + // degraded fallback as properly issued. + selfSigned := bytes.Equal(leaf.RawSubject, leaf.RawIssuer) && + leaf.CheckSignature(leaf.SignatureAlgorithm, leaf.RawTBSCertificate, leaf.Signature) == nil + return issuer, selfSigned, true +} diff --git a/services/protocols/internal/infrastructure/tls/provider_status_test.go b/services/protocols/internal/infrastructure/tls/provider_status_test.go new file mode 100644 index 0000000..c1d373e --- /dev/null +++ b/services/protocols/internal/infrastructure/tls/provider_status_test.go @@ -0,0 +1,49 @@ +package tlsprovider + +import ( + "testing" + "time" +) + +// The TLS panel answered 503 "the TLS panel needs a certificate watcher" on a +// real deployment, because the panel was only wired when the certificate came +// from the Traefik acme.json watcher. With TRAEFIK_ACME_DIR unset the server +// falls back to an ephemeral self-signed certificate - which is exactly the +// state an operator most needs the panel to show, and precisely the one it +// refused to report. +func TestProviderStatus_ReportsAnEphemeralSelfSignedCertificate(t *testing.T) { + provider, err := NewEphemeralSelfSignedCertProvider() + if err != nil { + t.Fatalf("build provider: %v", err) + } + status := NewProviderStatus(provider) + + if !status.HasCertificateFor("mail.example.test") { + t.Error("reported no certificate at all; the server is serving one") + } + + host, expiry, ok := status.EarliestExpiry() + if !ok { + t.Fatal("could not read the expiry, so the panel would show nothing") + } + if expiry.Before(time.Now()) { + t.Errorf("expiry %v is in the past", expiry) + } + if host == "" { + t.Error("no host named for the expiring certificate") + } + + // The whole point: an operator must be able to see this is not a real + // certificate. Serving self-signed on port 25 and 993 means every client + // that verifies is refusing the connection. + issuer, selfSigned, ok := status.CertificateSummary("mail.example.test") + if !ok { + t.Fatal("no summary available") + } + if !selfSigned { + t.Error("an ephemeral self-signed certificate was not reported as self-signed") + } + if issuer == "" { + t.Error("no issuer reported") + } +} diff --git a/services/protocols/internal/presentation/http/admin_tls_api.go b/services/protocols/internal/presentation/http/admin_tls_api.go index c13d27a..9b2b299 100644 --- a/services/protocols/internal/presentation/http/admin_tls_api.go +++ b/services/protocols/internal/presentation/http/admin_tls_api.go @@ -16,6 +16,13 @@ type TlsStatusSource interface { HasCertificateFor(host string) bool } +// TlsCertificateInspector is optional extra detail: who issued the +// certificate, and whether it is self-signed. A source that cannot say simply +// does not implement it. +type TlsCertificateInspector interface { + CertificateSummary(host string) (issuer string, selfSigned bool, ok bool) +} + type adminTlsAPI struct { source TlsStatusSource mailHost string @@ -76,6 +83,19 @@ func (a *adminTlsAPI) handleStatus(w http.ResponseWriter, r *http.Request) { } } + // Who signed it, and whether anything would trust it. A self-signed + // certificate on 25 and 993 is refused by every client that verifies, so + // it outranks an expiry that is comfortably far away. + if inspector, ok := a.source.(TlsCertificateInspector); ok { + if issuer, selfSigned, found := inspector.CertificateSummary(a.mailHost); found { + status["issuer"] = issuer + status["self_signed"] = selfSigned + if selfSigned { + status["state"] = "SELF_SIGNED" + } + } + } + if !status["has_certificate"].(bool) { status["state"] = "NO_CERTIFICATE" } diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index bf4d8bf..742e00c 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -95,7 +95,8 @@ "reconcileConflicts": "Left untouched — another value is already published:", "reconcileErrors": "The provider refused these:", "selectDomain": "Select", - "domainSelected": "Selected" + "domainSelected": "Selected", + "tlsSelfSigned": "This certificate is self-signed (issuer: {issuer}). Clients that verify certificates will refuse the connection on the mail ports. Set TRAEFIK_ACME_DIR or STATIC_CERT_PATH to serve an issued certificate." }, "auth": { "adminLoginTitle": "Admin console", diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index e32fa0f..bf7b35c 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -95,7 +95,8 @@ "reconcileConflicts": "Sin tocar — ya hay otro valor publicado:", "reconcileErrors": "El proveedor rechazó estos:", "selectDomain": "Seleccionar", - "domainSelected": "Seleccionado" + "domainSelected": "Seleccionado", + "tlsSelfSigned": "Este certificado es autofirmado (emisor: {issuer}). Los clientes que verifican el certificado rechazaran la conexion en los puertos de correo. Defina TRAEFIK_ACME_DIR o STATIC_CERT_PATH para servir un certificado emitido." }, "auth": { "adminLoginTitle": "Consola de administración", diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 85abe32..80b5fb3 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -95,7 +95,8 @@ "reconcileConflicts": "Não alterados — já existe outro valor publicado:", "reconcileErrors": "O provedor recusou estes:", "selectDomain": "Selecionar", - "domainSelected": "Selecionado" + "domainSelected": "Selecionado", + "tlsSelfSigned": "Este certificado e autoassinado (emissor: {issuer}). Clientes que verificam o certificado vao recusar a ligacao nas portas de correio. Defina TRAEFIK_ACME_DIR ou STATIC_CERT_PATH para servir um certificado emitido." }, "auth": { "adminLoginTitle": "Console de administração", diff --git a/services/webmail/src/app/(admin)/admin/queue/page.tsx b/services/webmail/src/app/(admin)/admin/queue/page.tsx index 6822788..9b92a78 100644 --- a/services/webmail/src/app/(admin)/admin/queue/page.tsx +++ b/services/webmail/src/app/(admin)/admin/queue/page.tsx @@ -156,8 +156,8 @@ export default function AdminQueuePage() { {jobs.map((j) => ( {j.id.slice(0, 8)} - {j.envelope_from} - {j.envelope_to} + {j.envelope_from} + {j.envelope_to} {j.destination_domain} {j.attempt} {/* The diagnostic wraps instead of being truncated: a queue diff --git a/services/webmail/src/app/(admin)/admin/security/page.tsx b/services/webmail/src/app/(admin)/admin/security/page.tsx index beb8fa5..edf386c 100644 --- a/services/webmail/src/app/(admin)/admin/security/page.tsx +++ b/services/webmail/src/app/(admin)/admin/security/page.tsx @@ -9,6 +9,7 @@ import { CheckCircle2, Search, Shield, + AlertTriangle, } from "lucide-react"; import { useTranslations } from "../../../../i18n/provider"; import { Badge } from "../../../../components/ui/Badge"; @@ -275,6 +276,18 @@ export default function AdminSecurityPage() { {t("admin.tlsTitle")} + {/* A self-signed certificate on the mail ports is refused by every + client that verifies, so it is stated outright rather than left + to be inferred from a status word. */} + {tlsStatus?.self_signed ? ( +
+ + + {t("admin.tlsSelfSigned", { issuer: String(tlsStatus.issuer ?? "-") })} + +
+ ) : null} + {tlsStatus ? (