fix(tls): o painel de seguranca estava vazio em qualquer modo menos um
/admin/security mostrava "Nao foi possivel carregar os dados" porque /api/v1/admin/tls respondia 503. A rota so era ligada quando o certificado vinha do watcher do acme.json do Traefik, o unico provider que tinha metodos de estado proprios. Nesta instalacao o TRAEFIK_ACME_DIR nao esta definido e o STATIC_CERT_PATH tambem nao, portanto o servidor cai no certificado autoassinado efemero -- ou seja, o painel calava-se exactamente no estado em que o operador mais precisa de ser avisado. - ProviderStatus deriva o estado de qualquer port.CertProvider, lendo a folha do certificado que ele realmente serve - o painel passa a ser ligado sempre que ha JWT_SECRET - passa a dizer quem emitiu e se e autoassinado, com um aviso proprio: nas portas de correio um autoassinado e recusado por qualquer cliente que verifique, o que pesa mais do que uma validade ainda longe CheckSignatureFrom nao serve para detectar isto: tambem exige que o emissor seja uma CA, coisa que uma folha descartavel nunca declara, e por isso dava a fallback degradada como correctamente emitida. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9689f26dda
commit
136f9f339a
9 files changed
+194
-7
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -156,8 +156,8 @@ export default function AdminQueuePage() {
|
||||
{jobs.map((j) => (
|
||||
<tr key={j.id}>
|
||||
<td className="pl-0 font-mono text-[12.5px] text-slate-100">{j.id.slice(0, 8)}</td>
|
||||
<td className="max-w-[180px] break-words text-[12.5px] text-slate-300">{j.envelope_from}</td>
|
||||
<td className="max-w-[180px] break-words text-[12.5px] text-slate-300">{j.envelope_to}</td>
|
||||
<td className="max-w-[220px] break-words text-[12.5px] text-slate-300">{j.envelope_from}</td>
|
||||
<td className="max-w-[220px] break-words text-[12.5px] text-slate-300">{j.envelope_to}</td>
|
||||
<td className="break-words text-[12.5px] text-slate-300">{j.destination_domain}</td>
|
||||
<td className="whitespace-nowrap text-[12.5px] tabular-nums text-slate-300">{j.attempt}</td>
|
||||
{/* The diagnostic wraps instead of being truncated: a queue
|
||||
|
||||
@@ -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")}
|
||||
</h2>
|
||||
|
||||
{/* 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 ? (
|
||||
<div className="flex items-start gap-2.5 rounded-xl bg-amber-500/10 px-3.5 py-3 shadow-edge">
|
||||
<AlertTriangle className="mt-px h-4 w-4 flex-none text-amber-400" />
|
||||
<span className="text-[12.5px] leading-relaxed text-amber-200">
|
||||
{t("admin.tlsSelfSigned", { issuer: String(tlsStatus.issuer ?? "-") })}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tlsStatus ? (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<div className="flex flex-col gap-1 rounded-xl bg-dark-card p-3.5 shadow-edge">
|
||||
|
||||
Reference in new issue
Block a user