The reconciler never wrote the BIMI record: only the console's verification knew about it, so a stored logo was never published to the zone. - SyncDnsRecordsInput carries the BIMI logo and certificate URLs; the record is created, updated (a= added or dropped) and, when the logo is removed, deleted. Only a record carrying this reconciler's comment is retired: one made by hand is left alone - bimi.Publish decides the record in one place for the timer sweep, the console's button and the panel's verification. A held certificate is advertised only while it is valid for the logo being served: replacing the logo ends the certificate, and a record that kept naming it makes every receiver reject BIMI, including those that need no certificate - saving or removing the logo or the certificate triggers the reconciliation in the background; the panel shows the record as the server writes it, says when a certificate is held back and why, and has a publish-now button - a read error is not 'no logo': the sync leaves the record alone instead of deleting it over a database hiccup Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
499 lines
17 KiB
Go
499 lines
17 KiB
Go
package httppresentation
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"lambdamail/protocols/internal/domain/bimi"
|
|
"lambdamail/protocols/internal/domain/entity"
|
|
)
|
|
|
|
// BimiStore holds the mark a domain publishes, and the certificate that
|
|
// vouches for it.
|
|
type BimiStore interface {
|
|
Save(ctx context.Context, domain string, svg []byte, vmcURL string) (string, error)
|
|
Load(ctx context.Context, domain string) (*BimiLogo, error)
|
|
Delete(ctx context.Context, domain string) error
|
|
// SaveCertificate reports false when the domain has no logo to attach it to.
|
|
SaveCertificate(ctx context.Context, domain string, pem []byte) (bool, error)
|
|
DeleteCertificate(ctx context.Context, domain string) error
|
|
}
|
|
|
|
// BimiLogo is a stored mark as this layer sees it.
|
|
type BimiLogo struct {
|
|
SVG []byte
|
|
ETag string
|
|
VmcURL string
|
|
// VmcPEM is the certificate chain held here, if the operator uploaded one.
|
|
VmcPEM []byte
|
|
}
|
|
|
|
type bimiAPI struct {
|
|
store BimiStore
|
|
sessions *WebSessionVerifier
|
|
// domainOf maps the host a request arrived on to the mail domain whose
|
|
// logo should answer, so one deployment can serve several.
|
|
defaultDomain string
|
|
// mailHost is where the logo and the certificate are served from: the host
|
|
// the DNS record names.
|
|
mailHost string
|
|
now func() time.Time
|
|
dns bimi.Resolver
|
|
fetch bimi.Fetcher
|
|
// publish is told when a domain's logo or certificate changed, so the DNS
|
|
// record can follow. Nil when there is no DNS provider to publish to.
|
|
publish func(domain string)
|
|
}
|
|
|
|
// SetBimiPublisher wires what happens after a logo or certificate changes.
|
|
func (r *Router) SetBimiPublisher(publish func(domain string)) {
|
|
if r.bimi != nil {
|
|
r.bimi.publish = publish
|
|
}
|
|
}
|
|
|
|
// changed tells the publisher, in the background: a change is not slowed
|
|
// down by a round trip to the DNS provider, and its outcome is the
|
|
// reconciliation result the console shows, not part of the save.
|
|
func (a *bimiAPI) changed(domain string) {
|
|
if a.publish != nil {
|
|
go a.publish(domain)
|
|
}
|
|
}
|
|
|
|
// SetBimiAPI wires the BIMI logo store.
|
|
func (r *Router) SetBimiAPI(store BimiStore, sessionSecret, defaultDomain, mailHost string) {
|
|
if store == nil {
|
|
return
|
|
}
|
|
r.bimi = &bimiAPI{
|
|
store: store,
|
|
defaultDomain: defaultDomain,
|
|
mailHost: mailHost,
|
|
now: time.Now,
|
|
dns: netResolver{},
|
|
fetch: newBimiFetcher(),
|
|
}
|
|
if sessionSecret != "" {
|
|
r.bimi.sessions = NewWebSessionVerifier(sessionSecret)
|
|
}
|
|
}
|
|
|
|
// handlePublicLogo serves the mark itself.
|
|
//
|
|
// Deliberately open: this is fetched by receiving mail providers evaluating a
|
|
// message, which hold no session here and never will. It is also the only
|
|
// thing in this service that is meant to be read by strangers, so it answers
|
|
// nothing but the stored bytes.
|
|
func (r *Router) handleBimiLogo(w http.ResponseWriter, req *http.Request) {
|
|
if r.bimi == nil {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
|
|
return
|
|
}
|
|
|
|
domain := r.bimi.domainFor(req)
|
|
logo, err := r.bimi.store.Load(req.Context(), domain)
|
|
if err != nil || logo == nil {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
|
|
if req.Header.Get("If-None-Match") == `"`+logo.ETag+`"` {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("ETag", `"`+logo.ETag+`"`)
|
|
// Public and long: receivers fetch this for every message they evaluate,
|
|
// and the mark changes about as often as a company rebrands.
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
// The document is served from the same origin as the console, and an SVG
|
|
// is a document a browser will happily execute. Nothing in a validated
|
|
// Tiny PS mark is active, but the header costs nothing and covers the case
|
|
// where validation is one day loosened.
|
|
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox")
|
|
if req.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
_, _ = w.Write(logo.SVG)
|
|
}
|
|
|
|
func (a *bimiAPI) domainFor(req *http.Request) string {
|
|
host := extractHost(req.Host)
|
|
// mail.example.test serves example.test's mark; anything else falls back
|
|
// to the domain this deployment was configured with.
|
|
if trimmed := strings.TrimPrefix(host, "mail."); trimmed != host && trimmed != "" {
|
|
return trimmed
|
|
}
|
|
return a.defaultDomain
|
|
}
|
|
|
|
// requireBimiAdmin answers the admin session and domain checks shared by every
|
|
// BIMI console route. It writes the refusal itself and returns false.
|
|
func (r *Router) requireBimiAdmin(w http.ResponseWriter, req *http.Request) (string, bool) {
|
|
if r.bimi == nil || r.bimi.sessions == nil {
|
|
writeError(w, http.StatusServiceUnavailable, "BIMI_DISABLED",
|
|
"The BIMI panel needs JWT_SECRET and a database")
|
|
return "", false
|
|
}
|
|
|
|
token := bearerOrCookie(req, "lm_admin_session")
|
|
if token == "" {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
|
|
return "", false
|
|
}
|
|
if _, err := r.bimi.sessions.RequireSurface(token, "admin"); err != nil {
|
|
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
|
|
return "", false
|
|
}
|
|
|
|
domain := strings.TrimSpace(req.URL.Query().Get("domain"))
|
|
if domain == "" {
|
|
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
|
|
return "", false
|
|
}
|
|
return domain, true
|
|
}
|
|
|
|
// handleBimiAdmin uploads, inspects or removes a domain's mark.
|
|
func (r *Router) handleBimiAdmin(w http.ResponseWriter, req *http.Request) {
|
|
domain, ok := r.requireBimiAdmin(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch req.Method {
|
|
case http.MethodGet:
|
|
r.bimi.get(w, req, domain)
|
|
case http.MethodPut:
|
|
r.bimi.put(w, req, domain)
|
|
case http.MethodDelete:
|
|
if err := r.bimi.store.Delete(req.Context(), domain); err != nil {
|
|
log.Printf("bimi: could not delete the logo for %s: %v", domain, err)
|
|
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the logo")
|
|
return
|
|
}
|
|
r.bimi.changed(domain)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET, PUT or DELETE")
|
|
}
|
|
}
|
|
|
|
// handleBimiCertificate serves the certificate chain, like the logo: fetched
|
|
// by receiving providers that hold no session.
|
|
func (r *Router) handleBimiCertificate(w http.ResponseWriter, req *http.Request) {
|
|
if r.bimi == nil {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet && req.Method != http.MethodHead {
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
|
|
return
|
|
}
|
|
|
|
logo, err := r.bimi.store.Load(req.Context(), r.bimi.domainFor(req))
|
|
if err != nil || logo == nil || len(logo.VmcPEM) == 0 {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
|
|
sum := sha256.Sum256(logo.VmcPEM)
|
|
etag := `"` + hex.EncodeToString(sum[:]) + `"`
|
|
if req.Header.Get("If-None-Match") == etag {
|
|
w.WriteHeader(http.StatusNotModified)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "application/x-pem-file")
|
|
w.Header().Set("ETag", etag)
|
|
// Shorter than the logo: a renewed certificate should reach receivers
|
|
// within the hour, not the day.
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
if req.Method == http.MethodHead {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
_, _ = w.Write(logo.VmcPEM)
|
|
}
|
|
|
|
// handleBimiCertificateAdmin uploads or removes the certificate chain.
|
|
func (r *Router) handleBimiCertificateAdmin(w http.ResponseWriter, req *http.Request) {
|
|
domain, ok := r.requireBimiAdmin(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
switch req.Method {
|
|
case http.MethodPut:
|
|
r.bimi.putCertificate(w, req, domain)
|
|
case http.MethodDelete:
|
|
if err := r.bimi.store.DeleteCertificate(req.Context(), domain); err != nil {
|
|
log.Printf("bimi: could not delete the certificate for %s: %v", domain, err)
|
|
writeError(w, http.StatusInternalServerError, "DELETE_FAILED", "Could not remove the certificate")
|
|
return
|
|
}
|
|
r.bimi.changed(domain)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
|
default:
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "PUT or DELETE")
|
|
}
|
|
}
|
|
|
|
// handleBimiCheck looks at everything a receiver would look at, and says which
|
|
// part is wrong.
|
|
func (r *Router) handleBimiCheck(w http.ResponseWriter, req *http.Request) {
|
|
domain, ok := r.requireBimiAdmin(w, req)
|
|
if !ok {
|
|
return
|
|
}
|
|
if req.Method != http.MethodGet {
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "GET required")
|
|
return
|
|
}
|
|
|
|
logo, err := r.bimi.store.Load(req.Context(), domain)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
|
|
return
|
|
}
|
|
if logo == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"configured": false, "checks": []any{}})
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(req.Context(), 25*time.Second)
|
|
defer cancel()
|
|
|
|
// What the record should say is decided once (bimi.Publish), exactly as the
|
|
// sync writes it, so "the record is wrong" means it differs from what the
|
|
// reconciler would publish.
|
|
publication := bimi.Publish(logo.SVG, logo.VmcPEM, logo.VmcURL, domain,
|
|
BimiLogoURL(r.bimi.mailHost), BimiVmcURL(r.bimi.mailHost), r.bimi.now())
|
|
in := bimi.Inputs{
|
|
Domain: domain,
|
|
LogoURL: publication.LogoURL,
|
|
CertificateURL: publication.CertificateURL,
|
|
SVG: logo.SVG,
|
|
CertificatePEM: logo.VmcPEM,
|
|
ExternalCertificateURL: logo.VmcURL,
|
|
Withheld: publication.Withheld,
|
|
Now: r.bimi.now(),
|
|
}
|
|
|
|
results := bimi.RunChecks(ctx, in, r.bimi.dns, r.bimi.fetch)
|
|
checks := make([]map[string]string, 0, len(results))
|
|
ready := true
|
|
for _, c := range results {
|
|
checks = append(checks, map[string]string{"id": c.ID, "status": string(c.Status), "detail": c.Detail})
|
|
// A missing certificate is a warning, not a fault: some receivers show the logo without one
|
|
if c.Status == bimi.StatusFail {
|
|
ready = false
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"configured": true, "ready": ready, "checks": checks})
|
|
}
|
|
|
|
func (a *bimiAPI) get(w http.ResponseWriter, req *http.Request, domain string) {
|
|
logo, err := a.store.Load(req.Context(), domain)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
|
|
return
|
|
}
|
|
if logo == nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"configured": false})
|
|
return
|
|
}
|
|
publication := bimi.Publish(logo.SVG, logo.VmcPEM, logo.VmcURL, domain,
|
|
BimiLogoURL(a.mailHost), BimiVmcURL(a.mailHost), a.now())
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"configured": true,
|
|
"etag": logo.ETag,
|
|
// The record exactly as the reconciler writes it, and the reason a held
|
|
// certificate is left out of it, if it is
|
|
"dns_record": entity.BimiRecordValue(publication.LogoURL, publication.CertificateURL),
|
|
"certificate_withheld": publication.Withheld,
|
|
"dns_publishing": a.publish != nil,
|
|
"vmc_url": logo.VmcURL,
|
|
"bytes": len(logo.SVG),
|
|
"svg": string(logo.SVG),
|
|
"logo_url": BimiLogoURL(a.mailHost),
|
|
"certificate_url": BimiVmcURL(a.mailHost),
|
|
"certificate": a.describeCertificate(domain, logo),
|
|
})
|
|
}
|
|
|
|
// describeCertificate reports the held certificate as it stands against the
|
|
// logo now being served. Read from the file each time, so it cannot drift from
|
|
// what receivers get.
|
|
func (a *bimiAPI) describeCertificate(domain string, logo *BimiLogo) map[string]any {
|
|
if len(logo.VmcPEM) == 0 {
|
|
return map[string]any{"present": false}
|
|
}
|
|
cert, violations := bimi.InspectCertificate(logo.VmcPEM, domain, logo.SVG, a.now())
|
|
reasons := make([]string, 0, len(violations))
|
|
for _, v := range violations {
|
|
reasons = append(reasons, v.String())
|
|
}
|
|
out := map[string]any{"present": true, "valid": len(violations) == 0, "violations": reasons}
|
|
if cert != nil {
|
|
out["subject"] = cert.Subject
|
|
out["organization"] = cert.Organization
|
|
out["issuer"] = cert.Issuer
|
|
out["not_before"] = cert.NotBefore.UTC().Format(time.RFC3339)
|
|
out["not_after"] = cert.NotAfter.UTC().Format(time.RFC3339)
|
|
out["dns_names"] = cert.DNSNames
|
|
out["mark_type"] = cert.MarkType
|
|
out["kind"] = string(cert.Kind)
|
|
out["chain_length"] = cert.ChainLength
|
|
out["matches_logo"] = cert.MatchesLogo
|
|
out["warnings"] = cert.Warnings
|
|
}
|
|
return out
|
|
}
|
|
|
|
// putCertificate stores a certificate chain after checking it against the
|
|
// logo and the domain. A certificate that does not match is refused rather
|
|
// than stored: publishing one makes receivers ignore BIMI without a word.
|
|
func (a *bimiAPI) putCertificate(w http.ResponseWriter, req *http.Request, domain string) {
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxCertificateBytes*2))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
|
|
return
|
|
}
|
|
var payload struct {
|
|
PEM string `json:"pem"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with a pem field")
|
|
return
|
|
}
|
|
|
|
logo, err := a.store.Load(req.Context(), domain)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "LOAD_FAILED", "Could not read the logo")
|
|
return
|
|
}
|
|
if logo == nil {
|
|
writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first: a certificate only means something next to the logo it was issued for")
|
|
return
|
|
}
|
|
|
|
pem := []byte(strings.TrimSpace(payload.PEM) + "\n")
|
|
_, violations := bimi.InspectCertificate(pem, domain, logo.SVG, a.now())
|
|
if len(violations) > 0 {
|
|
reasons := make([]string, 0, len(violations))
|
|
for _, v := range violations {
|
|
reasons = append(reasons, v.String())
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "INVALID_CERTIFICATE",
|
|
"message": "The certificate cannot be used for this domain and logo",
|
|
"violations": reasons,
|
|
})
|
|
return
|
|
}
|
|
|
|
saved, err := a.store.SaveCertificate(req.Context(), domain, pem)
|
|
if err != nil {
|
|
log.Printf("bimi: could not save the certificate for %s: %v", domain, err)
|
|
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the certificate")
|
|
return
|
|
}
|
|
if !saved {
|
|
writeError(w, http.StatusConflict, "NO_LOGO", "Save the logo first")
|
|
return
|
|
}
|
|
a.changed(domain)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"ok": true,
|
|
"dns_publishing": a.publish != nil,
|
|
"certificate": a.describeCertificate(domain, &BimiLogo{SVG: logo.SVG, VmcPEM: pem}),
|
|
})
|
|
}
|
|
|
|
func (a *bimiAPI) put(w http.ResponseWriter, req *http.Request, domain string) {
|
|
// A shade over the specification's ceiling, so an oversized upload is
|
|
// refused by the validator with a reason rather than truncated here.
|
|
body, err := io.ReadAll(io.LimitReader(req.Body, bimi.MaxLogoBytes*2))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "READ_FAILED", "Could not read the upload")
|
|
return
|
|
}
|
|
|
|
var payload struct {
|
|
SVG string `json:"svg"`
|
|
VmcURL string `json:"vmc_url"`
|
|
}
|
|
if err := json.Unmarshal(body, &payload); err != nil {
|
|
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Expected a JSON body with an svg field")
|
|
return
|
|
}
|
|
|
|
// Every reason at once. Fixing one rule at a time against a receiver that
|
|
// silently shows nothing is a miserable way to learn this format.
|
|
if violations := bimi.Validate([]byte(payload.SVG)); len(violations) > 0 {
|
|
reasons := make([]string, 0, len(violations))
|
|
for _, v := range violations {
|
|
reasons = append(reasons, v.String())
|
|
}
|
|
writeJSON(w, http.StatusBadRequest, map[string]any{
|
|
"error": "INVALID_LOGO",
|
|
"message": "The logo is not SVG Tiny Portable/Secure",
|
|
"violations": reasons,
|
|
})
|
|
return
|
|
}
|
|
|
|
vmc := strings.TrimSpace(payload.VmcURL)
|
|
if vmc != "" && !strings.HasPrefix(vmc, "https://") {
|
|
// A certificate fetched over plain HTTP is not evidence of anything,
|
|
// and every receiver refuses it.
|
|
writeError(w, http.StatusBadRequest, "INSECURE_VMC", "The certificate URL must be https")
|
|
return
|
|
}
|
|
|
|
etag, err := a.store.Save(req.Context(), domain, []byte(payload.SVG), vmc)
|
|
if err != nil {
|
|
log.Printf("bimi: could not save the logo for %s: %v", domain, err)
|
|
writeError(w, http.StatusInternalServerError, "SAVE_FAILED", "Could not store the logo")
|
|
return
|
|
}
|
|
// A certificate vouches for one exact SVG. Replacing the logo under a held
|
|
// certificate breaks it, which receivers do not report; say so now.
|
|
a.changed(domain)
|
|
response := map[string]any{"ok": true, "etag": etag, "dns_publishing": a.publish != nil}
|
|
if held, err := a.store.Load(req.Context(), domain); err == nil && held != nil && len(held.VmcPEM) > 0 {
|
|
if cert, _ := bimi.InspectCertificate(held.VmcPEM, domain, held.SVG, a.now()); cert != nil && !cert.MatchesLogo {
|
|
response["certificate_mismatch"] = true
|
|
}
|
|
}
|
|
writeJSON(w, http.StatusOK, response)
|
|
}
|
|
|
|
// BimiLogoURL is where a domain's mark is served from.
|
|
func BimiLogoURL(mailHost string) string {
|
|
return fmt.Sprintf("https://%s/.well-known/bimi/default.svg", mailHost)
|
|
}
|
|
|
|
// BimiVmcURL is where the certificate held here is served from.
|
|
func BimiVmcURL(mailHost string) string {
|
|
return fmt.Sprintf("https://%s/.well-known/bimi/vmc.pem", mailHost)
|
|
}
|