Receive DMARC aggregate reports on port 25 and show parsed summaries in the panel. Close Unreleased; pin compose and docs to 1.7.0. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultLocalPart is the mailbox local-part for the panel-wide hosted address.
|
||||
const DefaultLocalPart = "dmarc-reports"
|
||||
|
||||
// HostedReportAddress is the per-domain SelfPost-hosted rua= destination.
|
||||
func HostedReportAddress(hostname, domain string) string {
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
domain = strings.ToLower(strings.TrimSpace(domain))
|
||||
return DefaultLocalPart + "+" + domain + "@" + hostname
|
||||
}
|
||||
|
||||
// DefaultHostedReportAddress is the settings-level hosted rua= when ingest is on.
|
||||
func DefaultHostedReportAddress(hostname string) string {
|
||||
return DefaultLocalPart + "@" + strings.ToLower(strings.TrimSpace(hostname))
|
||||
}
|
||||
|
||||
// IsHostedOnHostname reports whether addr is delivered locally on hostname.
|
||||
func IsHostedOnHostname(addr, hostname string) bool {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
hostname = strings.ToLower(strings.TrimSpace(hostname))
|
||||
if hostname == "" || !strings.Contains(addr, "@") {
|
||||
return false
|
||||
}
|
||||
at := strings.LastIndex(addr, "@")
|
||||
return addr[at+1:] == hostname
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"mime/quotedprintable"
|
||||
"net/mail"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// IngestMessage parses a raw RFC 5322 message from r and stores any DMARC
|
||||
// aggregate attachment it finds.
|
||||
func IngestMessage(st *store.Store, r io.Reader, recipient string, receivedAt time.Time) error {
|
||||
raw, err := io.ReadAll(io.LimitReader(r, 12<<20))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read message: %w", err)
|
||||
}
|
||||
payload, err := extractAggregatePayload(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed, err := ParseAggregate(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rep := store.DMARCReport{
|
||||
Domain: parsed.Domain,
|
||||
Reporter: parsed.Reporter,
|
||||
ReportID: parsed.ReportID,
|
||||
PeriodBegin: parsed.PeriodBegin,
|
||||
PeriodEnd: parsed.PeriodEnd,
|
||||
ReceivedAt: receivedAt.UTC(),
|
||||
ContactEmail: parsed.ContactEmail,
|
||||
PolicyP: parsed.PolicyP,
|
||||
PolicySP: parsed.PolicySP,
|
||||
PolicyPct: parsed.PolicyPct,
|
||||
PolicyADKIM: parsed.PolicyADKIM,
|
||||
PolicyASPF: parsed.PolicyASPF,
|
||||
PassCount: parsed.PassCount,
|
||||
FailCount: parsed.FailCount,
|
||||
Recipient: strings.ToLower(strings.TrimSpace(recipient)),
|
||||
}
|
||||
for _, rec := range parsed.Records {
|
||||
rep.Records = append(rep.Records, store.DMARCReportRecord{
|
||||
SourceIP: rec.SourceIP,
|
||||
Count: rec.Count,
|
||||
Disposition: rec.Disposition,
|
||||
SPFResult: rec.SPFResult,
|
||||
DKIMResult: rec.DKIMResult,
|
||||
HeaderFrom: rec.HeaderFrom,
|
||||
})
|
||||
}
|
||||
if _, err := st.InsertDMARCReport(rep); err != nil {
|
||||
return err
|
||||
}
|
||||
return st.PruneDMARCReports()
|
||||
}
|
||||
|
||||
func extractAggregatePayload(raw []byte) ([]byte, error) {
|
||||
msg, err := mail.ReadMessage(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse message: %w", err)
|
||||
}
|
||||
ct := msg.Header.Get("Content-Type")
|
||||
if ct == "" {
|
||||
return io.ReadAll(io.LimitReader(msg.Body, 8<<20))
|
||||
}
|
||||
mediaType, params, err := mime.ParseMediaType(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("content-type: %w", err)
|
||||
}
|
||||
if strings.HasPrefix(mediaType, "multipart/") {
|
||||
boundary := params["boundary"]
|
||||
if boundary == "" {
|
||||
return nil, fmt.Errorf("multipart without boundary")
|
||||
}
|
||||
mr := multipart.NewReader(msg.Body, boundary)
|
||||
for {
|
||||
part, err := mr.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("multipart: %w", err)
|
||||
}
|
||||
name := strings.ToLower(part.FileName())
|
||||
if name == "" {
|
||||
name = strings.ToLower(part.Header.Get("Content-Type"))
|
||||
}
|
||||
if !looksLikeAggregate(name) {
|
||||
continue
|
||||
}
|
||||
data, err := readEncodedBody(textproto.MIMEHeader(part.Header), part)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(data) > 0 {
|
||||
return data, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no aggregate attachment found")
|
||||
}
|
||||
return readEncodedBody(textproto.MIMEHeader(msg.Header), msg.Body)
|
||||
}
|
||||
|
||||
func looksLikeAggregate(name string) bool {
|
||||
name = strings.ToLower(name)
|
||||
return strings.Contains(name, "xml") || strings.Contains(name, "gzip") || strings.Contains(name, "zip")
|
||||
}
|
||||
|
||||
func readEncodedBody(hdr textproto.MIMEHeader, r io.Reader) ([]byte, error) {
|
||||
encoding := strings.ToLower(hdr.Get("Content-Transfer-Encoding"))
|
||||
var body io.Reader = r
|
||||
switch encoding {
|
||||
case "base64":
|
||||
body = base64.NewDecoder(base64.StdEncoding, r)
|
||||
case "quoted-printable":
|
||||
body = quotedprintable.NewReader(r)
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(body, 8<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read part body: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParsedReport is the structured form of a DMARC aggregate XML payload.
|
||||
type ParsedReport struct {
|
||||
Reporter string
|
||||
ReportID string
|
||||
ContactEmail string
|
||||
PeriodBegin time.Time
|
||||
PeriodEnd time.Time
|
||||
Domain string
|
||||
PolicyP string
|
||||
PolicySP string
|
||||
PolicyPct int
|
||||
PolicyADKIM string
|
||||
PolicyASPF string
|
||||
Records []ParsedRecord
|
||||
PassCount int
|
||||
FailCount int
|
||||
}
|
||||
|
||||
// ParsedRecord is one <record> row from the aggregate XML.
|
||||
type ParsedRecord struct {
|
||||
SourceIP string
|
||||
Count int
|
||||
Disposition string
|
||||
SPFResult string
|
||||
DKIMResult string
|
||||
HeaderFrom string
|
||||
}
|
||||
|
||||
type feedbackXML struct {
|
||||
XMLName xml.Name `xml:"feedback"`
|
||||
Metadata struct {
|
||||
OrgName string `xml:"org_name"`
|
||||
Email string `xml:"email"`
|
||||
ReportID string `xml:"report_id"`
|
||||
DateRange struct {
|
||||
Begin int64 `xml:"begin"`
|
||||
End int64 `xml:"end"`
|
||||
} `xml:"date_range"`
|
||||
} `xml:"report_metadata"`
|
||||
Policy struct {
|
||||
Domain string `xml:"domain"`
|
||||
P string `xml:"p"`
|
||||
SP string `xml:"sp"`
|
||||
Pct int `xml:"pct"`
|
||||
ADKIM string `xml:"adkim"`
|
||||
ASPF string `xml:"aspf"`
|
||||
} `xml:"policy_published"`
|
||||
Records []struct {
|
||||
Row struct {
|
||||
SourceIP string `xml:"source_ip"`
|
||||
Count int `xml:"count"`
|
||||
Policy struct {
|
||||
Disposition string `xml:"disposition"`
|
||||
DKIM string `xml:"dkim"`
|
||||
SPF string `xml:"spf"`
|
||||
} `xml:"policy_evaluated"`
|
||||
} `xml:"row"`
|
||||
Identifiers struct {
|
||||
HeaderFrom string `xml:"header_from"`
|
||||
} `xml:"identifiers"`
|
||||
} `xml:"record"`
|
||||
}
|
||||
|
||||
// ParseAggregate decodes gzip-compressed or raw DMARC aggregate XML.
|
||||
func ParseAggregate(raw []byte) (ParsedReport, error) {
|
||||
data, err := maybeGunzip(raw)
|
||||
if err != nil {
|
||||
return ParsedReport{}, err
|
||||
}
|
||||
var doc feedbackXML
|
||||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||
return ParsedReport{}, fmt.Errorf("dmarc xml: %w", err)
|
||||
}
|
||||
if doc.Metadata.ReportID == "" || doc.Policy.Domain == "" {
|
||||
return ParsedReport{}, fmt.Errorf("dmarc xml: missing report_id or domain")
|
||||
}
|
||||
out := ParsedReport{
|
||||
Reporter: strings.TrimSpace(doc.Metadata.OrgName),
|
||||
ReportID: strings.TrimSpace(doc.Metadata.ReportID),
|
||||
ContactEmail: strings.TrimSpace(doc.Metadata.Email),
|
||||
PeriodBegin: time.Unix(doc.Metadata.DateRange.Begin, 0).UTC(),
|
||||
PeriodEnd: time.Unix(doc.Metadata.DateRange.End, 0).UTC(),
|
||||
Domain: strings.ToLower(strings.TrimSpace(doc.Policy.Domain)),
|
||||
PolicyP: strings.TrimSpace(doc.Policy.P),
|
||||
PolicySP: strings.TrimSpace(doc.Policy.SP),
|
||||
PolicyPct: doc.Policy.Pct,
|
||||
PolicyADKIM: strings.TrimSpace(doc.Policy.ADKIM),
|
||||
PolicyASPF: strings.TrimSpace(doc.Policy.ASPF),
|
||||
}
|
||||
if out.PolicyPct == 0 {
|
||||
out.PolicyPct = 100
|
||||
}
|
||||
for _, rec := range doc.Records {
|
||||
row := ParsedRecord{
|
||||
SourceIP: strings.TrimSpace(rec.Row.SourceIP),
|
||||
Count: rec.Row.Count,
|
||||
Disposition: strings.TrimSpace(rec.Row.Policy.Disposition),
|
||||
SPFResult: strings.TrimSpace(rec.Row.Policy.SPF),
|
||||
DKIMResult: strings.TrimSpace(rec.Row.Policy.DKIM),
|
||||
HeaderFrom: strings.TrimSpace(rec.Identifiers.HeaderFrom),
|
||||
}
|
||||
if row.Count <= 0 {
|
||||
row.Count = 1
|
||||
}
|
||||
if dmarcRecordPasses(row) {
|
||||
out.PassCount += row.Count
|
||||
} else {
|
||||
out.FailCount += row.Count
|
||||
}
|
||||
out.Records = append(out.Records, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func dmarcRecordPasses(r ParsedRecord) bool {
|
||||
return strings.EqualFold(r.SPFResult, "pass") || strings.EqualFold(r.DKIMResult, "pass")
|
||||
}
|
||||
|
||||
func maybeGunzip(raw []byte) ([]byte, error) {
|
||||
if len(raw) >= 2 && raw[0] == 0x1f && raw[1] == 0x8b {
|
||||
zr, err := gzip.NewReader(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc gzip: %w", err)
|
||||
}
|
||||
defer zr.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(zr, 8<<20))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dmarc gzip read: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
|
||||
// TightenPolicyHint summarises whether raising p= looks reasonable.
|
||||
func TightenPolicyHint(pass, fail int, sources []SourceHint) string {
|
||||
total := pass + fail
|
||||
if total == 0 {
|
||||
return "No messages in the reporting window yet."
|
||||
}
|
||||
failPct := float64(fail) * 100 / float64(total)
|
||||
if fail > 0 {
|
||||
for _, s := range sources {
|
||||
if s.FailCount > 0 && !s.ThisRelay {
|
||||
return "A third-party source is not aligned. Do not tighten p= until that sender is fixed or removed."
|
||||
}
|
||||
}
|
||||
}
|
||||
if failPct <= 2 && pass > 0 {
|
||||
return "Alignment looks strong. Tightening p= may be reasonable."
|
||||
}
|
||||
if fail > 0 {
|
||||
return "Some failures remain. Review sources before tightening p=."
|
||||
}
|
||||
return "Alignment looks clean for this window."
|
||||
}
|
||||
|
||||
// SourceHint is a panel-facing rollup row with relay detection.
|
||||
type SourceHint struct {
|
||||
SourceIP string
|
||||
PassCount int
|
||||
FailCount int
|
||||
ThisRelay bool
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
const sampleXML = `<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<email>noreply@google.com</email>
|
||||
<report_id>12345</report_id>
|
||||
<date_range><begin>1723593600</begin><end>1723680000</end></date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.com</domain>
|
||||
<p>none</p><sp>none</sp><pct>100</pct>
|
||||
<adkim>r</adkim><aspf>r</aspf>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>203.0.113.10</source_ip>
|
||||
<count>10</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim><spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.com</header_from></identifiers>
|
||||
</record>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.1</source_ip>
|
||||
<count>2</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>fail</dkim><spf>fail</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers><header_from>example.com</header_from></identifiers>
|
||||
</record>
|
||||
</feedback>`
|
||||
|
||||
func TestParseAggregateGzip(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
zw := gzip.NewWriter(&buf)
|
||||
if _, err := zw.Write([]byte(sampleXML)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := ParseAggregate(buf.Bytes())
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAggregate: %v", err)
|
||||
}
|
||||
if got.Domain != "example.com" || got.Reporter != "google.com" {
|
||||
t.Fatalf("metadata = %+v", got)
|
||||
}
|
||||
if got.PassCount != 10 || got.FailCount != 2 {
|
||||
t.Fatalf("counts = %d pass %d fail", got.PassCount, got.FailCount)
|
||||
}
|
||||
if !got.PeriodBegin.Equal(time.Unix(1723593600, 0).UTC()) {
|
||||
t.Fatalf("period begin = %v", got.PeriodBegin)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIngestMessageStoresReport(t *testing.T) {
|
||||
st, err := store.Open(t.TempDir() + "/test.db")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
var gz bytes.Buffer
|
||||
zw := gzip.NewWriter(&gz)
|
||||
_, _ = zw.Write([]byte(sampleXML))
|
||||
_ = zw.Close()
|
||||
|
||||
msg := bytes.NewBufferString(
|
||||
"From: noreply@google.com\r\n" +
|
||||
"To: dmarc-reports@mail.example.com\r\n" +
|
||||
"Subject: Report\r\n" +
|
||||
"Content-Type: application/gzip; name=\"report.xml.gz\"\r\n" +
|
||||
"\r\n",
|
||||
)
|
||||
// append raw gzip for simple single-part test
|
||||
msg.Write(gz.Bytes())
|
||||
|
||||
if err := IngestMessage(st, msg, "dmarc-reports@mail.example.com", time.Now().UTC()); err != nil {
|
||||
t.Fatalf("IngestMessage: %v", err)
|
||||
}
|
||||
list, err := st.ListDMARCReports(nil, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("reports = %d", len(list))
|
||||
}
|
||||
if list[0].PassCount != 10 {
|
||||
t.Fatalf("pass = %d", list[0].PassCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostedReportAddress(t *testing.T) {
|
||||
got := HostedReportAddress("mail.example.com", "Example.COM")
|
||||
want := "dmarc-reports+example.com@mail.example.com"
|
||||
if got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Package dmarc receives DMARC aggregate reports: Postfix pipes messages here,
|
||||
// gzip/XML is parsed, and summaries land in SQLite for the panel.
|
||||
package dmarc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/mixeme/selfpost/internal/dnscheck"
|
||||
"github.com/mixeme/selfpost/internal/postfix"
|
||||
"github.com/mixeme/selfpost/internal/store"
|
||||
)
|
||||
|
||||
// Maps is the Postfix manager surface the DMARC service needs.
|
||||
type Maps interface {
|
||||
RebuildDMARCMaps(cfg postfix.DMARCMapsConfig) error
|
||||
}
|
||||
|
||||
// Service coordinates DMARC ingest allow-lists across SQLite and Postfix.
|
||||
type Service struct {
|
||||
store *store.Store
|
||||
maps Maps
|
||||
hostname string
|
||||
enabled bool
|
||||
}
|
||||
|
||||
// NewService builds the DMARC ingest service.
|
||||
func NewService(st *store.Store, maps Maps, hostname string, enabled bool) *Service {
|
||||
return &Service{store: st, maps: maps, hostname: strings.ToLower(strings.TrimSpace(hostname)), enabled: enabled}
|
||||
}
|
||||
|
||||
// Enabled reports whether DMARC ingest is active in this deployment.
|
||||
func (s *Service) Enabled() bool { return s.enabled }
|
||||
|
||||
// Resync rebuilds the Postfix allow-list and transport maps from SQLite.
|
||||
func (s *Service) Resync() error {
|
||||
if !s.enabled {
|
||||
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{})
|
||||
}
|
||||
addrs, err := s.AllowedRecipients()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
domains := recipientDomains(addrs)
|
||||
return s.maps.RebuildDMARCMaps(postfix.DMARCMapsConfig{
|
||||
Recipients: addrs,
|
||||
Domains: domains,
|
||||
})
|
||||
}
|
||||
|
||||
// AllowedRecipients returns every address Postfix may accept for DMARC ingest.
|
||||
func (s *Service) AllowedRecipients() ([]string, error) {
|
||||
if !s.enabled || s.hostname == "" {
|
||||
return nil, nil
|
||||
}
|
||||
profile, err := s.store.GlobalDMARCReportEmail()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
add := func(addr string) {
|
||||
addr = strings.ToLower(strings.TrimSpace(addr))
|
||||
if addr == "" || !IsHostedOnHostname(addr, s.hostname) || seen[addr] {
|
||||
return
|
||||
}
|
||||
seen[addr] = true
|
||||
out = append(out, addr)
|
||||
}
|
||||
add(profile)
|
||||
if profile == "" {
|
||||
add(DefaultHostedReportAddress(s.hostname))
|
||||
}
|
||||
domains, err := s.store.ListDomains()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range domains {
|
||||
rua := dnscheck.ResolveDMARCRua(d.DMARCRua, profile)
|
||||
if rua == "" {
|
||||
continue
|
||||
}
|
||||
if IsHostedOnHostname(rua, s.hostname) {
|
||||
add(rua)
|
||||
continue
|
||||
}
|
||||
if d.DMARCRua.Valid && d.DMARCRua.String == "" {
|
||||
continue
|
||||
}
|
||||
hosted := HostedReportAddress(s.hostname, d.Name)
|
||||
if strings.EqualFold(rua, hosted) {
|
||||
add(rua)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func recipientDomains(addrs []string) []string {
|
||||
seen := make(map[string]bool)
|
||||
var out []string
|
||||
for _, addr := range addrs {
|
||||
d := dnscheck.EmailDomain(addr)
|
||||
if d == "" || seen[d] {
|
||||
continue
|
||||
}
|
||||
seen[d] = true
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// HostedSuggestion returns the address the panel should suggest for a domain.
|
||||
func (s *Service) HostedSuggestion(domain string) string {
|
||||
return HostedReportAddress(s.hostname, domain)
|
||||
}
|
||||
|
||||
// DefaultHostedSuggestion is the settings-level hosted address.
|
||||
func (s *Service) DefaultHostedSuggestion() string {
|
||||
return DefaultHostedReportAddress(s.hostname)
|
||||
}
|
||||
|
||||
// ValidateHostedAddress ensures addr is on this hostname before saving.
|
||||
func (s *Service) ValidateHostedAddress(addr string) error {
|
||||
if !IsHostedOnHostname(addr, s.hostname) {
|
||||
return fmt.Errorf("hosted report address must be on %s", s.hostname)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user