feat(bimi): reconcile the BIMI record to Cloudflare and keep it in step with the logo
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>
This commit is contained in:
1 parent
0ff9028927
commit
6e49ca00e2
17 files changed
+641
-59
No files matched your search
@@ -0,0 +1,36 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"lambdamail/protocols/internal/domain/bimi"
|
||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||
)
|
||||
|
||||
// bimiPublication is what a domain's BIMI record should say, for the sync, the
|
||||
// background sweep and the console alike. Read each time, so a logo saved a
|
||||
// moment ago is what gets published and a removed one stops being published.
|
||||
func bimiPublication(ctx context.Context, repo *postgres.BimiRepository, cfg config, domain string) bimi.Publication {
|
||||
if repo == nil {
|
||||
return bimi.Publication{}
|
||||
}
|
||||
logo, err := repo.Load(ctx, domain)
|
||||
if err != nil {
|
||||
// Unknown is not "no logo": returning an empty publication here would
|
||||
// have the sync delete the record over a database hiccup.
|
||||
log.Printf("BIMI: could not read the logo for %s, leaving its record alone: %v", domain, err)
|
||||
return bimi.Publication{LogoURL: httppresentation.BimiLogoURL(cfg.PrimaryMailHost)}
|
||||
}
|
||||
if logo == nil {
|
||||
return bimi.Publication{}
|
||||
}
|
||||
return bimi.Publish(
|
||||
logo.SVG, logo.VmcPEM, logo.VmcURL, domain,
|
||||
httppresentation.BimiLogoURL(cfg.PrimaryMailHost),
|
||||
httppresentation.BimiVmcURL(cfg.PrimaryMailHost),
|
||||
time.Now(),
|
||||
)
|
||||
}
|
||||
@@ -42,6 +42,7 @@ func (d domainReconciler) ReconcileDomain(
|
||||
Created: out.CreatedCount,
|
||||
Updated: out.UpdatedCount,
|
||||
Unchanged: out.UnchangedCount,
|
||||
Deleted: out.DeletedCount,
|
||||
Conflicts: conflicts,
|
||||
Errors: out.Errors,
|
||||
}, nil
|
||||
@@ -54,7 +55,7 @@ func (d domainReconciler) ReconcileDomain(
|
||||
// halfway through each other's zone updates.
|
||||
func reconcileDomainOnDemand(
|
||||
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
|
||||
dkimRepo *postgres.DkimRepository, domain string,
|
||||
dkimRepo *postgres.DkimRepository, bimiRepo *postgres.BimiRepository, domain string,
|
||||
) (*usecase.SyncDnsRecordsOutput, error) {
|
||||
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
|
||||
syncUC.SetVerifier(netdns.NewPublicVerifier())
|
||||
@@ -67,6 +68,8 @@ func reconcileDomainOnDemand(
|
||||
DaneEnabled: cfg.OutboundDane,
|
||||
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
|
||||
}
|
||||
publication := bimiPublication(ctx, bimiRepo, cfg, domain)
|
||||
input.BimiLogoURL, input.BimiVmcURL = publication.LogoURL, publication.CertificateURL
|
||||
|
||||
// A domain with no DKIM key of its own would publish a selector pointing
|
||||
// at nothing, so the keys are provisioned first, exactly as the timer does.
|
||||
|
||||
@@ -187,7 +187,7 @@ func run(cfg config) {
|
||||
// ------------------------------------------------------- background jobs
|
||||
go runDeliveryWorker(ctx, outboundWorker)
|
||||
if cfg.CloudflareToken != "" {
|
||||
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo, postgres.NewDomainRepository(pool))
|
||||
go runDnsReconciler(ctx, cfg, aliasRepo, dkimRepo, postgres.NewDomainRepository(pool), postgres.NewBimiRepository(pool))
|
||||
} else {
|
||||
log.Printf("CLOUDFLARE_API_TOKEN is not set: DNS reconciliation is disabled")
|
||||
}
|
||||
@@ -215,9 +215,19 @@ func run(cfg config) {
|
||||
// provider to talk to, so the route can say it is unavailable instead of
|
||||
// reaching Cloudflare with an empty token and reporting a refusal.
|
||||
if cfg.CloudflareToken != "" {
|
||||
bimiRepo := postgres.NewBimiRepository(pool)
|
||||
router.SetDnsReconciler(domainReconciler{run: func(c context.Context, domain string) (*usecase.SyncDnsRecordsOutput, error) {
|
||||
return reconcileDomainOnDemand(c, cfg, aliasRepo, dkimRepo, domain)
|
||||
return reconcileDomainOnDemand(c, cfg, aliasRepo, dkimRepo, bimiRepo, domain)
|
||||
}})
|
||||
// Saving or removing a logo or a certificate publishes the change: the
|
||||
// record in the zone follows the picture, with no button to remember.
|
||||
router.SetBimiPublisher(func(domain string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := reconcileDomainOnDemand(ctx, cfg, aliasRepo, dkimRepo, bimiRepo, domain); err != nil {
|
||||
log.Printf("BIMI: publishing the record for %s after a change failed: %v", domain, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
router.SetFolderAdmin(usecase.NewManageFoldersUseCase(
|
||||
postgres.NewWebmailRepository(pool), postgres.NewWebmailRepository(pool)))
|
||||
@@ -578,6 +588,7 @@ func runDeliveryWorker(ctx context.Context, worker *usecase.OutboundWorkerUseCas
|
||||
func runDnsReconciler(
|
||||
ctx context.Context, cfg config, aliasRepo port.SystemAliasRepository,
|
||||
dkimRepo *postgres.DkimRepository, domainRepo *postgres.DomainRepository,
|
||||
bimiRepo *postgres.BimiRepository,
|
||||
) {
|
||||
syncUC := usecase.NewSyncDnsRecordsUseCase(cloudflare.NewCloudflareAdapter(cfg.CloudflareToken), aliasRepo)
|
||||
syncUC.SetVerifier(netdns.NewPublicVerifier())
|
||||
@@ -600,6 +611,9 @@ func runDnsReconciler(
|
||||
// (PLAN.md section 10.4).
|
||||
RelaySpfInclude: relayConfig(cfg).SpfInclude(),
|
||||
}
|
||||
// The logo and certificate a domain publishes through BIMI
|
||||
publication := bimiPublication(ctx, bimiRepo, cfg, domain)
|
||||
input.BimiLogoURL, input.BimiVmcURL = publication.LogoURL, publication.CertificateURL
|
||||
|
||||
if provisioner != nil {
|
||||
keys, err := provisioner.Execute(ctx, domain)
|
||||
@@ -717,19 +731,12 @@ func (s *dnsSpecSource) ExpectedRecords(ctx context.Context, domain string) ([]e
|
||||
RelaySpfInclude: relayConfig(s.cfg).SpfInclude(),
|
||||
}
|
||||
|
||||
// The BIMI record is published only when a mark is actually stored, so
|
||||
// the panel never advertises a logo that would 404.
|
||||
// The same decision the sync makes (bimi.Publish), so the panel expects
|
||||
// exactly what the reconciler writes.
|
||||
if s.bimi != nil {
|
||||
if logo, err := s.bimi.Load(ctx, domain); err == nil && logo != nil {
|
||||
spec.BimiLogoURL = httppresentation.BimiLogoURL(s.cfg.PrimaryMailHost)
|
||||
// The certificate held here wins: it is the one verified against the
|
||||
// logo being served. An external URL is the fallback.
|
||||
if len(logo.VmcPEM) > 0 {
|
||||
spec.BimiVmcURL = httppresentation.BimiVmcURL(s.cfg.PrimaryMailHost)
|
||||
} else {
|
||||
spec.BimiVmcURL = logo.VmcURL
|
||||
}
|
||||
}
|
||||
p := bimiPublication(ctx, s.bimi, s.cfg, domain)
|
||||
spec.BimiLogoURL = p.LogoURL
|
||||
spec.BimiVmcURL = p.CertificateURL
|
||||
}
|
||||
|
||||
// Read rather than provisioned: verifying must not create keys as a side
|
||||
|
||||
Reference in new issue
Block a user