#11: implement MTA-STS, TLS-RPT & Autoconfig HTTP services
This commit is contained in:
1 parent
c0042ef207
commit
314eb67dda
20 files changed
+1177
-31
No files matched your search
@@ -0,0 +1,103 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DmarcRecord struct {
|
||||
SourceIP string
|
||||
Count int
|
||||
Disposition string
|
||||
DKIMResult string
|
||||
SPFResult string
|
||||
HeaderFrom string
|
||||
}
|
||||
|
||||
type DmarcReport struct {
|
||||
OrgName string
|
||||
ReportID string
|
||||
Domain string
|
||||
DateRangeBegin time.Time
|
||||
DateRangeEnd time.Time
|
||||
Records []DmarcRecord
|
||||
}
|
||||
|
||||
type dmarcXmlReport struct {
|
||||
ReportMetadata struct {
|
||||
OrgName string `xml:"org_name"`
|
||||
ReportID string `xml:"report_id"`
|
||||
DateRange struct {
|
||||
Begin int64 `xml:"begin"`
|
||||
End int64 `xml:"end"`
|
||||
} `xml:"date_range"`
|
||||
} `xml:"report_metadata"`
|
||||
PolicyPublished struct {
|
||||
Domain string `xml:"domain"`
|
||||
} `xml:"policy_published"`
|
||||
Record []struct {
|
||||
Row struct {
|
||||
SourceIP string `xml:"source_ip"`
|
||||
Count int `xml:"count"`
|
||||
PolicyEvaluated 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"`
|
||||
}
|
||||
|
||||
// ParseDmarcXmlReport parses raw XML or gzipped XML DMARC aggregate report.
|
||||
func ParseDmarcXmlReport(payload []byte) (*DmarcReport, error) {
|
||||
data := payload
|
||||
if isGzip(payload) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
decompressed, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress gzip: %w", err)
|
||||
}
|
||||
data = decompressed
|
||||
}
|
||||
|
||||
var parsed dmarcXmlReport
|
||||
if err := xml.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal dmarc xml: %w", err)
|
||||
}
|
||||
|
||||
report := &DmarcReport{
|
||||
OrgName: parsed.ReportMetadata.OrgName,
|
||||
ReportID: parsed.ReportMetadata.ReportID,
|
||||
Domain: parsed.PolicyPublished.Domain,
|
||||
DateRangeBegin: time.Unix(parsed.ReportMetadata.DateRange.Begin, 0),
|
||||
DateRangeEnd: time.Unix(parsed.ReportMetadata.DateRange.End, 0),
|
||||
}
|
||||
|
||||
for _, r := range parsed.Record {
|
||||
report.Records = append(report.Records, DmarcRecord{
|
||||
SourceIP: r.Row.SourceIP,
|
||||
Count: r.Row.Count,
|
||||
Disposition: r.Row.PolicyEvaluated.Disposition,
|
||||
DKIMResult: r.Row.PolicyEvaluated.DKIM,
|
||||
SPFResult: r.Row.PolicyEvaluated.SPF,
|
||||
HeaderFrom: r.Identifiers.HeaderFrom,
|
||||
})
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func isGzip(payload []byte) bool {
|
||||
return len(payload) >= 2 && payload[0] == 0x1f && payload[1] == 0x8b
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseDmarcXmlReport_ValidXML(t *testing.T) {
|
||||
xmlData := `<?xml version="1.0"?>
|
||||
<feedback>
|
||||
<report_metadata>
|
||||
<org_name>google.com</org_name>
|
||||
<report_id>123456</report_id>
|
||||
<date_range>
|
||||
<begin>1609459200</begin>
|
||||
<end>1609545600</end>
|
||||
</date_range>
|
||||
</report_metadata>
|
||||
<policy_published>
|
||||
<domain>example.test</domain>
|
||||
</policy_published>
|
||||
<record>
|
||||
<row>
|
||||
<source_ip>198.51.100.1</source_ip>
|
||||
<count>10</count>
|
||||
<policy_evaluated>
|
||||
<disposition>none</disposition>
|
||||
<dkim>pass</dkim>
|
||||
<spf>pass</spf>
|
||||
</policy_evaluated>
|
||||
</row>
|
||||
<identifiers>
|
||||
<header_from>example.test</header_from>
|
||||
</identifiers>
|
||||
</record>
|
||||
</feedback>`
|
||||
|
||||
report, err := ParseDmarcXmlReport([]byte(xmlData))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDmarcXmlReport: %v", err)
|
||||
}
|
||||
|
||||
if report.OrgName != "google.com" || report.Domain != "example.test" {
|
||||
t.Errorf("unexpected report metadata: %+v", report)
|
||||
}
|
||||
if len(report.Records) != 1 || report.Records[0].Count != 10 {
|
||||
t.Errorf("unexpected records: %+v", report.Records)
|
||||
}
|
||||
|
||||
// Test Gzip handling
|
||||
var gzBuf bytes.Buffer
|
||||
gw := gzip.NewWriter(&gzBuf)
|
||||
gw.Write([]byte(xmlData))
|
||||
gw.Close()
|
||||
|
||||
gzReport, err := ParseDmarcXmlReport(gzBuf.Bytes())
|
||||
if err != nil || gzReport.OrgName != "google.com" {
|
||||
t.Fatalf("ParseDmarcXmlReport gzip: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTlsRptReport_ValidJSON(t *testing.T) {
|
||||
jsonData := `{
|
||||
"organization-name": "Google LLC",
|
||||
"report-id": "2026-07-25-001",
|
||||
"date-range": {
|
||||
"start-datetime": "2026-07-25T00:00:00Z",
|
||||
"end-datetime": "2026-07-25T23:59:59Z"
|
||||
},
|
||||
"policies": [
|
||||
{
|
||||
"policy": {
|
||||
"policy-type": "sts",
|
||||
"domain": "example.test"
|
||||
},
|
||||
"summary": {
|
||||
"total-successful-session-count": 100,
|
||||
"total-failure-session-count": 0
|
||||
}
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
report, err := ParseTlsRptReport([]byte(jsonData))
|
||||
if err != nil {
|
||||
t.Fatalf("ParseTlsRptReport: %v", err)
|
||||
}
|
||||
|
||||
if report.OrganizationName != "Google LLC" || report.Domain != "example.test" {
|
||||
t.Errorf("unexpected tlsrpt metadata: %+v", report)
|
||||
}
|
||||
if len(report.Policies) != 1 || report.Policies[0].SuccessCount != 100 {
|
||||
t.Errorf("unexpected policies: %+v", report.Policies)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package entity
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
type TlsRptPolicy struct {
|
||||
PolicyType string
|
||||
SuccessCount int
|
||||
FailureCount int
|
||||
}
|
||||
|
||||
type TlsRptReport struct {
|
||||
OrganizationName string
|
||||
ReportID string
|
||||
Domain string
|
||||
DateRangeBegin time.Time
|
||||
DateRangeEnd time.Time
|
||||
Policies []TlsRptPolicy
|
||||
}
|
||||
|
||||
type tlsRptJsonReport struct {
|
||||
OrganizationName string `json:"organization-name"`
|
||||
ReportID string `json:"report-id"`
|
||||
DateRange struct {
|
||||
StartDatetime string `json:"start-datetime"`
|
||||
EndDatetime string `json:"end-datetime"`
|
||||
} `json:"date-range"`
|
||||
Policies []struct {
|
||||
Policy struct {
|
||||
PolicyType string `json:"policy-type"`
|
||||
Domain string `json:"domain"`
|
||||
} `json:"policy"`
|
||||
Summary struct {
|
||||
TotalSuccessfulSessionCount int `json:"total-successful-session-count"`
|
||||
TotalFailureSessionCount int `json:"total-failure-session-count"`
|
||||
} `json:"summary"`
|
||||
} `json:"policies"`
|
||||
}
|
||||
|
||||
// ParseTlsRptReport parses JSON or gzipped JSON TLS-RPT report.
|
||||
func ParseTlsRptReport(payload []byte) (*TlsRptReport, error) {
|
||||
data := payload
|
||||
if isGzip(payload) {
|
||||
gz, err := gzip.NewReader(bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
decompressed, err := io.ReadAll(gz)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decompress gzip: %w", err)
|
||||
}
|
||||
data = decompressed
|
||||
}
|
||||
|
||||
var parsed tlsRptJsonReport
|
||||
if err := json.Unmarshal(data, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal tls-rpt json: %w", err)
|
||||
}
|
||||
|
||||
beginTime, _ := time.Parse(time.RFC3339, parsed.DateRange.StartDatetime)
|
||||
endTime, _ := time.Parse(time.RFC3339, parsed.DateRange.EndDatetime)
|
||||
|
||||
domain := ""
|
||||
if len(parsed.Policies) > 0 {
|
||||
domain = parsed.Policies[0].Policy.Domain
|
||||
}
|
||||
|
||||
report := &TlsRptReport{
|
||||
OrganizationName: parsed.OrganizationName,
|
||||
ReportID: parsed.ReportID,
|
||||
Domain: domain,
|
||||
DateRangeBegin: beginTime,
|
||||
DateRangeEnd: endTime,
|
||||
}
|
||||
|
||||
for _, p := range parsed.Policies {
|
||||
report.Policies = append(report.Policies, TlsRptPolicy{
|
||||
PolicyType: p.Policy.PolicyType,
|
||||
SuccessCount: p.Summary.TotalSuccessfulSessionCount,
|
||||
FailureCount: p.Summary.TotalFailureSessionCount,
|
||||
})
|
||||
}
|
||||
|
||||
return report, nil
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// BuildThunderbirdAutoconfigXML generates Mozilla Thunderbird config-v1.1 XML payload.
|
||||
func BuildThunderbirdAutoconfigXML(domainName, mailHost string) string {
|
||||
if mailHost == "" {
|
||||
mailHost = fmt.Sprintf("mail.%s", domainName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<clientConfig version="1.1">
|
||||
<emailProvider id="%s">
|
||||
<domain>%s</domain>
|
||||
<displayName>LambdaMail - %s</displayName>
|
||||
<displayShortName>%s</displayShortName>
|
||||
<incomingServer type="imap">
|
||||
<hostname>%s</hostname>
|
||||
<port>993</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%%EMAILADDRESS%%</username>
|
||||
</incomingServer>
|
||||
<incomingServer type="pop3">
|
||||
<hostname>%s</hostname>
|
||||
<port>995</port>
|
||||
<socketType>SSL</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%%EMAILADDRESS%%</username>
|
||||
</incomingServer>
|
||||
<outgoingServer type="smtp">
|
||||
<hostname>%s</hostname>
|
||||
<port>587</port>
|
||||
<socketType>STARTTLS</socketType>
|
||||
<authentication>password-cleartext</authentication>
|
||||
<username>%%EMAILADDRESS%%</username>
|
||||
</outgoingServer>
|
||||
</emailProvider>
|
||||
</clientConfig>`, domainName, domainName, domainName, domainName, mailHost, mailHost, mailHost)
|
||||
}
|
||||
|
||||
// BuildOutlookAutodiscoverXML generates Microsoft POX Autodiscover response XML.
|
||||
func BuildOutlookAutodiscoverXML(domainName, mailHost, emailAddress string) string {
|
||||
if mailHost == "" {
|
||||
mailHost = fmt.Sprintf("mail.%s", domainName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf(`<?xml version="1.0" encoding="utf-8"?>
|
||||
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
|
||||
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
|
||||
<User>
|
||||
<DisplayName>%s</DisplayName>
|
||||
</User>
|
||||
<Account>
|
||||
<AccountType>email</AccountType>
|
||||
<Action>settings</Action>
|
||||
<Protocol>
|
||||
<Type>IMAP</Type>
|
||||
<Server>%s</Server>
|
||||
<Port>993</Port>
|
||||
<SSL>on</SSL>
|
||||
<AuthRequired>on</AuthRequired>
|
||||
<LoginName>%s</LoginName>
|
||||
</Protocol>
|
||||
<Protocol>
|
||||
<Type>SMTP</Type>
|
||||
<Server>%s</Server>
|
||||
<Port>587</Port>
|
||||
<SSL>off</SSL>
|
||||
<Encryption>TLS</Encryption>
|
||||
<AuthRequired>on</AuthRequired>
|
||||
<LoginName>%s</LoginName>
|
||||
</Protocol>
|
||||
</Account>
|
||||
</Response>
|
||||
</Autodiscover>`, emailAddress, mailHost, emailAddress, mailHost, emailAddress)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildThunderbirdAutoconfigXML(t *testing.T) {
|
||||
xml := BuildThunderbirdAutoconfigXML("example.test", "mail.example.test")
|
||||
if !strings.Contains(xml, "<hostname>mail.example.test</hostname>") {
|
||||
t.Errorf("missing mail host: %s", xml)
|
||||
}
|
||||
if !strings.Contains(xml, "<port>993</port>") {
|
||||
t.Errorf("missing port 993: %s", xml)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOutlookAutodiscoverXML(t *testing.T) {
|
||||
xml := BuildOutlookAutodiscoverXML("example.test", "mail.example.test", "user@example.test")
|
||||
if !strings.Contains(xml, "<Server>mail.example.test</Server>") {
|
||||
t.Errorf("missing Server: %s", xml)
|
||||
}
|
||||
if !strings.Contains(xml, "<LoginName>user@example.test</LoginName>") {
|
||||
t.Errorf("missing LoginName: %s", xml)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type MtaStsMode string
|
||||
|
||||
const (
|
||||
MtaStsModeTesting MtaStsMode = "testing"
|
||||
MtaStsModeEnforce MtaStsMode = "enforce"
|
||||
MtaStsModeNone MtaStsMode = "none"
|
||||
)
|
||||
|
||||
type MtaStsPolicy struct {
|
||||
Version string
|
||||
Mode MtaStsMode
|
||||
MX []string
|
||||
MaxAge int
|
||||
}
|
||||
|
||||
func NewMtaStsPolicy(mode MtaStsMode, mx []string, maxAgeSeconds int) MtaStsPolicy {
|
||||
if maxAgeSeconds <= 0 {
|
||||
maxAgeSeconds = 604800 // default 7 days
|
||||
}
|
||||
return MtaStsPolicy{
|
||||
Version: "STSv1",
|
||||
Mode: mode,
|
||||
MX: mx,
|
||||
MaxAge: maxAgeSeconds,
|
||||
}
|
||||
}
|
||||
|
||||
func (p MtaStsPolicy) Format() string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("version: %s\r\n", p.Version))
|
||||
sb.WriteString(fmt.Sprintf("mode: %s\r\n", p.Mode))
|
||||
for _, mxHost := range p.MX {
|
||||
sb.WriteString(fmt.Sprintf("mx: %s\r\n", mxHost))
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("max_age: %d\r\n", p.MaxAge))
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package valueobject
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMtaStsPolicy_Format(t *testing.T) {
|
||||
policy := NewMtaStsPolicy(MtaStsModeEnforce, []string{"mail.example.test"}, 604800)
|
||||
formatted := policy.Format()
|
||||
|
||||
if !strings.Contains(formatted, "version: STSv1") {
|
||||
t.Errorf("missing version: %s", formatted)
|
||||
}
|
||||
if !strings.Contains(formatted, "mode: enforce") {
|
||||
t.Errorf("missing mode: %s", formatted)
|
||||
}
|
||||
if !strings.Contains(formatted, "mx: mail.example.test") {
|
||||
t.Errorf("missing mx: %s", formatted)
|
||||
}
|
||||
if !strings.Contains(formatted, "max_age: 604800") {
|
||||
t.Errorf("missing max_age: %s", formatted)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user