#19: fix F1-F3 defects found auditing the phases against PLAN.md
Every fix here is a case where the code and the plan disagreed, and the code was wrong. Certificates (section 8.2, 8.4). The parser documented that it skipped expired entries but never checked, so an expired certificate would be served until every handshake failed. LastReload() only advanced when acme.json changed - and it does not change for sixty days - which made the timestamp risk R2 monitors indistinguishable from a watcher gone blind. Expiry is now checked on every pass, LastReload records the check and LastChange the swap, and the <7d/<24h alerts of section 8.4 exist. ARC (section 5). buildArcSealer resolved the DKIM key at startup, before runDnsReconciler had provisioned one. On a first boot ARC was disabled until someone restarted the process, which the log even admitted while offering no way for it to start. A LazySealer binds on first use instead. Health (section 8.4). There was no degraded state at all. /health now reports one - still 200, because restarting cannot fix a certificate Traefik never issued. Bounces. DSNs were sent From postmaster@lambdamail.local, a domain that does not resolve, so the notification was itself undeliverable. They now carry the configured mail host, a Message-ID, and Auto-Submitted so a vacation responder cannot loop against them. Fan-out atomicity (section 6.1). Persist ran one transaction per recipient, so a failure halfway through an alias fan-out left earlier recipients with a copy while the sender was told to retry - and the retry delivered to them twice. PersistAll commits the whole delivery or none of it. Verified against Postgres by reverting the repository to the old behaviour and confirming the new test fails on it. SMTP replies. The use case encoded replies in error text ("550 5.7.1 ...") which the adapter re-parsed by prefix, so rewording a message could turn a permanent refusal into a retry. SmtpRejection carries the decision and the adapter renders it. Repository tests. These only run with TEST_DATABASE_URL, which CI does set - so the job was red: copy and expunge seeded a 68-character digest into a CHAR(64) column, and three others used fixed digests that collide with the UNIQUE index on any re-run. A shared testBlobDigest() helper fixes both; the suite now passes twice in a row against one database. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2e0e65b167
commit
33d91fa247
23 files changed
+906
-139
No files matched your search
@@ -0,0 +1,75 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"sync"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// KeyParser turns a stored private key into a signer. It is injected so this
|
||||
// package stays independent of how DKIM keys are encoded.
|
||||
type KeyParser func(privateKeyPEM []byte) (crypto.Signer, error)
|
||||
|
||||
// LazySealer resolves the signing key on first use instead of at construction.
|
||||
//
|
||||
// The composition root builds the sealer before the DKIM provisioner has run,
|
||||
// so on a first boot no key exists yet. Binding to that empty result would
|
||||
// disable ARC until the process was restarted by hand - the failure PLAN.md
|
||||
// section 5 cannot tolerate, because the whole point of ARC is that it is
|
||||
// always applied, not applied when the ordering happened to work out.
|
||||
type LazySealer struct {
|
||||
keys port.DkimKeyRepository
|
||||
parse KeyParser
|
||||
domain string
|
||||
authServID string
|
||||
|
||||
mu sync.Mutex
|
||||
resolved port.ArcSealer
|
||||
}
|
||||
|
||||
// NewLazySealer returns a sealer that binds to the domain's active DKIM key
|
||||
// the first time a message needs sealing.
|
||||
func NewLazySealer(keys port.DkimKeyRepository, parse KeyParser, domain, authServID string) *LazySealer {
|
||||
return &LazySealer{keys: keys, parse: parse, domain: domain, authServID: authServID}
|
||||
}
|
||||
|
||||
// Seal delegates to the underlying sealer, resolving it if needed. Until a key
|
||||
// exists the message is returned untouched: an unsealed message is delivered,
|
||||
// which is strictly better than a rejected one.
|
||||
func (l *LazySealer) Seal(ctx context.Context, message []byte, authResult port.InboundAuthResult) ([]byte, error) {
|
||||
sealer, err := l.sealer(ctx)
|
||||
if err != nil || sealer == nil {
|
||||
return message, err
|
||||
}
|
||||
return sealer.Seal(ctx, message, authResult)
|
||||
}
|
||||
|
||||
func (l *LazySealer) sealer(ctx context.Context) (port.ArcSealer, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
|
||||
if l.resolved != nil {
|
||||
return l.resolved, nil
|
||||
}
|
||||
|
||||
active, err := l.keys.FindActiveKeys(ctx, l.domain)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, key := range active {
|
||||
signer, err := l.parse(key.PrivateKeyPEM)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l.resolved = NewSealer(l.domain, key.Selector, l.authServID, signer)
|
||||
return l.resolved, nil
|
||||
}
|
||||
|
||||
// Still no usable key. Leaving resolved nil means the next message tries
|
||||
// again, which is what turns "not provisioned yet" into a delay rather
|
||||
// than a permanent loss of sealing.
|
||||
return nil, nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package arc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"lambdamail/protocols/internal/application/port"
|
||||
)
|
||||
|
||||
// stubKeyRepo serves whatever the test decides is provisioned at that moment.
|
||||
type stubKeyRepo struct {
|
||||
mu sync.Mutex
|
||||
keys []port.DkimSigningKey
|
||||
// calls counts lookups, to prove the resolved key is cached.
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubKeyRepo) FindActiveKeys(_ context.Context, _ string) ([]port.DkimSigningKey, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls++
|
||||
return s.keys, nil
|
||||
}
|
||||
|
||||
func (s *stubKeyRepo) provision(t *testing.T) {
|
||||
t.Helper()
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
})
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.keys = []port.DkimSigningKey{{
|
||||
DomainName: "example.test",
|
||||
Selector: "lmail",
|
||||
Algorithm: "rsa2048",
|
||||
PrivateKeyPEM: pemBytes,
|
||||
}}
|
||||
}
|
||||
|
||||
func parseTestKey(pemBytes []byte) (crypto.Signer, error) {
|
||||
block, _ := pem.Decode(pemBytes)
|
||||
return x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
}
|
||||
|
||||
const testMessage = "From: sender@remote.test\r\n" +
|
||||
"To: user@example.test\r\n" +
|
||||
"Subject: hello\r\n" +
|
||||
"\r\n" +
|
||||
"body\r\n"
|
||||
|
||||
// The composition root builds the sealer before the DKIM provisioner has run.
|
||||
// Binding at construction would leave ARC off until the next restart.
|
||||
func TestLazySealer_StartsSealingOnceAKeyIsProvisioned(t *testing.T) {
|
||||
repo := &stubKeyRepo{}
|
||||
sealer := NewLazySealer(repo, parseTestKey, "example.test", "mail.example.test")
|
||||
ctx := context.Background()
|
||||
authResult := port.InboundAuthResult{SPF: port.AuthResultPass}
|
||||
|
||||
// No key yet: the message goes through unsealed rather than failing.
|
||||
out, err := sealer.Seal(ctx, []byte(testMessage), authResult)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal without a key: %v", err)
|
||||
}
|
||||
if strings.Contains(string(out), "ARC-Seal") {
|
||||
t.Fatal("a message was sealed before any key existed")
|
||||
}
|
||||
|
||||
repo.provision(t)
|
||||
|
||||
out, err = sealer.Seal(ctx, []byte(testMessage), authResult)
|
||||
if err != nil {
|
||||
t.Fatalf("Seal after provisioning: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(out), "ARC-Seal") {
|
||||
t.Fatal("sealing never started after the key was provisioned")
|
||||
}
|
||||
}
|
||||
|
||||
// Once bound, the key is not looked up again on every message.
|
||||
func TestLazySealer_CachesTheResolvedKey(t *testing.T) {
|
||||
repo := &stubKeyRepo{}
|
||||
repo.provision(t)
|
||||
sealer := NewLazySealer(repo, parseTestKey, "example.test", "mail.example.test")
|
||||
ctx := context.Background()
|
||||
|
||||
for range 3 {
|
||||
if _, err := sealer.Seal(ctx, []byte(testMessage), port.InboundAuthResult{}); err != nil {
|
||||
t.Fatalf("Seal: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if repo.calls != 1 {
|
||||
t.Errorf("FindActiveKeys called %d times, want 1", repo.calls)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user