Stage 1, step 1. BREAKING: a 1.x database is not migrated.
Schema
- internal/store/migrations/ holds one file, 0001_init.sql, equal to the
2.0 schema; 0002..0010 are deleted (they stay in history at 1.9.5). No
admin table created and dropped, no ALTERs.
- users.email replaces users.dmarc_report_email: the address is the
user's own. The default DMARC report address is a user's setting
(dmarc_default_mode hosted/account/custom/none, dmarc_default_address);
the instance setting that mirrored the global user's field is gone.
- domains.dmarc_rua_user_id: a domain follows a named user's default, or
has an address of its own in dmarc_rua ('' = none). ON DELETE SET NULL
leaves it at "no reports", never at someone else's default.
- user_inbound_domains, users.all_domains, users.all_inbound_domains: in
the schema for the inbound delegation of the next step.
- users.role is 'global' / 'domain' (was 'domain_admin');
rate_limits.allowed_ips, unused since 1.9.0, is dropped.
Opening a database
- The baseline sets PRAGMA application_id ("SP20"). store.Open refuses a
file that has a schema version without it (ErrForeignSchema) and one
newer than the build. A 1.x file at user_version 10 would otherwise read
as already migrated. The refused file is left untouched.
Code
- Store.DomainDMARCRua is the one place a domain's report address is
resolved; dnscheck.ResolveDMARCRua and Store.GlobalDMARCReportEmail are
gone. dmarc.AllowedRecipients, the domain page, the dashboard and the
DNS re-check go through it.
- The old templates keep working on the new model until their pages are
restyled: the Settings form's address field is the account e-mail and
switches the user's default between it and none; a domain's "Same as
Settings" makes it follow the user who chose it; a new or imported
domain follows its creator.
- Domain export/import: a domain that follows a user exports no address.
Tests: the baseline pinned table by table (TestBaselineSchema), refusal of a
1.x and of a newer database, constraints and cascades of the new columns and
table, resolution in every default mode, the deleted-user case, and the three
bridged handlers.
docs/schema-migrations.md is rewritten for the baseline; roadmap 11/27,
schema-squash marked built.
go test ./... (Windows)
ok for every package except two that fail the same way before this commit:
internal/domain TestWriteLoadPrivateKeyRoundtrip (file mode on NTFS)
internal/logtail TestFollowTailsAndRotates (rename of an open file)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
82 lines
3.0 KiB
Go
82 lines
3.0 KiB
Go
package dnscheck
|
|
|
|
import (
|
|
"net"
|
|
"strings"
|
|
)
|
|
|
|
// SPF and DMARC are the two records SelfPost cannot generate for the operator:
|
|
// unlike the DKIM record they are policy, not a key, and a domain may already
|
|
// publish one for other senders. The panel still has to say what "correct"
|
|
// looks like, and the checks below have to suggest the same thing when a record
|
|
// is missing — so both take their example from here rather than each spelling
|
|
// out its own.
|
|
|
|
// DMARCRecordName is the name a DMARC record is published at. (SPF has no such
|
|
// helper: it is published at the domain itself.)
|
|
func DMARCRecordName(domainName string) string { return "_dmarc." + domainName }
|
|
|
|
// ReportAuthRecordName is where a report-receiving domain authorises external
|
|
// DMARC aggregate destinations (RFC 7489 §7.1).
|
|
func ReportAuthRecordName(hubDomain string) string { return "_report._dmarc." + hubDomain }
|
|
|
|
// ReportAuthExample is the TXT value a hub domain publishes to accept reports.
|
|
func ReportAuthExample() string { return "v=DMARC1;" }
|
|
|
|
// SPFExample is the SPF record this server expects for a sending domain: the
|
|
// addresses its mail actually leaves from, and "-all" to say that nothing else
|
|
// is authorised. When the server's own addresses are not known (its hostname
|
|
// does not resolve) it falls back to an "a:" mechanism naming the host, so the
|
|
// panel always has something concrete to show.
|
|
func SPFExample(hostname string, serverIPs []string) string {
|
|
var mechanisms []string
|
|
for _, s := range serverIPs {
|
|
ip := net.ParseIP(strings.TrimSpace(s))
|
|
switch {
|
|
case ip == nil:
|
|
continue
|
|
case ip.To4() != nil:
|
|
mechanisms = append(mechanisms, "ip4:"+ip.String())
|
|
default:
|
|
mechanisms = append(mechanisms, "ip6:"+ip.String())
|
|
}
|
|
}
|
|
if len(mechanisms) == 0 {
|
|
mechanisms = []string{"a:" + hostname}
|
|
}
|
|
return "v=spf1 " + strings.Join(mechanisms, " ") + " -all"
|
|
}
|
|
|
|
// EmailDomain returns the lower-case domain part of addr, or "" when invalid.
|
|
func EmailDomain(addr string) string {
|
|
addr = strings.TrimSpace(addr)
|
|
at := strings.LastIndex(addr, "@")
|
|
if at < 0 || at == len(addr)-1 {
|
|
return ""
|
|
}
|
|
return strings.ToLower(strings.TrimSpace(addr[at+1:]))
|
|
}
|
|
|
|
// DMARCExample is the DMARC TXT record this server suggests for a sending
|
|
// domain. p=none is deliberate — it changes nothing about delivery. rua= is
|
|
// included only when reportEmail is set; SelfPost is send-only and most
|
|
// operators have no inbox on the sending domain itself.
|
|
func DMARCExample(reportEmail string) string {
|
|
base := "v=DMARC1; p=none"
|
|
if reportEmail == "" {
|
|
return base
|
|
}
|
|
return base + "; rua=mailto:" + reportEmail
|
|
}
|
|
|
|
// ExternalReportAuth reports whether the hub domain must publish a
|
|
// _report._dmarc authorisation for aggregate reports sent to reportEmail from
|
|
// sendingDomain.
|
|
func ExternalReportAuth(sendingDomain, reportEmail string) (name, value string, ok bool) {
|
|
hub := EmailDomain(reportEmail)
|
|
if hub == "" || strings.EqualFold(hub, sendingDomain) {
|
|
return "", "", false
|
|
}
|
|
return ReportAuthRecordName(hub), ReportAuthExample(), true
|
|
}
|