/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>
128 lines
3.9 KiB
Go
128 lines
3.9 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)
|
|
}
|
|
|
|
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,
|
|
"watcher_healthy": false,
|
|
"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"
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|