panel-notifications: the start puts the server's name into OpenDKIM's tables
test / test (push) Canceled after 0s

Found on the test server after the rollout of b905f3e: the SigningTable had
the three sending domains and no line for selfpost.mixfed.ru. The tables are
rebuilt when a domain is added or deleted, and nothing had done either since
the upgrade - notifications would have gone out unsigned until something did.
The e2e stand did not show it: it adds a domain right after its start.

The notifier now asks at its start whether the table on disk has the line
and rebuilds the tables once if it does not (domain.Service.EnsureServerSigned).
A failure is logged and costs the signature, not the message.

Checked on Windows: go vet clean; go test ./internal/domain passes apart from
TestWriteLoadPrivateKeyRoundtrip, which fails there on file modes and always
did; the new test holds one rebuild, none the second time, and that a longer
name is not taken for the server's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mix
2026-10-12 16:27:34 +03:00
co-authored by Claude Opus 5.5
parent b905f3e560
commit 400aa76737
4 changed files with 84 additions and 0 deletions
+10
View File
@@ -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)
+18
View File
@@ -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).
+37
View File
@@ -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{})
+19
View File
@@ -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) {