The panel's signer is the only one. Gone: the opendkim package and its
configuration, the syslog-relay and opendkim programs, /dev/log and
/run/opendkim, the reload by USR1 through supervisord
(internal/supervisor/supervisor.go), internal/syslogrelay, the port of
OpenDKIM's From: parser (internal/milter/mailparse.go and its probe,
harness and test/upstream scripts), OPENDKIM_SOCKET and OPENDKIM_DIR.
Keys: internal/domain/keys.go (Keys, NewKeys, DKIM_DIR=/data/dkim) keeps
the keys and writes a selector table, /data/dkim/selectors ("<name>
<selector>"), which the signer reads in place of the KeyTable - it reads
no database, as the sender check reads none. The table is written before
the signer serves (EnsureServerSigned moved there from the notifier).
Keys and table 0600, directories 0700, the panel's. The entrypoint copies
/data/opendkim/keys once, as the panel user, into /data/dkim.new renamed
when whole, and stops the start if the copy fails; /data/opendkim is left
for a rollback to 2.7.x.
Sender check: one RFC 5322 address as a mail program reads it, held
against the bindings; the 256-byte refusal and the texts naming OpenDKIM
go. Allowed() folded the domain through strings.ToLower, which makes the
Kelvin sign a "k" - the comparison with OpenDKIM's reading had hidden it;
it folds ASCII only now.
Health: two dkim-signer rows (applications, notifications) in Milter
sockets, opendkim out of the liveness set; the card no longer takes any
row with a note for the spam filter. Texts as in the design commit.
KILL stays in deploy/docker-compose.yml: measured on the test server,
root without it cannot signal a process of another uid - supervisord
stopping the panel, Postfix's master its children. The plan's "only use
was the reload" was wrong; the comment now gives the real uses.
e2e: one signature, the panel's, verified by dkimpy; the /data/dkim modes;
the OpenDKIM log and process checks, testNeverSignedForAnother and the
cited opendkim, libopendkim11 and libmilter1.0.1 go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,7 @@ import (
|
||||
// the old file back (fsync(2): syncing a file "does not necessarily ensure
|
||||
// that the entry in the directory containing the file has also reached
|
||||
// disk"; audit 2026-10-12, L7). Every map the panel writes for Postfix and
|
||||
// OpenDKIM goes through here.
|
||||
// the DKIM signer goes through here.
|
||||
func Write(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".tmp-*")
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
// boot if the manifest's version does not match the running binary so
|
||||
// schema/format skew between versions cannot silently corrupt state
|
||||
// (architecture.md § Persistence), or if the archive was unpacked in part
|
||||
// (ContentsName), and a one-time Resync of OpenDKIM's tables
|
||||
// (ContentsName), and a one-time Resync of the DKIM selector table
|
||||
// and the Postfix sender map from SQLite on that first boot, so any drift
|
||||
// between the archive and the database is healed before mail flows. If
|
||||
// on-disk state drifts again later — for example after a manual edit under
|
||||
|
||||
@@ -43,7 +43,7 @@ func seedDataDir(t *testing.T) (dataDir, dbPath, deployRoot string) {
|
||||
t.Fatalf("close store: %v", err)
|
||||
}
|
||||
|
||||
writeFile(t, filepath.Join(dataDir, "opendkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
||||
writeFile(t, filepath.Join(dataDir, "dkim", "keys", "example.com", "selfpost.private"), "PRIVATE KEY")
|
||||
writeFile(t, filepath.Join(dataDir, "sasl", "sasldb2"), "SASLDB")
|
||||
writeFile(t, filepath.Join(dataDir, "postfix", "sender_login_maps"), "@example.com login")
|
||||
writeFile(t, filepath.Join(dataDir, "antispam", "deny_domain.map"), "evil.example\n")
|
||||
@@ -117,7 +117,7 @@ func TestCreateIncludesStateExcludesTransient(t *testing.T) {
|
||||
for _, name := range []string{
|
||||
DataArchivePrefix + ManifestName,
|
||||
DataArchivePrefix + "selfpost.db",
|
||||
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||
DataArchivePrefix + "dkim/keys/example.com/selfpost.private",
|
||||
DataArchivePrefix + "sasl/sasldb2",
|
||||
DataArchivePrefix + "postfix/sender_login_maps",
|
||||
// The filter lists as the spam filter reads them. They are rewritten
|
||||
@@ -452,7 +452,7 @@ func TestCreateLeavesTheQueueOut(t *testing.T) {
|
||||
}
|
||||
for _, name := range []string{
|
||||
DataArchivePrefix + "selfpost.db",
|
||||
DataArchivePrefix + "opendkim/keys/example.com/selfpost.private",
|
||||
DataArchivePrefix + "dkim/keys/example.com/selfpost.private",
|
||||
DataArchivePrefix + "sasl/sasldb2",
|
||||
DataArchivePrefix + "postfix/sender_login_maps",
|
||||
ComposeFileName, EnvFileName,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//
|
||||
// The forbidden set stays with the caller rather than living here, because what
|
||||
// breaks a line differs per format — a comma separates values in a Postfix map,
|
||||
// a colon separates fields in an OpenDKIM table — and the set belongs next to
|
||||
// a slash would lead a key's path out of its directory — and the set belongs next to
|
||||
// the writer it protects, where a reviewer of that writer can see it.
|
||||
package configsafe
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestToken(t *testing.T) {
|
||||
}
|
||||
}
|
||||
// A character outside the caller's set is that format's business, not this
|
||||
// function's: a colon is fatal in an OpenDKIM table and fine in a texthash.
|
||||
// function's: a colon is refused for a key's name and fine in a texthash.
|
||||
if err := Token("domain", "a:b", " \t\r\n,\\"); err != nil {
|
||||
t.Errorf("character outside the forbidden set rejected: %v", err)
|
||||
}
|
||||
|
||||
@@ -24,22 +24,28 @@ func (c *countingApps) Resync() error {
|
||||
|
||||
// A deletion that fails after the domain's row is gone says the domain is
|
||||
// deleted, and runs every rebuild anyway: the sender map was left naming the
|
||||
// purged logins when OpenDKIM's tables failed first (audit 2026-10-12, L17).
|
||||
// The key stays while the tables may still name it.
|
||||
// purged logins when the signing tables failed first (audit 2026-10-12, L17).
|
||||
// The key stays while the selector table may still name it.
|
||||
func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
|
||||
apps := &countingApps{}
|
||||
s, odk := newTestService(t, apps)
|
||||
s, keys := newTestService(t, apps)
|
||||
d, err := s.Add("gone.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDir := filepath.Join(odk.keysDir, d.Name)
|
||||
keyDir := filepath.Join(keys.keysDir, d.Name)
|
||||
apps.resyncs = 0
|
||||
|
||||
odk.reload = func() error { return errors.New("supervisor away") }
|
||||
// A directory where the table goes: no file can be renamed over it.
|
||||
if err := os.Remove(keys.selectorsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(keys.selectorsPath, "x"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.Delete(d.ID)
|
||||
if !errors.Is(err, ErrDeletedIncomplete) {
|
||||
t.Fatalf("Delete with OpenDKIM's reload failing = %v, want ErrDeletedIncomplete", err)
|
||||
t.Fatalf("Delete with the selector table unwritable = %v, want ErrDeletedIncomplete", err)
|
||||
}
|
||||
if _, err := s.Get(d.ID); !errors.Is(err, store.ErrDomainNotFound) {
|
||||
t.Errorf("the domain is still in the registry: %v", err)
|
||||
@@ -53,7 +59,9 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
|
||||
|
||||
// Reload configuration finishes the deletion: the tables rebuilt, the
|
||||
// key of a domain that is gone goes too; the others' stay.
|
||||
odk.reload = func() error { return nil }
|
||||
if err := os.RemoveAll(keys.selectorsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keep, err := s.Add("kept.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -64,7 +72,7 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
|
||||
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
|
||||
t.Errorf("the deleted domain's key is still there after Resync: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(odk.keysDir, keep.Name)); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(keys.keysDir, keep.Name)); err != nil {
|
||||
t.Errorf("Resync removed a domain's key: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -73,16 +81,16 @@ func TestDeleteRunsEveryRebuildAndSaysTheDomainIsGone(t *testing.T) {
|
||||
// rebuilt, so the key goes.
|
||||
func TestDeleteWithTheSenderMapFailing(t *testing.T) {
|
||||
apps := &countingApps{resyncErr: errors.New("postfix away")}
|
||||
s, odk := newTestService(t, apps)
|
||||
s, keys := newTestService(t, apps)
|
||||
d, err := s.Add("gone.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDir := filepath.Join(odk.keysDir, d.Name)
|
||||
keyDir := filepath.Join(keys.keysDir, d.Name)
|
||||
if err := s.Delete(d.ID); !errors.Is(err, ErrDeletedIncomplete) {
|
||||
t.Fatalf("Delete with the sender map failing = %v, want ErrDeletedIncomplete", err)
|
||||
}
|
||||
if _, err := os.Stat(keyDir); !os.IsNotExist(err) {
|
||||
t.Errorf("the key is still there after the tables were rebuilt: %v", err)
|
||||
t.Errorf("the key is still there after the table was rebuilt: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-18
@@ -1,8 +1,8 @@
|
||||
// Package domain owns SelfPost's sending-domain model: per-domain DKIM key
|
||||
// generation, the OpenDKIM KeyTable/SigningTable that drive signing, and the
|
||||
// orchestration that keeps the SQLite registry, the on-disk keys and OpenDKIM
|
||||
// in agreement (architecture.md § OpenDKIM). Key material lives under /data so
|
||||
// it survives container restarts (architecture.md § OpenDKIM).
|
||||
// generation, the selector table the panel's signer reads, and the
|
||||
// orchestration that keeps the SQLite registry and the on-disk keys in
|
||||
// agreement (architecture.md § DKIM signing). Key material lives under /data
|
||||
// so it survives container restarts.
|
||||
package domain
|
||||
|
||||
import (
|
||||
@@ -32,23 +32,15 @@ func generateDKIMKey() (*rsa.PrivateKey, error) {
|
||||
}
|
||||
|
||||
// writePrivateKeyPEM writes key to path as a PKCS#1 "RSA PRIVATE KEY" PEM,
|
||||
// atomically and group-readable (0640). The file is owned by the panel user and
|
||||
// read by OpenDKIM through the shared `selfpost` group (see build/opendkim.conf
|
||||
// and entrypoint.sh); the parent directory carries setgid so the group is
|
||||
// inherited. The write is atomic (temp file + rename) so OpenDKIM never observes
|
||||
// a half-written key.
|
||||
//
|
||||
// OpenDKIM 2.11.0~beta2, libopendkim/dkim.c, dkim_privkey_load(): a key that
|
||||
// starts as PEM goes to OpenSSL's PEM_read_bio_PrivateKey(), which takes this
|
||||
// PKCS#1 form; opendkim/opendkim.c, dkimf_add_signrequest(): a key file
|
||||
// dkimf_securefile() finds insecure is only logged while RequireSafeKeys is no
|
||||
// (build/opendkim.conf).
|
||||
// atomically and readable by the panel user alone (0600): the panel's signer
|
||||
// is the only reader (internal/milter, readKey()). The write is atomic (temp
|
||||
// file + rename) so the signer never reads a half-written key.
|
||||
func writePrivateKeyPEM(path string, key *rsa.PrivateKey) error {
|
||||
block := &pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(key),
|
||||
}
|
||||
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o640)
|
||||
return writeFileAtomic(path, pem.EncodeToMemory(block), 0o600)
|
||||
}
|
||||
|
||||
// loadPrivateKeyPEM reads and parses a PKCS#1 RSA private key written by
|
||||
@@ -79,7 +71,7 @@ type DKIMRecord struct {
|
||||
}
|
||||
|
||||
// dkimRecord builds the published DKIM DNS record for a public key. The value
|
||||
// mirrors what opendkim-genkey emits: v=DKIM1, sha256, RSA, and the public key
|
||||
// is the one opendkim-genkey emitted: v=DKIM1, sha256, RSA, and the public key
|
||||
// as base64-encoded SubjectPublicKeyInfo (PKIX) DER.
|
||||
func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, error) {
|
||||
der, err := x509.MarshalPKIXPublicKey(pub)
|
||||
@@ -95,7 +87,7 @@ func dkimRecord(selector, domainName string, pub *rsa.PublicKey) (DKIMRecord, er
|
||||
|
||||
// writeFileAtomic writes data to path via a temp file in the same directory
|
||||
// followed by a rename, so readers only ever see the complete old or new file.
|
||||
// It is the single safe-write primitive for DKIM keys and OpenDKIM tables
|
||||
// It is the single safe-write primitive for DKIM keys and the selector table
|
||||
// (security.md).
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
return atomicfile.Write(path, data, perm)
|
||||
|
||||
@@ -21,8 +21,8 @@ func TestWriteLoadPrivateKeyRoundtrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("stat: %v", err)
|
||||
}
|
||||
if perm := info.Mode().Perm(); perm != 0o640 {
|
||||
t.Errorf("key perm = %o, want 0640", perm)
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
t.Errorf("key perm = %o, want 0600", perm)
|
||||
}
|
||||
|
||||
loaded, err := loadPrivateKeyPEM(path)
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/configsafe"
|
||||
)
|
||||
|
||||
// Keys manages the DKIM state on disk (architecture.md § DKIM signing): a
|
||||
// signing key per name under keysDir, keysDir/<name>/<selector>.private, and
|
||||
// the selector table that says, for every name the server signs, which of its
|
||||
// keys signs. The panel's signer (internal/milter, SignKeys) reads both; it
|
||||
// reads no database, so a page's long query never holds up a message.
|
||||
type Keys struct {
|
||||
keysDir string
|
||||
selectorsPath string
|
||||
}
|
||||
|
||||
// NewKeys builds a manager rooted at dir (typically /data/dkim), the layout
|
||||
// entrypoint.sh prepares.
|
||||
func NewKeys(dir string) *Keys {
|
||||
return &Keys{
|
||||
keysDir: filepath.Join(dir, "keys"),
|
||||
selectorsPath: filepath.Join(dir, "selectors"),
|
||||
}
|
||||
}
|
||||
|
||||
// SigningDomain is one row's worth of signing configuration.
|
||||
type SigningDomain struct {
|
||||
Name string
|
||||
Selector string
|
||||
}
|
||||
|
||||
// KeyPath is the private-key file of a name and selector.
|
||||
func KeyPath(keysDir, domainName, selector string) string {
|
||||
return filepath.Join(keysDir, domainName, selector+".private")
|
||||
}
|
||||
|
||||
func (o *Keys) keyPath(domainName, selector string) string {
|
||||
return KeyPath(o.keysDir, domainName, selector)
|
||||
}
|
||||
|
||||
// EnsureKey makes sure a signing key exists for the domain. An existing key is
|
||||
// reused untouched — critical because overwriting it would silently invalidate
|
||||
// the DKIM record already published in DNS (architecture.md § DKIM signing).
|
||||
// Returns whether a new key was generated.
|
||||
func (o *Keys) EnsureKey(domainName, selector string) (bool, error) {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := o.keyPath(domainName, selector)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false, nil // reuse existing key
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("stat dkim key: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return false, fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := writePrivateKeyPEM(path, key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemoveKey deletes a domain's key directory (architecture.md § DKIM
|
||||
// signing). A missing directory is not an error.
|
||||
func (o *Keys) RemoveKey(domainName string) error {
|
||||
if err := assertConfigSafe(domainName, "x"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
|
||||
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyDomains lists the names that have a key directory.
|
||||
func (o *Keys) KeyDomains() ([]string, error) {
|
||||
entries, err := os.ReadDir(o.keysDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list key dirs: %w", err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
|
||||
// a domain export so the receiving instance signs with the same key and the
|
||||
// DNS TXT record never has to change (architecture.md § Persistence). It
|
||||
// re-marshals the parsed key rather than returning the raw file, so a
|
||||
// malformed on-disk key is caught here.
|
||||
func (o *Keys) ExportKey(domainName, selector string) ([]byte, error) {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
|
||||
return pem.EncodeToMemory(block), nil
|
||||
}
|
||||
|
||||
// ImportKey writes an imported DKIM private key to disk for a domain
|
||||
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
|
||||
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
|
||||
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
|
||||
// the domain with exactly this key, which is the whole point of keeping the
|
||||
// published DNS record valid.
|
||||
func (o *Keys) ImportKey(domainName, selector string, pemKey []byte) error {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return err
|
||||
}
|
||||
block, _ := pem.Decode(pemKey)
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
|
||||
}
|
||||
path := o.keyPath(domainName, selector)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
return writePrivateKeyPEM(path, key)
|
||||
}
|
||||
|
||||
// Record returns the published DKIM DNS record for a domain, recomputed from the
|
||||
// private key on disk (product.md).
|
||||
func (o *Keys) Record(domainName, selector string) (DKIMRecord, error) {
|
||||
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
|
||||
if err != nil {
|
||||
return DKIMRecord{}, err
|
||||
}
|
||||
return dkimRecord(selector, domainName, &key.PublicKey)
|
||||
}
|
||||
|
||||
// Rebuild rewrites the selector table from the full domain set. Full
|
||||
// regeneration (rather than incremental edits) keeps the file a pure function
|
||||
// of the registry, so add and delete share one idempotent path. The signer
|
||||
// reads the file again when it changes; nothing is reloaded.
|
||||
func (o *Keys) Rebuild(domains []SigningDomain) error {
|
||||
table, err := renderSelectors(domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFileAtomic(o.selectorsPath, table, 0o600)
|
||||
}
|
||||
|
||||
// signs reports whether the selector table signs name with the key of
|
||||
// selector. A table that does not exist yet signs nothing. The selector is
|
||||
// part of the question because the panel shows and checks the record of one
|
||||
// key; a table that names another would have the signer sign with a key whose
|
||||
// record nobody is told to publish.
|
||||
func (o *Keys) signs(name, selector string) (bool, error) {
|
||||
data, err := os.ReadFile(o.selectorsPath)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read %s: %w", filepath.Base(o.selectorsPath), err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if line == name+" "+selector {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// renderSelectors builds the selector table for a domain set, a line
|
||||
// "<name> <selector>" each, sorted by name so the output is deterministic.
|
||||
// Every domain is re-checked for config-injection safety before being written
|
||||
// (security.md) — validation upstream already guarantees this, but the table
|
||||
// writer refuses to emit anything unsafe as a hard backstop.
|
||||
func renderSelectors(domains []SigningDomain) ([]byte, error) {
|
||||
sorted := append([]SigningDomain(nil), domains...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
var sb strings.Builder
|
||||
for _, d := range sorted {
|
||||
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s %s\n", d.Name, d.Selector)
|
||||
}
|
||||
return []byte(sb.String()), nil
|
||||
}
|
||||
|
||||
// keyForbidden is what may never appear in a name or a selector: white space
|
||||
// and the newline that would end the selector table's field or line, and the
|
||||
// path separators - the name and the selector are also the key's path - with
|
||||
// the colon, which no DNS name holds either.
|
||||
const keyForbidden = " \t\r\n:/\\"
|
||||
|
||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
||||
// single table line or out of the key directory. Domains are already
|
||||
// whitelisted to [a-z0-9.-] and selectors to a similar set before they reach
|
||||
// here (security.md); this is defence in depth against a validation gap ever
|
||||
// letting whitespace, a newline or a path separator through. A name of dots
|
||||
// alone - ".." - would also climb out of keysDir.
|
||||
func assertConfigSafe(domainName, selector string) error {
|
||||
for _, f := range []struct{ what, value string }{
|
||||
{"domain", domainName},
|
||||
{"selector", selector},
|
||||
} {
|
||||
if err := configsafe.Token(f.what, f.value, keyForbidden); err != nil {
|
||||
return fmt.Errorf("dkim: %w", err)
|
||||
}
|
||||
if strings.Trim(f.value, ".") == "" {
|
||||
return fmt.Errorf("dkim: unsafe %s %q", f.what, f.value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,41 +3,26 @@ package domain
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderTables(t *testing.T) {
|
||||
keysDir := "/data/opendkim/keys"
|
||||
func TestRenderSelectors(t *testing.T) {
|
||||
// Deliberately out of order to exercise the deterministic sort.
|
||||
domains := []SigningDomain{
|
||||
{Name: "zeta.example", Selector: "selfpost"},
|
||||
{Name: "alpha.example", Selector: "sel2"},
|
||||
}
|
||||
kt, st, err := renderTables(keysDir, domains)
|
||||
got, err := renderSelectors(domains)
|
||||
if err != nil {
|
||||
t.Fatalf("renderTables: %v", err)
|
||||
t.Fatalf("renderSelectors: %v", err)
|
||||
}
|
||||
|
||||
wantKT := "alpha.example alpha.example:sel2:/data/opendkim/keys/alpha.example/sel2.private\n" +
|
||||
"zeta.example zeta.example:selfpost:/data/opendkim/keys/zeta.example/selfpost.private\n"
|
||||
if string(kt) != wantKT {
|
||||
t.Errorf("KeyTable =\n%q\nwant\n%q", kt, wantKT)
|
||||
if want := "alpha.example sel2\nzeta.example selfpost\n"; string(got) != want {
|
||||
t.Errorf("selector table =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
|
||||
wantST := "*@alpha.example alpha.example\n*@zeta.example zeta.example\n"
|
||||
if string(st) != wantST {
|
||||
t.Errorf("SigningTable =\n%q\nwant\n%q", st, wantST)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTablesEmpty(t *testing.T) {
|
||||
kt, st, err := renderTables("/keys", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("renderTables(nil): %v", err)
|
||||
}
|
||||
if len(kt) != 0 || len(st) != 0 {
|
||||
t.Errorf("expected empty tables, got kt=%q st=%q", kt, st)
|
||||
if empty, err := renderSelectors(nil); err != nil || len(empty) != 0 {
|
||||
t.Errorf("renderSelectors(nil) = %q, %v; want an empty table", empty, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +34,9 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
|
||||
{"../etc", "selfpost"},
|
||||
{"", "selfpost"},
|
||||
{"example.com", ""},
|
||||
{"..", "selfpost"},
|
||||
{".", "selfpost"},
|
||||
{"example.com", ".."},
|
||||
}
|
||||
for _, b := range bad {
|
||||
if err := assertConfigSafe(b.name, b.sel); err == nil {
|
||||
@@ -60,21 +48,14 @@ func TestAssertConfigSafeRejectsInjection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// newTestOpenDKIM builds a manager rooted at a temp dir with reload stubbed out.
|
||||
func newTestOpenDKIM(t *testing.T) (*OpenDKIM, *int) {
|
||||
// newTestKeys builds a manager rooted at a temp dir.
|
||||
func newTestKeys(t *testing.T) *Keys {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "keys"), 0o750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloads := 0
|
||||
o := NewOpenDKIM(dir)
|
||||
o.reload = func() error { reloads++; return nil }
|
||||
return o, &reloads
|
||||
return NewKeys(t.TempDir())
|
||||
}
|
||||
|
||||
func TestEnsureKeyReusesExisting(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
o := newTestKeys(t)
|
||||
|
||||
created, err := o.EnsureKey("example.com", "selfpost")
|
||||
if err != nil {
|
||||
@@ -101,28 +82,41 @@ func TestEnsureKeyReusesExisting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebuildWritesTablesAndReloads(t *testing.T) {
|
||||
o, reloads := newTestOpenDKIM(t)
|
||||
// The key and the table are the panel's alone: no other program reads them.
|
||||
func TestRebuildWritesThePanelsOwnFiles(t *testing.T) {
|
||||
o := newTestKeys(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := o.Rebuild([]SigningDomain{{Name: "example.com", Selector: "selfpost"}}); err != nil {
|
||||
t.Fatalf("Rebuild: %v", err)
|
||||
}
|
||||
if *reloads != 1 {
|
||||
t.Errorf("reload called %d times, want 1", *reloads)
|
||||
if signed, err := o.signs("example.com", "selfpost"); err != nil || !signed {
|
||||
t.Errorf("signs after Rebuild = %v, %v", signed, err)
|
||||
}
|
||||
kt, err := os.ReadFile(o.keyTablePath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
if signed, err := o.signs("example.com", "other"); err != nil || signed {
|
||||
t.Errorf("signs with another selector = %v, %v", signed, err)
|
||||
}
|
||||
if !strings.Contains(string(kt), "example.com:selfpost:") {
|
||||
t.Errorf("KeyTable missing entry: %q", kt)
|
||||
if runtime.GOOS == "windows" {
|
||||
return // no Unix modes
|
||||
}
|
||||
for path, want := range map[string]os.FileMode{
|
||||
o.selectorsPath: 0o600,
|
||||
o.keyPath("example.com", "selfpost"): 0o600,
|
||||
filepath.Join(o.keysDir, "example.com"): 0o700,
|
||||
} {
|
||||
fi, err := os.Stat(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := fi.Mode().Perm(); got != want {
|
||||
t.Errorf("%s: mode %o, want %o", path, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveKey(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
o := newTestKeys(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -139,7 +133,7 @@ func TestRemoveKey(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRecordFromWrittenKey(t *testing.T) {
|
||||
o, _ := newTestOpenDKIM(t)
|
||||
o := newTestKeys(t)
|
||||
if _, err := o.EnsureKey("example.com", "selfpost"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/configsafe"
|
||||
"github.com/mixeme/selfpost/internal/supervisor"
|
||||
)
|
||||
|
||||
// OpenDKIM manages the on-disk OpenDKIM state the panel is responsible for
|
||||
// (architecture.md § OpenDKIM): per-domain signing keys under keysDir and the
|
||||
// KeyTable/SigningTable that map domains to those keys. After rewriting the
|
||||
// tables it asks OpenDKIM to reload them.
|
||||
type OpenDKIM struct {
|
||||
keysDir string
|
||||
keyTablePath string
|
||||
signingTablePath string
|
||||
|
||||
// reload sends the running OpenDKIM a reload signal. It is a field so tests
|
||||
// can substitute a no-op; the default drives supervisord (see reloadViaSupervisor).
|
||||
reload func() error
|
||||
}
|
||||
|
||||
// NewOpenDKIM builds a manager rooted at dir (typically /data/opendkim), the
|
||||
// same layout entrypoint.sh prepares. The default reload path signals OpenDKIM
|
||||
// through supervisord.
|
||||
func NewOpenDKIM(dir string) *OpenDKIM {
|
||||
return &OpenDKIM{
|
||||
keysDir: filepath.Join(dir, "keys"),
|
||||
keyTablePath: filepath.Join(dir, "KeyTable"),
|
||||
signingTablePath: filepath.Join(dir, "SigningTable"),
|
||||
reload: reloadViaSupervisor,
|
||||
}
|
||||
}
|
||||
|
||||
// SigningDomain is one row's worth of signing configuration.
|
||||
type SigningDomain struct {
|
||||
Name string
|
||||
Selector string
|
||||
}
|
||||
|
||||
// keyPath is the private-key path for a domain/selector, matching the KeyTable.
|
||||
func (o *OpenDKIM) keyPath(domainName, selector string) string {
|
||||
return filepath.Join(o.keysDir, domainName, selector+".private")
|
||||
}
|
||||
|
||||
// EnsureKey makes sure a signing key exists for the domain. An existing key is
|
||||
// reused untouched — critical because overwriting it would silently invalidate
|
||||
// the DKIM record already published in DNS (architecture.md § OpenDKIM).
|
||||
// Returns whether a new key was generated.
|
||||
func (o *OpenDKIM) EnsureKey(domainName, selector string) (bool, error) {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return false, err
|
||||
}
|
||||
path := o.keyPath(domainName, selector)
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return false, nil // reuse existing key
|
||||
} else if !os.IsNotExist(err) {
|
||||
return false, fmt.Errorf("stat dkim key: %w", err)
|
||||
}
|
||||
// setgid on keysDir (entrypoint.sh) makes the per-domain dir inherit the
|
||||
// shared `selfpost` group so OpenDKIM can traverse into it.
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return false, fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
key, err := generateDKIMKey()
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := writePrivateKeyPEM(path, key); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// RemoveKey deletes a domain's key directory (architecture.md § OpenDKIM). A
|
||||
// missing directory is not an error.
|
||||
func (o *OpenDKIM) RemoveKey(domainName string) error {
|
||||
if err := assertConfigSafe(domainName, "x"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.RemoveAll(filepath.Join(o.keysDir, domainName)); err != nil {
|
||||
return fmt.Errorf("remove key dir for %s: %w", domainName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KeyDomains lists the names that have a key directory.
|
||||
func (o *OpenDKIM) KeyDomains() ([]string, error) {
|
||||
entries, err := os.ReadDir(o.keysDir)
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list key dirs: %w", err)
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
out = append(out, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ExportKey returns a domain's DKIM private key as PKCS#1 PEM, for carrying in
|
||||
// a domain export so the receiving instance signs with the same key and the
|
||||
// DNS TXT record never has to change (architecture.md § Persistence). It
|
||||
// re-marshals the parsed key rather than returning the raw file, so a
|
||||
// malformed on-disk key is caught here.
|
||||
func (o *OpenDKIM) ExportKey(domainName, selector string) ([]byte, error) {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}
|
||||
return pem.EncodeToMemory(block), nil
|
||||
}
|
||||
|
||||
// ImportKey writes an imported DKIM private key to disk for a domain
|
||||
// (architecture.md § Persistence). The PEM is parsed and re-marshalled through
|
||||
// the same writer used for generated keys, so only a well-formed PKCS#1 RSA
|
||||
// key is ever stored. Unlike EnsureKey it overwrites: an import (re-)creates
|
||||
// the domain with exactly this key, which is the whole point of keeping the
|
||||
// published DNS record valid.
|
||||
func (o *OpenDKIM) ImportKey(domainName, selector string, pemKey []byte) error {
|
||||
if err := assertConfigSafe(domainName, selector); err != nil {
|
||||
return err
|
||||
}
|
||||
block, _ := pem.Decode(pemKey)
|
||||
if block == nil || block.Type != "RSA PRIVATE KEY" {
|
||||
return fmt.Errorf("import dkim key for %s: not a PKCS#1 RSA private key", domainName)
|
||||
}
|
||||
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("import dkim key for %s: %w", domainName, err)
|
||||
}
|
||||
path := o.keyPath(domainName, selector)
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("create key dir: %w", err)
|
||||
}
|
||||
return writePrivateKeyPEM(path, key)
|
||||
}
|
||||
|
||||
// Record returns the published DKIM DNS record for a domain, recomputed from the
|
||||
// private key on disk (product.md).
|
||||
func (o *OpenDKIM) Record(domainName, selector string) (DKIMRecord, error) {
|
||||
key, err := loadPrivateKeyPEM(o.keyPath(domainName, selector))
|
||||
if err != nil {
|
||||
return DKIMRecord{}, err
|
||||
}
|
||||
return dkimRecord(selector, domainName, &key.PublicKey)
|
||||
}
|
||||
|
||||
// SigningTablePath is the SigningTable's location: the names this server
|
||||
// signs for, which the sender check reads (internal/milter/sender.go).
|
||||
func (o *OpenDKIM) SigningTablePath() string {
|
||||
return o.signingTablePath
|
||||
}
|
||||
|
||||
// Rebuild regenerates KeyTable and SigningTable from the full domain set and
|
||||
// reloads OpenDKIM (architecture.md § OpenDKIM). Full regeneration (rather
|
||||
// than incremental edits) keeps the files a pure function of the registry, so
|
||||
// add and delete share one idempotent path. Both files are written atomically
|
||||
// before the reload signal is sent.
|
||||
func (o *OpenDKIM) Rebuild(domains []SigningDomain) error {
|
||||
keyTable, signingTable, err := renderTables(o.keysDir, domains)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(o.keyTablePath, keyTable, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFileAtomic(o.signingTablePath, signingTable, 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return o.reload()
|
||||
}
|
||||
|
||||
// signs reports whether the tables on disk sign name with the key of
|
||||
// selector: a line for the name in the SigningTable, and in the KeyTable the
|
||||
// line that gives it that selector. Tables that do not exist yet sign nothing.
|
||||
// The selector is part of the question because the panel shows and checks the
|
||||
// record of one key; tables that name another would have OpenDKIM sign with a
|
||||
// key whose record nobody is told to publish.
|
||||
//
|
||||
// OpenDKIM 2.11.0~beta2, opendkim/util.c, dkimf_mkregexp(): "*@name" is
|
||||
// matched as ^.*@name$ (case aside), so it signs name and none of its
|
||||
// subdomains (see Signers.Signs in internal/milter/sender.go).
|
||||
func (o *OpenDKIM) signs(name, selector string) (bool, error) {
|
||||
has := func(path, prefix string) (bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read %s: %w", filepath.Base(path), err)
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, prefix) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
signed, err := has(o.signingTablePath, "*@"+name+" ")
|
||||
if err != nil || !signed {
|
||||
return false, err
|
||||
}
|
||||
return has(o.keyTablePath, name+" "+name+":"+selector+":")
|
||||
}
|
||||
|
||||
// Reload asks OpenDKIM to re-read its tables without regenerating them. It
|
||||
// backs the panel's manual reload button (architecture.md § Panel HTTP
|
||||
// surface).
|
||||
func (o *OpenDKIM) Reload() error {
|
||||
return o.reload()
|
||||
}
|
||||
|
||||
// SetReloadHook replaces how configuration is applied after a rebuild. Tests
|
||||
// that cannot reach supervisord use this to verify file regeneration alone.
|
||||
func (o *OpenDKIM) SetReloadHook(fn func() error) {
|
||||
if fn != nil {
|
||||
o.reload = fn
|
||||
}
|
||||
}
|
||||
|
||||
// renderTables builds the KeyTable and SigningTable byte contents for a domain
|
||||
// set, sorted by name so the output is deterministic. Every domain is
|
||||
// re-checked for shell/config-injection safety before being written
|
||||
// (security.md) — validation upstream already guarantees this, but the table
|
||||
// writer refuses to emit anything unsafe as a hard backstop.
|
||||
func renderTables(keysDir string, domains []SigningDomain) (keyTable, signingTable []byte, err error) {
|
||||
sorted := append([]SigningDomain(nil), domains...)
|
||||
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
|
||||
|
||||
// OpenDKIM 2.11.0~beta2: KeyTable is a file: table, the key up to the
|
||||
// first blank (opendkim/opendkim-db.c, dkimf_db_open(), DKIMF_DB_TYPE_FILE);
|
||||
// opendkim/opendkim.c, dkimf_add_signrequest(), reads domain, selector and
|
||||
// key from its value, split at ":" (opendkim-db.c, dkimf_db_datasplit()),
|
||||
// a key starting with "/" being a file. SigningTable is refile:, a pattern
|
||||
// up to the first blank and the key name after it (dkimf_db_open(), case
|
||||
// DKIMF_DB_TYPE_REFILE).
|
||||
var kt, st strings.Builder
|
||||
for _, d := range sorted {
|
||||
if err := assertConfigSafe(d.Name, d.Selector); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
keyName := d.Name // one key per domain; the domain name is a fine handle
|
||||
// Absolute key path so OpenDKIM resolves it independently of its CWD.
|
||||
keyFile := path.Join(keysDir, d.Name, d.Selector+".private")
|
||||
// KeyTable: <key-name> <domain>:<selector>:<key-path>
|
||||
fmt.Fprintf(&kt, "%s %s:%s:%s\n", keyName, d.Name, d.Selector, keyFile)
|
||||
// SigningTable (refile): <address-pattern> <key-name>
|
||||
fmt.Fprintf(&st, "*@%s %s\n", d.Name, keyName)
|
||||
}
|
||||
return []byte(kt.String()), []byte(st.String()), nil
|
||||
}
|
||||
|
||||
// opendkimForbidden is what may never appear in a KeyTable or SigningTable
|
||||
// value: whitespace and the newline that would end the line, and the colon and
|
||||
// path separators that carry meaning inside a table entry. (OpenDKIM
|
||||
// 2.11.0~beta2, opendkim/opendkim-db.c, dkimf_db_datasplit(), splits a
|
||||
// KeyTable value at ":"; the domain and selector are also the key's path.)
|
||||
const opendkimForbidden = " \t\r\n:/\\"
|
||||
|
||||
// assertConfigSafe rejects any domain/selector value that could break out of a
|
||||
// single table line. Domains are already whitelisted to [a-z0-9.-] and selectors
|
||||
// to a similar set before they reach here (security.md); this is defence in depth
|
||||
// against a validation gap ever letting whitespace, a newline or a field
|
||||
// separator through into a config file (security.md).
|
||||
func assertConfigSafe(domainName, selector string) error {
|
||||
for _, f := range []struct{ what, value string }{
|
||||
{"domain", domainName},
|
||||
{"selector", selector},
|
||||
} {
|
||||
if err := configsafe.Token(f.what, f.value, opendkimForbidden); err != nil {
|
||||
return fmt.Errorf("opendkim: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// reloadViaSupervisor asks supervisord (PID 1, running as root) to send the
|
||||
// OpenDKIM process SIGUSR1, which makes it re-read KeyTable/SigningTable
|
||||
// (opendkim's documented reload signal). The panel runs unprivileged and cannot
|
||||
// signal another user's process directly, so it goes through the supervisor
|
||||
// control socket, reachable via the shared `selfpost` group (security.md).
|
||||
//
|
||||
// Arguments are fixed literals — no user input is interpolated into the command,
|
||||
// and it never goes through a shell (security.md).
|
||||
//
|
||||
// OpenDKIM 2.11.0~beta2, opendkim/opendkim.c, dkimf_reloader(): SIGUSR1 sets
|
||||
// reload when a configuration file was given (-x in build/supervisord.conf);
|
||||
// dkimf_config_reload(), from mlfi_negotiate() at the next milter connection,
|
||||
// loads the configuration and opens the tables again (opendkim.8, OPERATION).
|
||||
// supervisor 4.2.5, supervisor/rpcinterface.py, signalProcess(), and
|
||||
// process.py, signal(): kill() of the program's own pid - opendkim itself, run
|
||||
// with -f (not forked into the background) and exec'd with no shell.
|
||||
func reloadViaSupervisor() error {
|
||||
return supervisor.Signal("USR1", "opendkim")
|
||||
}
|
||||
@@ -6,37 +6,27 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readTables(t *testing.T, odk *OpenDKIM) (keyTable, signingTable string) {
|
||||
func readSelectors(t *testing.T, keys *Keys) string {
|
||||
t.Helper()
|
||||
kt, err := os.ReadFile(odk.keyTablePath)
|
||||
data, err := os.ReadFile(keys.selectorsPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read KeyTable: %v", err)
|
||||
t.Fatalf("read the selector table: %v", err)
|
||||
}
|
||||
st, err := os.ReadFile(odk.signingTablePath)
|
||||
if err != nil {
|
||||
t.Fatalf("read SigningTable: %v", err)
|
||||
}
|
||||
return string(kt), string(st)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// The server's own name is signed like a sending domain - a key, a line in
|
||||
// each table - without being one: no row in the registry, so no application
|
||||
// can send as it.
|
||||
// the selector table - without being one: no row in the registry, so no
|
||||
// application can send as it.
|
||||
func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc, keys := newTestService(t, &fakeApps{})
|
||||
svc.SetServerName("mail.example.org")
|
||||
|
||||
if _, err := svc.Add("example.com"); err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
kt, st := readTables(t, odk)
|
||||
for _, want := range []string{"mail.example.org mail.example.org:selfpost:", "example.com example.com:selfpost:"} {
|
||||
if !strings.Contains(kt, want) {
|
||||
t.Errorf("KeyTable lacks %q:\n%s", want, kt)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(st, "*@mail.example.org mail.example.org\n") {
|
||||
t.Errorf("SigningTable does not sign the server's own name:\n%s", st)
|
||||
if got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
|
||||
t.Errorf("selector table =\n%q\nwant\n%q", got, want)
|
||||
}
|
||||
domains, err := svc.List()
|
||||
if err != nil || len(domains) != 1 {
|
||||
@@ -61,16 +51,15 @@ func TestServerNameIsSignedWithoutBeingADomain(t *testing.T) {
|
||||
// When the hostname is itself a sending domain, there is one key and one line,
|
||||
// the domain's; deleting the domain leaves the server's mail signed with it.
|
||||
func TestServerNameThatIsAlsoADomain(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc, keys := newTestService(t, &fakeApps{})
|
||||
svc.SetServerName("example.com")
|
||||
|
||||
d, err := svc.Add("example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("add domain: %v", err)
|
||||
}
|
||||
_, st := readTables(t, odk)
|
||||
if strings.Count(st, "*@example.com ") != 1 {
|
||||
t.Errorf("the name is in the SigningTable more than once:\n%s", st)
|
||||
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
|
||||
t.Errorf("selector table = %q, want the name once", got)
|
||||
}
|
||||
asDomain, err := svc.DKIMRecord(d)
|
||||
if err != nil {
|
||||
@@ -84,9 +73,8 @@ func TestServerNameThatIsAlsoADomain(t *testing.T) {
|
||||
if err := svc.Delete(d.ID); err != nil {
|
||||
t.Fatalf("delete domain: %v", err)
|
||||
}
|
||||
_, st = readTables(t, odk)
|
||||
if !strings.Contains(st, "*@example.com example.com\n") {
|
||||
t.Errorf("deleting the domain stopped the server's own mail being signed:\n%s", st)
|
||||
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
|
||||
t.Errorf("deleting the domain stopped the server's own mail being signed: %q", got)
|
||||
}
|
||||
after, err := svc.ServerMail()
|
||||
if err != nil || after.Record.Value != asDomain.Value {
|
||||
@@ -94,48 +82,45 @@ 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.
|
||||
// The first start of 2.8.0 finds no selector table - OpenDKIM's tables were
|
||||
// the earlier versions' - and an installation may lack the server's line.
|
||||
// The start rebuilds, once.
|
||||
func TestEnsureServerSignedRebuildsOnce(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc, keys := newTestService(t, &fakeApps{})
|
||||
if _, err := svc.Add("example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reloads := 0
|
||||
odk.reload = func() error { reloads++; return nil }
|
||||
if err := os.Remove(keys.selectorsPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 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 got, want := readSelectors(t, keys), "example.com selfpost\nmail.example.org selfpost\n"; got != want {
|
||||
t.Errorf("selector table after the rebuild = %q, want %q", got, want)
|
||||
}
|
||||
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, otherKeys := 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", "selfpost"); err != nil || signed {
|
||||
if signed, err := otherKeys.signs("mail.example.org", "selfpost"); err != nil || signed {
|
||||
t.Errorf("a longer name was taken for the server's: %v, %v", signed, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Tables that sign the server's name with the key of another selector - the
|
||||
// default selector was changed - are rebuilt: OpenDKIM must sign with the key
|
||||
// A table that signs the server's name with the key of another selector - the
|
||||
// default selector was changed - is rebuilt: the signer must sign with the key
|
||||
// whose record the panel shows.
|
||||
func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc, keys := newTestService(t, &fakeApps{})
|
||||
svc.SetServerName("mail.example.org")
|
||||
if _, err := svc.EnsureServerSigned(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -145,24 +130,23 @@ func TestEnsureServerSignedFollowsTheSelector(t *testing.T) {
|
||||
if rebuilt, err := svc.EnsureServerSigned(); err != nil || !rebuilt {
|
||||
t.Fatalf("after the selector changed: rebuilt = %v, %v; want a rebuild", rebuilt, err)
|
||||
}
|
||||
kt, _ := readTables(t, odk)
|
||||
if !strings.Contains(kt, "mail.example.org mail.example.org:sp2027:") || strings.Contains(kt, ":selfpost:") {
|
||||
t.Errorf("KeyTable after the rebuild:\n%s", kt)
|
||||
if got := readSelectors(t, keys); got != "mail.example.org sp2027\n" {
|
||||
t.Errorf("selector table after the rebuild = %q", got)
|
||||
}
|
||||
mail, err := svc.ServerMail()
|
||||
if err != nil || mail.Selector != "sp2027" {
|
||||
t.Errorf("the panel shows selector %q (%v), the tables sign with sp2027", mail.Selector, err)
|
||||
t.Errorf("the panel shows selector %q (%v), the table signs with sp2027", mail.Selector, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Without a hostname nothing is added and nothing is signed for it.
|
||||
func TestNoServerName(t *testing.T) {
|
||||
svc, odk := newTestService(t, &fakeApps{})
|
||||
svc, keys := newTestService(t, &fakeApps{})
|
||||
if _, err := svc.Add("example.com"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, st := readTables(t, odk); strings.Count(st, "\n") != 1 {
|
||||
t.Errorf("SigningTable has more than the one domain:\n%s", st)
|
||||
if got := readSelectors(t, keys); got != "example.com selfpost\n" {
|
||||
t.Errorf("selector table has more than the one domain: %q", got)
|
||||
}
|
||||
if _, err := svc.ServerMail(); err == nil {
|
||||
t.Error("ServerMail without a hostname returned no error")
|
||||
|
||||
+45
-46
@@ -29,14 +29,14 @@ type Applications interface {
|
||||
}
|
||||
|
||||
// Service coordinates the places a sending domain lives: the SQLite registry,
|
||||
// the on-disk DKIM keys and OpenDKIM's tables, plus — on deletion — the SASL
|
||||
// database and Postfix sender map its applications touch. Callers (the web
|
||||
// handlers) validate user input first; Service keeps the stores in agreement
|
||||
// and drives the OpenDKIM/Postfix reloads (architecture.md § OpenDKIM,
|
||||
// product.md).
|
||||
// the on-disk DKIM keys and the selector table the signer reads, plus — on
|
||||
// deletion — the SASL database and Postfix sender map its applications touch.
|
||||
// Callers (the web handlers) validate user input first; Service keeps the
|
||||
// stores in agreement and drives the Postfix reloads (architecture.md § DKIM
|
||||
// signing, product.md).
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
odk *OpenDKIM
|
||||
keys *Keys
|
||||
apps Applications
|
||||
selector string
|
||||
// serverName is the server's own hostname, which the panel's notifications
|
||||
@@ -48,7 +48,7 @@ type Service struct {
|
||||
// SetServerName gives the service the server's own hostname. The panel sends
|
||||
// its notifications from postmaster@ that name, and for a receiver to believe
|
||||
// them the name is signed like a sending domain: it gets a DKIM key and a line
|
||||
// in OpenDKIM's tables, without being a row of the registry - no application
|
||||
// in the selector table, without being a row of the registry - no application
|
||||
// can send as it. The name comes from SELFPOST_HOSTNAME, which the entrypoint
|
||||
// has validated; it is not user input.
|
||||
func (s *Service) SetServerName(hostname string) {
|
||||
@@ -74,23 +74,23 @@ func (s *Service) ServerMail() (ServerMail, error) {
|
||||
if err != nil {
|
||||
return ServerMail{}, err
|
||||
}
|
||||
if _, err := s.odk.EnsureKey(s.serverName, selector); err != nil {
|
||||
if _, err := s.keys.EnsureKey(s.serverName, selector); err != nil {
|
||||
return ServerMail{}, err
|
||||
}
|
||||
rec, err := s.odk.Record(s.serverName, selector)
|
||||
rec, err := s.keys.Record(s.serverName, selector)
|
||||
if err != nil {
|
||||
return ServerMail{}, err
|
||||
}
|
||||
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, and one whose DKIM_SELECTOR_DEFAULT was changed would go on
|
||||
// signing with the key of the old selector while the panel shows the record
|
||||
// of the new. It reports whether it had to rebuild.
|
||||
// EnsureServerSigned makes sure the selector table has the line for the
|
||||
// server's own name, rebuilding it if it does not - or if there is no table
|
||||
// yet, as on the first start of 2.8.0, whose table replaced OpenDKIM's. The
|
||||
// table is otherwise rebuilt only when a domain is added or deleted, so an
|
||||
// installation whose DKIM_SELECTOR_DEFAULT was changed would go on signing
|
||||
// with the key of the old selector while the panel shows the record of the
|
||||
// new. It reports whether it had to rebuild.
|
||||
func (s *Service) EnsureServerSigned() (bool, error) {
|
||||
if s.serverName == "" {
|
||||
return false, nil
|
||||
@@ -99,7 +99,7 @@ func (s *Service) EnsureServerSigned() (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
signed, err := s.odk.signs(s.serverName, selector)
|
||||
signed, err := s.keys.signs(s.serverName, selector)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -129,8 +129,8 @@ func (s *Service) serverSelector() (string, error) {
|
||||
// DKIM_SELECTOR_DEFAULT); it is operator-configured, not user input. apps is
|
||||
// used only on deletion, to clear the SASL accounts and sender-map bindings of
|
||||
// the domain's applications.
|
||||
func NewService(st *store.Store, odk *OpenDKIM, apps Applications, selectorDefault string) *Service {
|
||||
return &Service{store: st, odk: odk, apps: apps, selector: selectorDefault}
|
||||
func NewService(st *store.Store, keys *Keys, apps Applications, selectorDefault string) *Service {
|
||||
return &Service{store: st, keys: keys, apps: apps, selector: selectorDefault}
|
||||
}
|
||||
|
||||
// List returns all domains with application counts (product.md).
|
||||
@@ -144,7 +144,7 @@ func (s *Service) Get(id int64) (store.Domain, error) {
|
||||
}
|
||||
|
||||
// Add registers a new sending domain: it records the row, ensures a DKIM key
|
||||
// exists on disk, and regenerates + reloads the OpenDKIM tables (product.md).
|
||||
// exists on disk, and regenerates the selector table (product.md).
|
||||
// name must already be normalised and validated by the caller. A duplicate
|
||||
// returns store.ErrDomainExists.
|
||||
//
|
||||
@@ -152,15 +152,15 @@ func (s *Service) Get(id int64) (store.Domain, error) {
|
||||
// arbiter of "already exists" (avoiding a check-then-act race). An existing
|
||||
// on-disk key is reused rather than overwritten, so re-adding a domain whose DB
|
||||
// row was lost keeps its published DNS record valid. If key generation or the
|
||||
// OpenDKIM rebuild fails, the row is rolled back so we never leave a registered
|
||||
// domain that OpenDKIM cannot sign.
|
||||
// table's rebuild fails, the row is rolled back so we never leave a registered
|
||||
// domain the signer cannot sign for.
|
||||
func (s *Service) Add(name string) (store.Domain, error) {
|
||||
d, err := s.store.AddDomain(name, s.selector)
|
||||
if err != nil {
|
||||
return store.Domain{}, err
|
||||
}
|
||||
|
||||
if _, err := s.odk.EnsureKey(d.Name, d.DKIMSelector); err != nil {
|
||||
if _, err := s.keys.EnsureKey(d.Name, d.DKIMSelector); err != nil {
|
||||
s.rollbackAdd(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
@@ -181,10 +181,10 @@ func (s *Service) rollbackAdd(id int64) {
|
||||
// Delete removes a domain and everything bound to it (product.md). The
|
||||
// order matters: the applications' SASL accounts are cleared first, while their
|
||||
// logins are still in the registry; then the registry rows (applications and
|
||||
// their addresses) go via the DB cascade; then the OpenDKIM tables and the
|
||||
// Postfix sender map are rebuilt from what remains — so OpenDKIM stops signing
|
||||
// and Postfix stops authorising the domain's senders — before the DKIM key is
|
||||
// deleted.
|
||||
// their addresses) go via the DB cascade; then the selector table and the
|
||||
// Postfix sender map are rebuilt from what remains — so the signer stops
|
||||
// signing and Postfix stops authorising the domain's senders — before the
|
||||
// DKIM key is deleted.
|
||||
func (s *Service) Delete(id int64) error {
|
||||
d, err := s.store.GetDomain(id)
|
||||
if err != nil {
|
||||
@@ -204,26 +204,26 @@ func (s *Service) Delete(id int64) error {
|
||||
return err
|
||||
}
|
||||
// From here the domain is deleted, whatever fails after: each rebuild
|
||||
// runs - the sender map was left with the purged logins when OpenDKIM's
|
||||
// failed first - and a failure says the domain is gone (audit
|
||||
// 2026-10-12, L17).
|
||||
// runs - the sender map was left with the purged logins when the
|
||||
// signing tables failed first - and a failure says the domain is gone
|
||||
// (audit 2026-10-12, L17).
|
||||
var errs []error
|
||||
tablesRebuilt := true
|
||||
if err := s.resync(); err != nil {
|
||||
tablesRebuilt = false
|
||||
errs = append(errs, fmt.Errorf("rebuild OpenDKIM's tables: %w", err))
|
||||
errs = append(errs, fmt.Errorf("rebuild the selector table: %w", err))
|
||||
}
|
||||
if err := s.apps.Resync(); err != nil {
|
||||
errs = append(errs, fmt.Errorf("rebuild the sender map: %w", err))
|
||||
}
|
||||
// A sending domain named like the server shared its key with the server's
|
||||
// own mail. The resync above kept the name in the tables under the default
|
||||
// own mail. The resync above kept the name in the table under the default
|
||||
// selector: the same key if that was the domain's selector, a new one -
|
||||
// with a new record to publish, which Settings shows - if it was not.
|
||||
// Either way the directory holds the key now in use. And while the tables
|
||||
// Either way the directory holds the key now in use. And while the table
|
||||
// may still name the deleted domain's key, the key stays.
|
||||
if d.Name != s.serverName && tablesRebuilt {
|
||||
if err := s.odk.RemoveKey(d.Name); err != nil {
|
||||
if err := s.keys.RemoveKey(d.Name); err != nil {
|
||||
errs = append(errs, fmt.Errorf("remove its DKIM key: %w", err))
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ var ErrDeletedIncomplete = errors.New("domain deleted, but the mail configuratio
|
||||
|
||||
// DKIMRecord returns the DNS TXT record to publish for a domain (product.md).
|
||||
func (s *Service) DKIMRecord(d store.Domain) (DKIMRecord, error) {
|
||||
return s.odk.Record(d.Name, d.DKIMSelector)
|
||||
return s.keys.Record(d.Name, d.DKIMSelector)
|
||||
}
|
||||
|
||||
// RateLimit returns the domain-level differentiated rate limit (guide § Rate
|
||||
@@ -263,13 +263,12 @@ func (s *Service) ClearRateLimit(domainID int64) error {
|
||||
return s.store.DeleteRateLimit(store.RateLimitScopeDomain, domainID)
|
||||
}
|
||||
|
||||
// Resync regenerates the OpenDKIM tables from the registry and reloads
|
||||
// OpenDKIM. It backs the manual reload button (architecture.md § Panel HTTP
|
||||
// surface) and doubles as a recovery path if the tables ever drift from the
|
||||
// database.
|
||||
// Resync regenerates the selector table from the registry. It backs the
|
||||
// manual reload button (architecture.md § Panel HTTP surface) and doubles as
|
||||
// a recovery path if the table ever drifts from the database.
|
||||
//
|
||||
// It also removes the key directories of names no domain has and that are
|
||||
// not the server's own: what a deletion left when the tables could not be
|
||||
// not the server's own: what a deletion left when the table could not be
|
||||
// rebuilt (Delete keeps the key then). The "Reload configuration" the page
|
||||
// points to after such a deletion finishes it here (review of audit stage 4).
|
||||
func (s *Service) Resync() error {
|
||||
@@ -284,13 +283,13 @@ func (s *Service) Resync() error {
|
||||
for _, d := range domains {
|
||||
keep[d.Name] = true
|
||||
}
|
||||
names, err := s.odk.KeyDomains()
|
||||
names, err := s.keys.KeyDomains()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, name := range names {
|
||||
if !keep[name] {
|
||||
if err := s.odk.RemoveKey(name); err != nil {
|
||||
if err := s.keys.RemoveKey(name); err != nil {
|
||||
return fmt.Errorf("remove the key of %s, a domain that is gone: %w", name, err)
|
||||
}
|
||||
}
|
||||
@@ -298,7 +297,7 @@ func (s *Service) Resync() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resync rebuilds KeyTable/SigningTable from the current domain set and reloads.
|
||||
// resync rebuilds the selector table from the current domain set.
|
||||
func (s *Service) resync() error {
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
@@ -313,12 +312,12 @@ func (s *Service) resync() error {
|
||||
}
|
||||
}
|
||||
// The server's own name, for the panel's notifications - unless it is a
|
||||
// sending domain already and so in the tables above.
|
||||
// sending domain already and so in the table above.
|
||||
if s.serverName != "" && !registered {
|
||||
if _, err := s.odk.EnsureKey(s.serverName, s.selector); err != nil {
|
||||
if _, err := s.keys.EnsureKey(s.serverName, s.selector); err != nil {
|
||||
return fmt.Errorf("dkim key for %s: %w", s.serverName, err)
|
||||
}
|
||||
signing = append(signing, SigningDomain{Name: s.serverName, Selector: s.selector})
|
||||
}
|
||||
return s.odk.Rebuild(signing)
|
||||
return s.keys.Rebuild(signing)
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
||||
if err != nil {
|
||||
return DomainExport{}, err
|
||||
}
|
||||
pem, err := s.odk.ExportKey(d.Name, d.DKIMSelector)
|
||||
pem, err := s.keys.ExportKey(d.Name, d.DKIMSelector)
|
||||
if err != nil {
|
||||
return DomainExport{}, fmt.Errorf("export DKIM key for %s: %w", d.Name, err)
|
||||
}
|
||||
@@ -109,7 +109,7 @@ func (s *Service) Export(id int64) (DomainExport, error) {
|
||||
// Import re-creates a domain from an export file on this instance
|
||||
// (architecture.md § Persistence): it stores the imported DKIM key (so the
|
||||
// published DNS record needs no change), registers the domain and rebuilds the
|
||||
// OpenDKIM tables, then re-creates each application with its working password
|
||||
// selector table, then re-creates each application with its working password
|
||||
// and rebuilds the Postfix sender map.
|
||||
//
|
||||
// exp.Domain must already be normalised and validated by the caller
|
||||
@@ -133,7 +133,7 @@ func (s *Service) Import(exp DomainExport) (store.Domain, error) {
|
||||
return store.Domain{}, err // ErrDomainExists surfaces to the caller
|
||||
}
|
||||
|
||||
if err := s.odk.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
|
||||
if err := s.keys.ImportKey(d.Name, d.DKIMSelector, []byte(exp.DKIMPrivateKey)); err != nil {
|
||||
s.importRollback(d.ID)
|
||||
return store.Domain{}, err
|
||||
}
|
||||
|
||||
@@ -43,18 +43,17 @@ func (f *fakeApps) ImportApplication(domainID int64, login, mode string, address
|
||||
return nil
|
||||
}
|
||||
|
||||
// newTestService builds a Service over a fresh SQLite store and OpenDKIM tree in
|
||||
// a temp dir, with the OpenDKIM reload signal stubbed out.
|
||||
func newTestService(t *testing.T, apps Applications) (*Service, *OpenDKIM) {
|
||||
// newTestService builds a Service over a fresh SQLite store and key tree in a
|
||||
// temp dir.
|
||||
func newTestService(t *testing.T, apps Applications) (*Service, *Keys) {
|
||||
t.Helper()
|
||||
st, err := store.Open(t.TempDir() + "/selfpost.db")
|
||||
if err != nil {
|
||||
t.Fatalf("open store: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
odk := NewOpenDKIM(t.TempDir())
|
||||
odk.reload = func() error { return nil }
|
||||
return NewService(st, odk, apps, "selfpost"), odk
|
||||
keys := NewKeys(t.TempDir())
|
||||
return NewService(st, keys, apps, "selfpost"), keys
|
||||
}
|
||||
|
||||
func TestExportImportRoundTrip(t *testing.T) {
|
||||
|
||||
@@ -116,14 +116,14 @@ func TestCheckCertificate(t *testing.T) {
|
||||
|
||||
func TestCheckSocket(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sock := filepath.Join(dir, "opendkim.sock")
|
||||
sock := filepath.Join(dir, "sign.sock")
|
||||
l, err := net.Listen("unix", sock)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets unavailable here: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
|
||||
if got := CheckSocket("OpenDKIM", sock, true); got.Status != StatusOK || !got.Present {
|
||||
if got := CheckSocket("dkim-signer", sock, true); got.Status != StatusOK || !got.Present {
|
||||
t.Errorf("live socket: status %q present=%v", got.Status, got.Present)
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func TestCheckSocket(t *testing.T) {
|
||||
if got := CheckSocket("journal", missing, false); got.Status != StatusWarn {
|
||||
t.Errorf("missing optional socket: status %q", got.Status)
|
||||
}
|
||||
if got := CheckSocket("OpenDKIM", missing, true); got.Status != StatusError {
|
||||
if got := CheckSocket("dkim-signer", missing, true); got.Status != StatusError {
|
||||
t.Errorf("missing required socket: status %q", got.Status)
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ func TestCheckSocket(t *testing.T) {
|
||||
if err := os.WriteFile(plain, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := CheckSocket("OpenDKIM", plain, true); got.Status != StatusError || got.Present {
|
||||
if got := CheckSocket("dkim-signer", plain, true); got.Status != StatusError || got.Present {
|
||||
t.Errorf("regular file in place of a socket: status %q present=%v", got.Status, got.Present)
|
||||
}
|
||||
}
|
||||
@@ -170,8 +170,7 @@ func writeCert(t *testing.T, path, cn string, validFor time.Duration) {
|
||||
|
||||
func TestLivenessFromParsedProcesses(t *testing.T) {
|
||||
// Composed, in the columns of TestParseProcesses.
|
||||
allRunning := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||
panel RUNNING pid 22, uptime 0:04:09
|
||||
allRunning := `panel RUNNING pid 22, uptime 0:04:09
|
||||
postfix RUNNING pid 23, uptime 0:04:08
|
||||
postfix-reload STOPPED Not started
|
||||
`
|
||||
@@ -182,8 +181,7 @@ postfix-reload STOPPED Not started
|
||||
}
|
||||
}
|
||||
|
||||
postfixDead := `opendkim RUNNING pid 21, uptime 0:04:10
|
||||
panel RUNNING pid 22, uptime 0:04:09
|
||||
postfixDead := `panel RUNNING pid 22, uptime 0:04:09
|
||||
postfix FATAL Exited too quickly
|
||||
`
|
||||
procs = parseProcesses(postfixDead)
|
||||
|
||||
@@ -8,14 +8,13 @@ import (
|
||||
// mailPathPrograms are the supervised processes whose absence means the
|
||||
// container should not report healthy to orchestrators.
|
||||
var mailPathPrograms = map[string]bool{
|
||||
"opendkim": true,
|
||||
"panel": true,
|
||||
"postfix": true,
|
||||
"panel": true,
|
||||
"postfix": true,
|
||||
}
|
||||
|
||||
// Liveness reports whether the mail path is healthy enough for container
|
||||
// probes. It requires opendkim, panel, and postfix to be RUNNING under
|
||||
// supervisord.
|
||||
// probes. It requires panel - whose roles include the DKIM signer - and
|
||||
// postfix to be RUNNING under supervisord.
|
||||
func Liveness() error {
|
||||
procs, err := Processes()
|
||||
if err != nil {
|
||||
|
||||
@@ -146,7 +146,7 @@ func TestCheckMilterInet6(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A socket file whose listener is gone - a killed OpenDKIM or panel leaves
|
||||
// A socket file whose listener is gone - a killed panel leaves
|
||||
// one (unix(7)) - is not a listener, though the file is there (A-38).
|
||||
func TestStaleSocketFileIsNotListening(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
@@ -163,8 +163,8 @@ func TestStaleSocketFileIsNotListening(t *testing.T) {
|
||||
t.Fatalf("the socket file should outlive its listener: %v", err)
|
||||
}
|
||||
|
||||
got := CheckSocket("OpenDKIM", sock, true)
|
||||
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the OpenDKIM socket") {
|
||||
got := CheckSocket("dkim-signer", sock, true)
|
||||
if got.Present || got.Status != StatusError || !strings.HasPrefix(got.Detail, "Nothing listens on the dkim-signer socket") {
|
||||
t.Errorf("stale required socket: %+v", got)
|
||||
}
|
||||
if got := CheckSocket("send-log", sock, false); got.Status != StatusWarn {
|
||||
|
||||
@@ -21,9 +21,10 @@ type Socket struct {
|
||||
Detail string
|
||||
}
|
||||
|
||||
// CheckSocket checks a milter socket. required distinguishes the two milters:
|
||||
// Postfix attaches OpenDKIM with default_action=tempfail, so a missing socket
|
||||
// stops mail leaving the server, while the journal-milter is attached with
|
||||
// CheckSocket checks a milter socket. required distinguishes the milters:
|
||||
// Postfix attaches the DKIM signer and the sender check with
|
||||
// default_action=tempfail, so a missing socket stops mail leaving the server,
|
||||
// while the journal-milter is attached with
|
||||
// default_action=accept and fails open — mail still goes out, only the send
|
||||
// log stops being written (build/postfix-config.sh smtpd_milters; Postfix
|
||||
// 3.10.13, src/milter/milter8.c, milter8_comm_error(): tempfail answers "451
|
||||
@@ -32,13 +33,9 @@ type Socket struct {
|
||||
// The socket is dialled, as Postfix will dial it; a file is not a listener.
|
||||
// A unix socket's file outlives the process that bound it unless that process
|
||||
// unlinks it, and a connect() to it then fails with ECONNREFUSED (unix(7)), so
|
||||
// a killed OpenDKIM or panel leaves a file that refuses every connection -
|
||||
// assumptions A-38. The connection is closed before a byte is sent, which
|
||||
// none of the three milters logs: OpenDKIM's libmilter reads the EOF as
|
||||
// SMFIC_EOF and ends the session without a line (libmilter 8.18.1,
|
||||
// libmilter/comm.c, mi_rd_cmd(); engine.c, mi_engine()), and OpenDKIM's
|
||||
// mlfi_close() has no connection to clean up (opendkim 2.11.0~beta2,
|
||||
// opendkim/opendkim.c); go-milter v0.4.1 returns quietly on io.EOF
|
||||
// a killed panel leaves a file that refuses every connection - assumptions
|
||||
// A-38. The connection is closed before a byte is sent, which none of the
|
||||
// panel's milters logs: go-milter v0.4.1 returns quietly on io.EOF
|
||||
// (session.go, HandleMilterCommands()).
|
||||
func CheckSocket(name, path string, required bool) Socket {
|
||||
s := Socket{Name: name, Path: path}
|
||||
|
||||
@@ -17,8 +17,7 @@ import (
|
||||
// The mail.log lines in this file are composed by hand, after the formats the
|
||||
// code cites (Postfix 3.10.13, src/global/log_adhoc.c and the others), unless a
|
||||
// comment says where one was seen; some carry no stamp, which the parsers
|
||||
// take as they come. One shape Postfix does not write here: opendkim lines in
|
||||
// mail.log (OpenDKIM logs through syslog(3), to the container log - build/opendkim.conf).
|
||||
// take as they come.
|
||||
|
||||
// qmgr's expiry line, as Postfix 3.10.13 writes it (src/qmgr/qmgr_active.c,
|
||||
// qmgr_active_done_25_generic(): "%s: from=<%s>, status=%s, returned to
|
||||
@@ -823,7 +822,7 @@ func TestQueueLinesPicksOutOneMessage(t *testing.T) {
|
||||
"host postfix/cleanup[15]: 41E862C00D9E: message-id=<abc@example.com>",
|
||||
// Same run of characters, longer id: not this message.
|
||||
"host postfix/smtp[26]: FF41E862C00D9E: to=<z@example.net>, status=sent (250 OK)",
|
||||
"host opendkim[30]: 41E862C00D9E: DKIM-Signature field added (s=mail d=example.com)",
|
||||
"host postfix/qmgr[10]: 41E862C00D9E: from=<a@example.com>, size=500, nrcpt=1 (queue active)",
|
||||
"host postfix/smtp[26]: 41E862C00D9E: to=<a@example.net>, relay=mx.example.net[203.0.113.9]:25, dsn=2.0.0, status=sent (250 OK)",
|
||||
)
|
||||
|
||||
|
||||
@@ -26,8 +26,8 @@ import (
|
||||
// it again.
|
||||
//
|
||||
// It is counted at the warning, which is the one line that names the socket.
|
||||
// The reject lines do not: the same words are written when OpenDKIM does not
|
||||
// answer and when the journal milter refuses under a level-2 limit.
|
||||
// The reject lines do not: the same words are written when the DKIM signer
|
||||
// does not answer and when the journal milter refuses under a level-2 limit.
|
||||
//
|
||||
// Postfix 3.10.13, src/milter/milter8.c: milter8_connect() warns "connect to
|
||||
// Milter service %s: %m" with the milter's configured name; then
|
||||
|
||||
@@ -36,9 +36,9 @@ func (d *deferredStore) AddSenderCheckDeferred(at time.Time, n int64, place stri
|
||||
// with the panel stopped and for a refused From: (2026-10-12), and with the
|
||||
// panel frozen in the middle of a message (cleanup 1339, 2026-10-14); the
|
||||
// stamps are put in ISO form. Around them are the lines of two other things
|
||||
// that use the same words - a level-2 refusal of the journal milter, OpenDKIM
|
||||
// not answering. Those - smtpd 1149, 1190 and 1201, the second client of
|
||||
// 1183, smtpd 1300 and the OpenDKIM warning of cleanup 1339 - are composed,
|
||||
// that use the same words - a level-2 refusal of the journal milter, the DKIM
|
||||
// signer not answering. Those - smtpd 1149, 1190 and 1201, the second client
|
||||
// of 1183, smtpd 1300 and the signer's warning of cleanup 1339 - are composed,
|
||||
// after src/smtpd/smtpd.c and src/milter/milter8.c (Postfix 3.10.13).
|
||||
func TestDeferredCounter(t *testing.T) {
|
||||
st := &deferredStore{}
|
||||
@@ -67,9 +67,9 @@ func TestDeferredCounter(t *testing.T) {
|
||||
`Oct 12 16:23:40 mail postfix/smtpd[1190]: warning: Illegal address syntax from unknown[10.77.0.1] in MAIL command: <warning: connect to Milter service unix:/run/selfpost/sender.sock: milter>`,
|
||||
`Oct 12 16:23:40 mail postfix/smtpd[1190]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; from=<a@sender.e2e.test> proto=ESMTP helo=<x>`,
|
||||
|
||||
// OpenDKIM does not answer: another socket, not counted.
|
||||
// The signer does not answer: another socket, not counted.
|
||||
`Oct 12 16:24:00 mail postfix/smtpd[1201]: connect from unknown[10.77.0.1]`,
|
||||
`Oct 12 16:24:00 mail postfix/smtpd[1201]: warning: connect to Milter service unix:/run/opendkim/opendkim.sock: Connection refused`,
|
||||
`Oct 12 16:24:00 mail postfix/smtpd[1201]: warning: connect to Milter service unix:/run/selfpost/sign.sock: Connection refused`,
|
||||
`Oct 12 16:24:00 mail postfix/smtpd[1201]: NOQUEUE: milter-reject: CONNECT from unknown[10.77.0.1]: 451 4.7.1 Service unavailable - try again later; proto=SMTP`,
|
||||
|
||||
// The check stops answering in the middle of a message, the next day:
|
||||
@@ -81,8 +81,8 @@ func TestDeferredCounter(t *testing.T) {
|
||||
`Oct 13 00:01:20 mail postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sender.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`,
|
||||
`Oct 13 00:01:20 mail postfix/cleanup[1339]: 8C1D43C75DA0: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 4.7.1 Service unavailable - try again later; from=<alerts@sender.e2e.test> to=<rcpt@sink.e2e.test> proto=ESMTP helo=<e2e-client.e2e.test>`,
|
||||
|
||||
// OpenDKIM hung in the middle of a message: another socket.
|
||||
`Oct 13 00:01:30 mail postfix/cleanup[1339]: warning: milter unix:/run/opendkim/opendkim.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`,
|
||||
// The signer hung in the middle of a message: another socket.
|
||||
`Oct 13 00:01:30 mail postfix/cleanup[1339]: warning: milter unix:/run/selfpost/sign.sock: can't read SMFIC_HEADER reply packet header: Connection timed out`,
|
||||
|
||||
// The check's own verdict is a refusal, not a deferral, and it is
|
||||
// cleanup that writes it.
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
package milter
|
||||
|
||||
// The address OpenDKIM signs for, found as OpenDKIM finds it: a port of
|
||||
// libopendkim's dkim_mail_parse() and its tokenizer, from Debian's
|
||||
// opendkim 2.11.0~beta2-9.1+b1 (libopendkim/dkim-mailparse.c, identical
|
||||
// there to upstream's develop branch), line for line. OpenDKIM calls it on the
|
||||
// value of the message's first From: field (opendkim/opendkim.c, mlfi_eoh(),
|
||||
// dkimf_findheader(dfc, "from", 0): build/opendkim.conf sets no SenderHeaders),
|
||||
// lower-cases the domain it returns, and signs with the key whose SigningTable
|
||||
// pattern matches "user@domain" (dkimf_apply_signtable()). Whatever the port
|
||||
// returns, OpenDKIM does: quoted strings and domain literals are kept with
|
||||
// their delimiters, comments are dropped, a source route resets the address,
|
||||
// an '@' before the first ':', ';' or ',' decides where the address starts,
|
||||
// and encoded words are not decoded.
|
||||
//
|
||||
// The C works in place on a NUL-terminated buffer, writing the result over
|
||||
// the input; here the input is read through at(), which gives 0 at its end as
|
||||
// the C finds the terminator, and the result goes to a buffer of its own, in
|
||||
// which 0 ends a string as '\0' does there.
|
||||
|
||||
// mailparse errors, DKIM_MAILPARSE_ERR_* (dkim-mailparse.c).
|
||||
const (
|
||||
mailparseOK = 0
|
||||
mailparsePUnbalanced = 1 // unbalanced parentheses
|
||||
mailparseQUnbalanced = 2 // unbalanced quotes
|
||||
mailparseSUnbalanced = 3 // unbalanced square brackets
|
||||
)
|
||||
|
||||
// mailparseSpecials is SPECIALS: "<>@,;:\\\"/[]?=".
|
||||
var mailparseSpecials = func() (t [256]bool) {
|
||||
for _, c := range []byte("<>@,;:\\\"/[]?=") {
|
||||
t[c] = true
|
||||
}
|
||||
return t
|
||||
}()
|
||||
|
||||
// mailparseSpace is isascii(c) && isspace(c) in the C locale.
|
||||
func mailparseSpace(c byte) bool {
|
||||
return c == ' ' || (c >= '\t' && c <= '\r')
|
||||
}
|
||||
|
||||
type mailparser struct {
|
||||
b []byte // the input up to its first NUL, as strlen() sees it
|
||||
}
|
||||
|
||||
// at is *p: the byte, or the terminator at and beyond the end.
|
||||
func (m *mailparser) at(i int) byte {
|
||||
if i >= len(m.b) {
|
||||
return 0
|
||||
}
|
||||
return m.b[i]
|
||||
}
|
||||
|
||||
// matchingParen is dkim_mail_matching_paren(): the position of the close
|
||||
// character that balances the open one before s, or e. An open character of
|
||||
// 0 is never met inside the string, so for quotes and brackets it only skips
|
||||
// to the close, past backslash escapes.
|
||||
func (m *mailparser) matchingParen(s, e int, open, close byte) int {
|
||||
paren := 1
|
||||
for ; s < e; s++ {
|
||||
c := m.b[s]
|
||||
if c == close {
|
||||
paren--
|
||||
if paren == 0 {
|
||||
break
|
||||
}
|
||||
} else if c == open {
|
||||
paren++
|
||||
} else if c == '\\' {
|
||||
if m.at(s+1) != 0 {
|
||||
s++
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// firstSpecial is dkim_mail_first_special(): the '<' that opens an address,
|
||||
// or the ':', ';' or ',' that ends a part - or the '@' seen before it - or
|
||||
// the end.
|
||||
func (m *mailparser) firstSpecial(p, e int) (int, int) {
|
||||
atPtr := -1
|
||||
for ; p < e && m.at(p) != 0; p++ {
|
||||
// skip white space between tokens
|
||||
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
|
||||
if m.at(p) != '(' {
|
||||
p++
|
||||
} else {
|
||||
p = m.matchingParen(p+1, e, '(', ')')
|
||||
if m.at(p) == 0 {
|
||||
return p, mailparsePUnbalanced
|
||||
}
|
||||
p++
|
||||
}
|
||||
}
|
||||
if m.at(p) == 0 {
|
||||
break
|
||||
}
|
||||
c := m.at(p)
|
||||
switch {
|
||||
case c == '"':
|
||||
p = m.matchingParen(p+1, e, 0, '"')
|
||||
if m.at(p) == 0 {
|
||||
return p, mailparseQUnbalanced
|
||||
}
|
||||
case c == '[':
|
||||
p = m.matchingParen(p+1, e, 0, ']')
|
||||
if m.at(p) == 0 {
|
||||
return p, mailparseSUnbalanced
|
||||
}
|
||||
case mailparseSpecials[c]:
|
||||
if c == '<' {
|
||||
return p, mailparseOK
|
||||
} else if c == ':' || c == ';' || c == ',' {
|
||||
if atPtr >= 0 {
|
||||
return atPtr, mailparseOK
|
||||
}
|
||||
return p, mailparseOK
|
||||
} else if c == '@' {
|
||||
atPtr = p
|
||||
}
|
||||
default:
|
||||
for m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
|
||||
p++
|
||||
}
|
||||
p--
|
||||
}
|
||||
}
|
||||
return p, mailparseOK
|
||||
}
|
||||
|
||||
// token is dkim_mail_token(): the next token from s - a quoted string or a
|
||||
// domain literal with its delimiters, a special character alone, or an atom
|
||||
// ('x') - its type (0 at the end), and whether whitespace outside a comment
|
||||
// came before it (ws is set, never cleared, as in the C).
|
||||
func (m *mailparser) token(s, e int, ws *bool) (typ int, start, end int, err int) {
|
||||
p := s
|
||||
// skip white space between tokens
|
||||
for p < e && (m.at(p) == '(' || mailparseSpace(m.at(p))) {
|
||||
if m.at(p) != '(' {
|
||||
*ws = true
|
||||
p++
|
||||
} else {
|
||||
p = m.matchingParen(p+1, e, '(', ')')
|
||||
if m.at(p) == 0 {
|
||||
return 0, 0, 0, mailparsePUnbalanced
|
||||
}
|
||||
p++
|
||||
}
|
||||
}
|
||||
if p >= e || m.at(p) == 0 {
|
||||
return 0, 0, 0, mailparseOK
|
||||
}
|
||||
start = p
|
||||
c := m.at(p)
|
||||
switch {
|
||||
case c == '"':
|
||||
end = m.matchingParen(p+1, e, 0, '"')
|
||||
typ = '"'
|
||||
if m.at(end) != 0 {
|
||||
end++
|
||||
} else {
|
||||
err = mailparseQUnbalanced
|
||||
}
|
||||
case c == '[':
|
||||
end = m.matchingParen(p+1, e, 0, ']')
|
||||
typ = '['
|
||||
if m.at(end) != 0 {
|
||||
end++
|
||||
} else {
|
||||
err = mailparseSUnbalanced
|
||||
}
|
||||
case mailparseSpecials[c]:
|
||||
end = p + 1
|
||||
typ = int(c)
|
||||
default:
|
||||
for p < e && m.at(p) != 0 && !mailparseSpecials[m.at(p)] && !mailparseSpace(m.at(p)) && m.at(p) != '(' {
|
||||
p++
|
||||
}
|
||||
end = p
|
||||
typ = 'x'
|
||||
}
|
||||
return typ, start, end, err
|
||||
}
|
||||
|
||||
// mailParsed is what dkim_mail_parse() returns: the local part and the
|
||||
// domain, each absent (NULL) or a string, and the error. rest is not the
|
||||
// C's: where in the line it stopped reading, for the caller's question
|
||||
// whether anything follows the address.
|
||||
type mailParsed struct {
|
||||
user, domain string
|
||||
hasUser, hasDomain bool
|
||||
err int
|
||||
rest int
|
||||
}
|
||||
|
||||
// dkimMailParse is dkim_mail_parse().
|
||||
func dkimMailParse(line string) mailParsed {
|
||||
if i := indexByte(line, 0); i >= 0 {
|
||||
line = line[:i] // strlen()
|
||||
}
|
||||
m := &mailparser{b: []byte(line)}
|
||||
e := len(m.b)
|
||||
var out []byte
|
||||
user, domain := -1, -1
|
||||
result := func(err, rest int) mailParsed {
|
||||
r := mailParsed{err: err, rest: rest}
|
||||
if user >= 0 {
|
||||
r.user, r.hasUser = cstring(out[user:]), true
|
||||
}
|
||||
if domain >= 0 {
|
||||
r.domain, r.hasDomain = cstring(out[domain:]), true
|
||||
}
|
||||
return r
|
||||
}
|
||||
ws := false
|
||||
pos := 0
|
||||
for {
|
||||
special, err := m.firstSpecial(pos, e)
|
||||
if err != 0 {
|
||||
return result(err, special)
|
||||
}
|
||||
switch m.at(special) {
|
||||
case '<':
|
||||
// display name <address>
|
||||
pos = special + 1
|
||||
for {
|
||||
typ, ts, te, err := m.token(pos, e, &ws)
|
||||
if err != 0 {
|
||||
return result(err, pos)
|
||||
}
|
||||
if typ == '>' || typ == 0 {
|
||||
out = append(out, 0)
|
||||
if typ == 0 {
|
||||
te = e
|
||||
}
|
||||
return result(mailparseOK, te)
|
||||
} else if typ == '@' {
|
||||
out = append(out, 0)
|
||||
domain = len(out)
|
||||
} else if typ == ',' || typ == ':' {
|
||||
// source route punctuation
|
||||
user, domain = -1, -1
|
||||
} else {
|
||||
if user < 0 {
|
||||
user = len(out)
|
||||
}
|
||||
out = append(out, m.b[ts:te]...)
|
||||
}
|
||||
pos = te
|
||||
}
|
||||
case ';', ':', ',':
|
||||
// skip a group name or result
|
||||
pos = special + 1
|
||||
default:
|
||||
// (display name) addr(display name)ess
|
||||
ws = false
|
||||
for {
|
||||
typ, ts, te, err := m.token(pos, e, &ws)
|
||||
if err != 0 {
|
||||
return result(err, pos)
|
||||
}
|
||||
if typ == 0 || typ == ',' || typ == ';' {
|
||||
out = append(out, 0)
|
||||
if typ == 0 {
|
||||
te = e
|
||||
}
|
||||
return result(mailparseOK, te)
|
||||
} else if typ == '@' {
|
||||
out = append(out, 0)
|
||||
domain = len(out)
|
||||
ws = false
|
||||
} else {
|
||||
if user < 0 {
|
||||
user = len(out)
|
||||
} else if typ == 'x' && ws {
|
||||
out = append(out, ' ')
|
||||
}
|
||||
out = append(out, m.b[ts:te]...)
|
||||
ws = false
|
||||
}
|
||||
pos = te
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// moreAfter says whether anything but list and group punctuation follows
|
||||
// position rest of line: another address, or text after the one found.
|
||||
func moreAfter(line string, rest int) bool {
|
||||
if i := indexByte(line, 0); i >= 0 {
|
||||
line = line[:i]
|
||||
}
|
||||
m := &mailparser{b: []byte(line)}
|
||||
ws := false
|
||||
for pos := rest; ; {
|
||||
typ, _, te, err := m.token(pos, len(m.b), &ws)
|
||||
if err != 0 {
|
||||
return true
|
||||
}
|
||||
switch typ {
|
||||
case 0:
|
||||
return false
|
||||
case ',', ';':
|
||||
pos = te
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func indexByte(s string, c byte) int {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// cstring reads a NUL-terminated string from b.
|
||||
func cstring(b []byte) string {
|
||||
if i := indexByte(string(b), 0); i >= 0 {
|
||||
return string(b[:i])
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
package milter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// mailparseCases are lines whose result is written down here, read off
|
||||
// dkim-mailparse.c by hand and confirmed against it by
|
||||
// TestMailParseMatchesOpenDKIM.
|
||||
var mailparseCases = []struct {
|
||||
line string
|
||||
user, domain string // "-" for absent
|
||||
err int
|
||||
}{
|
||||
{"user@example.com", "user", "example.com", 0},
|
||||
{" Name <user@example.com>", "user", "example.com", 0},
|
||||
{`"Some One" <user@example.com>`, "user", "example.com", 0},
|
||||
{"user@example.com (Some One)", "user", "example.com", 0},
|
||||
{"Some One <user@ex(comment)ample.com>", "user", "example.com", 0},
|
||||
// An '@' before the first ',' decides: the address is read from the start.
|
||||
{"ceo@other.com <me@mine.com>", "me", "mine.com", 0},
|
||||
{"me@mine.com, you@yours.com", "me", "mine.com", 0},
|
||||
// Quoted local parts keep their quotes; an '@' inside them is no '@'.
|
||||
{`"x@other.com"@mine.com`, `"x@other.com"`, "mine.com", 0},
|
||||
{`<"a b"@mine.com>`, `"a b"`, "mine.com", 0},
|
||||
// A source route is dropped.
|
||||
{"<@relay.example,@r2.example:u@mine.com>", "u", "mine.com", 0},
|
||||
// Group syntax: the name is skipped.
|
||||
{"group: u@mine.com;", "u", "mine.com", 0},
|
||||
// No local part: the domain is taken for it, so OpenDKIM has both.
|
||||
{"@mine.com", "mine.com", "mine.com", 0},
|
||||
// Whitespace between atoms of the default form becomes one space.
|
||||
{"a b@mine.com", "a b", "mine.com", 0},
|
||||
// Unbalanced.
|
||||
{"Name (comment <u@mine.com>", "-", "-", mailparsePUnbalanced},
|
||||
{`"open <u@mine.com>`, "-", "-", mailparseQUnbalanced},
|
||||
{"u@[1.2.3.4", "-", "-", mailparseSUnbalanced},
|
||||
// Encoded words are not decoded.
|
||||
{"=?utf-8?q?boss=40other.com?= <u@mine.com>", "u", "mine.com", 0},
|
||||
// No address at all.
|
||||
{"Undisclosed recipients:;", "-", "-", 0},
|
||||
{"", "-", "-", 0},
|
||||
}
|
||||
|
||||
func parsedParts(r mailParsed) (string, string) {
|
||||
u, d := "-", "-"
|
||||
if r.hasUser {
|
||||
u = r.user
|
||||
}
|
||||
if r.hasDomain {
|
||||
d = r.domain
|
||||
}
|
||||
return u, d
|
||||
}
|
||||
|
||||
func TestMailParseCases(t *testing.T) {
|
||||
for _, c := range mailparseCases {
|
||||
r := dkimMailParse(c.line)
|
||||
u, d := parsedParts(r)
|
||||
if r.err != c.err || (c.err == 0 && (u != c.user || d != c.domain)) {
|
||||
t.Errorf("%q: user %q domain %q err %d; want %q %q %d", c.line, u, d, r.err, c.user, c.domain, c.err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMoreAfter(t *testing.T) {
|
||||
for line, want := range map[string]bool{
|
||||
"user@example.com": false,
|
||||
"Name <user@example.com>": false,
|
||||
"Name <user@example.com> (note)": false,
|
||||
"group: u@mine.com;": false,
|
||||
"me@mine.com, you@yours.com": true,
|
||||
"Name <a@x>, b@y": true,
|
||||
"Name <a@x> trailing": true,
|
||||
} {
|
||||
r := dkimMailParse(line)
|
||||
if got := moreAfter(line, r.rest); got != want {
|
||||
t.Errorf("%q: moreAfter %v, want %v", line, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mailparseCorpus is the cases above and n random lines over an alphabet
|
||||
// weighted toward what the parser treats specially.
|
||||
func mailparseCorpus(n int) []string {
|
||||
lines := []string{}
|
||||
for _, c := range mailparseCases {
|
||||
lines = append(lines, c.line)
|
||||
}
|
||||
alphabet := []string{"<", ">", "@", ",", ";", ":", "\\", `"`, "/", "[", "]", "?", "=", "(", ")",
|
||||
" ", "\t", "\n", "\r", ".", "a", "b", "x", "mine.com", "other.com", "\xc3\xa9", "\xff", "=?utf-8?q?", "?="}
|
||||
rng := rand.New(rand.NewSource(20261015))
|
||||
for i := 0; i < n; i++ {
|
||||
var b strings.Builder
|
||||
for j := rng.Intn(24); j >= 0; j-- {
|
||||
b.WriteString(alphabet[rng.Intn(len(alphabet))])
|
||||
}
|
||||
lines = append(lines, b.String())
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// TestMailParseMatchesOpenDKIM feeds the corpus to OpenDKIM's own C
|
||||
// dkim_mail_parse() through testdata/mailparse_harness.c, built against the
|
||||
// opendkim source the image runs, and wants the port's answer for every line.
|
||||
// It runs where MAILPARSE_C names the built harness.
|
||||
func TestMailParseMatchesOpenDKIM(t *testing.T) {
|
||||
bin := os.Getenv("MAILPARSE_C")
|
||||
if bin == "" {
|
||||
t.Skip("MAILPARSE_C not set (see testdata/mailparse_harness.c)")
|
||||
}
|
||||
lines := mailparseCorpus(200000)
|
||||
var in strings.Builder
|
||||
for _, l := range lines {
|
||||
in.WriteString(hex.EncodeToString([]byte(l)))
|
||||
in.WriteByte('\n')
|
||||
}
|
||||
cmd := exec.Command(bin)
|
||||
cmd.Stdin = strings.NewReader(in.String())
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
t.Fatalf("harness: %v", err)
|
||||
}
|
||||
sc := bufio.NewScanner(strings.NewReader(string(out)))
|
||||
sc.Buffer(make([]byte, 1<<20), 1<<20)
|
||||
enc := func(present bool, s string) string {
|
||||
if !present {
|
||||
return "-"
|
||||
}
|
||||
return "=" + hex.EncodeToString([]byte(s))
|
||||
}
|
||||
diffs, failed := 0, 0
|
||||
for i := 0; sc.Scan(); i++ {
|
||||
r := dkimMailParse(lines[i])
|
||||
got := fmt.Sprintf("%d %s %s", r.err, enc(r.hasUser, r.user), enc(r.hasDomain, r.domain))
|
||||
want := sc.Text()
|
||||
if r.err != 0 {
|
||||
// On an error the C returns before it writes the result's end, so
|
||||
// what it prints of user and domain runs on into the unread
|
||||
// input; OpenDKIM uses neither then (mlfi_eoh(): status != 0 is
|
||||
// BADFORMAT, no signature). The error itself must agree.
|
||||
failed++
|
||||
got, want = fmt.Sprint(r.err), strings.Fields(want)[0]
|
||||
}
|
||||
if got != want {
|
||||
diffs++
|
||||
if diffs <= 20 {
|
||||
t.Errorf("line %q: port %s, OpenDKIM %s", lines[i], got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
if diffs > 0 {
|
||||
t.Fatalf("%d of %d lines differ", diffs, len(lines))
|
||||
}
|
||||
t.Logf("%d lines, the port and OpenDKIM agree on every one (%d of them errors, compared by code)", len(lines), failed)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// sender check (sender.go) and the inbound journal (inbound.go).
|
||||
//
|
||||
// The journal-milter is a lightweight milter (architecture.md § Mail path)
|
||||
// attached to Postfix's smtpd_milters alongside OpenDKIM. On the receive path it reads the SASL login, From, recipients and
|
||||
// attached to Postfix's smtpd_milters after the sender check and the signer. On the receive path it reads the SASL login, From, recipients and
|
||||
// Subject of each accepted message and records one send-log row per (queue-id,
|
||||
// recipient), giving the panel a structured, filterable history that raw
|
||||
// mail.log cannot.
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
package milter
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-milter"
|
||||
)
|
||||
|
||||
// TestPortAgainstOpenDKIM checks the port where it matters, in OpenDKIM
|
||||
// itself: for every From: line, the domain OpenDKIM signs the message for
|
||||
// must be the one the port predicts - the port's address, matched against
|
||||
// the SigningTable as dkimf_apply_signtable() matches it, cut to 256 bytes
|
||||
// (MAXADDRESS) - or both must say no signature. It runs where
|
||||
// OPENDKIM_PROBE_SOCKET names a running OpenDKIM's socket and
|
||||
// OPENDKIM_PROBE_TABLE its refile SigningTable of "*@<domain>" lines:
|
||||
// test/upstream/opendkim-probe.sh starts one from the image and runs this
|
||||
// (audit 2026-10-12, stage 2c).
|
||||
func TestPortAgainstOpenDKIM(t *testing.T) {
|
||||
sock, table := os.Getenv("OPENDKIM_PROBE_SOCKET"), os.Getenv("OPENDKIM_PROBE_TABLE")
|
||||
if sock == "" || table == "" {
|
||||
t.Skip("OPENDKIM_PROBE_SOCKET and OPENDKIM_PROBE_TABLE not set")
|
||||
}
|
||||
signed := map[string]bool{}
|
||||
f, err := os.Open(table)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
if fields := strings.Fields(sc.Text()); len(fields) > 0 && strings.HasPrefix(fields[0], "*@") {
|
||||
signed[strings.ToLower(fields[0][2:])] = true
|
||||
}
|
||||
}
|
||||
f.Close()
|
||||
|
||||
const own, other, server = "sender.e2e.test", "other.e2e.test", "mail.e2e.test"
|
||||
lines := probeLines(t, own, other, server)
|
||||
|
||||
agree, signedCount := 0, 0
|
||||
for i, line := range lines {
|
||||
want := predictSigner(line, signed)
|
||||
got, err := opendkimSigns(sock, line)
|
||||
if err != nil {
|
||||
t.Fatalf("From: %q: %v", line, err)
|
||||
}
|
||||
if i >= len(lines)-2 && got != other {
|
||||
// The padded lines: cut at 256 bytes they end in "@<other>",
|
||||
// and OpenDKIM signs them with that domain's key - the reason
|
||||
// the sender check refuses an address over 256 bytes.
|
||||
t.Errorf("a padded line of %d bytes: OpenDKIM signs for %q, not %q", len(line), got, other)
|
||||
}
|
||||
if got != "" {
|
||||
signedCount++
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("From: %q: OpenDKIM signs for %q, the port predicts %q", line, got, want)
|
||||
continue
|
||||
}
|
||||
agree++
|
||||
}
|
||||
t.Logf("%d From: lines, OpenDKIM and the port agree on %d; %d signed", len(lines), agree, signedCount)
|
||||
}
|
||||
|
||||
// predictSigner is the domain OpenDKIM signs a message with this From: line
|
||||
// for, by the port: none when it reads no address; otherwise the domain of
|
||||
// the SigningTable whose "^.*@<domain>$" (case-insensitive) matches
|
||||
// "user@domain" as snprintf() leaves it in MAXADDRESS + 1 bytes, the domain
|
||||
// first cut to DKIM_MAXHOSTNAMELEN by strlcpy() and lower-cased.
|
||||
func predictSigner(line string, signed map[string]bool) string {
|
||||
r := dkimMailParse(line)
|
||||
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
|
||||
return ""
|
||||
}
|
||||
domain := r.domain
|
||||
if len(domain) > 256 {
|
||||
domain = domain[:256]
|
||||
}
|
||||
addr := r.user + "@" + asciiLower(domain)
|
||||
if len(addr) > 256 {
|
||||
addr = addr[:256]
|
||||
}
|
||||
d := asciiLower(addr[strings.LastIndexByte(addr, '@')+1:])
|
||||
if signed[d] {
|
||||
return d
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// opendkimSigns plays one message to OpenDKIM as Postfix would for a client
|
||||
// it trusts (127.0.0.1, its InternalHosts by default) and returns the d= of
|
||||
// the signature it adds, lower-cased, or "".
|
||||
func opendkimSigns(sock, from string) (string, error) {
|
||||
cl := milter.NewClientWithOptions("unix", sock, milter.ClientOptions{
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
ActionMask: milter.OptAddHeader | milter.OptChangeHeader,
|
||||
})
|
||||
defer cl.Close()
|
||||
s, err := cl.Session()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer s.Close()
|
||||
// A step OpenDKIM answers with anything but "continue" ends the message
|
||||
// for it - an accept where it has nothing to do (libmilter, engine.c) -
|
||||
// and it answers nothing after; no signature then.
|
||||
step := func(act *milter.Action, err error) (bool, error) {
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return act.Code == milter.ActContinue, nil
|
||||
}
|
||||
if ok, err := step(s.Conn("localhost", milter.FamilyInet, 40000, "127.0.0.1")); !ok {
|
||||
return "", err
|
||||
}
|
||||
if ok, err := step(s.Mail("<probe@sender.e2e.test>", nil)); !ok {
|
||||
return "", err
|
||||
}
|
||||
if ok, err := step(s.Rcpt("<user@elsewhere.example>", nil)); !ok {
|
||||
return "", err
|
||||
}
|
||||
for _, h := range [][2]string{{"From", from}, {"To", "user@elsewhere.example"}, {"Subject", "probe"}, {"Date", "Thu, 15 Oct 2026 03:00:00 +0000"}} {
|
||||
if ok, err := step(s.HeaderField(h[0], h[1])); !ok {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if ok, err := step(s.HeaderEnd()); !ok {
|
||||
return "", err
|
||||
}
|
||||
// No body chunk: OpenDKIM signs an empty body as well.
|
||||
mods, _, err := s.End()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("end: %w", err)
|
||||
}
|
||||
for _, m := range mods {
|
||||
if !strings.EqualFold(m.HeaderName, "DKIM-Signature") {
|
||||
continue
|
||||
}
|
||||
for _, tag := range strings.Split(m.HeaderValue, ";") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(tag), "=")
|
||||
if ok && strings.TrimSpace(k) == "d" {
|
||||
return strings.ToLower(strings.TrimSpace(v)), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// probeLines are the shared files' lines and several hundred composed from
|
||||
// pieces: display names plain, quoted, escaped and encoded, comments nested
|
||||
// and left open, groups, lists, brackets, source routes, domain literals and
|
||||
// quoted local parts - with the other sender's name, and the server's, in
|
||||
// each place - and the line padded to be cut at 256 bytes.
|
||||
func probeLines(t *testing.T, own, other, server string) []string {
|
||||
var lines []string
|
||||
for _, file := range []string{"from-lines-passed.txt", "from-lines-other.txt"} {
|
||||
lines = append(lines, fromLines(t, file, own, other)...)
|
||||
}
|
||||
names := []string{"", "Shop ", `"Shop, Inc." `, `"x@` + other + `" `, "x@" + other + " ", `"a\"b <x@` + other + `>" `,
|
||||
"=?utf-8?q?x=40" + other + "?= ", "(x@" + other + ") ", "((x@" + other + ")) ", "(open ", `"open `, "[x@" + other + "] ",
|
||||
"group: ", other + ": "}
|
||||
addrs := []string{
|
||||
"a@" + own, "<a@" + own + ">", "<a@" + other + ">", "a@" + other, "<a@" + server + ">", "a@" + strings.ToUpper(own),
|
||||
`<"x@` + other + `"@` + own + `>`, `"x@` + own + `"@` + other, "<@" + other + ":a@" + own + ">", "<@" + own + ":a@" + other + ">",
|
||||
"a@[192.0.2.1]", "<a@" + own + ">, b@" + other, "a@" + own + ", <b@" + other + ">", "a(c)@" + own, "a@" + own + "(x@" + other + ")",
|
||||
"a@ " + own, "a @" + own, "<a@" + own, "a@" + own + ";", "@" + own, "<>", "a", `"a b"@` + own, `a\@` + other + "@" + own,
|
||||
"a@" + own + " x@" + other,
|
||||
}
|
||||
for _, n := range names {
|
||||
for _, a := range addrs {
|
||||
lines = append(lines, n+a)
|
||||
}
|
||||
}
|
||||
// Cut at 256 bytes, the quoted local part ends in "@<other>".
|
||||
pad := strings.Repeat("a", 256-len("@"+other)-1)
|
||||
lines = append(lines, `"`+pad+`@`+other+`x"@`+own, `<"`+pad+`@`+other+`x"@`+own+`>`)
|
||||
return lines
|
||||
}
|
||||
+29
-61
@@ -22,12 +22,11 @@ import (
|
||||
|
||||
// The sender check: a message's From: line is held against the application
|
||||
// that sends it, under the rule Postfix holds the envelope sender to
|
||||
// (docs/plans/from-header-binding.md). OpenDKIM chooses the signing key by
|
||||
// that line, so without the check an application could have mail signed as
|
||||
// another domain of the server, or as the server itself.
|
||||
//
|
||||
// The line is read with OpenDKIM's own parser, ported (mailparse.go), so the
|
||||
// address judged is the one OpenDKIM signs for.
|
||||
// (docs/plans/from-header-binding.md). The signer signs with the key of the
|
||||
// domain the login belongs to (sign.go), and a receiver holds that signature
|
||||
// to the domain of this line (DMARC, RFC 7489 § 3.1.1), so without the check
|
||||
// an application could send under the server's signature as another domain
|
||||
// of the server, or as the server itself.
|
||||
//
|
||||
// Unlike the journal milter this one is attached strictly
|
||||
// (default_action=tempfail in build/postfix-config.sh): while it does not
|
||||
@@ -42,9 +41,7 @@ import (
|
||||
// Postfix 3.10.13, src/smtpd/smtpd_check.c, reject_auth_sender_login_mismatch():
|
||||
// the SASL login is compared with an owner by strcasecmp_utf8();
|
||||
// src/milter/milter8.c, milter8_comm_error(): default_action=tempfail answers
|
||||
// "451 4.7.1" for a milter that does not answer. OpenDKIM 2.11.0~beta2,
|
||||
// opendkim/opendkim.c, mlfi_eoh(): with SenderHeaders unset the signing
|
||||
// domain is that of the first From: field, as dkim_mail_parse() reads it.
|
||||
// "451 4.7.1" for a milter that does not answer.
|
||||
|
||||
// tableFile is a table of the mail path that the panel rewrites and the check
|
||||
// reads: it is read again when it has changed. An unreadable file is an error
|
||||
@@ -208,8 +205,8 @@ func splitTableLine(line string) (key, value string, ok bool) {
|
||||
// Case is folded in ASCII only. Postfix folds as Unicode (src/util/casefold.c
|
||||
// with ICU, which Debian builds with), but every key and login the panel
|
||||
// writes is ASCII; a Unicode fold would only let a look-alike pass - the
|
||||
// Kelvin sign for "k" - for an address OpenDKIM, folding ASCII, does not
|
||||
// sign (review of stage 2c).
|
||||
// Kelvin sign for "k" - for an address that is not the domain's (review of
|
||||
// stage 2c).
|
||||
func (b *Bindings) Allowed(login, addr string) (bool, error) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
@@ -223,8 +220,10 @@ func (b *Bindings) Allowed(login, addr string) (bool, error) {
|
||||
if bare := withoutExtension(addr); bare != addr {
|
||||
keys = append(keys, bare)
|
||||
}
|
||||
if d := domainOf(addr); d != "" {
|
||||
keys = append(keys, "@"+d)
|
||||
// The domain as written, folded in ASCII alone: domainOf folds as
|
||||
// Unicode, which would make the Kelvin sign a "k".
|
||||
if at := strings.LastIndexByte(addr, '@'); at >= 0 && at < len(addr)-1 {
|
||||
keys = append(keys, addr[at:])
|
||||
}
|
||||
for _, k := range keys {
|
||||
if owners, found := b.logins[k]; found {
|
||||
@@ -380,53 +379,23 @@ func (s *senderSession) judge() (shown, why string, err error) {
|
||||
default:
|
||||
return printable(strings.Join(s.froms, " | ")), "the message has more than one From: line", nil
|
||||
}
|
||||
// The value is the one OpenDKIM gets - Postfix hands both milters the
|
||||
// field as it came, folded lines and all, and with the leading space each
|
||||
// asked for (OptHeaderLeadingSpace, ServeSender) - and it is read with
|
||||
// OpenDKIM's own parser (mailparse.go). So the address judged here is the
|
||||
// address OpenDKIM signs for, whatever the display name, a comment or a
|
||||
// quoted string around it holds; the domain of the key it signs with is
|
||||
// that address's (assumptions B-PM12; audit 2026-10-12, stage 2c).
|
||||
// The line must be one address in RFC 5322's grammar (net/mail), the
|
||||
// address a mail program shows; a display name may say anything. A line
|
||||
// a lenient program could show as another address - "ceo@bank.com
|
||||
// <me@mine.com>", "ceo@bank.com:me@mine.com", "<b@evil.com, a@mine.com>"
|
||||
// - is refused (review of stage 2c, F1). The value comes as the client
|
||||
// wrote it, folded lines and all (assumptions B-PM12). And that address,
|
||||
// its local part unquoted as Postfix holds the envelope's (Postfix
|
||||
// 3.10.13, src/global/tok822_parse.c), is the one held against the
|
||||
// bindings: "x"@mine.com is x@mine.com, which may be another
|
||||
// application's (F2).
|
||||
raw := s.froms[0]
|
||||
shown = printable(raw)
|
||||
r := dkimMailParse(raw)
|
||||
if r.err != mailparseOK || !r.hasUser || !r.hasDomain || r.user == "" || r.domain == "" {
|
||||
// OpenDKIM does not sign then either (mlfi_eoh(): BADFORMAT).
|
||||
return shown, "it holds no address OpenDKIM can read", nil
|
||||
}
|
||||
if moreAfter(raw, r.rest) {
|
||||
// OpenDKIM takes the first; a reader may show the others.
|
||||
return shown, "it must be one address", nil
|
||||
}
|
||||
signedDomain := asciiLower(r.domain)
|
||||
addr := r.user + "@" + signedDomain
|
||||
// OpenDKIM matches its SigningTable against "user@domain" as
|
||||
// snprintf() leaves it in a buffer of MAXADDRESS + 1 = 257 bytes
|
||||
// (opendkim.c, dkimf_apply_signtable(); libopendkim/dkim-internal.h).
|
||||
// Cut there, a quoted local part could end in another tenant's
|
||||
// "@domain" and pick that tenant's key; an address that fits is read
|
||||
// whole, and its domain (DKIM_MAXHOSTNAMELEN, 256) with it.
|
||||
if len(addr) > 256 {
|
||||
return printable(addr), "the address is longer than OpenDKIM reads whole", nil
|
||||
}
|
||||
// The address a mail program shows. OpenDKIM's parser reads lines no
|
||||
// standard reader takes for one address - "ceo@bank.com:me@mine.com",
|
||||
// "ceo@bank.com <me@mine.com>", "<b@evil.com, a@mine.com>" - and signs
|
||||
// for the last of them while a lenient program shows the first. So the
|
||||
// line must also be one address in RFC 5322's grammar (net/mail), at the
|
||||
// domain OpenDKIM signs for; a quoted display name may say anything.
|
||||
// And that address, its local part unquoted as Postfix holds the
|
||||
// envelope's (Postfix 3.10.13, src/global/tok822_parse.c), is the one
|
||||
// held against the bindings: "x"@mine.com is x@mine.com, which may be
|
||||
// another application's (review of stage 2c, F1, F2).
|
||||
list, perr := fromParser.ParseList(unfold(raw))
|
||||
if perr != nil || len(list) != 1 {
|
||||
return shown, "it must be one address, as a mail program reads it", nil
|
||||
}
|
||||
seen := asciiLower(list[0].Address)
|
||||
if domainOf(seen) != signedDomain {
|
||||
return shown, "a mail program would show another address than the one signed", nil
|
||||
}
|
||||
shown = printable(seen)
|
||||
ok, err := s.bind.Allowed(s.login, seen)
|
||||
if err != nil {
|
||||
@@ -452,8 +421,8 @@ func unfold(v string) string {
|
||||
return strings.NewReplacer("\r\n", "", "\n", "", "\r", "").Replace(v)
|
||||
}
|
||||
|
||||
// asciiLower lower-cases A-Z only, as OpenDKIM's dkimf_lowercase() and
|
||||
// Postfix's table lookups do; strings.ToLower would also fold characters
|
||||
// asciiLower lower-cases A-Z only, as Postfix's table lookups do for the
|
||||
// ASCII keys the panel writes; strings.ToLower would also fold characters
|
||||
// outside ASCII - the Kelvin sign to "k" - into names they are not.
|
||||
func asciiLower(v string) string {
|
||||
b := []byte(v)
|
||||
@@ -571,12 +540,11 @@ func ServeSender(ctx context.Context, ln net.Listener, bind *Bindings, rec Refus
|
||||
},
|
||||
Actions: 0, // read-only
|
||||
// The headers decide; and each header value with its leading space,
|
||||
// as OpenDKIM asks for it (opendkim.c, mlfi_negotiate(),
|
||||
// SMFIP_HDR_LEADSPC), so that both read the same bytes. Postfix
|
||||
// 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps the event
|
||||
// mask a milter of any version asks for (go-milter v0.4.1 says
|
||||
// version 2), and milter8_header() drops the space only for a milter
|
||||
// without SMFIP_HDR_LEADSPC.
|
||||
// as the client wrote it, the bytes the signer reads too (sign.go).
|
||||
// Postfix 3.10.13, src/milter/milter8.c: milter8_negotiate() keeps
|
||||
// the event mask a milter of any version asks for (go-milter v0.4.1
|
||||
// says version 2), and milter8_header() drops the space only for a
|
||||
// milter without SMFIP_HDR_LEADSPC.
|
||||
Protocol: milter.OptNoBody | milter.OptHeaderLeadingSpace,
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,9 @@ func TestSenderCheck(t *testing.T) {
|
||||
{"a name in windows-1251", "shop", [][2]string{from("=?windows-1251?B?zODj4Ofo7Q==?= <a@shop.example.org>")}, true, ""},
|
||||
{"a name in koi8-r, quoted-printable", "shop", [][2]string{from("=?koi8-r?Q?=ED=C1=C7=C1=DA=C9=CE?= <a@shop.example.org>")}, true, ""},
|
||||
{"a comment after the address", "shop", [][2]string{from("a@shop.example.org (the shop)")}, true, ""},
|
||||
{"the leading space Postfix keeps for OpenDKIM", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
|
||||
// Whatever the name, a comment or a quoted local part holds: OpenDKIM
|
||||
// signs for the address it reads, and that is the one judged.
|
||||
{"the leading space the check asks Postfix to keep", "shop", [][2]string{from(" a@shop.example.org")}, true, ""},
|
||||
// Whatever the name, a comment or a quoted local part holds: the
|
||||
// address a mail program reads is the one judged.
|
||||
{"another sender in the display name", "shop", [][2]string{from(`"director@example.org" <a@shop.example.org>`)}, true, ""},
|
||||
{"the server's name in a comment", "shop", [][2]string{from("a@shop.example.org (postmaster@mail.example.org)")}, true, ""},
|
||||
{"a domain with a key and no application, in the display name", "shop", [][2]string{from(`"x@empty.example.com" <a@shop.example.org>`)}, true, ""},
|
||||
@@ -107,9 +107,9 @@ func TestSenderCheck(t *testing.T) {
|
||||
{"a stranger's address in a comment", "shop", [][2]string{from("a@shop.example.org (someone@gmail.example)")}, true, ""},
|
||||
{"an unquoted address as the display name", "shop", [][2]string{from("director@example.org <a@shop.example.org>")}, false, "as a mail program reads it"},
|
||||
{"another domain inside a quoted local part", "shop", [][2]string{from(`"director@example.org"@shop.example.org`)}, true, ""},
|
||||
{"no local part: OpenDKIM takes the domain for it", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"},
|
||||
// Lines OpenDKIM signs for the application's domain while a lenient
|
||||
// mail program shows another address first (review of stage 2c, F1).
|
||||
{"no local part", "shop", [][2]string{from("@shop.example.org")}, false, "as a mail program reads it"},
|
||||
// Lines a lenient mail program could show as another address
|
||||
// (review of stage 2c, F1).
|
||||
{"another address before a colon", "shop", [][2]string{from("ceo@bank.example:me@shop.example.org")}, false, "as a mail program reads it"},
|
||||
{"a route-like colon inside the brackets", "shop", [][2]string{from("Name <ceo@bank.example:me@shop.example.org>")}, false, "as a mail program reads it"},
|
||||
{"a list inside the brackets", "shop", [][2]string{from("<b@evil.example, a@shop.example.org>")}, false, "as a mail program reads it"},
|
||||
@@ -119,9 +119,8 @@ func TestSenderCheck(t *testing.T) {
|
||||
{"another application's address, quoted", "shop", [][2]string{from(`"desk"@shop.example.org`)}, false, "not an address this application sends as"},
|
||||
{"its own bound address, quoted", "shop2", [][2]string{from(`"desk"@shop.example.org`)}, true, ""},
|
||||
// A look-alike of the domain in a character a Unicode fold maps
|
||||
// to ASCII (F3): OpenDKIM would not sign it, and it is not the
|
||||
// application's.
|
||||
{"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "a mail program would show another address"},
|
||||
// to ASCII (F3): it is not the application's.
|
||||
{"the Kelvin sign for k in the domain", "kiosk", [][2]string{from("a@\u212Aiosk.example.net")}, false, "not an address this application sends as"},
|
||||
{"the same domain in ASCII", "kiosk", [][2]string{from("a@kiosk.example.net")}, true, ""},
|
||||
|
||||
{"another registered domain", "shop", [][2]string{from("director@example.org")}, false, "not an address this application sends as"},
|
||||
@@ -134,14 +133,14 @@ func TestSenderCheck(t *testing.T) {
|
||||
{"no From: line", "shop", [][2]string{{"Subject", "hello"}}, false, "no From: line"},
|
||||
{"two From: lines", "shop", [][2]string{from("a@shop.example.org"), from("director@example.org")}, false, "more than one From: line"},
|
||||
{"two addresses in one line", "shop", [][2]string{from("a@shop.example.org, director@example.org")}, false, "one address"},
|
||||
{"not an address", "shop", [][2]string{from("the shop")}, false, "no address OpenDKIM can read"},
|
||||
{"not an address", "shop", [][2]string{from("the shop")}, false, "as a mail program reads it"},
|
||||
{"an address before a list comma, then another", "shop", [][2]string{from("director@example.org, <a@shop.example.org>")}, false, "one address"},
|
||||
// Past 256 bytes OpenDKIM matches its SigningTable against a cut
|
||||
// "user@domain" - which could end in another tenant's domain.
|
||||
{"an address longer than OpenDKIM reads", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, false, "longer than OpenDKIM reads whole"},
|
||||
// The key is the login's, so a long quoted local part naming another
|
||||
// domain picks nothing: OpenDKIM's 256-byte cut went with it.
|
||||
{"a long quoted local part naming another domain", "shop", [][2]string{from(`"` + strings.Repeat("a", 240) + `@example.org"@shop.example.org`)}, true, ""},
|
||||
{"a group", "shop", [][2]string{from("friends: a@shop.example.org, b@shop.example.org;")}, false, "one address"},
|
||||
{"another sender folded onto the next line", "shop", [][2]string{from("\"x\"\r\n <director@example.org>")}, false, "not an address this application sends as"},
|
||||
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "no address OpenDKIM can read"},
|
||||
{"a quote left open before another address", "shop", [][2]string{from(`"a@shop.example.org <director@example.org>`)}, false, "as a mail program reads it"},
|
||||
{"no login", "", [][2]string{from("a@shop.example.org")}, false, "no authenticated application"},
|
||||
{"an unknown login", "ghost", [][2]string{from("a@shop.example.org")}, false, "not an address this application sends as"},
|
||||
}
|
||||
@@ -389,8 +388,8 @@ func fromLines(t *testing.T, file, domain, other string) []string {
|
||||
}
|
||||
|
||||
// No line of the shared file of lines naming another sender is passed to be
|
||||
// signed with that sender's key: a line the check passes is one OpenDKIM
|
||||
// reads the application's own address in (the e2e looks at the signature).
|
||||
// passed as that sender: a line the check passes is one a mail program reads
|
||||
// as an address of the application's own domain.
|
||||
func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, senderMaps))
|
||||
for _, line := range fromLines(t, "from-lines-other.txt", "shop.example.org", "news.example.net") {
|
||||
@@ -398,14 +397,15 @@ func TestSenderCheckNeverPassesTheSharedLinesForAnother(t *testing.T) {
|
||||
if resp != milter.RespContinue {
|
||||
continue
|
||||
}
|
||||
if r := dkimMailParse(line); asciiLower(r.domain) != "shop.example.org" {
|
||||
t.Errorf("From: %q passed, and OpenDKIM reads the domain %q in it", line, r.domain)
|
||||
list, err := fromParser.ParseList(unfold(line))
|
||||
if err != nil || len(list) != 1 || domainOf(asciiLower(list[0].Address)) != "shop.example.org" {
|
||||
t.Errorf("From: %q passed, and a mail program reads it as %v (%v)", line, list, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Every line of the shared file is passed for the domain's application; the
|
||||
// e2e then checks OpenDKIM signs each for that domain alone.
|
||||
// e2e then checks each arrives signed for that domain alone.
|
||||
func TestSenderCheckPassesTheSharedLines(t *testing.T) {
|
||||
bind := NewBindings(writeMaps(t, senderMaps))
|
||||
for _, line := range passedFromLines(t, "shop.example.org") {
|
||||
|
||||
+33
-31
@@ -27,10 +27,11 @@ import (
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
// signHeaders is the set of header names signed when present: OpenDKIM's
|
||||
// dkim_should_signhdrs[], RFC 6376 § 5.4.1's list (OpenDKIM 2.11.0~beta2,
|
||||
// libopendkim/dkim.c:223-246), which OpenDKIM signed with until now. Every
|
||||
// occurrence is signed, in message order, and From once more (signLines).
|
||||
// signHeaders is the set of header names signed when present: RFC 6376
|
||||
// § 5.4.1's list, as OpenDKIM's dkim_should_signhdrs[] held it (OpenDKIM
|
||||
// 2.11.0~beta2, libopendkim/dkim.c:223-246), which SelfPost signed with until
|
||||
// 2.8.0. Every occurrence is signed, in message order, and From once more
|
||||
// (signLines).
|
||||
var signHeaders = map[string]bool{
|
||||
"from": true, "reply-to": true, "subject": true, "date": true, "to": true, "cc": true,
|
||||
"resent-date": true, "resent-from": true, "resent-sender": true, "resent-to": true, "resent-cc": true,
|
||||
@@ -49,11 +50,11 @@ var signHeaders = map[string]bool{
|
||||
// far above Postfix's own bounds on a transaction.
|
||||
const signLimit = time.Hour
|
||||
|
||||
// SignKeys reads the domains the server signs for, their selectors and key
|
||||
// files from the KeyTable the panel writes (internal/domain, renderTables():
|
||||
// "<domain> <domain>:<selector>:<keysDir>/<domain>/<selector>.private"), the
|
||||
// table OpenDKIM reads too until it leaves the image. A key file is read
|
||||
// when it changes, and only from under keysDir.
|
||||
// SignKeys reads the names the server signs for and their selectors from the
|
||||
// selector table the panel writes (internal/domain, renderSelectors(): a line
|
||||
// "<name> <selector>" each); the key of a name is
|
||||
// keysDir/<name>/<selector>.private (domain.KeyPath). The table is read again
|
||||
// when it changes, a key file when it does.
|
||||
type SignKeys struct {
|
||||
table, keysDir string
|
||||
|
||||
@@ -81,13 +82,13 @@ type fileStamp struct {
|
||||
|
||||
func stampOf(fi os.FileInfo) fileStamp { return fileStamp{fi.ModTime(), fi.Size()} }
|
||||
|
||||
// errNoKey is a domain the KeyTable has no line for.
|
||||
// errNoKey is a domain the selector table has no line for.
|
||||
var errNoKey = errors.New("no signing key")
|
||||
|
||||
// NewSignKeys reads keyTable; keysDir is the directory every key in it must
|
||||
// be under.
|
||||
func NewSignKeys(keyTable, keysDir string) *SignKeys {
|
||||
return &SignKeys{table: keyTable, keysDir: keysDir, keys: map[string]loadedKey{}}
|
||||
// NewSignKeys reads the selector table at selectors and the keys under
|
||||
// keysDir.
|
||||
func NewSignKeys(selectors, keysDir string) *SignKeys {
|
||||
return &SignKeys{table: selectors, keysDir: keysDir, keys: map[string]loadedKey{}}
|
||||
}
|
||||
|
||||
// Lookup gives the selector and key of domain.
|
||||
@@ -120,39 +121,39 @@ func (k *SignKeys) Lookup(domain string) (string, crypto.Signer, error) {
|
||||
func (k *SignKeys) readTable() error {
|
||||
fi, err := os.Stat(k.table)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key table: %w", err)
|
||||
return fmt.Errorf("selector table: %w", err)
|
||||
}
|
||||
if k.rows != nil && k.stamp == stampOf(fi) {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(k.table)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key table: %w", err)
|
||||
return fmt.Errorf("selector table: %w", err)
|
||||
}
|
||||
rows := map[string]keyRow{}
|
||||
for n, line := range strings.Split(string(data), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
name, value, ok := strings.Cut(line, " ")
|
||||
// Domain and selector never hold a colon (internal/domain,
|
||||
// opendkimForbidden); the path takes the rest.
|
||||
parts := strings.SplitN(value, ":", 3)
|
||||
if !ok || len(parts) != 3 || !strings.EqualFold(parts[0], name) {
|
||||
return fmt.Errorf("key table: line %d is not \"<domain> <domain>:<selector>:<path>\"", n+1)
|
||||
name, selector, ok := strings.Cut(line, " ")
|
||||
// The two are names of a directory and a file under keysDir: nothing
|
||||
// that would lead out of it (internal/domain, assertConfigSafe()).
|
||||
if !ok || !pathToken(name) || !pathToken(selector) {
|
||||
return fmt.Errorf("selector table: line %d is not \"<name> <selector>\"", n+1)
|
||||
}
|
||||
domain, selector, path := strings.ToLower(name), parts[1], parts[2]
|
||||
// The panel writes exactly this path (renderTables); anything else
|
||||
// is not read.
|
||||
if want := filepath.Join(k.keysDir, domain, selector+".private"); filepath.Clean(path) != want {
|
||||
return fmt.Errorf("key table: line %d names %s, not %s", n+1, path, want)
|
||||
}
|
||||
rows[domain] = keyRow{selector, path}
|
||||
domain := strings.ToLower(name)
|
||||
rows[domain] = keyRow{selector, filepath.Join(k.keysDir, domain, selector+".private")}
|
||||
}
|
||||
k.rows, k.stamp = rows, stampOf(fi)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pathToken reports whether v can name a directory or a file of the key
|
||||
// tree: not empty, no separator or white space, not dots alone.
|
||||
func pathToken(v string) bool {
|
||||
return v != "" && !strings.ContainsAny(v, " \t\r\n:/\\") && strings.Trim(v, ".") != ""
|
||||
}
|
||||
|
||||
// readKey reads a PEM private key: PKCS#1 as internal/domain writes it
|
||||
// (dkim.go, writePrivateKeyPEM()), PKCS#8 as well.
|
||||
func readKey(path string) (crypto.Signer, error) {
|
||||
@@ -281,7 +282,7 @@ func watch(signer *dkim.Signer, expired, done chan struct{}) {
|
||||
// with CRLF; and the empty line that ends the block. The names: every header
|
||||
// in signHeaders, in message order, and From once more, so that a From:
|
||||
// added later breaks the signature (RFC 6376 § 5.4.2; OversignHeaders From in
|
||||
// OpenDKIM's configuration until now; go-msgauth v0.7.0, dkim/sign.go,
|
||||
// OpenDKIM's configuration until 2.8.0; go-msgauth v0.7.0, dkim/sign.go,
|
||||
// NewSigner(): a name with no instance left is kept in h= and hashes
|
||||
// nothing).
|
||||
func signLines(headers []field) ([]string, []byte) {
|
||||
@@ -315,7 +316,8 @@ func (s *signSession) BodyChunk(chunk []byte, m *milter.Modifier) (milter.Respon
|
||||
}
|
||||
|
||||
// Body is the end of the message: the signature goes in at the top, where
|
||||
// OpenDKIM put it (opendkim/opendkim.c:3673, dkimf_insheader(ctx, 0, ...)).
|
||||
// OpenDKIM put it until 2.8.0 (OpenDKIM 2.11.0~beta2, opendkim/opendkim.c:3673,
|
||||
// dkimf_insheader(ctx, 0, ...)).
|
||||
// Postfix 3.10.13, src/milter/milter8.c:1445-1465, SMFIR_INSHEADER: the
|
||||
// index is passed on plus one; src/cleanup/cleanup_milter.c:1193,
|
||||
// cleanup_ins_header(): "Index 1 is the top-most header" - above Postfix's
|
||||
|
||||
@@ -18,8 +18,8 @@ import (
|
||||
"github.com/emersion/go-msgauth/dkim"
|
||||
)
|
||||
|
||||
// signFixture is a key directory and KeyTable laid out as internal/domain
|
||||
// writes them (renderTables(), writePrivateKeyPEM(): PKCS#1 PEM) for
|
||||
// signFixture is a key directory and selector table laid out as
|
||||
// internal/domain writes them (renderSelectors(), writePrivateKeyPEM(): PKCS#1 PEM) for
|
||||
// example.org with selector sel, and the DNS record of the key.
|
||||
type signFixture struct {
|
||||
dir, table, keysDir string
|
||||
@@ -35,15 +35,15 @@ func newSignFixture(t *testing.T) signFixture {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyPath := filepath.Join(keysDir, "example.org", "sel.private")
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0o750); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(keyPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0o640); err != nil {
|
||||
if err := os.WriteFile(keyPath, pemBytes, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
table := filepath.Join(dir, "KeyTable")
|
||||
if err := os.WriteFile(table, []byte("example.org example.org:sel:"+keyPath+"\n"), 0o640); err != nil {
|
||||
table := filepath.Join(dir, "selectors")
|
||||
if err := os.WriteFile(table, []byte("example.org sel\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pub, err := x509.MarshalPKIXPublicKey(&key.PublicKey)
|
||||
@@ -247,7 +247,7 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
|
||||
}{
|
||||
{"a login with no domain", "stranger", nil},
|
||||
{"no line for the domain", "app1", func(t *testing.T, f signFixture) {
|
||||
if err := os.WriteFile(f.table, []byte("other.example other.example:sel:"+filepath.Join(f.keysDir, "other.example", "sel.private")+"\n"), 0o640); err != nil {
|
||||
if err := os.WriteFile(f.table, []byte("other.example sel\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
@@ -261,12 +261,23 @@ func TestSignerDefersWhatItCannotSign(t *testing.T) {
|
||||
if err := os.Rename(filepath.Join(f.keysDir, "example.org", "sel.private"), outside); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(f.table, []byte("example.org example.org:sel:"+outside+"\n"), 0o640); err != nil {
|
||||
if err := os.WriteFile(f.table, []byte("example.org ../../sel\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{"a key table that is not one", "app1", func(t *testing.T, f signFixture) {
|
||||
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o640); err != nil {
|
||||
{"a name of dots", "app1", func(t *testing.T, f signFixture) {
|
||||
if err := os.WriteFile(f.table, []byte("example.org sel\n.. sel\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{"a selector table that is not one", "app1", func(t *testing.T, f signFixture) {
|
||||
if err := os.WriteFile(f.table, []byte("example.org\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
{"an old KeyTable line", "app1", func(t *testing.T, f signFixture) {
|
||||
line := "example.org example.org:sel:" + filepath.Join(f.keysDir, "example.org", "sel.private") + "\n"
|
||||
if err := os.WriteFile(f.table, []byte(line), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}},
|
||||
|
||||
+6
-10
@@ -1,15 +1,11 @@
|
||||
# From: lines that name another sender of this server, {other}, beside or in
|
||||
# place of the application's own address at {domain} (review of audit stage
|
||||
# 2). None may be signed with {other}'s key. Since stage 2c the check reads a
|
||||
# line with OpenDKIM's own parser, so it passes those whose address OpenDKIM
|
||||
# reads at {domain} - {other} in a display name, a comment or a quoted local
|
||||
# part - and refuses the rest. The unit test
|
||||
# (internal/milter/sender_test.go) wants every line it passes read at
|
||||
# {domain}; the e2e (test/e2e/dkim_from_test.go) sends each and wants
|
||||
# nothing signed for {other}. {OTHER} is {other} in capitals; {other_qp} is {other} with each
|
||||
# dot escaped as a quoted pair. One Go-quoted string per line. The lines are
|
||||
# composed by hand; that OpenDKIM signs none for {other} is measured, by the
|
||||
# e2e on every run.
|
||||
# 2). None may pass as {other}: the check passes a line only when a mail
|
||||
# program reads it as one address at {domain} - {other} in a quoted display
|
||||
# name or a comment, or inside a quoted local part - and refuses the rest.
|
||||
# The unit test (internal/milter/sender_test.go) wants every line it passes
|
||||
# read so. {OTHER} is {other} in capitals; {other_qp} is {other} with each dot
|
||||
# escaped as a quoted pair. One Go-quoted string per line.
|
||||
"b@{other}"
|
||||
"<b@{other}>"
|
||||
"\"a@{domain}\"@{other}"
|
||||
|
||||
+5
-8
@@ -1,15 +1,12 @@
|
||||
# From: lines the sender check lets through for an application of {domain}
|
||||
# (audit 2026-10-12, stage 2: which key OpenDKIM signs with, seen and not
|
||||
# reasoned). One Go-quoted string per line; {domain} is the application's
|
||||
# domain. Two tests read this file: the check's unit test
|
||||
# (audit 2026-10-12, stage 2). One Go-quoted string per line; {domain} is the
|
||||
# application's domain. Two tests read this file: the check's unit test
|
||||
# (internal/milter/sender_test.go) wants each line passed, and the e2e
|
||||
# (test/e2e/dkim_from_test.go) sends each one and wants the message signed
|
||||
# for {domain} and nothing else. A line added here is run by both. {DOMAIN}
|
||||
# for {domain} - the signer's key is the login's - with a signature that
|
||||
# validates over these headers. A line added here is run by both. {DOMAIN}
|
||||
# is {domain} in capitals; {other} is another sender of the server, which a
|
||||
# line here names in a way the check's scan does not see (a space after the
|
||||
# @) - passed by design, and so signed for {domain} alone.
|
||||
# The lines are composed by hand; what OpenDKIM signs each with is measured,
|
||||
# by the e2e on every run.
|
||||
# line here names inside a quoted display name - passed by design.
|
||||
"a@{domain}"
|
||||
"<a@{domain}>"
|
||||
"\"The Shop\" <Orders@{domain}>"
|
||||
|
||||
-56
@@ -1,56 +0,0 @@
|
||||
/*
|
||||
* Differential harness for internal/milter/mailparse.go: OpenDKIM's own
|
||||
* dkim_mail_parse() (libopendkim/dkim-mailparse.c of the opendkim source the
|
||||
* image runs, compiled in by #include), fed one hex-encoded line per input
|
||||
* line. Output per line: the error, then the local part and the domain, each
|
||||
* "-" when NULL and "=" followed by its bytes in hex otherwise.
|
||||
*
|
||||
* test/upstream/mailparse-diff.sh <opendkim source dir>
|
||||
*
|
||||
* builds it in debian:trixie and runs TestMailParseMatchesOpenDKIM with it.
|
||||
*/
|
||||
#include <sys/types.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include "dkim-mailparse.c"
|
||||
|
||||
static int hexval(int c)
|
||||
{
|
||||
if (c >= '0' && c <= '9') return c - '0';
|
||||
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
|
||||
return -1;
|
||||
}
|
||||
|
||||
static void put(const unsigned char *s)
|
||||
{
|
||||
if (s == NULL) {
|
||||
fputs(" -", stdout);
|
||||
return;
|
||||
}
|
||||
fputs(" =", stdout);
|
||||
for (; *s != '\0'; s++)
|
||||
printf("%02x", *s);
|
||||
}
|
||||
|
||||
int main(void)
|
||||
{
|
||||
static char hex[1 << 20];
|
||||
static unsigned char buf[1 << 19];
|
||||
|
||||
while (fgets(hex, sizeof hex, stdin) != NULL) {
|
||||
size_t n = strcspn(hex, "\r\n"), i;
|
||||
unsigned char *user, *domain;
|
||||
int err;
|
||||
|
||||
for (i = 0; i + 1 < n; i += 2)
|
||||
buf[i / 2] = (unsigned char) (hexval(hex[i]) << 4 | hexval(hex[i + 1]));
|
||||
buf[n / 2] = '\0';
|
||||
err = dkim_mail_parse(buf, &user, &domain);
|
||||
printf("%d", err);
|
||||
put(user);
|
||||
put(domain);
|
||||
putchar('\n');
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ const (
|
||||
// Mailer hands notifications to Postfix by local submission, as
|
||||
// postmaster@<hostname>. They are the panel's own mail: they pass no SASL
|
||||
// login, are not rows of the send log and count against no rate limit.
|
||||
// Postfix signs them through OpenDKIM with the key of the server's own name
|
||||
// The panel's signer signs them with the key of the server's own name
|
||||
// (non_smtpd_milters in build/postfix-config.sh, domain.Service.SetServerName).
|
||||
type Mailer struct {
|
||||
hostname string
|
||||
|
||||
@@ -74,7 +74,7 @@ func Open(path string) (*Store, error) {
|
||||
// keepPrivate makes the database file and its WAL and shared-memory files
|
||||
// the panel's alone (0600). They hold the panel users' password hashes and
|
||||
// the session token hashes, and /data is traversable by every user of the
|
||||
// container (entrypoint.sh) - Postfix and OpenDKIM among them. SQLite
|
||||
// container (entrypoint.sh) - Postfix among them. SQLite
|
||||
// creates a file with 0644 less the umask, and the panel sets no umask; the
|
||||
// -wal and -shm files take the database file's mode when they are created
|
||||
// (SQLite 3.53.4 as modernc.org/sqlite v1.60.1 builds it,
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Package supervisor provides helpers for controlling processes through
|
||||
// supervisord's control socket (architecture.md, security.md).
|
||||
package supervisor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const confPath = "/etc/supervisor/supervisord.conf"
|
||||
|
||||
// Signal sends a signal to a supervised program via `supervisorctl signal`.
|
||||
func Signal(sig, program string) error {
|
||||
cmd := exec.Command("supervisorctl", "-c", confPath, "signal", sig, program)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("supervisor signal %s %s: %w: %s", sig, program, err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
// Package syslogrelay passes what programs in the container send to syslog(3)
|
||||
// on to standard output, where supervisord takes it into the container's log.
|
||||
//
|
||||
// OpenDKIM logs through syslog(3) and nothing else (OpenDKIM 2.11.0~beta2,
|
||||
// opendkim/opendkim.conf.5.in: Syslog, SyslogFacility and SyslogName are the
|
||||
// only logging options; opendkim/opendkim.c calls openlog() and syslog()).
|
||||
// syslog(3) sends each message as a datagram to /dev/log, and in the container
|
||||
// nothing listened there: OpenDKIM's errors - a key it cannot read, a table it
|
||||
// cannot open - went nowhere (audit 2026-10-12, L30). Postfix is not among the
|
||||
// senders: it logs through its own postlogd (build/postfix-config.sh).
|
||||
package syslogrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stamp is the BSD syslog timestamp that follows the priority, "Oct 15
|
||||
// 09:00:00 " (RFC 3164 § 4.1.2: "Mmm dd hh:mm:ss", the day padded with a
|
||||
// space). The container's log has times of its own.
|
||||
var stamp = regexp.MustCompile(`^[A-Z][a-z]{2} [ 0-9][0-9] [0-9]{2}:[0-9]{2}:[0-9]{2} `)
|
||||
|
||||
// Prefix begins every relayed line. The socket takes datagrams from any user
|
||||
// in the container, so a relayed line must not pass for one of the panel's
|
||||
// or supervisord's own (security review of audit stage 5).
|
||||
const Prefix = "syslog: "
|
||||
|
||||
// Line turns one datagram into the line written out: the priority and the
|
||||
// timestamp taken off, trailing newlines too; what remains is "ident[pid]:
|
||||
// message". A severity of error or worse (RFC 3164 § 4.1.1: the priority is
|
||||
// facility*8 + severity, and 0-3 are emergency to error) is said in front. A
|
||||
// control character within it - a newline that would start a line of its own
|
||||
// - is written as \xNN.
|
||||
func Line(datagram []byte) string {
|
||||
b := bytes.TrimRight(datagram, "\r\n\x00")
|
||||
severity := -1
|
||||
if len(b) > 2 && b[0] == '<' {
|
||||
if end := bytes.IndexByte(b, '>'); end > 1 && end <= 4 {
|
||||
pri := 0
|
||||
ok := true
|
||||
for _, c := range b[1:end] {
|
||||
if c < '0' || c > '9' {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
pri = pri*10 + int(c-'0')
|
||||
}
|
||||
if ok {
|
||||
severity = pri % 8
|
||||
b = b[end+1:]
|
||||
}
|
||||
}
|
||||
}
|
||||
if loc := stamp.FindIndex(b); loc != nil {
|
||||
b = b[loc[1]:]
|
||||
}
|
||||
var out bytes.Buffer
|
||||
out.WriteString(Prefix)
|
||||
if severity >= 0 && severity <= 3 {
|
||||
out.WriteString("error: ")
|
||||
}
|
||||
for _, c := range b {
|
||||
if c < 0x20 || c == 0x7f {
|
||||
fmt.Fprintf(&out, `\x%02x`, c)
|
||||
continue
|
||||
}
|
||||
out.WriteByte(c)
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// The relay keeps reading whatever it can write: a datagram is read at once
|
||||
// and queued, and dropped when the queue is full or more than ratePerSecond
|
||||
// came in the last second. A sender to a unix datagram socket waits while the
|
||||
// receiver's queue is full, so a relay that stopped reading would have held
|
||||
// OpenDKIM in syslog(3) - with mail waiting on it (review of audit stage 5) -
|
||||
// and a flood would have filled the container's log.
|
||||
const (
|
||||
queueLen = 1024
|
||||
ratePerSecond = 200
|
||||
)
|
||||
|
||||
// Run listens on a datagram socket at path, which anyone in the container may
|
||||
// write to, and writes each message to w as a line until the socket fails.
|
||||
// /dev/log is a link to path (build/entrypoint.sh). What it drops it says,
|
||||
// once a second at most.
|
||||
func Run(path string, w io.Writer) error {
|
||||
_ = os.Remove(path)
|
||||
conn, err := net.ListenUnixgram("unixgram", &net.UnixAddr{Name: path, Net: "unixgram"})
|
||||
if err != nil {
|
||||
return fmt.Errorf("syslog relay: listen on %s: %w", path, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// OpenDKIM runs as its own user; the socket is the container's local log,
|
||||
// as /dev/log is on a host.
|
||||
if err := os.Chmod(path, 0o666); err != nil {
|
||||
return fmt.Errorf("syslog relay: %w", err)
|
||||
}
|
||||
|
||||
lines := make(chan string, queueLen)
|
||||
go func() {
|
||||
for l := range lines {
|
||||
fmt.Fprintln(w, l)
|
||||
}
|
||||
}()
|
||||
defer close(lines)
|
||||
|
||||
buf := make([]byte, 64<<10)
|
||||
var second time.Time
|
||||
inSecond, dropped := 0, 0
|
||||
for {
|
||||
n, _, err := conn.ReadFromUnix(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("syslog relay: read: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
if now.Sub(second) >= time.Second {
|
||||
if dropped > 0 {
|
||||
select {
|
||||
case lines <- Prefix + "relay: dropped " + strconv.Itoa(dropped) + " lines - more than " + strconv.Itoa(ratePerSecond) + " a second, or the log was not taking them":
|
||||
default:
|
||||
}
|
||||
}
|
||||
second, inSecond, dropped = now, 0, 0
|
||||
}
|
||||
inSecond++
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
if inSecond > ratePerSecond {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case lines <- Line(buf[:n]):
|
||||
default:
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
package syslogrelay
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLine(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"<22>Oct 15 09:00:00 opendkim[45]: 3A2B: DKIM-Signature field added (s=sp, d=example.com)\n": "syslog: opendkim[45]: 3A2B: DKIM-Signature field added (s=sp, d=example.com)",
|
||||
"<19>Oct 5 09:00:00 opendkim[45]: key retrieval failed": "syslog: error: opendkim[45]: key retrieval failed",
|
||||
"<20>opendkim[45]: no timestamp": "syslog: opendkim[45]: no timestamp",
|
||||
"not syslog at all": "syslog: not syslog at all",
|
||||
"<x>Oct 15 09:00:00 odd": "syslog: <x>Oct 15 09:00:00 odd",
|
||||
// A line of its own cannot be made: a newline inside is written out.
|
||||
"<14>opendkim[1]: one\npanel: forged": `syslog: opendkim[1]: one\x0apanel: forged`,
|
||||
} {
|
||||
if got := Line([]byte(in)); got != want {
|
||||
t.Errorf("Line(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncBuffer is a bytes.Buffer the relay and the test may share.
|
||||
type syncBuffer struct {
|
||||
mu sync.Mutex
|
||||
b bytes.Buffer
|
||||
}
|
||||
|
||||
func (s *syncBuffer) Write(p []byte) (int, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.Write(p)
|
||||
}
|
||||
|
||||
func (s *syncBuffer) String() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.b.String()
|
||||
}
|
||||
|
||||
// startRelay runs the relay on a socket of its own and returns a connection
|
||||
// to it.
|
||||
func startRelay(t *testing.T, w *syncBuffer) net.Conn {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("no unixgram sockets on Windows; the container is Linux")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "log")
|
||||
go Run(path, w)
|
||||
var conn net.Conn
|
||||
var err error
|
||||
for i := 0; i < 50; i++ {
|
||||
if conn, err = net.Dial("unixgram", path); err == nil {
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
t.Fatal(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// What a program sends to the socket comes out as a line.
|
||||
func TestRunRelaysADatagram(t *testing.T) {
|
||||
out := &syncBuffer{}
|
||||
conn := startRelay(t, out)
|
||||
if _, err := conn.Write([]byte("<19>Oct 15 09:00:00 opendkim[7]: can't load key")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && !strings.Contains(out.String(), "\n") {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if got := out.String(); got != "syslog: error: opendkim[7]: can't load key\n" {
|
||||
t.Errorf("relayed %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A flood is cut at the rate, and what was dropped is said.
|
||||
func TestRunDropsAFlood(t *testing.T) {
|
||||
out := &syncBuffer{}
|
||||
conn := startRelay(t, out)
|
||||
for i := 0; i < ratePerSecond+100; i++ {
|
||||
if _, err := conn.Write([]byte("<14>flood")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
if _, err := conn.Write([]byte("<14>after")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) && !strings.Contains(out.String(), "syslog: after") {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
got := out.String()
|
||||
if n := strings.Count(got, "syslog: flood\n"); n != ratePerSecond {
|
||||
t.Errorf("%d flood lines written, want %d", n, ratePerSecond)
|
||||
}
|
||||
if !strings.Contains(got, "syslog: relay: dropped 100 lines") {
|
||||
t.Errorf("the drop was not said:\n%s", got)
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,8 @@ type Config struct {
|
||||
TLSCertFile string
|
||||
TLSServedCertFile string
|
||||
TLSReloadInterval time.Duration
|
||||
OpenDKIMSocket string
|
||||
SignSocket string
|
||||
SignLocalSocket string
|
||||
JournalSocket string
|
||||
SenderSocket string
|
||||
// Level-1 Postfix anvil backstop (env RATE_LIMIT_*), shown in the panel
|
||||
|
||||
@@ -56,11 +56,11 @@ func newAppStandAt(t *testing.T, dbPath string) appStand {
|
||||
// The domain page is rendered with the domain's DKIM record and its DNS
|
||||
// state: a real key in a temporary directory, and a resolver nobody
|
||||
// listens on, so every check answers at once without leaving the machine.
|
||||
odk := domain.NewOpenDKIM(t.TempDir())
|
||||
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
|
||||
keys := domain.NewKeys(t.TempDir())
|
||||
if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
|
||||
t.Fatalf("EnsureKey: %v", err)
|
||||
}
|
||||
h.domains = domain.NewService(h.store, odk, h.apps, "mail")
|
||||
h.domains = domain.NewService(h.store, keys, h.apps, "mail")
|
||||
h.dns = offlineDNS
|
||||
h.cfg.RateLimitMessagesPerIP, h.cfg.RateLimitWindowSeconds = 600, 3600
|
||||
var err error
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -81,13 +83,11 @@ func contains(list []string, want string) bool {
|
||||
// and its DMARC record says so.
|
||||
func TestNewDomainHasNoReports(t *testing.T) {
|
||||
s, _ := hostedStand(t)
|
||||
// A second OpenDKIM that reloads nothing: the stand's has no supervisor.
|
||||
odk := domain.NewOpenDKIM(t.TempDir())
|
||||
odk.SetReloadHook(func() error { return nil })
|
||||
if _, err := odk.EnsureKey("example.org", "mail"); err != nil {
|
||||
keys := domain.NewKeys(t.TempDir())
|
||||
if _, err := keys.EnsureKey("example.org", "mail"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
|
||||
s.h.domains = domain.NewService(s.h.store, keys, s.h.apps, "mail")
|
||||
rec := postFormAs(s.h.HandleAddDomain, globalPrincipal, "/outbound/domains", nil, url.Values{"name": {"new.example"}})
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("adding a domain = %d:\n%s", rec.Code, rec.Body.String())
|
||||
@@ -309,11 +309,9 @@ func (failingDMARCMaps) RebuildDMARCMaps(postfix.DMARCMapsConfig) error {
|
||||
// not a plain-text page (audit 2026-10-12, L13).
|
||||
func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
|
||||
s, maps := hostedStand(t)
|
||||
// An OpenDKIM that reloads nothing - the stand has no supervisor - and
|
||||
// a sampler for the Health page a failure is shown on.
|
||||
odk := domain.NewOpenDKIM(t.TempDir())
|
||||
odk.SetReloadHook(func() error { return nil })
|
||||
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
|
||||
// Keys of its own, and a sampler for the Health page a failure is shown
|
||||
// on.
|
||||
s.h.domains = domain.NewService(s.h.store, domain.NewKeys(t.TempDir()), s.h.apps, "mail")
|
||||
s.h.machine = &health.MachineSampler{}
|
||||
rec := postFormAs(s.h.HandleReload, globalPrincipal, "/server/health/reload", nil, url.Values{})
|
||||
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/server/health?reloaded=1" {
|
||||
@@ -335,14 +333,20 @@ func TestReloadWritesTheDMARCMapsAndSaysWhereItFailed(t *testing.T) {
|
||||
// the way to finish, not "internal error" (audit 2026-10-12, L17).
|
||||
func TestDeleteDomainSaysItIsGoneWhenARebuildFails(t *testing.T) {
|
||||
s, _ := hostedStand(t)
|
||||
odk := domain.NewOpenDKIM(t.TempDir())
|
||||
odk.SetReloadHook(func() error { return nil })
|
||||
s.h.domains = domain.NewService(s.h.store, odk, s.h.apps, "mail")
|
||||
dir := t.TempDir()
|
||||
s.h.domains = domain.NewService(s.h.store, domain.NewKeys(dir), s.h.apps, "mail")
|
||||
d, err := s.h.domains.Add("gone.example")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
odk.SetReloadHook(func() error { return errors.New("supervisor away") })
|
||||
// A directory where the selector table goes: it cannot be rewritten.
|
||||
table := filepath.Join(dir, "selectors")
|
||||
if err := os.Remove(table); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(table, "x"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := postFormAs(s.h.HandleDeleteDomain, globalPrincipal, "/outbound/domains/x/delete", map[string]string{"id": strconv.FormatInt(d.ID, 10)}, url.Values{})
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("delete with a failing rebuild = %d", rec.Code)
|
||||
|
||||
@@ -85,7 +85,7 @@ func dashboardFlash(r *http.Request) string {
|
||||
}
|
||||
|
||||
// HandleAddDomain validates the submitted name, creates the domain (DKIM key +
|
||||
// OpenDKIM reload), and redirects to the domain's page so the DNS record to
|
||||
// selector table), and redirects to the domain's page so the DNS record to
|
||||
// publish is shown (product.md).
|
||||
func (h *Handlers) HandleAddDomain(w http.ResponseWriter, r *http.Request) {
|
||||
_, ok := h.requireGlobal(w, r)
|
||||
@@ -171,9 +171,10 @@ func (h *Handlers) HandleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// HandleReload writes again, from the database, every file the panel keeps
|
||||
// for the mail path and has OpenDKIM and Postfix re-read them - what the
|
||||
// first start after a restore does (cmd/panel mailStack.Resync): OpenDKIM's
|
||||
// tables, the sender map, with the inbound relay on its maps and the filter
|
||||
// for the mail path and has Postfix re-read them - what the first start after
|
||||
// a restore does (cmd/panel mailStack.Resync): the DKIM selector table (and
|
||||
// the keys of deleted domains removed), the sender map, with the inbound
|
||||
// relay on its maps and the filter
|
||||
// lists, with DMARC reports on their maps (architecture.md § Panel HTTP
|
||||
// surface). Before 2.5.4 it left the last two out, and said "regenerated"
|
||||
// over them (audit 2026-10-12, L13). A failure is a message on Health that
|
||||
@@ -187,7 +188,7 @@ func (h *Handlers) HandleReload(w http.ResponseWriter, r *http.Request) {
|
||||
run func() error
|
||||
}
|
||||
steps := []step{
|
||||
{"OpenDKIM's tables", h.domains.Resync},
|
||||
{"the DKIM selector table", h.domains.Resync},
|
||||
{"the sender map", h.apps.Resync},
|
||||
}
|
||||
if h.cfg.InboundEnabled && h.inbound != nil {
|
||||
|
||||
@@ -16,17 +16,18 @@ import (
|
||||
// The inbound spam filter's socket is one of the milter sockets on Health and
|
||||
// counts in the Milter sockets card of Overview — and only when the filter is on.
|
||||
|
||||
// socketsServer is a server whose two Postfix milter sockets answer, so that
|
||||
// socketsServer is a server whose Postfix milter sockets answer, so that
|
||||
// every difference in the Milter sockets card comes from the filter.
|
||||
func socketsServer(t *testing.T) *Handlers {
|
||||
t.Helper()
|
||||
h, _ := settingsServer(t)
|
||||
h.machine = &health.MachineSampler{}
|
||||
dir := t.TempDir()
|
||||
h.cfg.OpenDKIMSocket = filepath.Join(dir, "opendkim.sock")
|
||||
h.cfg.SignSocket = filepath.Join(dir, "sign.sock")
|
||||
h.cfg.SignLocalSocket = filepath.Join(dir, "sign-local.sock")
|
||||
h.cfg.JournalSocket = filepath.Join(dir, "journal.sock")
|
||||
h.cfg.SenderSocket = filepath.Join(dir, "sender.sock")
|
||||
for _, path := range []string{h.cfg.OpenDKIMSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
|
||||
for _, path := range []string{h.cfg.SignSocket, h.cfg.SignLocalSocket, h.cfg.SenderSocket, h.cfg.JournalSocket} {
|
||||
l, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
t.Skipf("unix sockets unavailable here: %v", err)
|
||||
@@ -56,24 +57,24 @@ func TestFilterOffAddsNoSocket(t *testing.T) {
|
||||
h := socketsServer(t)
|
||||
h.cfg.InboundEnabled = true
|
||||
h.cfg.InboundAntispamMilter = "" // no filter attached
|
||||
if got := socketsOf(t, h); len(got) != 3 {
|
||||
t.Fatalf("%d sockets with the filter off, want 3: %+v", len(got), got)
|
||||
if got := socketsOf(t, h); len(got) != 4 {
|
||||
t.Fatalf("%d sockets with the filter off, want 4: %+v", len(got), got)
|
||||
}
|
||||
|
||||
// A milter address with the inbound relay off is not a filter either.
|
||||
h.cfg.InboundEnabled = false
|
||||
h.cfg.InboundAntispamMilter = "inet:127.0.0.1:1"
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("%d sockets with the inbound relay off, want 3: %+v", len(got), got)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("%d sockets with the inbound relay off, want 4: %+v", len(got), got)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "3 of 3 answering" || card.Sub != "OpenDKIM, sender-check, send-log" || card.Level != "" {
|
||||
if card.Value != "4 of 4 answering" || card.Sub != "dkim-signer, sender-check, send-log" || card.Level != "" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
||||
func TestFilterOnAnsweringIsTheLastSocket(t *testing.T) {
|
||||
h := socketsServer(t)
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
@@ -84,7 +85,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
||||
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = "inet:"+l.Addr().String(), "tempfail"
|
||||
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 4 || got[3].Name != "Spam filter" || got[3].Note != "inbound" || got[3].Path != "inet:"+l.Addr().String() {
|
||||
if len(got) != 5 || got[4].Name != "Spam filter" || got[4].Note != "inbound" || got[4].Path != "inet:"+l.Addr().String() {
|
||||
t.Fatalf("sockets = %+v", got)
|
||||
}
|
||||
for _, s := range got {
|
||||
@@ -93,7 +94,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
||||
}
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "4 of 4 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||
if card.Value != "5 of 5 answering" || card.Sub != "incl. the spam filter" || card.Level != "" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ func TestFilterOnAnsweringIsTheThirdSocket(t *testing.T) {
|
||||
}
|
||||
|
||||
// A filter that does not answer is a socket that is missing: an error when
|
||||
// Postfix defers inbound mail while it is down (as for OpenDKIM), a warning when
|
||||
// Postfix defers inbound mail while it is down (as for the signer), a warning when
|
||||
// it accepts the mail unfiltered (as for the send log).
|
||||
func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
h := socketsServer(t)
|
||||
@@ -133,10 +134,10 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
} {
|
||||
h.cfg.InboundAntispamMilter, h.cfg.InboundAntispamAction = dead, tt.action
|
||||
got := socketsOf(t, h)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("action %q: %d sockets, want 4", tt.action, len(got))
|
||||
if len(got) != 5 {
|
||||
t.Fatalf("action %q: %d sockets, want 5", tt.action, len(got))
|
||||
}
|
||||
f := got[3]
|
||||
f := got[4]
|
||||
refused := "Connection to " + l.Addr().String() + " refused. " + tt.detail
|
||||
if f.Present || f.Status != tt.status || f.Detail != refused {
|
||||
t.Errorf("action %q: filter = %+v, want status %q, detail %q", tt.action, f, tt.status, refused)
|
||||
@@ -147,7 +148,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
t.Errorf("action %q: SocketStatus = %q, want %q", tt.action, c.SocketStatus, tt.status)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "3 of 4 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
if card.Value != "4 of 5 answering" || card.Level != tt.level || !strings.HasSuffix(card.Sub, "Spam filter") {
|
||||
t.Errorf("action %q: card = %+v", tt.action, card)
|
||||
}
|
||||
|
||||
@@ -161,7 +162,7 @@ func TestFilterDownIsAMissingSocket(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
h.HandleOverviewFragment(rec, reqAs("/overview/fragment", globalPrincipal))
|
||||
page := rec.Body.String()
|
||||
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (3 of 4 answering)`} {
|
||||
for _, want := range []string{`>` + tt.stamp + `<`, `Milter sockets</a> (4 of 5 answering)`} {
|
||||
if !strings.Contains(page, want) {
|
||||
t.Errorf("action %q: Overview is missing %q", tt.action, want)
|
||||
}
|
||||
@@ -201,3 +202,19 @@ func TestFilterAddressIsGlobalOnly(t *testing.T) {
|
||||
func reqAs(path string, p auth.Principal) *http.Request {
|
||||
return auth.RequestWithPrincipal(httptest.NewRequest("GET", path, nil), p)
|
||||
}
|
||||
|
||||
// The signer's two rows are one name on the card, and a row that does not
|
||||
// answer says which of the two it is.
|
||||
func TestSignerSocketsOnTheCard(t *testing.T) {
|
||||
h := socketsServer(t)
|
||||
h.cfg.SignLocalSocket = filepath.Join(t.TempDir(), "gone.sock")
|
||||
got := socketsOf(t, h)
|
||||
if got[0].Name != "dkim-signer" || got[0].Note != "applications" || !got[0].Present ||
|
||||
got[1].Name != "dkim-signer" || got[1].Note != "notifications" || got[1].Present || got[1].Status != health.StatusError {
|
||||
t.Fatalf("sockets = %+v", got)
|
||||
}
|
||||
card := socketCard(t, h)
|
||||
if card.Value != "3 of 4 answering" || card.Sub != "dkim-signer · notifications" || card.Level != "fail" {
|
||||
t.Errorf("card = %+v", card)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func healthReading() healthReadings {
|
||||
c := checksFixture()
|
||||
c.Processes = []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
|
||||
{Name: "panel", State: "RUNNING", Detail: "pid 21", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "FATAL", Detail: "exited too quickly", Status: health.StatusError},
|
||||
}
|
||||
c.Cert = health.Certificate{
|
||||
@@ -44,7 +44,7 @@ func healthReading() healthReadings {
|
||||
Status: health.StatusOK,
|
||||
}
|
||||
c.Sockets = []health.Socket{
|
||||
{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||
{Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", Present: true, Status: health.StatusOK, Detail: "Listening"},
|
||||
{Name: "send-log", Path: "/run/selfpost/journal.sock", Status: health.StatusWarn, Detail: "send-log socket missing: the log stops being written."},
|
||||
}
|
||||
c.SocketStatus = health.StatusWarn
|
||||
@@ -73,7 +73,7 @@ func TestHealthPageRendersEveryCheck(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
out := renderHealth(t, h, healthReading(), "")
|
||||
for _, want := range []string{
|
||||
"opendkim", "fatal", "exited too quickly",
|
||||
"panel", "fatal", "exited too quickly",
|
||||
"mail.example.com", "203.0.113.10 → no PTR record", "2026-11-08 09:14 UTC",
|
||||
`action="/server/health/reload"`, `action="/server/health/recheck"`,
|
||||
`hx-get="/server/health/fragment"`,
|
||||
@@ -208,7 +208,7 @@ func TestHealthFragmentCarriesTheSameRows(t *testing.T) {
|
||||
func TestOverviewPageShowsVerdictsAndDomains(t *testing.T) {
|
||||
h, _ := settingsServer(t)
|
||||
h.machine = &health.MachineSampler{}
|
||||
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost")
|
||||
h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "selfpost")
|
||||
h.cfg.Hostname = "mail.example.org"
|
||||
d, err := h.store.AddDomain("example.org", "mail")
|
||||
if err != nil {
|
||||
|
||||
@@ -153,7 +153,7 @@ func serverMailStand(t *testing.T) (*Handlers, *fakeZone, domain.ServerMail) {
|
||||
h.dns = dnscheck.New([]string{addr})
|
||||
h.cfg.Hostname = notifyHost
|
||||
h.machine = &health.MachineSampler{}
|
||||
h.domains = domain.NewService(h.store, domain.NewOpenDKIM(t.TempDir()), nil, "sp2026")
|
||||
h.domains = domain.NewService(h.store, domain.NewKeys(t.TempDir()), nil, "sp2026")
|
||||
h.domains.SetServerName(notifyHost)
|
||||
mail, err := h.domains.ServerMail()
|
||||
if err != nil {
|
||||
|
||||
@@ -124,7 +124,7 @@ func settingsServerAt(t *testing.T, dbPath string) (*Handlers, string) {
|
||||
// A domain service that does not know the server's own name, so that
|
||||
// the records of Settings and Health come out as "cannot be read";
|
||||
// the tests of those records use serverMailStand.
|
||||
domains: domain.NewService(st, domain.NewOpenDKIM(t.TempDir()), nil, "selfpost"),
|
||||
domains: domain.NewService(st, domain.NewKeys(t.TempDir()), nil, "selfpost"),
|
||||
cfg: Config{SendLogRetentionEnvDefault: 90},
|
||||
}, password
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -346,19 +347,24 @@ func healthCards(c healthReadings) []view.HealthCard {
|
||||
}
|
||||
|
||||
// The line under the count has to fit a card: the sockets that do not
|
||||
// answer when some do not; otherwise their names, or - with the inbound spam
|
||||
// filter among them, when three names no longer fit - that it is included.
|
||||
// answer when some do not; otherwise their names - the signer's two once -
|
||||
// or, with the inbound spam filter among them, when three names no longer
|
||||
// fit, that it is included.
|
||||
answering, socketNames, silent, withFilter := 0, make([]string, 0, len(sockets)), []string{}, false
|
||||
for _, s := range sockets {
|
||||
if s.Present {
|
||||
answering++
|
||||
} else if s.Note != "" && s.Name != filterName {
|
||||
silent = append(silent, s.Name+" · "+s.Note)
|
||||
} else {
|
||||
silent = append(silent, s.Name)
|
||||
}
|
||||
if s.Note != "" {
|
||||
if s.Name == filterName {
|
||||
withFilter = true
|
||||
}
|
||||
socketNames = append(socketNames, s.Name)
|
||||
if !slices.Contains(socketNames, s.Name) {
|
||||
socketNames = append(socketNames, s.Name)
|
||||
}
|
||||
}
|
||||
socketSub := strings.Join(socketNames, ", ")
|
||||
switch {
|
||||
@@ -455,7 +461,8 @@ func (h *Handlers) healthChecks() healthReadings {
|
||||
|
||||
cert := health.CheckServedCertificate(h.cfg.TLSServedCertFile, h.cfg.TLSCertFile, h.cfg.TLSReloadInterval, time.Now())
|
||||
sockets := []health.Socket{
|
||||
health.CheckSocket("OpenDKIM", h.cfg.OpenDKIMSocket, true),
|
||||
signerSocket(h.cfg.SignSocket, "applications"),
|
||||
signerSocket(h.cfg.SignLocalSocket, "notifications"),
|
||||
health.CheckSocket("sender-check", h.cfg.SenderSocket, true),
|
||||
health.CheckSocket("send-log", h.cfg.JournalSocket, false),
|
||||
}
|
||||
@@ -486,15 +493,27 @@ func (h *Handlers) healthChecks() healthReadings {
|
||||
}
|
||||
}
|
||||
|
||||
// filterName is the inbound spam filter's row among the milter sockets.
|
||||
const filterName = "Spam filter"
|
||||
|
||||
// signerSocket is a row of the DKIM signer's: one socket for what
|
||||
// applications send, one for the panel's own notifications. Postfix attaches
|
||||
// both strictly, so either one down defers the mail that goes through it.
|
||||
func signerSocket(path, note string) health.Socket {
|
||||
s := health.CheckSocket("dkim-signer", path, true)
|
||||
s.Note = note
|
||||
return s
|
||||
}
|
||||
|
||||
// filterSocket is the inbound spam filter's row among the milter sockets. A
|
||||
// filter that is down costs what the server's setting says: with the action
|
||||
// tempfail Postfix defers inbound mail (an error, like OpenDKIM), otherwise it
|
||||
// tempfail Postfix defers inbound mail (an error, like the signer), otherwise it
|
||||
// accepts the mail unfiltered (a warning, like the send log). The address is the
|
||||
// one in INBOUND_ANTISPAM_MILTER as configured; Health and Overview, the only
|
||||
// pages that show it, are for the global administrator.
|
||||
func filterSocket(milter, action string) health.Socket {
|
||||
tempfail := action == "tempfail"
|
||||
s := health.CheckMilter("Spam filter", milter, tempfail)
|
||||
s := health.CheckMilter(filterName, milter, tempfail)
|
||||
s.Note = "inbound"
|
||||
switch {
|
||||
case s.Status != health.StatusError && s.Status != health.StatusWarn:
|
||||
@@ -519,7 +538,7 @@ func queueSummary(out string) string {
|
||||
func statusFlash(r *http.Request) string {
|
||||
switch {
|
||||
case r.URL.Query().Get("reloaded") != "":
|
||||
return "Configuration regenerated from the database - OpenDKIM's tables, every map the panel writes for Postfix, the filter lists - and OpenDKIM and Postfix have re-read it."
|
||||
return "Configuration regenerated from the database - the DKIM selector table, every map the panel writes for Postfix, the filter lists - and Postfix has re-read it."
|
||||
case r.URL.Query().Get("rechecked") != "":
|
||||
return "DNS re-checked."
|
||||
default:
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
func checksFixture() healthReadings {
|
||||
return healthReadings{
|
||||
Processes: []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "FATAL", Status: health.StatusError},
|
||||
},
|
||||
ProcessError: false,
|
||||
@@ -24,7 +24,7 @@ func checksFixture() healthReadings {
|
||||
Machine: health.Machine{Status: health.StatusUnknown},
|
||||
Cert: health.Certificate{Subject: "mail.example.org", DaysLeft: 12, Status: health.StatusWarn, Detail: "Expires soon."},
|
||||
Sockets: []health.Socket{
|
||||
{Name: "OpenDKIM", Present: true, Status: health.StatusOK},
|
||||
{Name: "dkim-signer", Present: true, Status: health.StatusOK},
|
||||
{Name: "send-log", Present: false, Status: health.StatusWarn},
|
||||
},
|
||||
SocketStatus: health.StatusWarn,
|
||||
@@ -87,7 +87,7 @@ func TestQueueCardValue(t *testing.T) {
|
||||
func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
|
||||
c := checksFixture()
|
||||
c.Processes = []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix-reload", State: "STOPPED", Status: health.StatusOK, OneShot: true},
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func TestProcessesCardLeavesOutTheIdleOneShot(t *testing.T) {
|
||||
}
|
||||
|
||||
c.Processes = []health.Process{
|
||||
{Name: "opendkim", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "panel", State: "RUNNING", Status: health.StatusOK},
|
||||
{Name: "postfix-reload", State: "FATAL", Status: health.StatusError, OneShot: true},
|
||||
}
|
||||
card = healthCards(c)[1]
|
||||
|
||||
@@ -52,15 +52,15 @@ func isASCIILetterOrDigit(r rune) bool {
|
||||
const maxDomainLen = 253 // RFC 1035 limit on a fully-qualified name
|
||||
|
||||
// NormalizeDomain lower-cases and trims a domain name. Domain names are
|
||||
// case-insensitive, and the generated OpenDKIM tables/keys use the canonical
|
||||
// case-insensitive, and the DKIM selector table and keys use the canonical
|
||||
// lower-case form, so we normalise before both validation and storage.
|
||||
func NormalizeDomain(name string) string {
|
||||
return strings.ToLower(strings.TrimSpace(name))
|
||||
}
|
||||
|
||||
// Domain enforces a strict server-side whitelist for sending-domain names
|
||||
// (security.md). The result is safe to write verbatim into the OpenDKIM
|
||||
// KeyTable/SigningTable and to use as a filesystem path segment: only
|
||||
// (security.md). The result is safe to write verbatim into the DKIM
|
||||
// selector table and to use as a filesystem path segment: only
|
||||
// lower-case letters, digits, '.' and '-' are allowed, in valid DNS label
|
||||
// shape. Input must already be normalised with NormalizeDomain.
|
||||
func Domain(name string) error {
|
||||
@@ -85,7 +85,7 @@ func Domain(name string) error {
|
||||
// Selector checks a DKIM selector: one or more DNS labels as Domain allows
|
||||
// them, at most 63 characters in all. The panel's own selector is fixed in
|
||||
// its configuration; this is for one read from a domain export file, which
|
||||
// goes into OpenDKIM's tables, a key file's name and every signature's s=
|
||||
// goes into the selector table, a key file's name and every signature's s=
|
||||
// (review of audit stage 2).
|
||||
func Selector(s string) error {
|
||||
if s == "" {
|
||||
|
||||
@@ -65,10 +65,10 @@ func admin() Meta { return Meta{User: "admin", IsGlobal: true} }
|
||||
func overviewFixture() *Overview {
|
||||
cards := []HealthCard{
|
||||
{Name: "Machine", Value: "CPU 12%", Sub: "RAM 41%", Icon: "ti-cpu", Href: "/server/health"},
|
||||
{Name: "Processes", Value: "5 of 5 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health"},
|
||||
{Name: "Processes", Value: "4 of 4 running", Sub: "all programs", Icon: "ti-server-cog", Href: "/server/health"},
|
||||
{Name: "TLS certificate", Value: "Expires in 12 days", Sub: "mail.example.org", Icon: "ti-certificate", Href: "/server/health", Level: LevelWarn},
|
||||
{Name: "Queue", Value: "Empty", Sub: "outbound", Icon: "ti-stack-2", Href: "/outbound/queue"},
|
||||
{Name: "Milter sockets", Value: "2 of 2 answering", Sub: "OpenDKIM, send-log", Icon: "ti-plug-connected", Href: "/server/health"},
|
||||
{Name: "Milter sockets", Value: "5 of 5 answering", Sub: "incl. the spam filter", Icon: "ti-plug-connected", Href: "/server/health"},
|
||||
{Name: "Reverse DNS", Value: "Forward = reverse", Sub: "mail.example.org", Icon: "ti-arrows-exchange", Href: "/server/health"},
|
||||
}
|
||||
o := NewOverview(admin(), "mail.example.org", cards, time.Date(2026, 10, 9, 14, 2, 31, 0, time.UTC), 30, true)
|
||||
@@ -87,8 +87,8 @@ func overviewFixture() *Overview {
|
||||
return o
|
||||
}
|
||||
|
||||
// healthFixture is the mockup's Health: a healthy machine and five processes,
|
||||
// a certificate close to expiry, the two milter sockets and a good reverse
|
||||
// healthFixture is the mockup's Health: a healthy machine and four processes,
|
||||
// a certificate close to expiry, the milter sockets and a good reverse
|
||||
// record. refresh renders it as the polled fragment.
|
||||
func healthFixture(refresh bool) *Health {
|
||||
h := NewHealth(admin(), "")
|
||||
@@ -107,7 +107,7 @@ func healthFixture(refresh bool) *Health {
|
||||
{Resource: "Network", Usage: "↓ 18.0 KiB/s · ↑ 44.0 KiB/s", Detail: []string{"eth0: 2.1 GiB in, 6.4 GiB out"}},
|
||||
}
|
||||
h.Processes.End = Verdict("ok")
|
||||
for i, name := range []string{"postfix", "opendkim", "journal-milter", "panel", "dmarc-ingest"} {
|
||||
for i, name := range []string{"postfix", "journal-milter", "panel", "dmarc-ingest"} {
|
||||
h.ProcessRows = append(h.ProcessRows, ProcessRow{Name: name, State: Tag{Status: "ok", Label: "running"},
|
||||
Detail: "pid " + strconv.Itoa(41-i) + ", uptime 18 days"})
|
||||
}
|
||||
@@ -119,7 +119,8 @@ func healthFixture(refresh bool) *Health {
|
||||
h.CertDetail, h.CertProblem = "Expires in 12 day(s). Check that renewal on the host still works.", true
|
||||
h.Sockets.End = Verdict("ok")
|
||||
h.SocketRows = []SocketRow{
|
||||
{Name: "OpenDKIM", Path: "inet:127.0.0.1:8891", State: Tag{Status: "ok"}},
|
||||
{Name: "dkim-signer", Note: "applications", Path: "unix:/run/selfpost/sign.sock", State: Tag{Status: "ok"}},
|
||||
{Name: "dkim-signer", Note: "notifications", Path: "unix:/run/selfpost/sign-local.sock", State: Tag{Status: "ok"}},
|
||||
{Name: "sender-check", Path: "unix:/run/selfpost/sender.sock", State: Tag{Status: "ok"}},
|
||||
{Name: "send-log", Path: "unix:/run/selfpost/journal.sock", State: Tag{Status: "ok"}},
|
||||
}
|
||||
@@ -271,7 +272,7 @@ func outLogFixture() *OutLog {
|
||||
}
|
||||
|
||||
// outMessageFixture is the mockup's message page: a deferred alert, with the
|
||||
// four lines Postfix and OpenDKIM wrote about it.
|
||||
// four lines Postfix wrote about it.
|
||||
func outMessageFixture() *OutMessage {
|
||||
at := time.Date(2026, 9, 21, 13, 58, 7, 0, time.UTC)
|
||||
p := NewOutMessage(admin(), MessageInput{
|
||||
@@ -290,7 +291,7 @@ func outMessageFixture() *OutMessage {
|
||||
return p.WithDeliveryLog([]LogLine{
|
||||
{Time: "13:58:07", Text: "postfix/smtpd[2114]: 4XcB7k2Jm9z1: client=unknown[198.51.100.7], sasl_username=alerts"},
|
||||
{Time: "13:58:07", Text: "postfix/cleanup[2120]: 4XcB7k2Jm9z1: message-id=<a81f0c@alertmanager>"},
|
||||
{Time: "13:58:07", Text: "opendkim[39]: 4XcB7k2Jm9z1: DKIM-Signature field added (s=sp2026, d=example.org)"},
|
||||
{Time: "13:58:07", Text: "postfix/qmgr[2061]: 4XcB7k2Jm9z1: from=<alerts@example.org>, size=2214, nrcpt=1 (queue active)"},
|
||||
{Time: "13:58:09", Level: "warn", Text: "postfix/smtp[2123]: 4XcB7k2Jm9z1: to=<noc@example.org>, relay=mx1.example.org[192.0.2.10]:25, delay=1.9, status=deferred (host mx1.example.org said: 451 4.7.1 Greylisted, try again in 5 minutes)"},
|
||||
}, "")
|
||||
}
|
||||
@@ -389,12 +390,11 @@ func outQueueFixture() *OutQueue {
|
||||
return NewOutQueue(admin(), listing, "").WithRetryPolicy("5 minutes", "about 1 hour 7 minutes", "5 days", false)
|
||||
}
|
||||
|
||||
// systemLogFixture is the mockup's System log: ten lines of mail.log, as the
|
||||
// systemLogFixture is the mockup's System log: nine lines of mail.log, as the
|
||||
// handler reads them (oldest first), with the deferrals in amber and the
|
||||
// refusals and the bounce in red.
|
||||
func systemLogFixture() *SystemLog {
|
||||
return NewSystemLog(admin(), []LogLine{
|
||||
{Time: "Sep 21 13:00:00", Text: "opendkim[39]: key table reloaded (3 domains)"},
|
||||
{Time: "Sep 21 13:40:02", Text: "journal-milter[40]: rate limit: app=prod-server 201/200 in 3600s — tempfail at MAIL FROM", Level: "warn"},
|
||||
{Time: "Sep 21 13:41:19", Text: "postfix/smtp[2098]: 4XcB1c3Rk8z1: to=<j.doe@nonexistent.example>, status=bounced (Host or domain name not found)", Level: "error"},
|
||||
{Time: "Sep 21 13:44:02", Text: "postfix/smtp[2101]: 4XcB2m7Hd4z1: to=<jobs@acme.io>, relay=none, status=deferred (connect to 10.0.4.12[10.0.4.12]:2525: Connection timed out)", Level: "warn"},
|
||||
|
||||
@@ -167,9 +167,9 @@ var outboundTopics = []HelpTopic{
|
||||
}},
|
||||
{ID: HelpSenderCheck, Title: "Sender check", Body: []Text{
|
||||
Rich("An application sends only as itself, and that holds for both senders a message has. The envelope sender is held against the login by Postfix. The ",
|
||||
Code("From:"), " line, the one a mail program shows and the one the DKIM signature is chosen by, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
|
||||
Code("From:"), " line, the one a mail program shows and the one DMARC holds the DKIM signature to, is held against it by the panel, under the same rule: an application set to any address of its domain may write any address of that domain, and one with a list of addresses only those. A message with any other ",
|
||||
Code("From:"), " - another of your domains, the server's own name, an address somewhere else, or none at all - is refused for good with ",
|
||||
Code("550 5.7.1"), ". The line is read the way the signer, OpenDKIM, reads it, so only its address counts: what a display name or a comment around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from getting mail signed with another domain's key.",
|
||||
Code("550 5.7.1"), ". The line must hold one address, read as a mail program reads it: what a display name around it says is not judged, and a line holding more than one address is refused. It is a rejected row of the Outbound log, and its page says which address was refused. This is what keeps one application from sending, under this server's signature, as another domain or another application.",
|
||||
Br(), Br(),
|
||||
"The check is strict. While the panel does not answer, Postfix does not accept mail unchecked: it replies with a temporary error and the application sends again later. Server › Health counts what that costs: for how long the panel did not answer while Postfix itself was running; how many connections Postfix turned away because of it - it refuses from the connection on, before a message is offered, so a client that tries three times is three and a monitor that only connects is one; and how many messages were refused with a temporary error in the middle, when the panel stopped answering after a message had begun. A time marked ≈ includes a start at which supervisord could not say since when Postfix was running: the time since the panel's last sign of life is counted, which may be more than Postfix was up. The card warns when a connection was turned away or a message refused in the last 24 hours. If the numbers stay at nothing, the check is where it should be; if they do not, it belongs in a process of its own."),
|
||||
}},
|
||||
|
||||
@@ -797,6 +797,6 @@ func NewOutDomainDelete(m Meta, id int64, name string, logins []string) *OutDoma
|
||||
item = append(item, Inline{Text: ") with their credentials and sender bindings;"})
|
||||
p.Consequences = append(p.Consequences, item)
|
||||
}
|
||||
p.Consequences = append(p.Consequences, Rich("reload OpenDKIM so the domain is no longer signed."))
|
||||
p.Consequences = append(p.Consequences, Rich("stop signing mail as the domain."))
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -213,7 +213,7 @@ func TestStepLevelAndLogLineLevel(t *testing.T) {
|
||||
"postfix/smtp[1]: Q1: to=<a@b>, status=bounced (no such user)": "error",
|
||||
"postfix/qmgr[1]: Q1: status=expired, returned to sender": "error",
|
||||
"postfix/smtp[1]: warning: TLS library problem": "warn",
|
||||
"opendkim[1]: Q1: DKIM-Signature field added": "",
|
||||
"postfix/qmgr[1]: Q1: from=<a@b>, size=1, nrcpt=1 (queue active)": "",
|
||||
"postfix/cleanup[1]: Q1: milter-reject: END-OF-MESSAGE from unknown[10.77.0.1]: 5.7.1 From: x - no": "error",
|
||||
"postfix/smtpd[1]: NOQUEUE: milter-reject: MAIL from unknown[10.77.0.1]: 451 4.7.1 Service unavailable": "warn",
|
||||
"postfix/smtpd[1]: NOQUEUE: reject: RCPT from unknown[2001:db8::1]: 554 5.7.1 <a@b>: Relay access denied": "error",
|
||||
|
||||
@@ -434,7 +434,7 @@ func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
|
||||
`<h1 class="title is-3">Delete example.org</h1>`, `<div class="box sp-danger-zone">`, `<h2>This cannot be undone</h2>`,
|
||||
`Deleting <strong>example.org</strong> will:`, `permanently delete its DKIM signing key;`,
|
||||
`delete <strong>both applications</strong> (<span class="sp-mono">prod-server</span>, <span class="sp-mono">alerts</span>) with their credentials and sender bindings;`,
|
||||
`reload OpenDKIM so the domain is no longer signed.`, `Inbound domains are not touched.`,
|
||||
`stop signing mail as the domain.`, `Inbound domains are not touched.`,
|
||||
`<form method="post" action="/outbound/domains/1/delete">`, `class="button is-danger"`, `Delete example.org</button>`,
|
||||
`href="/outbound/domains/1">Keep it</a>`,
|
||||
`<h2>Moving it instead?</h2>`, `<a href="/outbound/domains/1/settings">Export the domain</a>`,
|
||||
@@ -443,7 +443,7 @@ func TestOutDomainDeleteNamesWhatGoesWithIt(t *testing.T) {
|
||||
// No applications: nothing to say about them.
|
||||
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", nil))
|
||||
pageLacks(t, "Delete", out, `applications`, `credentials`)
|
||||
pageHas(t, "Delete", out, `permanently delete its DKIM signing key;`, `reload OpenDKIM`)
|
||||
pageHas(t, "Delete", out, `permanently delete its DKIM signing key;`, `stop signing mail as the domain.`)
|
||||
out = renderSignedIn(t, "out-domain-delete", NewOutDomainDelete(admin(), 1, "example.org", []string{"only"}))
|
||||
pageHas(t, "Delete", out, `<strong>its application</strong> (<span class="sp-mono">only</span>)`)
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ func TestHealthShowsEveryCheckAndBothActions(t *testing.T) {
|
||||
`↓ 18.0 KiB/s`, `eth0: 2.1 GiB in, 6.4 GiB out`,
|
||||
`pid 41, uptime 18 days`, `<span class="tag is-success is-light">running</span>`,
|
||||
`2026-10-21 09:14 UTC`, `class="mt-4 has-text-danger"`, `Check that renewal on the host still works.`,
|
||||
`inet:127.0.0.1:8891`, `→ 203.0.113.25<br>← mail.example.org`,
|
||||
`dkim-signer <span class="sp-muted">· notifications</span>`, `unix:/run/selfpost/sign-local.sock`, `→ 203.0.113.25<br>← mail.example.org`,
|
||||
`hx-get="/server/health/fragment"`,
|
||||
)
|
||||
pageLacks(t, "Health", out, `hx-swap-oob`, `/overview/fragment`)
|
||||
@@ -252,8 +252,8 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
|
||||
h.ProcessError = true
|
||||
h.ProcessRows = nil
|
||||
h.CertFacts, h.CertDetail, h.CertProblem = nil, "No certificate path is configured (TLS_CERT_FILE).", true
|
||||
h.SocketRows = []SocketRow{{Name: "OpenDKIM", Path: "/run/opendkim/opendkim.sock", State: Tag{Status: "error"},
|
||||
Detail: "OpenDKIM socket missing: mail cannot leave."}}
|
||||
h.SocketRows = []SocketRow{{Name: "dkim-signer", Note: "applications", Path: "/run/selfpost/sign.sock", State: Tag{Status: "error"},
|
||||
Detail: "dkim-signer socket missing: mail cannot leave."}}
|
||||
h.SocketRows = append(h.SocketRows, SocketRow{Name: "rspamd", Note: "inbound spam filter", Path: "inet:antispam:11332", State: Tag{Status: "ok"}})
|
||||
h.HostFacts = []Fact{{Label: "Hostname", Value: Plain("(SELFPOST_HOSTNAME is not set)"), Mono: true}}
|
||||
h.Flash = &Flash{Text: Plain("DNS re-checked.")}
|
||||
@@ -261,7 +261,7 @@ func TestHealthStatesTheFixtureDoesNotDraw(t *testing.T) {
|
||||
pageHas(t, "Health", out,
|
||||
`Could not ask supervisord for the process list.`,
|
||||
`No certificate path is configured`,
|
||||
`<span class="tag is-danger is-light">error</span>`, `OpenDKIM socket missing: mail cannot leave.`,
|
||||
`<span class="tag is-danger is-light">error</span>`, `dkim-signer socket missing: mail cannot leave.`,
|
||||
`SELFPOST_HOSTNAME is not set`, `rspamd <span class="sp-muted">· inbound spam filter</span>`,
|
||||
`<div class="notification is-success is-light">`, `DNS re-checked.`,
|
||||
)
|
||||
|
||||
@@ -51,7 +51,7 @@ func NewSystemLog(m Meta, lines []LogLine, errText string) *SystemLog {
|
||||
Head: Head{
|
||||
Kicker: "Server",
|
||||
Title: "System log",
|
||||
Lead: Rich("The tail of ", Code("mail.log"), " — Postfix, OpenDKIM and the milters. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."),
|
||||
Lead: Rich("The tail of ", Code("mail.log"), ", the log Postfix writes. For one message, open it from the ", Link(outboundLog, "Outbound log"), " instead."),
|
||||
},
|
||||
Log: Box{No: "01", Title: "Recent lines", ID: "system-log", Poll: serverLog + "/fragment",
|
||||
End: Plain("Last 200 lines · refreshes on its own")},
|
||||
|
||||
@@ -18,7 +18,7 @@ func TestSystemLogShowsNewestFirstAndPollsItsBox(t *testing.T) {
|
||||
`<span class="sp-d">Sep 21 14:01:52</span> postfix/smtp[2131]`,
|
||||
`<span class="sp-w">postfix/smtp[2123]`, `<span class="sp-e">postfix/smtpd[2119]`,
|
||||
)
|
||||
if strings.Index(out, "postfix/smtp[2131]") > strings.Index(out, "opendkim[39]") {
|
||||
if strings.Index(out, "postfix/smtp[2131]") > strings.Index(out, "journal-milter[40]") {
|
||||
t.Error("the log is not shown newest line first")
|
||||
}
|
||||
// What a line says is escaped, not interpreted.
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
{{template "health_mail" .}}
|
||||
{{template "box_open" .Configuration}}
|
||||
{{template "body_open"}}
|
||||
<p class="mb-3">Regenerates the OpenDKIM and Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads both daemons. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
<p class="mb-3">Regenerates the Postfix configuration and the spam filter's lists from the database, removes the keys of deleted domains, and reloads Postfix. Use it after editing the files by hand, after restoring a backup, or when the running configuration looks out of step with the domain and application lists. It does not touch the queue or the TLS certificate.</p>
|
||||
{{template "action" .Reload}}
|
||||
{{template "body_close"}}
|
||||
{{template "box_close"}}
|
||||
|
||||
+11
-8
@@ -66,12 +66,14 @@ type Config struct {
|
||||
TLSCertFile string
|
||||
TLSServedCertFile string
|
||||
TLSReloadInterval time.Duration
|
||||
// OpenDKIMSocket and JournalSocket are the two milter sockets Postfix
|
||||
// connects to. The status page dials them: the first is required for mail
|
||||
// to leave at all (OpenDKIM runs with default_action=tempfail), the second
|
||||
// only for the send log (the journal-milter fails open).
|
||||
OpenDKIMSocket string
|
||||
JournalSocket string
|
||||
// SignSocket and SignLocalSocket are the DKIM signer's two milter
|
||||
// sockets, applications' mail and the panel's own; JournalSocket is the
|
||||
// journal-milter's. The status page dials them: the signer's are required
|
||||
// for mail to leave at all (default_action=tempfail), the journal's only
|
||||
// for the send log (the journal-milter fails open).
|
||||
SignSocket string
|
||||
SignLocalSocket string
|
||||
JournalSocket string
|
||||
// SenderSocket is the sender check's socket, attached strictly.
|
||||
SenderSocket string
|
||||
// SessionIdleDays is the sliding inactivity window after which a login
|
||||
@@ -118,7 +120,7 @@ type Server struct {
|
||||
|
||||
// New builds the panel server. setupTokenPath is where the current setup token
|
||||
// is mirrored on disk (security.md); domains is the sending-domain service
|
||||
// that owns DKIM keys and the OpenDKIM tables (architecture.md § OpenDKIM);
|
||||
// that owns DKIM keys and the selector table (architecture.md § DKIM signing);
|
||||
// apps owns application SASL accounts and the Postfix sender map
|
||||
// (architecture.md § Mail path).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc *inbound.Service, lists *inbound.Lists, dmarcSvc *dmarc.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
@@ -145,7 +147,8 @@ func New(st *store.Store, domains *domain.Service, apps *app.Service, inboundSvc
|
||||
TLSCertFile: cfg.TLSCertFile,
|
||||
TLSServedCertFile: cfg.TLSServedCertFile,
|
||||
TLSReloadInterval: cfg.TLSReloadInterval,
|
||||
OpenDKIMSocket: cfg.OpenDKIMSocket,
|
||||
SignSocket: cfg.SignSocket,
|
||||
SignLocalSocket: cfg.SignLocalSocket,
|
||||
JournalSocket: cfg.JournalSocket,
|
||||
SenderSocket: cfg.SenderSocket,
|
||||
RateLimitMessagesPerIP: cfg.RateLimitMessagesPerIP,
|
||||
|
||||
Reference in New Issue
Block a user