Files
LambdaMail/services/protocols/internal/presentation/http/admin_tls_api.go
T
Lucas RayanandClaude Opus 5 712885c091 feat(tls): o painel diz o digest que o registo TLSA tem de carregar
Uma associacao DANE e o SHA-256 sobre o SubjectPublicKeyInfo do certificado
que esta a ser servido. Se o que esta no DNS e o que o servidor apresenta se
afastarem, qualquer remetente que valide DANE recusa o correio -- e nada no
painel dizia porque.

Passa a mostrar o numero, para poder ser comparado com o registo publicado.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 12:08:25 -03:00

146 lines
4.7 KiB
Go

package httppresentation
import (
"net/http"
"time"
)
// TlsStatusSource reports what the certificate watcher actually sees. The
// admin console used to receive fixed values - "VALID", 72 days - which told
// an operator the certificate was healthy no matter what the process held.
type TlsStatusSource interface {
EarliestExpiry() (host string, expiry time.Time, ok bool)
LastReload() time.Time
LastChange() time.Time
UnknownSNICount() int64
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)
}
// TlsDaneInspector reports the digest a TLSA "3 1 1" record must carry for the
// certificate currently being served.
type TlsDaneInspector interface {
CertificateSpkiDigest(host string) (string, bool)
}
type adminTlsAPI struct {
source TlsStatusSource
mailHost string
tlsMode string
sessions *WebSessionVerifier
pollPeriod time.Duration
}
func (a *adminTlsAPI) handleStatus(w http.ResponseWriter, r *http.Request) {
token := bearerOrCookie(r, "lm_admin_session")
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
}
if _, err := a.sessions.RequireSurface(token, "admin"); err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return
}
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,
// 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",
}
if last := a.source.LastReload(); !last.IsZero() {
status["last_reload"] = last.UTC().Format(time.RFC3339)
// Two missed polls is the smallest gap that is not a scheduling
// hiccup, and it is the symptom of a watcher gone blind (risk R2).
status["watcher_healthy"] = time.Since(last) < 3*a.pollPeriod
}
if change := a.source.LastChange(); !change.IsZero() {
status["last_change"] = change.UTC().Format(time.RFC3339)
}
if host, expiry, ok := a.source.EarliestExpiry(); ok {
remaining := time.Until(expiry)
status["certificate_expires"] = expiry.UTC().Format(time.RFC3339)
status["expires_in_days"] = int(remaining.Hours() / 24)
status["soonest_expiring_host"] = host
switch {
case remaining <= 0:
status["state"] = "EXPIRED"
case remaining < 24*time.Hour:
status["state"] = "CRITICAL"
case remaining < 7*24*time.Hour:
status["state"] = "WARNING"
default:
status["state"] = "OK"
}
}
// 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"
}
}
}
// The number a DANE association has to match. Published so an operator can
// compare it against the TLSA record in DNS: if the two drift apart, every
// validating sender refuses the mail and nothing else here would say why.
if dane, ok := a.source.(TlsDaneInspector); ok {
if digest, found := dane.CertificateSpkiDigest(a.mailHost); found {
status["tlsa_digest"] = digest
}
}
if !status["has_certificate"].(bool) {
status["state"] = "NO_CERTIFICATE"
}
writeJSON(w, http.StatusOK, status)
}
// SetAdminTlsAPI wires the TLS panel to the live certificate source.
func (r *Router) SetAdminTlsAPI(source TlsStatusSource, mailHost, tlsMode, sessionSecret string, pollPeriod time.Duration) {
if source == nil || sessionSecret == "" {
return
}
if pollPeriod <= 0 {
pollPeriod = time.Minute
}
r.tls = &adminTlsAPI{
source: source, mailHost: mailHost, tlsMode: tlsMode,
sessions: NewWebSessionVerifier(sessionSecret), pollPeriod: pollPeriod,
}
}
func (r *Router) handleAdminTls(w http.ResponseWriter, req *http.Request) {
if r.tls == nil {
writeError(w, http.StatusServiceUnavailable, "TLS_API_DISABLED",
"The TLS panel needs a certificate watcher and JWT_SECRET")
return
}
r.tls.handleStatus(w, req)
}