#27: never publish a DKIM record with an empty key, and say why one is missing
A fresh deployment published "v=DKIM1; k=rsa; p=" for its own domain. An empty p= is not an absent key, it is a revoked one (RFC 6376 section 3.6.1): every verifier reading it treats all signatures from that selector as invalid. On a domain whose DMARC policy is p=quarantine with strict alignment - which this reconciler generates - that quarantines all of its own outbound mail. Worse than publishing nothing. The chain: DkimRepository.Insert is an INSERT ... SELECT conditional on the domain existing in the domains table, so on an empty database it inserts no rows and returns no error. FindPublicKey then finds nothing, ensure() returns an empty string with no error, and the reconciler faithfully published the empty value. Every field along that path was behaving as written; nothing checked that the result meant anything. Two changes: - The record builder omits a DKIM record when its key is empty, so the revocation cannot be published regardless of why the key is missing. - Provisioning logs when it produced no key, naming the likely cause. The insert being conditional on an onboarded domain is exactly the case a new operator hits, and "no key, domain probably not onboarded" is the difference between a diagnosis and unsigned mail with no explanation. It stays non-fatal: erroring here would abort the whole reconcile and leave a new install with no A, MX or SPF records either. Found while migrating to a new host: the fresh database had no domain row, so the first reconcile revoked the domain's DKIM. The two published records have been deleted from the zone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
7c3a2d165a
commit
de16efe874
5 files changed
+170
-18
No files matched your search
+14
-2
@@ -6,6 +6,8 @@
|
|||||||
# secret in a published repository is not a secret.
|
# secret in a published repository is not a secret.
|
||||||
#
|
#
|
||||||
# Generate each secret with: openssl rand -base64 36
|
# Generate each secret with: openssl rand -base64 36
|
||||||
|
# except POSTGRES_PASSWORD and REDIS_PASSWORD, which go inside connection URIs
|
||||||
|
# and must be URL-safe - see the note above them.
|
||||||
|
|
||||||
# --- Container images -------------------------------------------------------
|
# --- Container images -------------------------------------------------------
|
||||||
# Published by .github/workflows/release.yml for linux/amd64 and linux/arm64.
|
# Published by .github/workflows/release.yml for linux/amd64 and linux/arm64.
|
||||||
@@ -26,10 +28,20 @@ PUBLIC_IPV4=203.0.113.10 # CHANGE - the VPS public IPv4
|
|||||||
PUBLIC_IPV6=
|
PUBLIC_IPV6=
|
||||||
|
|
||||||
# --- Secrets ----------------------------------------------------------------
|
# --- Secrets ----------------------------------------------------------------
|
||||||
|
# These two are substituted into connection URIs - postgres://user:PASS@db/... and
|
||||||
|
# redis://:PASS@redis:6379 - so they must contain no character that means
|
||||||
|
# something in a URL. Use hex:
|
||||||
|
#
|
||||||
|
# openssl rand -hex 24
|
||||||
|
#
|
||||||
|
# NOT `openssl rand -base64`, whose alphabet includes "/" and "+". A "/" ends
|
||||||
|
# the authority component, so the host is parsed as part of the password and
|
||||||
|
# every connection fails authentication - which surfaces as the migrations
|
||||||
|
# container exiting 1 while Postgres itself reports healthy.
|
||||||
POSTGRES_DB=lambdamail
|
POSTGRES_DB=lambdamail
|
||||||
POSTGRES_USER=lambdamail
|
POSTGRES_USER=lambdamail
|
||||||
POSTGRES_PASSWORD=CHANGE-generate-with-openssl-rand
|
POSTGRES_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24
|
||||||
REDIS_PASSWORD=CHANGE-generate-with-openssl-rand
|
REDIS_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24
|
||||||
# Signs webmail and admin sessions. Minimum 32 characters; the auth service
|
# Signs webmail and admin sessions. Minimum 32 characters; the auth service
|
||||||
# refuses to start below that.
|
# refuses to start below that.
|
||||||
JWT_SECRET=CHANGE-generate-with-openssl-rand-at-least-32-chars
|
JWT_SECRET=CHANGE-generate-with-openssl-rand-at-least-32-chars
|
||||||
|
|||||||
@@ -70,6 +70,58 @@ make create-admin EMAIL=you@example.com PASSWORD='...'
|
|||||||
`make create-admin` is not optional: a fresh install has no mailbox, and every
|
`make create-admin` is not optional: a fresh install has no mailbox, and every
|
||||||
route into the console needs one.
|
route into the console needs one.
|
||||||
|
|
||||||
|
### Ports
|
||||||
|
|
||||||
|
Everything below is TCP. "Inbound" is what the host's firewall and the
|
||||||
|
provider's security group must allow; "outbound" is what the host must be able
|
||||||
|
to reach.
|
||||||
|
|
||||||
|
| Port | Direction | Who connects | Required? | Notes |
|
||||||
|
| ---: | :--- | :--- | :--- | :--- |
|
||||||
|
| 25 | in | Any sending MTA | **Yes** to receive mail | Must be open to the world. Rate-limited, not authenticated — that is how SMTP works. |
|
||||||
|
| 25 | **out** | This host → remote MTAs | **Yes** to send mail | The one most providers block. Without it, set a relay (`RELAY_HOST`); see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). |
|
||||||
|
| 80 | in | Let's Encrypt, browsers | **Yes** | ACME HTTP-01 challenge, and the redirect to HTTPS. Closing it means no certificate. |
|
||||||
|
| 443 | in | Browsers | **Yes** | Webmail, admin console, MTA-STS policy, autodiscover. |
|
||||||
|
| 587 | in | Mail clients | Recommended | Submission with STARTTLS. The port clients should use. |
|
||||||
|
| 465 | in | Mail clients | Optional | Submission with implicit TLS, for clients that insist. |
|
||||||
|
| 143 | in | Mail clients | Optional | IMAP with STARTTLS. Prefer 993. |
|
||||||
|
| 993 | in | Mail clients | Recommended | IMAPS. |
|
||||||
|
| 110 / 995 | in | Mail clients | Optional | POP3 / POP3S. Leave closed unless something needs POP3. |
|
||||||
|
| 4190 | in | Mail clients | Optional | ManageSieve, for editing filters from a desktop client. |
|
||||||
|
| 53 | out | This host → resolver | **Yes** | MX, SPF, DKIM, DANE and MTA-STS lookups. UDP **and** TCP — DNSSEC answers overflow UDP. |
|
||||||
|
| 443 | out | This host → APIs | If used | Cloudflare DNS automation, MTA-STS policy fetch, ClamAV signature updates. |
|
||||||
|
|
||||||
|
Ports 110, 995 and 4190 are published by the compose file but are worth closing
|
||||||
|
at the firewall unless a client actually needs them — every open port is one
|
||||||
|
more thing to keep patched.
|
||||||
|
|
||||||
|
### What the host has to allow
|
||||||
|
|
||||||
|
- **Reverse DNS (PTR)** for the public IPv4 must resolve to `PRIMARY_MAIL_HOST`,
|
||||||
|
and that name must resolve back to the same address. Set by whoever owns the
|
||||||
|
IP block — your provider's panel, never Cloudflare. Gmail and Outlook
|
||||||
|
spam-folder or refuse mail without it. If the host has a public IPv6 and
|
||||||
|
publishes an AAAA record, it needs a PTR too: senders that connect over IPv6
|
||||||
|
are judged on the IPv6 reverse.
|
||||||
|
- **Outbound port 25 unblocked.** Ask the provider; a timeout rather than a
|
||||||
|
refusal when connecting to a remote MX is the signature of a block.
|
||||||
|
- **Docker** with the compose plugin, and a user in the `docker` group or root.
|
||||||
|
- **Ports below 1024** are bound by Docker's proxy on the host, so the daemon
|
||||||
|
needs the privilege — the default on a normal install. The containers
|
||||||
|
themselves run unprivileged.
|
||||||
|
- **The proxy's certificate directory** must be readable by the `protocols`
|
||||||
|
container. Under `TLS_MODE=traefik` it reads the certificate the proxy
|
||||||
|
obtained, from the directory given by `COOLIFY_PROXY_DIR`, mounted read-only.
|
||||||
|
The images run as a non-root user, so an `acme.json` left at mode `600` and
|
||||||
|
owned by root cannot be read, and the mail listeners fall back to a
|
||||||
|
self-signed certificate while HTTPS keeps working — a confusing failure that
|
||||||
|
looks like a certificate problem and is a permissions problem.
|
||||||
|
- **Disk** for the mail spool (`protocols_spool`) and Postgres. Mail is stored
|
||||||
|
once and reference-counted, but plan for growth.
|
||||||
|
- **A firewall that does not rate-limit port 25 into uselessness.** Some
|
||||||
|
providers apply aggressive SYN limits that look like intermittent delivery
|
||||||
|
failures.
|
||||||
|
|
||||||
## Running locally
|
## Running locally
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package usecase
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DkimKeyProvisioner is the storage side of key provisioning. It is declared
|
// DkimKeyProvisioner is the storage side of key provisioning. It is declared
|
||||||
@@ -95,6 +96,17 @@ func (uc *ProvisionDkimKeysUseCase) ensure(ctx context.Context, domainName, algo
|
|||||||
return "", fmt.Errorf("confirm %s key for %s: %w", algorithm, domainName, err)
|
return "", fmt.Errorf("confirm %s key for %s: %w", algorithm, domainName, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The insert is conditional on the domain existing, and inserting nothing
|
||||||
|
// is not an error at the SQL level - so a domain that has never been
|
||||||
|
// onboarded silently produces no key here. Saying so is the difference
|
||||||
|
// between an operator seeing "DKIM is not set up yet, onboard the domain"
|
||||||
|
// and seeing unsigned mail with no explanation.
|
||||||
|
if stored == "" {
|
||||||
|
log.Printf("dkim: no %s key for %s after provisioning - the domain is probably not onboarded yet, so mail from it will not be signed",
|
||||||
|
algorithm, domainName)
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
out.Created = append(out.Created, algorithm)
|
out.Created = append(out.Created, algorithm)
|
||||||
return stored, nil
|
return stored, nil
|
||||||
}
|
}
|
||||||
@@ -77,22 +77,6 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
|
|||||||
TTL: 1,
|
TTL: 1,
|
||||||
Comment: "LambdaMail SPF Record",
|
Comment: "LambdaMail SPF Record",
|
||||||
},
|
},
|
||||||
// 5. RSA DKIM TXT record
|
|
||||||
{
|
|
||||||
Type: "TXT",
|
|
||||||
Name: fmt.Sprintf("default._domainkey.%s", domainName),
|
|
||||||
Value: fmt.Sprintf("v=DKIM1; k=rsa; p=%s", rsaDkimPubKey),
|
|
||||||
TTL: 1,
|
|
||||||
Comment: "LambdaMail RSA DKIM Key",
|
|
||||||
},
|
|
||||||
// 6. Ed25519 DKIM TXT record
|
|
||||||
{
|
|
||||||
Type: "TXT",
|
|
||||||
Name: fmt.Sprintf("default-ed._domainkey.%s", domainName),
|
|
||||||
Value: fmt.Sprintf("v=DKIM1; k=ed25519; p=%s", edDkimPubKey),
|
|
||||||
TTL: 1,
|
|
||||||
Comment: "LambdaMail Ed25519 DKIM Key",
|
|
||||||
},
|
|
||||||
// 7. DMARC TXT record
|
// 7. DMARC TXT record
|
||||||
{
|
{
|
||||||
Type: "TXT",
|
Type: "TXT",
|
||||||
@@ -173,6 +157,34 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 5 and 6. DKIM records, published only once there is a key to put in them.
|
||||||
|
//
|
||||||
|
// "p=" with nothing after it is not an absent key, it is a revoked one
|
||||||
|
// (RFC 6376 section 3.6.1): a verifier reading it treats every signature
|
||||||
|
// from that selector as broken. These used to be emitted unconditionally,
|
||||||
|
// so a deployment that had not provisioned keys yet - a fresh database, or
|
||||||
|
// a missing master key - published a revocation for its own domain. That is
|
||||||
|
// strictly worse than publishing nothing, and on a domain with a strict
|
||||||
|
// DMARC policy it quarantines all of its own outbound mail.
|
||||||
|
if rsaDkimPubKey != "" {
|
||||||
|
records = append(records, DnsRecord{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("default._domainkey.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=DKIM1; k=rsa; p=%s", rsaDkimPubKey),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail RSA DKIM Key",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if edDkimPubKey != "" {
|
||||||
|
records = append(records, DnsRecord{
|
||||||
|
Type: "TXT",
|
||||||
|
Name: fmt.Sprintf("default-ed._domainkey.%s", domainName),
|
||||||
|
Value: fmt.Sprintf("v=DKIM1; k=ed25519; p=%s", edDkimPubKey),
|
||||||
|
TTL: 1,
|
||||||
|
Comment: "LambdaMail Ed25519 DKIM Key",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 2. Conditional AAAA record if IPv6 is supplied
|
// 2. Conditional AAAA record if IPv6 is supplied
|
||||||
if serverIPv6 != "" {
|
if serverIPv6 != "" {
|
||||||
records = append(records, DnsRecord{
|
records = append(records, DnsRecord{
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package entity
|
package entity
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,3 +69,66 @@ func TestBuildDnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) {
|
|||||||
t.Error("DMARC record not found in specs")
|
t.Error("DMARC record not found in specs")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An empty "p=" is a revocation, not an absent key (RFC 6376 section 3.6.1).
|
||||||
|
// Publishing one for a domain that simply has not provisioned keys yet tells
|
||||||
|
// every verifier that the selector's signatures are invalid - and on a domain
|
||||||
|
// whose DMARC policy is strict, that quarantines all of its own outbound mail.
|
||||||
|
func TestDkimRecordsAreOmittedWhenThereIsNoKey(t *testing.T) {
|
||||||
|
records := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||||
|
DomainName: "example.test",
|
||||||
|
MailHost: "mail.example.test",
|
||||||
|
ServerIPv4: "198.51.100.10",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range records {
|
||||||
|
if strings.Contains(r.Name, "_domainkey") {
|
||||||
|
t.Errorf("published a DKIM record with no key: %s = %q", r.Name, r.Value)
|
||||||
|
}
|
||||||
|
if strings.Contains(r.Value, "p=\"") || strings.HasSuffix(r.Value, "p=") {
|
||||||
|
t.Errorf("published an empty public key: %s = %q", r.Name, r.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDkimRecordsArePublishedWhenKeysExist(t *testing.T) {
|
||||||
|
records := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||||
|
DomainName: "example.test",
|
||||||
|
MailHost: "mail.example.test",
|
||||||
|
ServerIPv4: "198.51.100.10",
|
||||||
|
RsaDkimPubKey: "MIIBIjANBgkq",
|
||||||
|
EdDkimPubKey: "11qYAYKxCrfV",
|
||||||
|
})
|
||||||
|
|
||||||
|
var rsa, ed bool
|
||||||
|
for _, r := range records {
|
||||||
|
if r.Name == "default._domainkey.example.test" && strings.Contains(r.Value, "MIIBIjANBgkq") {
|
||||||
|
rsa = true
|
||||||
|
}
|
||||||
|
if r.Name == "default-ed._domainkey.example.test" && strings.Contains(r.Value, "11qYAYKxCrfV") {
|
||||||
|
ed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !rsa {
|
||||||
|
t.Error("the RSA DKIM record was not published despite a key being present")
|
||||||
|
}
|
||||||
|
if !ed {
|
||||||
|
t.Error("the Ed25519 DKIM record was not published despite a key being present")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One key present and the other absent must publish exactly one record: an
|
||||||
|
// Ed25519 rollout should not revoke the RSA selector that is still signing.
|
||||||
|
func TestOnlyTheAvailableDkimKeyIsPublished(t *testing.T) {
|
||||||
|
records := BuildDnsRecordSpecs(DnsRecordSpec{
|
||||||
|
DomainName: "example.test",
|
||||||
|
MailHost: "mail.example.test",
|
||||||
|
RsaDkimPubKey: "MIIBIjANBgkq",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, r := range records {
|
||||||
|
if strings.Contains(r.Name, "default-ed._domainkey") {
|
||||||
|
t.Errorf("published an Ed25519 record with no key: %q", r.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user