Fix DMARC guidance for send-only relays with optional rua= settings.
test / test (push) Has been cancelled
test / test (push) Has been cancelled
The panel now suggests policy-only DMARC by default, lets operators configure a default and per-domain report address, and DNS-checks hub _report._dmarc records. Future in-panel report ingestion is tracked as dmarc-reports in the roadmap. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
@@ -17,7 +20,13 @@ import (
|
||||
func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
s.renderAccount(w, r, http.StatusOK, "", currentUser(r))
|
||||
admin, err := s.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: account: get admin failed: %v", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.renderAccount(w, r, http.StatusOK, "", admin.Username, admin.DMARCReportEmail)
|
||||
case http.MethodPost:
|
||||
s.submitAccount(w, r)
|
||||
default:
|
||||
@@ -26,16 +35,28 @@ func (s *Server) handleAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// renderAccount draws the settings form. formUsername repopulates the username
|
||||
// field after a rejected submission; the password fields are never repopulated.
|
||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername string) {
|
||||
// renderAccount draws the settings form. formUsername and formDMARCEmail
|
||||
// repopulate fields after a rejected submission; password fields are never
|
||||
// repopulated.
|
||||
func (s *Server) renderAccount(w http.ResponseWriter, r *http.Request, status int, formErr, formUsername, formDMARCEmail string) {
|
||||
var reportAuth dnscheck.Result
|
||||
if hub := dnscheck.EmailDomain(formDMARCEmail); hub != "" {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
reportAuth = s.dns.ReportAuth(ctx, hub)
|
||||
cancel()
|
||||
}
|
||||
s.render(w, status, "account", map[string]any{
|
||||
"Title": "SelfPost — settings",
|
||||
"User": currentUser(r),
|
||||
"Active": "account",
|
||||
"FormUsername": formUsername,
|
||||
"Error": formErr,
|
||||
"Flash": accountFlash(r),
|
||||
"Title": "SelfPost — settings",
|
||||
"User": currentUser(r),
|
||||
"Active": "account",
|
||||
"FormUsername": formUsername,
|
||||
"FormDMARCEmail": formDMARCEmail,
|
||||
"ReportAuthName": dnscheck.ReportAuthRecordName(dnscheck.EmailDomain(formDMARCEmail)),
|
||||
"ReportAuthExample": dnscheck.ReportAuthExample(),
|
||||
"ReportAuthDNS": reportAuth,
|
||||
"ReportAuthHub": dnscheck.EmailDomain(formDMARCEmail),
|
||||
"Error": formErr,
|
||||
"Flash": accountFlash(r),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -49,6 +70,14 @@ func accountFlash(r *http.Request) string {
|
||||
return "Password changed. Any other signed-in sessions were signed out."
|
||||
case "both":
|
||||
return "Username and password changed. Any other signed-in sessions were signed out."
|
||||
case "email":
|
||||
return "DMARC report address updated."
|
||||
case "username-email":
|
||||
return "Username and DMARC report address updated."
|
||||
case "password-email":
|
||||
return "Password and DMARC report address updated. Any other signed-in sessions were signed out."
|
||||
case "all":
|
||||
return "Settings updated. Any other signed-in sessions were signed out."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -62,11 +91,11 @@ func accountFlash(r *http.Request) string {
|
||||
func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.loginLimiter.Allow(clientIP(r, s.trustedProxies)) {
|
||||
s.renderAccount(w, r, http.StatusTooManyRequests,
|
||||
"Too many attempts. Please wait and try again.", currentUser(r))
|
||||
"Too many attempts. Please wait and try again.", currentUser(r), "")
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r))
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "Invalid form submission.", currentUser(r), "")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -74,6 +103,7 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
current := r.PostFormValue("current_password")
|
||||
password := r.PostFormValue("new_password")
|
||||
confirm := r.PostFormValue("new_password_confirm")
|
||||
dmarcEmail := strings.TrimSpace(r.PostFormValue("dmarc_report_email"))
|
||||
|
||||
admin, err := s.store.GetAdmin()
|
||||
if err != nil {
|
||||
@@ -86,34 +116,39 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(current)); err != nil {
|
||||
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username)
|
||||
s.renderAccount(w, r, http.StatusUnauthorized, "Current password is incorrect.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
renaming := username != admin.Username
|
||||
if renaming {
|
||||
if err := validateUsername(username); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// An empty pair of new-password fields means "leave the password alone", so
|
||||
// the username can be changed on its own.
|
||||
if err := validateEmail(dmarcEmail); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
emailChanging := dmarcEmail != admin.DMARCReportEmail
|
||||
|
||||
repassword := password != "" || confirm != ""
|
||||
if repassword {
|
||||
if password != confirm {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username)
|
||||
s.renderAccount(w, r, http.StatusBadRequest, "New passwords do not match.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
if err := validateAdminPassword(password); err != nil {
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username)
|
||||
s.renderAccount(w, r, http.StatusBadRequest, err.Error(), username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
}
|
||||
if !renaming && !repassword {
|
||||
if !renaming && !repassword && !emailChanging {
|
||||
s.renderAccount(w, r, http.StatusBadRequest,
|
||||
"Nothing to change: enter a new username, a new password, or both.", username)
|
||||
"Nothing to change: enter a new username, password, or DMARC report address.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -123,25 +158,22 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
if err != nil {
|
||||
logf("panel: account: hashing password failed: %v", err)
|
||||
s.renderAccount(w, r, http.StatusInternalServerError,
|
||||
"Internal error. Please try again.", username)
|
||||
"Internal error. Please try again.", username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
hash = string(newHash)
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAdmin(username, hash); err != nil {
|
||||
if err := s.store.UpdateAdmin(username, hash, dmarcEmail); err != nil {
|
||||
logf("panel: account: update admin failed: %v", err)
|
||||
msg := "Could not save the changes. Please check the logs and try again."
|
||||
if errors.Is(err, store.ErrNoAdmin) {
|
||||
msg = "There is no administrator account to update."
|
||||
}
|
||||
s.renderAccount(w, r, http.StatusInternalServerError, msg, username)
|
||||
s.renderAccount(w, r, http.StatusInternalServerError, msg, username, dmarcEmail)
|
||||
return
|
||||
}
|
||||
|
||||
// Keep this session usable under the new name, and — when the password
|
||||
// changed — drop every other session so a cookie captured under the old
|
||||
// password stops working.
|
||||
if token, ok := s.sessionToken(r); ok {
|
||||
if renaming {
|
||||
s.sessions.Rename(token, username)
|
||||
@@ -151,18 +183,25 @@ func (s *Server) submitAccount(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
logf("panel: administrator account updated (username changed: %t, password changed: %t)", renaming, repassword)
|
||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword), http.StatusSeeOther)
|
||||
logf("panel: administrator account updated (username: %t, password: %t, dmarc email: %t)", renaming, repassword, emailChanging)
|
||||
http.Redirect(w, r, "/account?updated="+updatedFlag(renaming, repassword, emailChanging), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// updatedFlag names what changed, for the fixed post-redirect flash message.
|
||||
func updatedFlag(renamed, repassword bool) string {
|
||||
func updatedFlag(renamed, repassword, emailChanged bool) string {
|
||||
switch {
|
||||
case renamed && repassword && emailChanged:
|
||||
return "all"
|
||||
case renamed && emailChanged:
|
||||
return "username-email"
|
||||
case repassword && emailChanged:
|
||||
return "password-email"
|
||||
case renamed && repassword:
|
||||
return "both"
|
||||
case renamed:
|
||||
return "username"
|
||||
default:
|
||||
case repassword:
|
||||
return "password"
|
||||
default:
|
||||
return "email"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,35 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
// What DNS actually publishes for the domain today, checked against the key
|
||||
// this server signs with. Cached by the checker, so re-rendering the page
|
||||
// after a form post costs nothing.
|
||||
dns, srv := s.domainDNS(d, record, false)
|
||||
admin, err := s.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: domain %d: get admin: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
profileEmail := admin.DMARCReportEmail
|
||||
reportEmail := dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail)
|
||||
dns, srv := s.domainDNS(d, record, profileEmail, false)
|
||||
reportAuthName, reportAuthValue, needsReportAuth := dnscheck.ExternalReportAuth(d.Name, reportEmail)
|
||||
dmarcMode := "inherit"
|
||||
dmarcCustom := ""
|
||||
if d.DMARCRua.Valid {
|
||||
if d.DMARCRua.String == "" {
|
||||
dmarcMode = "none"
|
||||
} else {
|
||||
dmarcMode = "custom"
|
||||
dmarcCustom = d.DMARCRua.String
|
||||
}
|
||||
}
|
||||
dmarcSource := "policy"
|
||||
switch {
|
||||
case dmarcMode == "custom":
|
||||
dmarcSource = "custom"
|
||||
case dmarcMode == "none":
|
||||
dmarcSource = "none"
|
||||
case profileEmail != "":
|
||||
dmarcSource = "settings"
|
||||
}
|
||||
|
||||
s.render(w, status, "domain_detail", map[string]any{
|
||||
"Title": "SelfPost — " + d.Name,
|
||||
@@ -118,7 +146,16 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
// page and the checks below it never recommend different records.
|
||||
"SPFExample": dnscheck.SPFExample(s.cfg.Hostname, srv.IPs),
|
||||
"DMARCName": dnscheck.DMARCRecordName(d.Name),
|
||||
"DMARCExample": dnscheck.DMARCExample(d.Name),
|
||||
"DMARCExample": dnscheck.DMARCExample(reportEmail),
|
||||
"DMARCSource": dmarcSource,
|
||||
"ProfileDMARCEmail": profileEmail,
|
||||
"ResolvedDMARCEmail": reportEmail,
|
||||
"DMARCRuaMode": dmarcMode,
|
||||
"DMARCRuaCustom": dmarcCustom,
|
||||
"ReportAuthName": reportAuthName,
|
||||
"ReportAuthValue": reportAuthValue,
|
||||
"NeedsReportAuth": needsReportAuth,
|
||||
"SameDomainRUA": reportEmail != "" && strings.EqualFold(dnscheck.EmailDomain(reportEmail), d.Name),
|
||||
// Client connection settings (the same for every domain on this
|
||||
// instance): the hostname clients connect to, and whether the optional
|
||||
// submission listener is enabled in this deployment.
|
||||
@@ -149,14 +186,15 @@ func (s *Server) renderDomainDetail(w http.ResponseWriter, r *http.Request, stat
|
||||
// and no extra environment variable is needed. That server result is returned
|
||||
// alongside, because the page's suggested SPF record is built from the same
|
||||
// addresses. force bypasses the cache, for the Re-check button.
|
||||
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
func (s *Server) domainDNS(d store.Domain, record domain.DKIMRecord, profileEmail string, force bool) (dnscheck.Domain, dnscheck.Server) {
|
||||
srv := s.dns.Server(s.cfg.Hostname, false)
|
||||
return s.dns.Domain(dnscheck.Query{
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: record.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
Name: d.Name,
|
||||
Selector: d.DKIMSelector,
|
||||
ExpectedDKIM: record.Value,
|
||||
Hostname: srv.Hostname,
|
||||
ServerIPs: srv.IPs,
|
||||
DMARCReportEmail: dnscheck.ResolveDMARCRua(d.DMARCRua, profileEmail),
|
||||
}, force), srv
|
||||
}
|
||||
|
||||
@@ -173,7 +211,13 @@ func (s *Server) handleDomainDNSRecheck(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.domainDNS(d, record, true)
|
||||
admin, err := s.store.GetAdmin()
|
||||
if err != nil {
|
||||
logf("panel: domain %d: get admin: %v", d.ID, err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.domainDNS(d, record, admin.DMARCReportEmail, true)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?rechecked=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
@@ -205,6 +249,8 @@ func detailFlash(r *http.Request) string {
|
||||
return "Application address mode updated."
|
||||
case r.URL.Query().Get("ratelimit") != "":
|
||||
return "Rate limit updated."
|
||||
case r.URL.Query().Get("dmarc") != "":
|
||||
return "DMARC report settings updated."
|
||||
case r.URL.Query().Get("imported") != "":
|
||||
return "Domain imported. Its DKIM DNS record is unchanged — no DNS update is needed."
|
||||
case r.URL.Query().Get("rechecked") != "":
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
)
|
||||
|
||||
// handleDomainDMARC saves per-domain DMARC rua= settings.
|
||||
func (s *Server) handleDomainDMARC(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", "POST")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
d, ok := s.lookupDomain(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseForm(); err != nil {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Invalid form submission."})
|
||||
return
|
||||
}
|
||||
|
||||
var rua sql.NullString
|
||||
switch strings.TrimSpace(r.PostFormValue("dmarc_rua_mode")) {
|
||||
case "inherit":
|
||||
rua = sql.NullString{}
|
||||
case "none":
|
||||
rua = sql.NullString{Valid: true, String: ""}
|
||||
case "custom":
|
||||
email := strings.TrimSpace(r.PostFormValue("dmarc_rua_email"))
|
||||
if err := validateEmail(email); err != nil {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: err.Error()})
|
||||
return
|
||||
}
|
||||
if email == "" {
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Enter a custom report address or choose another mode."})
|
||||
return
|
||||
}
|
||||
rua = sql.NullString{Valid: true, String: email}
|
||||
default:
|
||||
s.renderDomainDetail(w, r, http.StatusBadRequest, d, detailView{FormErr: "Choose how aggregate reports are addressed for this domain."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.store.UpdateDomainDMARCRua(d.ID, rua); err != nil {
|
||||
logf("panel: domain %d: save dmarc rua: %v", d.ID, err)
|
||||
s.renderDomainDetail(w, r, http.StatusInternalServerError, d, detailView{FormErr: "Could not save DMARC settings. Please check the logs and try again."})
|
||||
return
|
||||
}
|
||||
s.dns.Forget(d.Name)
|
||||
http.Redirect(w, r, fmt.Sprintf("/domains/%d?dmarc=1", d.ID), http.StatusSeeOther)
|
||||
}
|
||||
@@ -54,6 +54,10 @@ func (s *Server) renderDashboard(w http.ResponseWriter, r *http.Request, status
|
||||
// so a repeat view of the list costs no lookups at all, and it is the same
|
||||
// cache the domain page fills — opening a domain after the list is free.
|
||||
func (s *Server) domainRows(domains []store.Domain) []domainRow {
|
||||
profileEmail := ""
|
||||
if admin, err := s.store.GetAdmin(); err == nil {
|
||||
profileEmail = admin.DMARCReportEmail
|
||||
}
|
||||
rows := make([]domainRow, len(domains))
|
||||
var wg sync.WaitGroup
|
||||
for i, d := range domains {
|
||||
@@ -69,7 +73,7 @@ func (s *Server) domainRows(domains []store.Domain) []domainRow {
|
||||
logf("panel: dashboard: domain %d: dkim record: %v", d.ID, err)
|
||||
return
|
||||
}
|
||||
dns, _ := s.domainDNS(d, record, false)
|
||||
dns, _ := s.domainDNS(d, record, profileEmail, false)
|
||||
rows[i].DNS = dns.Overall
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -23,10 +23,46 @@
|
||||
<label for="new_password_confirm">Confirm new password</label>
|
||||
<input id="new_password_confirm" name="new_password_confirm" type="password" autocomplete="new-password">
|
||||
|
||||
<h3>DMARC aggregate reports</h3>
|
||||
<p class="muted">Optional default <code>rua=</code> address for every sending
|
||||
domain (can be overridden per domain). Use a mailbox on a domain that
|
||||
receives inbound mail. SelfPost is send-only today; a future release will
|
||||
be able to receive reports in the panel itself.</p>
|
||||
|
||||
<label for="dmarc_report_email">Default report address</label>
|
||||
<input id="dmarc_report_email" name="dmarc_report_email" type="email"
|
||||
autocomplete="email" autocapitalize="none" spellcheck="false"
|
||||
value="{{.FormDMARCEmail}}" placeholder="reports@your-mail-domain.com">
|
||||
|
||||
{{if .FormDMARCEmail}}
|
||||
<p class="muted">When <code>rua=</code> points at another domain, that hub
|
||||
domain must publish a report-authorisation record so receivers will deliver
|
||||
the XML aggregates.</p>
|
||||
|
||||
<label>Report authorization — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Report authorization — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
{{if .ReportAuthDNS.Status}}
|
||||
<label>Report authorization DNS <span class="st st-{{.ReportAuthDNS.Status}}">{{.ReportAuthDNS.Status}}</span></label>
|
||||
<p class="{{if eq .ReportAuthDNS.Status "ok"}}muted{{else}}error{{end}}">{{.ReportAuthDNS.Detail}}</p>
|
||||
{{if .ReportAuthDNS.Records}}<span class="code">{{range .ReportAuthDNS.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
<button type="submit">Save changes</button>
|
||||
</form>
|
||||
<p class="muted">Leave both new-password fields empty to change the username
|
||||
only. Changing the password signs out every other session; this one stays
|
||||
signed in.</p>
|
||||
or DMARC address only. Changing the password signs out every other session;
|
||||
this one stays signed in.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
@@ -51,8 +51,10 @@
|
||||
<h2>SPF and DMARC records</h2>
|
||||
<p class="muted">These two are not generated the way the DKIM record above is —
|
||||
they are policy, and the domain may already publish an SPF record for other
|
||||
senders. What follows is what this server expects to see and what the checks
|
||||
below look for. Publish both as TXT records.</p>
|
||||
senders. SelfPost is a send-only relay — most operators have no inbox on the
|
||||
sending domain, so the suggested DMARC record omits <code>rua=</code> unless a
|
||||
report address is configured below or in <a href="/account">Settings</a>.
|
||||
Publish both as TXT records.</p>
|
||||
|
||||
<label>SPF — host / name</label>
|
||||
<div class="code-row">
|
||||
@@ -68,9 +70,23 @@
|
||||
|
||||
<p class="muted">A domain may have only one SPF record. If it already has one,
|
||||
do not add a second — add this server's mechanism to the existing record
|
||||
instead, before its <code>all</code> term. <code>-all</code> at the end means
|
||||
nothing else may send as the domain; use <code>~all</code> while other senders
|
||||
are still being sorted out.</p>
|
||||
instead, before its <code>all</code> term.</p>
|
||||
|
||||
<form method="post" action="/domains/{{.Domain.ID}}/dmarc">
|
||||
<label for="dmarc_rua_mode">DMARC aggregate reports (rua=)</label>
|
||||
<select id="dmarc_rua_mode" name="dmarc_rua_mode">
|
||||
<option value="inherit"{{if eq .DMARCRuaMode "inherit"}} selected{{end}}>Same as Settings{{if .ProfileDMARCEmail}} ({{.ProfileDMARCEmail}}){{end}}</option>
|
||||
<option value="none"{{if eq .DMARCRuaMode "none"}} selected{{end}}>No aggregate reports</option>
|
||||
<option value="custom"{{if eq .DMARCRuaMode "custom"}} selected{{end}}>Custom address</option>
|
||||
</select>
|
||||
|
||||
<label for="dmarc_rua_email">Custom report address</label>
|
||||
<input id="dmarc_rua_email" name="dmarc_rua_email" type="email"
|
||||
autocapitalize="none" spellcheck="false" value="{{.DMARCRuaCustom}}"
|
||||
placeholder="reports@your-mail-domain.com">
|
||||
|
||||
<button type="submit">Save DMARC report settings</button>
|
||||
</form>
|
||||
|
||||
<label>DMARC — host / name</label>
|
||||
<div class="code-row">
|
||||
@@ -78,17 +94,36 @@
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>DMARC — value</label>
|
||||
<label>DMARC — value{{if eq .DMARCSource "settings"}} <span class="muted">(from Settings)</span>{{else if eq .DMARCSource "custom"}} <span class="muted">(custom)</span>{{else if eq .DMARCSource "none"}} <span class="muted">(no reports)</span>{{end}}</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.DMARCExample}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<p class="muted">Point <code>rua=</code> at a mailbox that is actually read —
|
||||
the aggregate reports are how you find out who else sends as the domain.
|
||||
<code>p=none</code> changes nothing about delivery, so it is safe to publish
|
||||
straight away; tighten it to <code>p=quarantine</code> and then
|
||||
<code>p=reject</code> once the reports come back clean.</p>
|
||||
{{if .SameDomainRUA}}
|
||||
<p class="error">The report address is on this sending domain. SelfPost does
|
||||
not receive inbound mail — use a mailbox elsewhere or wait for in-panel report
|
||||
reception in a future release.</p>
|
||||
{{end}}
|
||||
|
||||
{{if .NeedsReportAuth}}
|
||||
<label>Report authorization — host / name</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthName}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
|
||||
<label>Report authorization — value</label>
|
||||
<div class="code-row">
|
||||
<span class="code">{{.ReportAuthValue}}</span>
|
||||
<button type="button" class="copy">Copy</button>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="muted"><code>p=none</code> changes nothing about delivery. Omit
|
||||
<code>rua=</code> on a send-only domain, or point it at a mailbox that
|
||||
receives inbound mail. Tighten to <code>p=quarantine</code> and then
|
||||
<code>p=reject</code> once aggregate reports look clean.</p>
|
||||
</div>
|
||||
|
||||
<div class="card" id="dns-status">
|
||||
@@ -116,6 +151,13 @@
|
||||
{{if .DNS.DMARC.Records}}<span class="code">{{range .DNS.DMARC.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
|
||||
{{if .DNS.DMARCReportAuth.Status}}
|
||||
<label>Report authorization <span class="st st-{{.DNS.DMARCReportAuth.Status}}">{{.DNS.DMARCReportAuth.Status}}</span></label>
|
||||
<p class="{{if eq .DNS.DMARCReportAuth.Status "ok"}}muted{{else}}error{{end}}">{{.DNS.DMARCReportAuth.Detail}}</p>
|
||||
{{if .DNS.DMARCReportAuth.Records}}<span class="code">{{range .DNS.DMARCReportAuth.Records}}{{.}}
|
||||
{{end}}</span>{{end}}
|
||||
{{end}}
|
||||
|
||||
<form class="inline" method="post" action="/domains/{{.Domain.ID}}/dns-recheck">
|
||||
<button type="submit">Re-check</button>
|
||||
</form>
|
||||
|
||||
@@ -105,3 +105,47 @@ func validateDomainLabel(label string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// freemailDomains lists public mail hosts that cannot publish _report._dmarc
|
||||
// authorisation for third-party sending domains.
|
||||
var freemailDomains = map[string]struct{}{
|
||||
"gmail.com": {},
|
||||
"googlemail.com": {},
|
||||
"outlook.com": {},
|
||||
"hotmail.com": {},
|
||||
"live.com": {},
|
||||
"yahoo.com": {},
|
||||
"icloud.com": {},
|
||||
"me.com": {},
|
||||
"proton.me": {},
|
||||
"protonmail.com": {},
|
||||
}
|
||||
|
||||
// validateEmail checks a DMARC rua= mailbox. Empty is allowed (policy-only).
|
||||
func validateEmail(addr string) error {
|
||||
addr = strings.TrimSpace(addr)
|
||||
if addr == "" {
|
||||
return nil
|
||||
}
|
||||
at := strings.LastIndex(addr, "@")
|
||||
if at <= 0 || at >= len(addr)-1 {
|
||||
return fmt.Errorf("enter a valid email address")
|
||||
}
|
||||
local := addr[:at]
|
||||
domain := normalizeDomain(addr[at+1:])
|
||||
if err := validateDomain(domain); err != nil {
|
||||
return fmt.Errorf("email domain is invalid: %w", err)
|
||||
}
|
||||
for _, r := range local {
|
||||
if r > unicode.MaxASCII {
|
||||
return fmt.Errorf("email address must be ASCII")
|
||||
}
|
||||
if !isASCIILetterOrDigit(r) && r != '.' && r != '-' && r != '_' && r != '+' {
|
||||
return fmt.Errorf("email address contains invalid characters")
|
||||
}
|
||||
}
|
||||
if _, blocked := freemailDomains[domain]; blocked {
|
||||
return fmt.Errorf("use an address on a domain you control; public mail hosts cannot receive authorised DMARC reports")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package web
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestValidateEmail(t *testing.T) {
|
||||
if err := validateEmail(""); err != nil {
|
||||
t.Errorf("empty: %v", err)
|
||||
}
|
||||
if err := validateEmail("reports@mail.example.com"); err != nil {
|
||||
t.Errorf("valid: %v", err)
|
||||
}
|
||||
if err := validateEmail("bad"); err == nil {
|
||||
t.Error("bad address accepted")
|
||||
}
|
||||
if err := validateEmail("x@gmail.com"); err == nil {
|
||||
t.Error("gmail accepted")
|
||||
}
|
||||
}
|
||||
+16
-14
@@ -1,4 +1,4 @@
|
||||
// Package web implements the SelfPost control panel's HTTP surface: the
|
||||
// Package web implements the SelfPost control panel's HTTP surface: the
|
||||
// one-time administrator setup flow (security.md), login/session handling
|
||||
// (security.md) and the authenticated shell the later phases build on.
|
||||
package web
|
||||
@@ -24,7 +24,7 @@ var assetsFS embed.FS
|
||||
// Config holds the panel's HTTP-facing configuration.
|
||||
type Config struct {
|
||||
// Hostname is the server's external hostname, used to build the absolute
|
||||
// setup link shown in the logs (security.md; guide § Environment
|
||||
// setup link shown in the logs (security.md; guide В§ Environment
|
||||
// variables for SELFPOST_HOSTNAME).
|
||||
Hostname string
|
||||
// CookieSecure sets the Secure attribute on the session cookie. It defaults
|
||||
@@ -33,16 +33,16 @@ type Config struct {
|
||||
CookieSecure bool
|
||||
// SubmissionEnabled mirrors SUBMISSION_ENABLE: whether this deployment also
|
||||
// runs the 587/STARTTLS submission listener next to the primary 465 one
|
||||
// (architecture.md § Mail path). The panel only reports it on the domain
|
||||
// (architecture.md В§ Mail path). The panel only reports it on the domain
|
||||
// page's connection settings; it is a deploy-time flag, not something the
|
||||
// panel can verify.
|
||||
SubmissionEnabled bool
|
||||
// MailLogPath is where Postfix's delivery log lives, read by the mail.log
|
||||
// monitoring view (architecture.md § Panel HTTP surface). It is the same path
|
||||
// monitoring view (architecture.md В§ Panel HTTP surface). It is the same path
|
||||
// the log-tailer role follows in cmd/panel.
|
||||
MailLogPath string
|
||||
// DataDir and DBPath locate the persistent state a full backup archives
|
||||
// (architecture.md § Persistence); Version is stamped into the backup
|
||||
// (architecture.md В§ Persistence); Version is stamped into the backup
|
||||
// manifest. They mirror the panel's own configuration.
|
||||
DataDir string
|
||||
DBPath string
|
||||
@@ -53,7 +53,7 @@ type Config struct {
|
||||
// honoured, so the header can't be spoofed by anyone but a trusted proxy.
|
||||
// Empty (the default) keeps rate-limiting keyed on RemoteAddr only.
|
||||
TrustedProxyCIDRs []*net.IPNet
|
||||
// TLSCertFile is the certificate Postfix serves on 465/587 (guide §
|
||||
// TLSCertFile is the certificate Postfix serves on 465/587 (guide В§
|
||||
// Environment variables), read read-only by the status page to report how
|
||||
// much validity is left.
|
||||
TLSCertFile string
|
||||
@@ -69,8 +69,8 @@ type Config struct {
|
||||
SessionIdleDays int
|
||||
// DNSResolvers are the recursive resolvers the deliverability checks query
|
||||
// (env SELFPOST_DNS_RESOLVERS). Empty uses dnscheck.DefaultResolvers. The
|
||||
// checks must not go through the system resolver — see dnscheck's
|
||||
// externalResolver — so this is how a closed network points them at its own.
|
||||
// checks must not go through the system resolver — see dnscheck's
|
||||
// externalResolver — so this is how a closed network points them at its own.
|
||||
DNSResolvers []string
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ 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 OpenDKIM tables (architecture.md В§ OpenDKIM);
|
||||
// apps owns application SASL accounts and the Postfix sender map
|
||||
// (architecture.md § Mail path).
|
||||
// (architecture.md В§ Mail path).
|
||||
func New(st *store.Store, domains *domain.Service, apps *app.Service, cfg Config, setupTokenPath string) (*Server, error) {
|
||||
tmpl, err := loadTemplates()
|
||||
if err != nil {
|
||||
@@ -183,6 +183,7 @@ func (s *Server) Handler() http.Handler {
|
||||
authed.HandleFunc("POST /domains/{id}/delete", s.handleDeleteDomain)
|
||||
authed.HandleFunc("POST /domains/{id}/applications", s.handleAddApplication)
|
||||
authed.HandleFunc("POST /domains/{id}/ratelimit", s.handleDomainRateLimit)
|
||||
authed.HandleFunc("POST /domains/{id}/dmarc", s.handleDomainDMARC)
|
||||
authed.HandleFunc("POST /domains/{id}/export", s.handleExportDomain)
|
||||
authed.HandleFunc("POST /applications/{aid}/mode", s.handleUpdateAppMode)
|
||||
authed.HandleFunc("POST /applications/{aid}/password", s.handleRegenPassword)
|
||||
@@ -193,13 +194,13 @@ func (s *Server) Handler() http.Handler {
|
||||
// Administrator's own panel credentials.
|
||||
authed.HandleFunc("/account", s.handleAccount)
|
||||
|
||||
// Backup and migration: the page with both actions (architecture.md §
|
||||
// Backup and migration: the page with both actions (architecture.md В§
|
||||
// Persistence-B), and the full-server backup download itself.
|
||||
authed.HandleFunc("GET /backup", s.handleBackupPage)
|
||||
authed.HandleFunc("POST /backup", s.handleBackup)
|
||||
|
||||
// Monitoring screens (architecture.md § Panel HTTP surface): each page and
|
||||
// its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows
|
||||
// Monitoring screens (architecture.md В§ Panel HTTP surface): each page and
|
||||
// its HTMX polling fragment (architecture.md § Panel HTTP surface — the /rows
|
||||
// and /body endpoints return HTML, not JSON).
|
||||
authed.HandleFunc("GET /deliveries", s.handleDeliveries)
|
||||
authed.HandleFunc("GET /deliveries/rows", s.handleDeliveriesRows)
|
||||
@@ -234,7 +235,7 @@ func handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
|
||||
// clientIP extracts the peer IP for rate-limiting. By default it is the
|
||||
// transport peer (RemoteAddr), which cannot be spoofed. If RemoteAddr matches
|
||||
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
||||
// one of trustedProxies, the last entry of X-Forwarded-For is used instead —
|
||||
// that is the address the trusted proxy itself appended, so a client can't
|
||||
// forge it by sending its own XFF header. With no trusted
|
||||
// proxies configured, behind a reverse proxy this is the proxy's own address,
|
||||
@@ -272,3 +273,4 @@ func ipInAny(ip net.IP, nets []*net.IPNet) bool {
|
||||
func logf(format string, args ...any) {
|
||||
log.Printf(format, args...)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user