#11: implement MTA-STS, TLS-RPT & Autoconfig HTTP services
This commit is contained in:
1 parent
c0042ef207
commit
314eb67dda
20 files changed
+1171
-25
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
DROP TABLE IF EXISTS tls_rpt_report_policies;
|
||||||
|
DROP TABLE IF EXISTS tls_rpt_reports;
|
||||||
|
DROP TABLE IF EXISTS dmarc_report_records;
|
||||||
|
DROP TABLE IF EXISTS dmarc_reports;
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
-- 0003_create_report_tables.up.sql
|
||||||
|
-- Tables for DMARC aggregate (RUA) records and TLS-RPT failure reports
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dmarc_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
domain_id UUID REFERENCES domains(id) ON DELETE CASCADE,
|
||||||
|
org_name VARCHAR(255) NOT NULL,
|
||||||
|
report_id VARCHAR(255) NOT NULL,
|
||||||
|
domain VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
date_begin TIMESTAMPTZ,
|
||||||
|
date_end TIMESTAMPTZ,
|
||||||
|
raw_xml TEXT,
|
||||||
|
summary JSONB,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE dmarc_reports ALTER COLUMN domain_id DROP NOT NULL;
|
||||||
|
ALTER TABLE dmarc_reports ADD COLUMN IF NOT EXISTS domain VARCHAR(255) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE dmarc_reports ADD COLUMN IF NOT EXISTS date_range_begin TIMESTAMPTZ;
|
||||||
|
ALTER TABLE dmarc_reports ADD COLUMN IF NOT EXISTS date_range_end TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS dmarc_report_records (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
report_id UUID NOT NULL REFERENCES dmarc_reports(id) ON DELETE CASCADE,
|
||||||
|
source_ip INET NOT NULL,
|
||||||
|
count INT NOT NULL,
|
||||||
|
disposition VARCHAR(32) NOT NULL,
|
||||||
|
dkim_result VARCHAR(32) NOT NULL,
|
||||||
|
spf_result VARCHAR(32) NOT NULL,
|
||||||
|
header_from VARCHAR(255) NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tls_rpt_reports (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
organization_name VARCHAR(255) NOT NULL,
|
||||||
|
report_id VARCHAR(255) NOT NULL,
|
||||||
|
domain VARCHAR(255) NOT NULL,
|
||||||
|
date_range_begin TIMESTAMPTZ NOT NULL,
|
||||||
|
date_range_end TIMESTAMPTZ NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tls_rpt_report_policies (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
report_id UUID NOT NULL REFERENCES tls_rpt_reports(id) ON DELETE CASCADE,
|
||||||
|
policy_type VARCHAR(64) NOT NULL,
|
||||||
|
success_count INT NOT NULL DEFAULT 0,
|
||||||
|
failure_count INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
@@ -13,11 +13,11 @@ import (
|
|||||||
gosmtp "github.com/emersion/go-smtp"
|
gosmtp "github.com/emersion/go-smtp"
|
||||||
|
|
||||||
"lambdamail/protocols/internal/application/usecase"
|
"lambdamail/protocols/internal/application/usecase"
|
||||||
"lambdamail/protocols/internal/health"
|
|
||||||
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
"lambdamail/protocols/internal/infrastructure/diskstorage"
|
||||||
"lambdamail/protocols/internal/infrastructure/postgres"
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
tlsprovider "lambdamail/protocols/internal/infrastructure/tls"
|
||||||
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
imappresentation "lambdamail/protocols/internal/presentation/imap"
|
||||||
|
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||||
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
managesievepresentation "lambdamail/protocols/internal/presentation/managesieve"
|
||||||
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
pop3presentation "lambdamail/protocols/internal/presentation/pop3"
|
||||||
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
smtppresentation "lambdamail/protocols/internal/presentation/smtp"
|
||||||
@@ -134,11 +134,14 @@ func main() {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
handler := health.Handler(func() error { return pool.Ping(ctx) })
|
reportRepo := postgres.NewReportRepository(pool)
|
||||||
|
reportUseCase := usecase.NewIngestReportsUseCase(reportRepo)
|
||||||
|
router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
|
||||||
|
|
||||||
addr := ":8080"
|
addr := ":8080"
|
||||||
log.Printf("lambdamail-protocols health listening on %s", addr)
|
log.Printf("lambdamail-protocols HTTP service listening on %s", addr)
|
||||||
if err := http.ListenAndServe(addr, handler); err != nil {
|
if err := http.ListenAndServe(addr, router); err != nil {
|
||||||
log.Fatalf("serve: %v", err)
|
log.Fatalf("http serve: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package port
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReportRepository manages storage for ingested DMARC and TLS-RPT reports.
|
||||||
|
type ReportRepository interface {
|
||||||
|
SaveDmarcReport(ctx context.Context, report *entity.DmarcReport) error
|
||||||
|
SaveTlsRptReport(ctx context.Context, report *entity.TlsRptReport) error
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/application/port"
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type IngestReportsUseCase struct {
|
||||||
|
reportRepo port.ReportRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIngestReportsUseCase(reportRepo port.ReportRepository) *IngestReportsUseCase {
|
||||||
|
return &IngestReportsUseCase{reportRepo: reportRepo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *IngestReportsUseCase) IngestDmarc(ctx context.Context, payload []byte) (*entity.DmarcReport, error) {
|
||||||
|
report, err := entity.ParseDmarcXmlReport(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse dmarc report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uc.reportRepo != nil {
|
||||||
|
if err := uc.reportRepo.SaveDmarcReport(ctx, report); err != nil {
|
||||||
|
return nil, fmt.Errorf("save dmarc report: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (uc *IngestReportsUseCase) IngestTlsRpt(ctx context.Context, payload []byte) (*entity.TlsRptReport, error) {
|
||||||
|
report, err := entity.ParseTlsRptReport(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse tls-rpt report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if uc.reportRepo != nil {
|
||||||
|
if err := uc.reportRepo.SaveTlsRptReport(ctx, report); err != nil {
|
||||||
|
return nil, fmt.Errorf("save tls-rpt report: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeReportRepository struct {
|
||||||
|
dmarcReports []*entity.DmarcReport
|
||||||
|
tlsRptReports []*entity.TlsRptReport
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReportRepository) SaveDmarcReport(_ context.Context, report *entity.DmarcReport) error {
|
||||||
|
f.dmarcReports = append(f.dmarcReports, report)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeReportRepository) SaveTlsRptReport(_ context.Context, report *entity.TlsRptReport) error {
|
||||||
|
f.tlsRptReports = append(f.tlsRptReports, report)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIngestReportsUseCase_IngestDmarcAndTlsRpt(t *testing.T) {
|
||||||
|
repo := &fakeReportRepository{}
|
||||||
|
uc := NewIngestReportsUseCase(repo)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// DMARC XML
|
||||||
|
dmarcPayload := `<?xml version="1.0"?>
|
||||||
|
<feedback>
|
||||||
|
<report_metadata><org_name>test.org</org_name><report_id>r1</report_id><date_range><begin>100</begin><end>200</end></date_range></report_metadata>
|
||||||
|
<policy_published><domain>test.domain</domain></policy_published>
|
||||||
|
<record><row><source_ip>1.2.3.4</source_ip><count>5</count><policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated></row><identifiers><header_from>test.domain</header_from></identifiers></record>
|
||||||
|
</feedback>`
|
||||||
|
|
||||||
|
report1, err := uc.IngestDmarc(ctx, []byte(dmarcPayload))
|
||||||
|
if err != nil || report1.OrgName != "test.org" {
|
||||||
|
t.Fatalf("IngestDmarc: %v", err)
|
||||||
|
}
|
||||||
|
if len(repo.dmarcReports) != 1 {
|
||||||
|
t.Errorf("dmarcReports count = %d, want 1", len(repo.dmarcReports))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLS-RPT JSON
|
||||||
|
tlsRptPayload := `{
|
||||||
|
"organization-name": "test.org",
|
||||||
|
"report-id": "r2",
|
||||||
|
"date-range": {"start-datetime": "2026-07-25T00:00:00Z", "end-datetime": "2026-07-25T23:59:59Z"},
|
||||||
|
"policies": [{"policy": {"policy-type": "sts", "domain": "test.domain"}, "summary": {"total-successful-session-count": 10, "total-failure-session-count": 0}}]
|
||||||
|
}`
|
||||||
|
|
||||||
|
report2, err := uc.IngestTlsRpt(ctx, []byte(tlsRptPayload))
|
||||||
|
if err != nil || report2.OrganizationName != "test.org" {
|
||||||
|
t.Fatalf("IngestTlsRpt: %v", err)
|
||||||
|
}
|
||||||
|
if len(repo.tlsRptReports) != 1 {
|
||||||
|
t.Errorf("tlsRptReports count = %d, want 1", len(repo.tlsRptReports))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,20 +39,19 @@ func TestDnsSyncAndSystemAliasesEndToEnd(t *testing.T) {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
root := repoRoot(t)
|
||||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
|
migrations := []string{
|
||||||
|
"0001_init_schema.up.sql",
|
||||||
|
"0002_add_is_system_to_aliases.up.sql",
|
||||||
|
"0003_create_report_tables.up.sql",
|
||||||
|
}
|
||||||
|
for _, m := range migrations {
|
||||||
|
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read migration 0001: %v", err)
|
t.Fatalf("read migration %s: %v", m, err)
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
t.Fatalf("apply migration 0001: %v", err)
|
t.Fatalf("apply migration %s: %v", m, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read migration 0002: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, string(sql0002)); err != nil {
|
|
||||||
t.Fatalf("apply migration 0002: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed domain & admin mailbox
|
// Seed domain & admin mailbox
|
||||||
|
|||||||
@@ -53,20 +53,19 @@ func TestF2CompleteSuiteEndToEnd(t *testing.T) {
|
|||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
root := repoRoot(t)
|
root := repoRoot(t)
|
||||||
sql, err := os.ReadFile(filepath.Join(root, "migrations", "0001_init_schema.up.sql"))
|
migrations := []string{
|
||||||
|
"0001_init_schema.up.sql",
|
||||||
|
"0002_add_is_system_to_aliases.up.sql",
|
||||||
|
"0003_create_report_tables.up.sql",
|
||||||
|
}
|
||||||
|
for _, m := range migrations {
|
||||||
|
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("read migration 0001: %v", err)
|
t.Fatalf("read migration %s: %v", m, err)
|
||||||
}
|
}
|
||||||
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
t.Fatalf("apply migration 0001: %v", err)
|
t.Fatalf("apply migration %s: %v", m, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
sql0002, err := os.ReadFile(filepath.Join(root, "migrations", "0002_add_is_system_to_aliases.up.sql"))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read migration 0002: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := pool.Exec(ctx, string(sql0002)); err != nil {
|
|
||||||
t.Fatalf("apply migration 0002: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Seed domain, mailbox, INBOX, Archive
|
// 2. Seed domain, mailbox, INBOX, Archive
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
||||||
|
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
||||||
|
httppresentation "lambdamail/protocols/internal/presentation/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHttpServicesEndToEnd(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("e2e test skipped in -short mode")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
runtimeDir := t.TempDir()
|
||||||
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
||||||
|
Port(54337).
|
||||||
|
RuntimePath(runtimeDir).
|
||||||
|
StartTimeout(120 * time.Second))
|
||||||
|
if err := pg.Start(); err != nil {
|
||||||
|
t.Fatalf("start embedded postgres: %v", err)
|
||||||
|
}
|
||||||
|
defer pg.Stop()
|
||||||
|
|
||||||
|
dbURL := "postgres://postgres:postgres@localhost:54337/postgres?sslmode=disable"
|
||||||
|
pool, err := postgres.NewPool(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
root := repoRoot(t)
|
||||||
|
migrations := []string{
|
||||||
|
"0001_init_schema.up.sql",
|
||||||
|
"0002_add_is_system_to_aliases.up.sql",
|
||||||
|
"0003_create_report_tables.up.sql",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range migrations {
|
||||||
|
sql, err := os.ReadFile(filepath.Join(root, "migrations", m))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration %s: %v", m, err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, string(sql)); err != nil {
|
||||||
|
t.Fatalf("apply migration %s: %v", m, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportRepo := postgres.NewReportRepository(pool)
|
||||||
|
reportUseCase := appusecase.NewIngestReportsUseCase(reportRepo)
|
||||||
|
router := httppresentation.NewRouter(reportUseCase, func() error { return pool.Ping(ctx) })
|
||||||
|
|
||||||
|
ts := httptest.NewServer(router)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
client := ts.Client()
|
||||||
|
|
||||||
|
// 1. MTA-STS policy check
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/.well-known/mta-sts.txt", nil)
|
||||||
|
req.Host = "mta-sts.httpdomain.test"
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("mta-sts request failed: err=%v, code=%d", err, resp.StatusCode)
|
||||||
|
}
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if !strings.Contains(string(bodyBytes), "version: STSv1") || !strings.Contains(string(bodyBytes), "mx: mail.httpdomain.test") {
|
||||||
|
t.Errorf("mta-sts policy mismatch: %s", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Thunderbird autoconfig XML check
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/mail/config-v1.1.xml", nil)
|
||||||
|
req.Host = "autoconfig.httpdomain.test"
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("autoconfig request failed: err=%v, code=%d", err, resp.StatusCode)
|
||||||
|
}
|
||||||
|
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if !strings.Contains(string(bodyBytes), "<clientConfig version=\"1.1\">") {
|
||||||
|
t.Errorf("autoconfig xml mismatch: %s", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Security.txt check
|
||||||
|
req, _ = http.NewRequest(http.MethodGet, ts.URL+"/.well-known/security.txt", nil)
|
||||||
|
req.Host = "httpdomain.test"
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("security.txt request failed: err=%v, code=%d", err, resp.StatusCode)
|
||||||
|
}
|
||||||
|
bodyBytes, _ = io.ReadAll(resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if !strings.Contains(string(bodyBytes), "Contact: mailto:security@httpdomain.test") {
|
||||||
|
t.Errorf("security.txt mismatch: %s", string(bodyBytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Ingest DMARC report over HTTP and verify PostgreSQL persistence
|
||||||
|
dmarcPayload := `<?xml version="1.0"?>
|
||||||
|
<feedback>
|
||||||
|
<report_metadata><org_name>httporg.com</org_name><report_id>e2e123</report_id><date_range><begin>1600000000</begin><end>1600086400</end></date_range></report_metadata>
|
||||||
|
<policy_published><domain>httpdomain.test</domain></policy_published>
|
||||||
|
<record><row><source_ip>198.51.100.5</source_ip><count>12</count><policy_evaluated><disposition>none</disposition><dkim>pass</dkim><spf>pass</spf></policy_evaluated></row><identifiers><header_from>httpdomain.test</header_from></identifiers></record>
|
||||||
|
</feedback>`
|
||||||
|
|
||||||
|
resp, err = client.Post(ts.URL+"/api/v1/reports/dmarc", "application/xml", bytes.NewBufferString(dmarcPayload))
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("dmarc ingest failed: err=%v, code=%d", err, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dmarcCount int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM dmarc_reports WHERE org_name = 'httporg.com'`).Scan(&dmarcCount); err != nil || dmarcCount != 1 {
|
||||||
|
t.Fatalf("expected 1 dmarc report in DB, got count=%d, err=%v", dmarcCount, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Ingest TLS-RPT report over HTTP and verify PostgreSQL persistence
|
||||||
|
tlsRptPayload := `{
|
||||||
|
"organization-name": "httporg.com",
|
||||||
|
"report-id": "tls123",
|
||||||
|
"date-range": {"start-datetime": "2026-07-25T00:00:00Z", "end-datetime": "2026-07-25T23:59:59Z"},
|
||||||
|
"policies": [{"policy": {"policy-type": "sts", "domain": "httpdomain.test"}, "summary": {"total-successful-session-count": 50, "total-failure-session-count": 0}}]
|
||||||
|
}`
|
||||||
|
|
||||||
|
resp, err = client.Post(ts.URL+"/api/v1/reports/tlsrpt", "application/json", bytes.NewBufferString(tlsRptPayload))
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("tls-rpt ingest failed: err=%v, code=%d", err, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tlsRptCount int
|
||||||
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM tls_rpt_reports WHERE organization_name = 'httporg.com'`).Scan(&tlsRptCount); err != nil || tlsRptCount != 1 {
|
||||||
|
t.Fatalf("expected 1 tls-rpt report in DB, got count=%d, err=%v", tlsRptCount, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ReportRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewReportRepository(pool *pgxpool.Pool) *ReportRepository {
|
||||||
|
return &ReportRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReportRepository) SaveDmarcReport(ctx context.Context, report *entity.DmarcReport) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var reportID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO dmarc_reports (org_name, report_id, domain, date_range_begin, date_range_end)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id
|
||||||
|
`, report.OrgName, report.ReportID, report.Domain, report.DateRangeBegin, report.DateRangeEnd).Scan(&reportID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert dmarc report header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rec := range report.Records {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO dmarc_report_records (report_id, source_ip, count, disposition, dkim_result, spf_result, header_from)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
`, reportID, rec.SourceIP, rec.Count, rec.Disposition, rec.DKIMResult, rec.SPFResult, rec.HeaderFrom)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert dmarc record: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ReportRepository) SaveTlsRptReport(ctx context.Context, report *entity.TlsRptReport) error {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var reportID uuid.UUID
|
||||||
|
err = tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO tls_rpt_reports (organization_name, report_id, domain, date_range_begin, date_range_end)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
RETURNING id
|
||||||
|
`, report.OrganizationName, report.ReportID, report.Domain, report.DateRangeBegin, report.DateRangeEnd).Scan(&reportID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert tls-rpt report header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, pol := range report.Policies {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
INSERT INTO tls_rpt_report_policies (report_id, policy_type, success_count, failure_count)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
`, reportID, pol.PolicyType, pol.SuccessCount, pol.FailureCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert tls-rpt policy: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit(ctx)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package postgres
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lambdamail/protocols/internal/domain/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestReportRepository_SaveDmarcAndTlsRpt(t *testing.T) {
|
||||||
|
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dbURL == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set - skipping Postgres integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := NewPool(ctx, dbURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect pool: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
repo := NewReportRepository(pool)
|
||||||
|
|
||||||
|
// Save DMARC
|
||||||
|
dmarc := &entity.DmarcReport{
|
||||||
|
OrgName: "google.com",
|
||||||
|
ReportID: "r123",
|
||||||
|
Domain: "example.test",
|
||||||
|
DateRangeBegin: time.Now().Add(-24 * time.Hour),
|
||||||
|
DateRangeEnd: time.Now(),
|
||||||
|
Records: []entity.DmarcRecord{
|
||||||
|
{SourceIP: "192.0.2.1", Count: 5, Disposition: "none", DKIMResult: "pass", SPFResult: "pass", HeaderFrom: "example.test"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SaveDmarcReport(ctx, dmarc); err != nil {
|
||||||
|
t.Fatalf("SaveDmarcReport: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save TLS-RPT
|
||||||
|
tlsRpt := &entity.TlsRptReport{
|
||||||
|
OrganizationName: "Google LLC",
|
||||||
|
ReportID: "tr123",
|
||||||
|
Domain: "example.test",
|
||||||
|
DateRangeBegin: time.Now().Add(-24 * time.Hour),
|
||||||
|
DateRangeEnd: time.Now(),
|
||||||
|
Policies: []entity.TlsRptPolicy{
|
||||||
|
{PolicyType: "sts", SuccessCount: 10, FailureCount: 0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.SaveTlsRptReport(ctx, tlsRpt); err != nil {
|
||||||
|
t.Fatalf("SaveTlsRptReport: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package httppresentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
"lambdamail/protocols/internal/domain/valueobject"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Router struct {
|
||||||
|
mux *http.ServeMux
|
||||||
|
reportUseCase *appusecase.IngestReportsUseCase
|
||||||
|
pingFunc func() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(reportUseCase *appusecase.IngestReportsUseCase, pingFunc func() error) *Router {
|
||||||
|
r := &Router{
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
reportUseCase: reportUseCase,
|
||||||
|
pingFunc: pingFunc,
|
||||||
|
}
|
||||||
|
r.registerRoutes()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
r.mux.ServeHTTP(w, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) registerRoutes() {
|
||||||
|
r.mux.HandleFunc("/health", r.handleHealth)
|
||||||
|
r.mux.HandleFunc("/.well-known/mta-sts.txt", r.handleMtaSts)
|
||||||
|
r.mux.HandleFunc("/.well-known/security.txt", r.handleSecurityTxt)
|
||||||
|
r.mux.HandleFunc("/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
|
||||||
|
r.mux.HandleFunc("/.well-known/autoconfig/mail/config-v1.1.xml", r.handleThunderbirdAutoconfig)
|
||||||
|
r.mux.HandleFunc("/autodiscover/autodiscover.xml", r.handleOutlookAutodiscover)
|
||||||
|
r.mux.HandleFunc("/api/v1/reports/dmarc", r.handleDmarcIngest)
|
||||||
|
r.mux.HandleFunc("/api/v1/reports/tlsrpt", r.handleTlsRptIngest)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if r.pingFunc != nil {
|
||||||
|
if err := r.pingFunc(); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("unhealthy: %v", err), http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("OK\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleMtaSts(w http.ResponseWriter, req *http.Request) {
|
||||||
|
host := extractHost(req.Host)
|
||||||
|
domainName := strings.TrimPrefix(host, "mta-sts.")
|
||||||
|
|
||||||
|
policy := valueobject.NewMtaStsPolicy(valueobject.MtaStsModeEnforce, []string{fmt.Sprintf("mail.%s", domainName)}, 604800)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(policy.Format()))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleSecurityTxt(w http.ResponseWriter, req *http.Request) {
|
||||||
|
host := extractHost(req.Host)
|
||||||
|
expires := time.Now().AddDate(1, 0, 0).Format(time.RFC3339)
|
||||||
|
|
||||||
|
content := fmt.Sprintf("Contact: mailto:security@%s\r\nExpires: %s\r\nPreferred-Languages: en, pt-BR, es\r\n", host, expires)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleThunderbirdAutoconfig(w http.ResponseWriter, req *http.Request) {
|
||||||
|
host := extractHost(req.Host)
|
||||||
|
domainName := strings.TrimPrefix(host, "autoconfig.")
|
||||||
|
|
||||||
|
xml := valueobject.BuildThunderbirdAutoconfigXML(domainName, fmt.Sprintf("mail.%s", domainName))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(xml))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleOutlookAutodiscover(w http.ResponseWriter, req *http.Request) {
|
||||||
|
host := extractHost(req.Host)
|
||||||
|
domainName := strings.TrimPrefix(host, "autodiscover.")
|
||||||
|
|
||||||
|
xml := valueobject.BuildOutlookAutodiscoverXML(domainName, fmt.Sprintf("mail.%s", domainName), fmt.Sprintf("user@%s", domainName))
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/xml; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(xml))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleDmarcIngest(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Read body failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.reportUseCase == nil {
|
||||||
|
http.Error(w, "Ingestion unconfigured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := r.reportUseCase.IngestDmarc(req.Context(), body); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Parse error: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Router) handleTlsRptIngest(w http.ResponseWriter, req *http.Request) {
|
||||||
|
if req.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(req.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Read body failed", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.reportUseCase == nil {
|
||||||
|
http.Error(w, "Ingestion unconfigured", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := r.reportUseCase.IngestTlsRpt(req.Context(), body); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("Parse error: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{"status":"ok"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractHost(hostHeader string) string {
|
||||||
|
if h, _, err := strings.Cut(hostHeader, ":"); err && h != "" {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
if hostHeader == "" {
|
||||||
|
return "example.test"
|
||||||
|
}
|
||||||
|
return hostHeader
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package httppresentation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
appusecase "lambdamail/protocols/internal/application/usecase"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRouter_MtaSts_Autoconfig_SecurityTxt_Ingest(t *testing.T) {
|
||||||
|
uc := appusecase.NewIngestReportsUseCase(nil)
|
||||||
|
router := NewRouter(uc, func() error { return nil })
|
||||||
|
|
||||||
|
// 1. Health
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/health", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("health status = %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. MTA-STS
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/.well-known/mta-sts.txt", nil)
|
||||||
|
req.Host = "mta-sts.example.test"
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "version: STSv1") {
|
||||||
|
t.Errorf("mta-sts response = %q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Thunderbird Autoconfig
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/mail/config-v1.1.xml", nil)
|
||||||
|
req.Host = "autoconfig.example.test"
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "<clientConfig") {
|
||||||
|
t.Errorf("autoconfig response = %q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Outlook Autodiscover
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/autodiscover/autodiscover.xml", nil)
|
||||||
|
req.Host = "autodiscover.example.test"
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "<Autodiscover") {
|
||||||
|
t.Errorf("autodiscover response = %q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Security.txt
|
||||||
|
req = httptest.NewRequest(http.MethodGet, "/.well-known/security.txt", nil)
|
||||||
|
req.Host = "example.test"
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Contact: mailto:security@example.test") {
|
||||||
|
t.Errorf("security.txt response = %q", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Report Ingestion (DMARC)
|
||||||
|
dmarcPayload := `<?xml version="1.0"?><feedback><report_metadata><org_name>org</org_name><report_id>id</report_id><date_range><begin>1</begin><end>2</end></date_range></report_metadata><policy_published><domain>dom</domain></policy_published></feedback>`
|
||||||
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/reports/dmarc", bytes.NewBufferString(dmarcPayload))
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"ok"`) {
|
||||||
|
t.Errorf("dmarc ingest status = %d, body = %q", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user