diff --git a/cmd/panel/notifier.go b/cmd/panel/notifier.go index f816ed5..029f9c1 100644 --- a/cmd/panel/notifier.go +++ b/cmd/panel/notifier.go @@ -2,6 +2,7 @@ package main import ( "context" + "log" "github.com/mixeme/selfpost/internal/dnscheck" "github.com/mixeme/selfpost/internal/health" @@ -19,6 +20,15 @@ func runNotifier(ctx context.Context, cfg config, st *store.Store) error { ms := newMailStack(cfg, st) dns := dnscheck.New(cfg.dnsResolvers) + // OpenDKIM's tables are rebuilt when a domain is added or deleted. On the + // first start of a version that signs the server's own name they do not + // have its line yet. A failure here costs the signature, not the message. + if rebuilt, err := ms.Domains.EnsureServerSigned(); err != nil { + log.Printf("notifier: sign as %s: %v", cfg.hostname, err) + } else if rebuilt { + log.Printf("notifier: OpenDKIM now signs mail of %s", cfg.hostname) + } + n := notify.New(st, notify.Checks{ Domain: func(d store.Domain) (dnscheck.Domain, error) { rec, err := ms.Domains.DKIMRecord(d) diff --git a/internal/domain/opendkim.go b/internal/domain/opendkim.go index fcb9177..a820413 100644 --- a/internal/domain/opendkim.go +++ b/internal/domain/opendkim.go @@ -163,6 +163,24 @@ func (o *OpenDKIM) Rebuild(domains []SigningDomain) error { return o.reload() } +// signs reports whether the SigningTable on disk has a line for name. A table +// that does not exist yet signs nothing. +func (o *OpenDKIM) signs(name string) (bool, error) { + data, err := os.ReadFile(o.signingTablePath) + if os.IsNotExist(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("read SigningTable: %w", err) + } + for _, line := range strings.Split(string(data), "\n") { + if strings.HasPrefix(line, "*@"+name+" ") { + return true, nil + } + } + return false, nil +} + // Reload asks OpenDKIM to re-read its tables without regenerating them. It // backs the panel's manual reload button (architecture.md ยง Panel HTTP // surface). diff --git a/internal/domain/servermail_test.go b/internal/domain/servermail_test.go index 6ffd315..f546514 100644 --- a/internal/domain/servermail_test.go +++ b/internal/domain/servermail_test.go @@ -94,6 +94,43 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) { } } +// An installation upgraded to a version that signs the server's own name has +// tables without its line until something rebuilds them. The start does, once. +func TestEnsureServerSignedRebuildsOnce(t *testing.T) { + svc, odk := newTestService(t, &fakeApps{}) + if _, err := svc.Add("example.com"); err != nil { + t.Fatal(err) + } + reloads := 0 + odk.reload = func() error { reloads++; return nil } + + // The tables as the earlier version left them; now the name is known. + svc.SetServerName("mail.example.org") + if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt { + t.Fatalf("first start: rebuilt = %v, %v; want a rebuild", rebuilt, err) + } + if _, st := readTables(t, odk); !strings.Contains(st, "*@mail.example.org mail.example.org\n") || !strings.Contains(st, "*@example.com ") { + t.Errorf("SigningTable after the rebuild:\n%s", st) + } + if rebuilt, err := svc.EnsureServerSigned(); err != nil || rebuilt { + t.Errorf("second start: rebuilt = %v, %v; want nothing to do", rebuilt, err) + } + if reloads != 1 { + t.Errorf("OpenDKIM was reloaded %d times, want once", reloads) + } + + // A name that only begins like the server's is not it. + other, otherOdk := newTestService(t, &fakeApps{}) + other.SetServerName("mail.example.org.evil.example") + if _, err := other.EnsureServerSigned(); err != nil { + t.Fatal(err) + } + other.SetServerName("mail.example.org") + if signed, err := otherOdk.signs("mail.example.org"); err != nil || signed { + t.Errorf("a longer name was taken for the server's: %v, %v", signed, err) + } +} + // Without a hostname nothing is added and nothing is signed for it. func TestNoServerName(t *testing.T) { svc, odk := newTestService(t, &fakeApps{}) diff --git a/internal/domain/service.go b/internal/domain/service.go index acc778e..77727b7 100644 --- a/internal/domain/service.go +++ b/internal/domain/service.go @@ -83,6 +83,25 @@ func (s *Service) ServerMail() (ServerMail, error) { return ServerMail{Name: s.serverName, Selector: selector, Record: rec}, nil } +// EnsureServerSigned makes sure OpenDKIM's tables have the line for the +// server's own name, rebuilding them if they do not. The tables are otherwise +// rebuilt only when a domain is added or deleted, so an installation that was +// upgraded to a version with notifications would go on sending them unsigned +// until then. It reports whether it had to rebuild. +func (s *Service) EnsureServerSigned() (bool, error) { + if s.serverName == "" { + return false, nil + } + signed, err := s.odk.signs(s.serverName) + if err != nil { + return false, err + } + if signed { + return false, nil + } + return true, s.resync() +} + // serverSelector is the selector the server's own name is signed with: that of // the sending domain of the same name if there is one, the default otherwise. func (s *Service) serverSelector() (string, error) {