diff --git a/.env.production.example b/.env.production.example index 5698db7..4157295 100644 --- a/.env.production.example +++ b/.env.production.example @@ -6,6 +6,8 @@ # secret in a published repository is not a secret. # # 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 ------------------------------------------------------- # 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= # --- 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_USER=lambdamail -POSTGRES_PASSWORD=CHANGE-generate-with-openssl-rand -REDIS_PASSWORD=CHANGE-generate-with-openssl-rand +POSTGRES_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24 +REDIS_PASSWORD=CHANGE-generate-with-openssl-rand-hex-24 # Signs webmail and admin sessions. Minimum 32 characters; the auth service # refuses to start below that. JWT_SECRET=CHANGE-generate-with-openssl-rand-at-least-32-chars diff --git a/README.md b/README.md index e42ce48..d904bb2 100644 --- a/README.md +++ b/README.md @@ -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 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 ```bash diff --git a/services/protocols/internal/application/usecase/provision_dkim_keys.go b/services/protocols/internal/application/usecase/provision_dkim_keys.go index 9c6dad8..991ab16 100644 --- a/services/protocols/internal/application/usecase/provision_dkim_keys.go +++ b/services/protocols/internal/application/usecase/provision_dkim_keys.go @@ -3,6 +3,7 @@ package usecase import ( "context" "fmt" + "log" ) // 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) } + // 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) return stored, nil } diff --git a/services/protocols/internal/domain/entity/dns_record.go b/services/protocols/internal/domain/entity/dns_record.go index cc9ccdf..7f02bf7 100644 --- a/services/protocols/internal/domain/entity/dns_record.go +++ b/services/protocols/internal/domain/entity/dns_record.go @@ -77,22 +77,6 @@ func BuildDnsRecordSpecs(spec DnsRecordSpec) []DnsRecord { TTL: 1, 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 { 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 if serverIPv6 != "" { records = append(records, DnsRecord{ diff --git a/services/protocols/internal/domain/entity/dns_record_test.go b/services/protocols/internal/domain/entity/dns_record_test.go index 25b1427..c8b940d 100644 --- a/services/protocols/internal/domain/entity/dns_record_test.go +++ b/services/protocols/internal/domain/entity/dns_record_test.go @@ -1,6 +1,7 @@ package entity import ( + "strings" "testing" ) @@ -68,3 +69,66 @@ func TestBuildDnsRecordSpecs_GeneratesMandatoryRecords(t *testing.T) { 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) + } + } +}