mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 00:13:47 +03:00
add more documentation, examples with tests to illustrate reusable components
This commit is contained in:
parent
810cbdc61d
commit
d1b66035a9
40 changed files with 973 additions and 119 deletions
|
@ -44,6 +44,7 @@ Below are the incompatible changes between v0.0.8 and v0.0.9, per package.
|
||||||
# sasl
|
# sasl
|
||||||
|
|
||||||
# scram
|
# scram
|
||||||
|
- HMAC: removed
|
||||||
|
|
||||||
# smtp
|
# smtp
|
||||||
|
|
||||||
|
|
|
@ -36,7 +36,7 @@ func TestAutotls(t *testing.T) {
|
||||||
if err := m.HostPolicy(context.Background(), "mox.example"); err == nil || !errors.Is(err, errHostNotAllowed) {
|
if err := m.HostPolicy(context.Background(), "mox.example"); err == nil || !errors.Is(err, errHostNotAllowed) {
|
||||||
t.Fatalf("hostpolicy, got err %v, expected errHostNotAllowed", err)
|
t.Fatalf("hostpolicy, got err %v, expected errHostNotAllowed", err)
|
||||||
}
|
}
|
||||||
m.SetAllowedHostnames(log, dns.StrictResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
|
m.SetAllowedHostnames(log, dns.MockResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
|
||||||
l = m.Hostnames()
|
l = m.Hostnames()
|
||||||
if !reflect.DeepEqual(l, []dns.Domain{{ASCII: "mox.example"}}) {
|
if !reflect.DeepEqual(l, []dns.Domain{{ASCII: "mox.example"}}) {
|
||||||
t.Fatalf("hostnames, got %v, expected single mox.example", l)
|
t.Fatalf("hostnames, got %v, expected single mox.example", l)
|
||||||
|
@ -90,7 +90,7 @@ func TestAutotls(t *testing.T) {
|
||||||
t.Fatalf("private key changed after reload")
|
t.Fatalf("private key changed after reload")
|
||||||
}
|
}
|
||||||
m.shutdown = make(chan struct{})
|
m.shutdown = make(chan struct{})
|
||||||
m.SetAllowedHostnames(log, dns.StrictResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
|
m.SetAllowedHostnames(log, dns.MockResolver{}, map[dns.Domain]struct{}{{ASCII: "mox.example"}: {}}, nil, false)
|
||||||
if err := m.HostPolicy(context.Background(), "mox.example"); err != nil {
|
if err := m.HostPolicy(context.Background(), "mox.example"); err != nil {
|
||||||
t.Fatalf("hostpolicy, got err %v, expected no error", err)
|
t.Fatalf("hostpolicy, got err %v, expected no error", err)
|
||||||
}
|
}
|
||||||
|
|
27
dane/dane.go
27
dane/dane.go
|
@ -36,11 +36,11 @@
|
||||||
//
|
//
|
||||||
// For TLS certificate verification that requires PKIX/WebPKI/trusted-anchor
|
// For TLS certificate verification that requires PKIX/WebPKI/trusted-anchor
|
||||||
// verification (all except DANE-EE), the potential second TLSA candidate base
|
// verification (all except DANE-EE), the potential second TLSA candidate base
|
||||||
// domain name is also valid. With SMTP, additionally for hosts found in MX records
|
// domain name is also a valid hostname. With SMTP, additionally for hosts found in
|
||||||
// for a "next-hop domain", the "original next-hop domain" (domain of an email
|
// MX records for a "next-hop domain", the "original next-hop domain" (domain of an
|
||||||
// address to deliver to) is also a valid name, as is the "CNAME-expanded original
|
// email address to deliver to) is also a valid name, as is the "CNAME-expanded
|
||||||
// next-hop domain", bringing the potential total allowed names to four (if CNAMEs
|
// original next-hop domain", bringing the potential total allowed names to four
|
||||||
// are followed for the MX hosts).
|
// (if CNAMEs are followed for the MX hosts).
|
||||||
package dane
|
package dane
|
||||||
|
|
||||||
// todo: why is https://datatracker.ietf.org/doc/html/draft-barnes-dane-uks-00 not in use? sounds reasonable.
|
// todo: why is https://datatracker.ietf.org/doc/html/draft-barnes-dane-uks-00 not in use? sounds reasonable.
|
||||||
|
@ -105,10 +105,10 @@ func (e VerifyError) Unwrap() error {
|
||||||
return e.Err
|
return e.Err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dial looks up a DNSSEC-protected DANE TLSA record for the domain name and
|
// Dial looks up DNSSEC-protected DANE TLSA records for the domain name and
|
||||||
// port/service in address, checks for allowed usages, makes a network connection
|
// port/service in address, checks for allowed usages, makes a network connection
|
||||||
// and verifies the remote certificate against the TLSA records. If
|
// and verifies the remote certificate against the TLSA records. If verification
|
||||||
// verification succeeds, the verified record is returned.
|
// succeeds, the verified record is returned.
|
||||||
//
|
//
|
||||||
// Different protocols require different usages. For example, SMTP with STARTTLS
|
// Different protocols require different usages. For example, SMTP with STARTTLS
|
||||||
// for delivery only allows usages DANE-TA and DANE-EE. If allowedUsages is
|
// for delivery only allows usages DANE-TA and DANE-EE. If allowedUsages is
|
||||||
|
@ -273,7 +273,7 @@ func Dial(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, network
|
||||||
// TLSClientConfig returns a tls.Config to be used for dialing/handshaking a
|
// TLSClientConfig returns a tls.Config to be used for dialing/handshaking a
|
||||||
// TLS connection with DANE verification.
|
// TLS connection with DANE verification.
|
||||||
//
|
//
|
||||||
// Callers should only pass records that are allowed for the use of DANE. DANE
|
// Callers should only pass records that are allowed for the intended use. DANE
|
||||||
// with SMTP only allows DANE-EE and DANE-TA usages, not the PKIX-usages.
|
// with SMTP only allows DANE-EE and DANE-TA usages, not the PKIX-usages.
|
||||||
//
|
//
|
||||||
// The config has InsecureSkipVerify set to true, with a custom VerifyConnection
|
// The config has InsecureSkipVerify set to true, with a custom VerifyConnection
|
||||||
|
@ -317,11 +317,16 @@ func TLSClientConfig(elog *slog.Logger, records []adns.TLSA, allowedHost dns.Dom
|
||||||
//
|
//
|
||||||
// When one of the records matches, Verify returns true, along with the matching
|
// When one of the records matches, Verify returns true, along with the matching
|
||||||
// record and a nil error.
|
// record and a nil error.
|
||||||
// If there is no match, then in the typical case false, a zero record value and a
|
// If there is no match, then in the typical case Verify returns: false, a zero
|
||||||
// nil error is returned.
|
// record value and a nil error.
|
||||||
// If an error is encountered while verifying a record, e.g. for x509
|
// If an error is encountered while verifying a record, e.g. for x509
|
||||||
// trusted-anchor verification, an error may be returned, typically one or more
|
// trusted-anchor verification, an error may be returned, typically one or more
|
||||||
// (wrapped) errors of type VerifyError.
|
// (wrapped) errors of type VerifyError.
|
||||||
|
//
|
||||||
|
// Verify is useful when DANE verification and its results has to be done
|
||||||
|
// separately from other validation, e.g. for MTA-STS. The caller can create a
|
||||||
|
// tls.Config with a VerifyConnection function that checks DANE and MTA-STS
|
||||||
|
// separately.
|
||||||
func Verify(elog *slog.Logger, records []adns.TLSA, cs tls.ConnectionState, allowedHost dns.Domain, moreAllowedHosts []dns.Domain, pkixRoots *x509.CertPool) (verified bool, matching adns.TLSA, rerr error) {
|
func Verify(elog *slog.Logger, records []adns.TLSA, cs tls.ConnectionState, allowedHost dns.Domain, moreAllowedHosts []dns.Domain, pkixRoots *x509.CertPool) (verified bool, matching adns.TLSA, rerr error) {
|
||||||
log := mlog.New("dane", elog)
|
log := mlog.New("dane", elog)
|
||||||
MetricVerify.Inc()
|
MetricVerify.Inc()
|
||||||
|
|
33
dane/examples_test.go
Normal file
33
dane/examples_test.go
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
package dane_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/x509"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/adns"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dane"
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleDial() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
usages := []adns.TLSAUsage{adns.TLSAUsageDANETA, adns.TLSAUsageDANEEE}
|
||||||
|
pkixRoots, err := x509.SystemCertPool()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("system pkix roots: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect to SMTP server, use STARTTLS, and verify TLS certificate with DANE.
|
||||||
|
conn, verifiedRecord, err := dane.Dial(ctx, slog.Default(), resolver, "tcp", "mx.example.com", usages, pkixRoots)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("dial: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
log.Printf("connected, conn %v, verified record %s", conn, verifiedRecord)
|
||||||
|
}
|
|
@ -58,7 +58,8 @@ const (
|
||||||
// Result is a DMARC policy evaluation.
|
// Result is a DMARC policy evaluation.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
// Whether to reject the message based on policies. If false, the message should
|
// Whether to reject the message based on policies. If false, the message should
|
||||||
// not necessarily be accepted, e.g. due to reputation or content-based analysis.
|
// not necessarily be accepted: other checks such as reputation-based and
|
||||||
|
// content-based analysis may lead to reject the message.
|
||||||
Reject bool
|
Reject bool
|
||||||
// Result of DMARC validation. A message can fail validation, but still
|
// Result of DMARC validation. A message can fail validation, but still
|
||||||
// not be rejected, e.g. if the policy is "none".
|
// not be rejected, e.g. if the policy is "none".
|
||||||
|
@ -86,12 +87,12 @@ type Result struct {
|
||||||
// domain is the domain with the DMARC record.
|
// domain is the domain with the DMARC record.
|
||||||
//
|
//
|
||||||
// rauthentic indicates if the DNS results were DNSSEC-verified.
|
// rauthentic indicates if the DNS results were DNSSEC-verified.
|
||||||
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from dns.Domain) (status Status, domain dns.Domain, record *Record, txt string, rauthentic bool, rerr error) {
|
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFrom dns.Domain) (status Status, domain dns.Domain, record *Record, txt string, rauthentic bool, rerr error) {
|
||||||
log := mlog.New("dmarc", elog)
|
log := mlog.New("dmarc", elog)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
log.Debugx("dmarc lookup result", rerr,
|
log.Debugx("dmarc lookup result", rerr,
|
||||||
slog.Any("fromdomain", from),
|
slog.Any("fromdomain", msgFrom),
|
||||||
slog.Any("status", status),
|
slog.Any("status", status),
|
||||||
slog.Any("domain", domain),
|
slog.Any("domain", domain),
|
||||||
slog.Any("record", record),
|
slog.Any("record", record),
|
||||||
|
@ -99,15 +100,15 @@ func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// ../rfc/7489:859 ../rfc/7489:1370
|
// ../rfc/7489:859 ../rfc/7489:1370
|
||||||
domain = from
|
domain = msgFrom
|
||||||
status, record, txt, authentic, err := lookupRecord(ctx, resolver, domain)
|
status, record, txt, authentic, err := lookupRecord(ctx, resolver, domain)
|
||||||
if status != StatusNone {
|
if status != StatusNone {
|
||||||
return status, domain, record, txt, authentic, err
|
return status, domain, record, txt, authentic, err
|
||||||
}
|
}
|
||||||
if record == nil {
|
if record == nil {
|
||||||
// ../rfc/7489:761 ../rfc/7489:1377
|
// ../rfc/7489:761 ../rfc/7489:1377
|
||||||
domain = publicsuffix.Lookup(ctx, log.Logger, from)
|
domain = publicsuffix.Lookup(ctx, log.Logger, msgFrom)
|
||||||
if domain == from {
|
if domain == msgFrom {
|
||||||
return StatusNone, domain, nil, txt, authentic, err
|
return StatusNone, domain, nil, txt, authentic, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,8 +223,9 @@ func LookupExternalReportsAccepted(ctx context.Context, elog *slog.Logger, resol
|
||||||
// Verify always returns the result of verifying the DMARC policy
|
// Verify always returns the result of verifying the DMARC policy
|
||||||
// against the message (for inclusion in Authentication-Result headers).
|
// against the message (for inclusion in Authentication-Result headers).
|
||||||
//
|
//
|
||||||
// useResult indicates if the result should be applied in a policy decision.
|
// useResult indicates if the result should be applied in a policy decision,
|
||||||
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from dns.Domain, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, applyRandomPercentage bool) (useResult bool, result Result) {
|
// based on the "pct" field in the DMARC record.
|
||||||
|
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, msgFrom dns.Domain, dkimResults []dkim.Result, spfResult spf.Status, spfIdentity *dns.Domain, applyRandomPercentage bool) (useResult bool, result Result) {
|
||||||
log := mlog.New("dmarc", elog)
|
log := mlog.New("dmarc", elog)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
|
@ -237,7 +239,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
||||||
}
|
}
|
||||||
MetricVerify.ObserveLabels(float64(time.Since(start))/float64(time.Second), string(result.Status), reject, use)
|
MetricVerify.ObserveLabels(float64(time.Since(start))/float64(time.Second), string(result.Status), reject, use)
|
||||||
log.Debugx("dmarc verify result", result.Err,
|
log.Debugx("dmarc verify result", result.Err,
|
||||||
slog.Any("fromdomain", from),
|
slog.Any("fromdomain", msgFrom),
|
||||||
slog.Any("dkimresults", dkimResults),
|
slog.Any("dkimresults", dkimResults),
|
||||||
slog.Any("spfresult", spfResult),
|
slog.Any("spfresult", spfResult),
|
||||||
slog.Any("status", result.Status),
|
slog.Any("status", result.Status),
|
||||||
|
@ -246,7 +248,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
||||||
slog.Duration("duration", time.Since(start)))
|
slog.Duration("duration", time.Since(start)))
|
||||||
}()
|
}()
|
||||||
|
|
||||||
status, recordDomain, record, _, authentic, err := Lookup(ctx, log.Logger, resolver, from)
|
status, recordDomain, record, _, authentic, err := Lookup(ctx, log.Logger, resolver, msgFrom)
|
||||||
if record == nil {
|
if record == nil {
|
||||||
return false, Result{false, status, false, false, recordDomain, record, authentic, err}
|
return false, Result{false, status, false, false, recordDomain, record, authentic, err}
|
||||||
}
|
}
|
||||||
|
@ -261,7 +263,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
||||||
// We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
|
// We treat "quarantine" and "reject" the same. Thus, we also don't "downgrade"
|
||||||
// from reject to quarantine if this message was sampled out.
|
// from reject to quarantine if this message was sampled out.
|
||||||
// ../rfc/7489:1446 ../rfc/7489:1024
|
// ../rfc/7489:1446 ../rfc/7489:1024
|
||||||
if recordDomain != from && record.SubdomainPolicy != PolicyEmpty {
|
if recordDomain != msgFrom && record.SubdomainPolicy != PolicyEmpty {
|
||||||
result.Reject = record.SubdomainPolicy != PolicyNone
|
result.Reject = record.SubdomainPolicy != PolicyNone
|
||||||
} else {
|
} else {
|
||||||
result.Reject = record.Policy != PolicyNone
|
result.Reject = record.Policy != PolicyNone
|
||||||
|
@ -288,7 +290,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
||||||
|
|
||||||
// ../rfc/7489:1319
|
// ../rfc/7489:1319
|
||||||
// ../rfc/7489:544
|
// ../rfc/7489:544
|
||||||
if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == from || result.Record.ASPF == "r" && pubsuffix(from) == pubsuffix(*spfIdentity)) {
|
if spfResult == spf.StatusPass && spfIdentity != nil && (*spfIdentity == msgFrom || result.Record.ASPF == "r" && pubsuffix(msgFrom) == pubsuffix(*spfIdentity)) {
|
||||||
result.AlignedSPFPass = true
|
result.AlignedSPFPass = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -299,7 +301,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// ../rfc/7489:511
|
// ../rfc/7489:511
|
||||||
if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == from || result.Record.ADKIM == "r" && pubsuffix(from) == pubsuffix(dkimResult.Sig.Domain)) {
|
if dkimResult.Status == dkim.StatusPass && dkimResult.Sig != nil && (dkimResult.Sig.Domain == msgFrom || result.Record.ADKIM == "r" && pubsuffix(msgFrom) == pubsuffix(dkimResult.Sig.Domain)) {
|
||||||
// ../rfc/7489:535
|
// ../rfc/7489:535
|
||||||
result.AlignedDKIMPass = true
|
result.AlignedDKIMPass = true
|
||||||
break
|
break
|
||||||
|
|
86
dmarc/examples_test.go
Normal file
86
dmarc/examples_test.go
Normal file
|
@ -0,0 +1,86 @@
|
||||||
|
package dmarc_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dkim"
|
||||||
|
"github.com/mjl-/mox/dmarc"
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/message"
|
||||||
|
"github.com/mjl-/mox/spf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleLookup() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
msgFrom, err := dns.ParseDomain("sub.example.com")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parsing from domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup DMARC DNS record for domain.
|
||||||
|
status, domain, record, txt, authentic, err := dmarc.Lookup(ctx, slog.Default(), resolver, msgFrom)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("dmarc lookup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("status %s, domain %s, record %v, txt %q, dnssec %v", status, domain, record, txt, authentic)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleVerify() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
|
||||||
|
// Message to verify.
|
||||||
|
msg := strings.NewReader("From: <sender@example.com>\r\nMore: headers\r\n\r\nBody\r\n")
|
||||||
|
msgFrom, _, _, err := message.From(slog.Default(), true, msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parsing message for from header: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify SPF, for use with DMARC.
|
||||||
|
args := spf.Args{
|
||||||
|
RemoteIP: net.ParseIP("10.11.12.13"),
|
||||||
|
MailFromDomain: dns.Domain{ASCII: "sub.example.com"},
|
||||||
|
}
|
||||||
|
spfReceived, spfDomain, _, _, err := spf.Verify(ctx, slog.Default(), resolver, args)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("verifying spf: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DKIM-Signature headers, for use with DMARC.
|
||||||
|
smtputf8 := false
|
||||||
|
ignoreTestMode := false
|
||||||
|
dkimResults, err := dkim.Verify(ctx, slog.Default(), resolver, smtputf8, dkim.DefaultPolicy, msg, ignoreTestMode)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("verifying dkim: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify DMARC, based on DKIM and SPF results.
|
||||||
|
applyRandomPercentage := true
|
||||||
|
useResult, result := dmarc.Verify(ctx, slog.Default(), resolver, msgFrom.Domain, dkimResults, spfReceived.Result, &spfDomain, applyRandomPercentage)
|
||||||
|
|
||||||
|
// Print results.
|
||||||
|
log.Printf("dmarc status: %s", result.Status)
|
||||||
|
log.Printf("use result: %v", useResult)
|
||||||
|
if useResult && result.Reject {
|
||||||
|
log.Printf("should reject message")
|
||||||
|
}
|
||||||
|
log.Printf("result: %#v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleParseRecord() {
|
||||||
|
txt := "v=DMARC1; p=reject; rua=mailto:postmaster@mox.example"
|
||||||
|
|
||||||
|
record, isdmarc, err := dmarc.ParseRecord(txt)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parsing dmarc record: %v (isdmarc: %v)", err, isdmarc)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("parsed record: %v", record)
|
||||||
|
}
|
|
@ -19,12 +19,17 @@ func (e parseErr) Error() string {
|
||||||
// for easy comparison.
|
// for easy comparison.
|
||||||
//
|
//
|
||||||
// DefaultRecord provides default values for tags not present in s.
|
// DefaultRecord provides default values for tags not present in s.
|
||||||
|
//
|
||||||
|
// isdmarc indicates if the record starts tag "v" with value "DMARC1", and should
|
||||||
|
// be treated as a valid DMARC record. Used to detect possibly multiple DMARC
|
||||||
|
// records (invalid) for a domain with multiple TXT record (quite common).
|
||||||
func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
|
func ParseRecord(s string) (record *Record, isdmarc bool, rerr error) {
|
||||||
return parseRecord(s, true)
|
return parseRecord(s, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseRecordNoRequired is like ParseRecord, but don't check for required fields
|
// ParseRecordNoRequired is like ParseRecord, but don't check for required fields
|
||||||
// for regular DMARC records. Useful for checking the _report._dmarc record.
|
// for regular DMARC records. Useful for checking the _report._dmarc record,
|
||||||
|
// used for opting into receiving reports for other domains.
|
||||||
func ParseRecordNoRequired(s string) (record *Record, isdmarc bool, rerr error) {
|
func ParseRecordNoRequired(s string) (record *Record, isdmarc bool, rerr error) {
|
||||||
return parseRecord(s, false)
|
return parseRecord(s, false)
|
||||||
}
|
}
|
||||||
|
|
16
dmarc/txt.go
16
dmarc/txt.go
|
@ -55,17 +55,17 @@ const (
|
||||||
//
|
//
|
||||||
// v=DMARC1; p=reject; rua=mailto:postmaster@mox.example
|
// v=DMARC1; p=reject; rua=mailto:postmaster@mox.example
|
||||||
type Record struct {
|
type Record struct {
|
||||||
Version string // "v=DMARC1"
|
Version string // "v=DMARC1", fixed.
|
||||||
Policy DMARCPolicy // Required, for "p=".
|
Policy DMARCPolicy // Required, for "p=".
|
||||||
SubdomainPolicy DMARCPolicy // Like policy but for subdomains. Optional, for "sp=".
|
SubdomainPolicy DMARCPolicy // Like policy but for subdomains. Optional, for "sp=".
|
||||||
AggregateReportAddresses []URI // Optional, for "rua=".
|
AggregateReportAddresses []URI // Optional, for "rua=". Destination addresses for aggregate reports.
|
||||||
FailureReportAddresses []URI // Optional, for "ruf="
|
FailureReportAddresses []URI // Optional, for "ruf=". Destination addresses for failure reports.
|
||||||
ADKIM Align // "r" (default) for relaxed or "s" for simple. For "adkim=".
|
ADKIM Align // Alignment: "r" (default) for relaxed or "s" for simple. For "adkim=".
|
||||||
ASPF Align // "r" (default) for relaxed or "s" for simple. For "aspf=".
|
ASPF Align // Alignment: "r" (default) for relaxed or "s" for simple. For "aspf=".
|
||||||
AggregateReportingInterval int // Default 86400. For "ri="
|
AggregateReportingInterval int // In seconds, default 86400. For "ri="
|
||||||
FailureReportingOptions []string // "0" (default), "1", "d", "s". For "fo=".
|
FailureReportingOptions []string // "0" (default), "1", "d", "s". For "fo=".
|
||||||
ReportingFormat []string // "afrf" (default). Ffor "rf=".
|
ReportingFormat []string // "afrf" (default). For "rf=".
|
||||||
Percentage int // Between 0 and 100, default 100. For "pct=".
|
Percentage int // Between 0 and 100, default 100. For "pct=". Policy applies randomly to this percentage of messages.
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultRecord holds the defaults for a DMARC record.
|
// DefaultRecord holds the defaults for a DMARC record.
|
||||||
|
|
14
dns/dns.go
14
dns/dns.go
|
@ -23,13 +23,14 @@ var (
|
||||||
|
|
||||||
// Domain is a domain name, with one or more labels, with at least an ASCII
|
// Domain is a domain name, with one or more labels, with at least an ASCII
|
||||||
// representation, and for IDNA non-ASCII domains a unicode representation.
|
// representation, and for IDNA non-ASCII domains a unicode representation.
|
||||||
// The ASCII string must be used for DNS lookups.
|
// The ASCII string must be used for DNS lookups. The strings do not have a
|
||||||
|
// trailing dot. When using with StrictResolver, add the trailing dot.
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
// A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved
|
// A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved
|
||||||
// letters/digits/hyphens) labels. Always in lower case.
|
// letters/digits/hyphens) labels. Always in lower case. No trailing dot.
|
||||||
ASCII string
|
ASCII string
|
||||||
|
|
||||||
// Name as U-labels. Empty if this is an ASCII-only domain.
|
// Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.
|
||||||
Unicode string
|
Unicode string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,7 +69,8 @@ func (d Domain) String() string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LogString returns a domain for logging.
|
// LogString returns a domain for logging.
|
||||||
// For IDNA names, the string contains both the unicode and ASCII name.
|
// For IDNA names, the string is the slash-separated Unicode and ASCII name.
|
||||||
|
// For ASCII-only domain names, just the ASCII string is returned.
|
||||||
func (d Domain) LogString() string {
|
func (d Domain) LogString() string {
|
||||||
if d.Unicode == "" {
|
if d.Unicode == "" {
|
||||||
return d.ASCII
|
return d.ASCII
|
||||||
|
@ -151,8 +153,8 @@ func ParseDomainLax(s string) (Domain, error) {
|
||||||
//
|
//
|
||||||
// A DNS server can respond to a lookup with an error "nxdomain" to indicate a
|
// A DNS server can respond to a lookup with an error "nxdomain" to indicate a
|
||||||
// name does not exist (at all), or with a success status with an empty list.
|
// name does not exist (at all), or with a success status with an empty list.
|
||||||
// The Go resolver returns an IsNotFound error for both cases, there is no need
|
// The adns resolver (just like the Go resolver) returns an IsNotFound error for
|
||||||
// to explicitly check for zero entries.
|
// both cases, there is no need to explicitly check for zero entries.
|
||||||
func IsNotFound(err error) bool {
|
func IsNotFound(err error) bool {
|
||||||
var dnsErr *adns.DNSError
|
var dnsErr *adns.DNSError
|
||||||
return err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound
|
return err != nil && errors.As(err, &dnsErr) && dnsErr.IsNotFound
|
||||||
|
|
36
dns/examples_test.go
Normal file
36
dns/examples_test.go
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
package dns_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleParseDomain() {
|
||||||
|
// ASCII-only domain.
|
||||||
|
basic, err := dns.ParseDomain("example.com")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse domain: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", basic)
|
||||||
|
|
||||||
|
// IDNA domain xn--74h.example.
|
||||||
|
smile, err := dns.ParseDomain("☺.example")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse domain: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", smile)
|
||||||
|
|
||||||
|
// ASCII only domain curl.se in surprisingly allowed spelling.
|
||||||
|
surprising, err := dns.ParseDomain("ℂᵤⓇℒ。𝐒🄴")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parse domain: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", surprising)
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// example.com
|
||||||
|
// ☺.example/xn--74h.example
|
||||||
|
// curl.se
|
||||||
|
}
|
|
@ -1,4 +1,17 @@
|
||||||
// Package dnsbl implements DNS block lists (RFC 5782), for checking incoming messages from sources without reputation.
|
// Package dnsbl implements DNS block lists (RFC 5782), for checking incoming messages from sources without reputation.
|
||||||
|
//
|
||||||
|
// A DNS block list contains IP addresses that should be blocked. The DNSBL is
|
||||||
|
// queried using DNS "A" lookups. The DNSBL starts at a "zone", e.g.
|
||||||
|
// "dnsbl.example". To look up whether an IP address is listed, a DNS name is
|
||||||
|
// composed: For 10.11.12.13, that name would be "13.12.11.10.dnsbl.example". If
|
||||||
|
// the lookup returns "record does not exist", the IP is not listed. If an IP
|
||||||
|
// address is returned, the IP is listed. If an IP is listed, an additional TXT
|
||||||
|
// lookup is done for more information about the block. IPv6 addresses are also
|
||||||
|
// looked up with an DNS "A" lookup of a name similar to an IPv4 address, but with
|
||||||
|
// 4-bit hexadecimal dot-separated characters, in reverse.
|
||||||
|
//
|
||||||
|
// The health of a DNSBL "zone" can be check through a lookup of 127.0.0.1
|
||||||
|
// (must not be present) and 127.0.0.2 (must be present).
|
||||||
package dnsbl
|
package dnsbl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -21,7 +34,7 @@ var (
|
||||||
MetricLookup stub.HistogramVec = stub.HistogramVecIgnore{}
|
MetricLookup stub.HistogramVec = stub.HistogramVecIgnore{}
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrDNS = errors.New("dnsbl: dns error")
|
var ErrDNS = errors.New("dnsbl: dns error") // Temporary error.
|
||||||
|
|
||||||
// Status is the result of a DNSBL lookup.
|
// Status is the result of a DNSBL lookup.
|
||||||
type Status string
|
type Status string
|
||||||
|
|
31
dnsbl/examples_test.go
Normal file
31
dnsbl/examples_test.go
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
package dnsbl_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/dnsbl"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleLookup() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
|
||||||
|
// Lookup if ip 127.0.0.2 is in spamhaus blocklist at zone sbl.spamhaus.org.
|
||||||
|
status, explanation, err := dnsbl.Lookup(ctx, slog.Default(), resolver, dns.Domain{ASCII: "sbl.spamhaus.org"}, net.ParseIP("127.0.0.2"))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("dnsbl lookup: %v", err)
|
||||||
|
}
|
||||||
|
switch status {
|
||||||
|
case dnsbl.StatusTemperr:
|
||||||
|
log.Printf("dnsbl lookup, temporary dns error: %v", err)
|
||||||
|
case dnsbl.StatusPass:
|
||||||
|
log.Printf("dnsbl lookup, ip not listed")
|
||||||
|
case dnsbl.StatusFail:
|
||||||
|
log.Printf("dnsbl lookup, ip listed: %s", explanation)
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,7 +25,7 @@ var (
|
||||||
// Lookup errors.
|
// Lookup errors.
|
||||||
var (
|
var (
|
||||||
ErrNoRecord = errors.New("iprev: no reverse dns record")
|
ErrNoRecord = errors.New("iprev: no reverse dns record")
|
||||||
ErrDNS = errors.New("iprev: dns lookup")
|
ErrDNS = errors.New("iprev: dns lookup") // Temporary error.
|
||||||
)
|
)
|
||||||
|
|
||||||
// ../rfc/8601:1082
|
// ../rfc/8601:1082
|
||||||
|
|
196
message/examples_test.go
Normal file
196
message/examples_test.go
Normal file
|
@ -0,0 +1,196 @@
|
||||||
|
package message_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/message"
|
||||||
|
"github.com/mjl-/mox/smtp"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleDecodeReader() {
|
||||||
|
// Convert from iso-8859-1 to utf-8.
|
||||||
|
input := []byte{'t', 0xe9, 's', 't'}
|
||||||
|
output, err := io.ReadAll(message.DecodeReader("iso-8859-1", bytes.NewReader(input)))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("read from decoder: %v", err)
|
||||||
|
}
|
||||||
|
fmt.Printf("%s\n", string(output))
|
||||||
|
// Output: tést
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleMessageIDCanonical() {
|
||||||
|
// Valid message-id.
|
||||||
|
msgid, invalidAddress, err := message.MessageIDCanonical("<ok@localhost>")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("invalid message-id: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("canonical: %s %v\n", msgid, invalidAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Missing <>.
|
||||||
|
msgid, invalidAddress, err = message.MessageIDCanonical("bogus@localhost")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("invalid message-id: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("canonical: %s %v\n", msgid, invalidAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalid address, but returned as not being in error.
|
||||||
|
msgid, invalidAddress, err = message.MessageIDCanonical("<invalid>")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("invalid message-id: %v\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("canonical: %s %v\n", msgid, invalidAddress)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// canonical: ok@localhost false
|
||||||
|
// invalid message-id: not a message-id: missing <
|
||||||
|
// canonical: invalid true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleThreadSubject() {
|
||||||
|
// Basic subject.
|
||||||
|
s, isResp := message.ThreadSubject("nothing special", false)
|
||||||
|
fmt.Printf("%s, response: %v\n", s, isResp)
|
||||||
|
|
||||||
|
// List tags and "re:" are stripped.
|
||||||
|
s, isResp = message.ThreadSubject("[list1] [list2] Re: test", false)
|
||||||
|
fmt.Printf("%s, response: %v\n", s, isResp)
|
||||||
|
|
||||||
|
// "fwd:" is stripped.
|
||||||
|
s, isResp = message.ThreadSubject("fwd: a forward", false)
|
||||||
|
fmt.Printf("%s, response: %v\n", s, isResp)
|
||||||
|
|
||||||
|
// Trailing "(fwd)" is also a forward.
|
||||||
|
s, isResp = message.ThreadSubject("another forward (fwd)", false)
|
||||||
|
fmt.Printf("%s, response: %v\n", s, isResp)
|
||||||
|
|
||||||
|
// [fwd: ...] is stripped.
|
||||||
|
s, isResp = message.ThreadSubject("[fwd: [list] fwd: re: it's complicated]", false)
|
||||||
|
fmt.Printf("%s, response: %v\n", s, isResp)
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// nothing special, response: false
|
||||||
|
// test, response: true
|
||||||
|
// a forward, response: true
|
||||||
|
// another forward, response: true
|
||||||
|
// it's complicated, response: true
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleComposer() {
|
||||||
|
// We store in a buffer. We could also write to a file.
|
||||||
|
var b bytes.Buffer
|
||||||
|
|
||||||
|
// NewComposer. Keep in mind that operations on a Composer will panic on error.
|
||||||
|
xc := message.NewComposer(&b, 10*1024*1024)
|
||||||
|
|
||||||
|
// Catch and handle errors when composing.
|
||||||
|
defer func() {
|
||||||
|
x := recover()
|
||||||
|
if x == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
|
||||||
|
log.Printf("compose: %v", err)
|
||||||
|
}
|
||||||
|
panic(x)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Add an address header.
|
||||||
|
xc.HeaderAddrs("From", []message.NameAddress{{DisplayName: "Charlie", Address: smtp.Address{Localpart: "root", Domain: dns.Domain{ASCII: "localhost"}}}})
|
||||||
|
|
||||||
|
// Add subject header, with encoding
|
||||||
|
xc.Subject("hi ☺")
|
||||||
|
|
||||||
|
// Add Date and Message-ID headers, required.
|
||||||
|
tm, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05+07:00")
|
||||||
|
xc.Header("Date", tm.Format(message.RFC5322Z))
|
||||||
|
xc.Header("Message-ID", "<unique@host>") // Should generate unique id for each message.
|
||||||
|
|
||||||
|
xc.Header("MIME-Version", "1.0")
|
||||||
|
|
||||||
|
// Write content-* headers for the text body.
|
||||||
|
body, ct, cte := xc.TextPart("this is the body")
|
||||||
|
xc.Header("Content-Type", ct)
|
||||||
|
xc.Header("Content-Transfer-Encoding", cte)
|
||||||
|
|
||||||
|
// Header/Body separator
|
||||||
|
xc.Line()
|
||||||
|
|
||||||
|
// The part body. Use mime/multipart to make messages with multiple parts.
|
||||||
|
xc.Write(body)
|
||||||
|
|
||||||
|
// Flush any buffered writes to the original writer.
|
||||||
|
xc.Flush()
|
||||||
|
|
||||||
|
fmt.Println(strings.ReplaceAll(b.String(), "\r\n", "\n"))
|
||||||
|
// Output:
|
||||||
|
// From: "Charlie" <root@localhost>
|
||||||
|
// Subject: hi =?utf-8?q?=E2=98=BA?=
|
||||||
|
// Date: 2 Jan 2006 15:04:05 +0700
|
||||||
|
// Message-ID: <unique@host>
|
||||||
|
// MIME-Version: 1.0
|
||||||
|
// Content-Type: text/plain; charset=us-ascii
|
||||||
|
// Content-Transfer-Encoding: 7bit
|
||||||
|
//
|
||||||
|
// this is the body
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExamplePart() {
|
||||||
|
// Parse a message from an io.ReaderAt, which could be a file.
|
||||||
|
strict := false
|
||||||
|
r := strings.NewReader("header: value\r\nanother: value\r\n\r\nbody ...\r\n")
|
||||||
|
part, err := message.Parse(slog.Default(), strict, r)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parsing message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The headers of the first part have been parsed, i.e. the message headers.
|
||||||
|
// A message can be multipart (e.g. alternative, related, mixed), and possibly
|
||||||
|
// nested.
|
||||||
|
|
||||||
|
// By walking the entire message, all part metadata (like offsets into the file
|
||||||
|
// where a part starts) is recorded.
|
||||||
|
err = part.Walk(slog.Default(), nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("walking message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Messages can have a recursive multipart structure. Print the structure.
|
||||||
|
var printPart func(indent string, p message.Part)
|
||||||
|
printPart = func(indent string, p message.Part) {
|
||||||
|
log.Printf("%s- part: %v", indent, part)
|
||||||
|
for _, pp := range p.Parts {
|
||||||
|
printPart(" "+indent, pp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printPart("", part)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleWriter() {
|
||||||
|
// NewWriter on a string builder.
|
||||||
|
var b strings.Builder
|
||||||
|
w := message.NewWriter(&b)
|
||||||
|
|
||||||
|
// Write some lines, some with proper CRLF line ending, others without.
|
||||||
|
fmt.Fprint(w, "header: value\r\n")
|
||||||
|
fmt.Fprint(w, "another: value\n") // missing \r
|
||||||
|
fmt.Fprint(w, "\r\n")
|
||||||
|
fmt.Fprint(w, "hi ☺\n") // missing \r
|
||||||
|
|
||||||
|
fmt.Printf("%q\n", b.String())
|
||||||
|
fmt.Printf("%v %v", w.HaveBody, w.Has8bit)
|
||||||
|
// Output:
|
||||||
|
// "header: value\r\nanother: value\r\n\r\nhi ☺\r\n"
|
||||||
|
// true true
|
||||||
|
}
|
|
@ -8,8 +8,9 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// ParseHeaderFields parses only the header fields in "fields" from the complete
|
// ParseHeaderFields parses only the header fields in "fields" from the complete
|
||||||
// header buffer "header", while using "scratch" as temporary space, prevent lots
|
// header buffer "header". It uses "scratch" as temporary space, which can be
|
||||||
// of unneeded allocations when only a few headers are needed.
|
// reused across calls, potentially saving lots of unneeded allocations when only a
|
||||||
|
// few headers are needed and/or many messages are parsed.
|
||||||
func ParseHeaderFields(header []byte, scratch []byte, fields [][]byte) (textproto.MIMEHeader, error) {
|
func ParseHeaderFields(header []byte, scratch []byte, fields [][]byte) (textproto.MIMEHeader, error) {
|
||||||
// todo: should not use mail.ReadMessage, it allocates a bufio.Reader. should implement header parsing ourselves.
|
// todo: should not use mail.ReadMessage, it allocates a bufio.Reader. should implement header parsing ourselves.
|
||||||
|
|
||||||
|
|
|
@ -580,7 +580,7 @@ func (p *Part) Reader() io.Reader {
|
||||||
return p.bodyReader(p.RawReader())
|
return p.bodyReader(p.RawReader())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReaderUTF8OrBinary returns a reader for the decode body content, transformed to
|
// ReaderUTF8OrBinary returns a reader for the decoded body content, transformed to
|
||||||
// utf-8 for known mime/iana encodings (only if they aren't us-ascii or utf-8
|
// utf-8 for known mime/iana encodings (only if they aren't us-ascii or utf-8
|
||||||
// already). For unknown or missing character sets/encodings, the original reader
|
// already). For unknown or missing character sets/encodings, the original reader
|
||||||
// is returned.
|
// is returned.
|
||||||
|
|
|
@ -4,8 +4,10 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NeedsQuotedPrintable returns whether text should be encoded with
|
// NeedsQuotedPrintable returns whether text, with crlf-separated lines, should be
|
||||||
// quoted-printable. If not, it can be included as 7bit or 8bit encoding.
|
// encoded with quoted-printable, based on line lengths and any bare carriage
|
||||||
|
// return or bare newline. If not, it can be included as 7bit or 8bit encoding in a
|
||||||
|
// new message.
|
||||||
func NeedsQuotedPrintable(text string) bool {
|
func NeedsQuotedPrintable(text string) bool {
|
||||||
// ../rfc/2045:1025
|
// ../rfc/2045:1025
|
||||||
for _, line := range strings.Split(text, "\r\n") {
|
for _, line := range strings.Split(text, "\r\n") {
|
||||||
|
|
|
@ -9,6 +9,9 @@ import (
|
||||||
// always required to match to an existing thread, both if
|
// always required to match to an existing thread, both if
|
||||||
// References/In-Reply-To header(s) are present, and if not.
|
// References/In-Reply-To header(s) are present, and if not.
|
||||||
//
|
//
|
||||||
|
// isResponse indicates if this message is a response, such as a reply or a
|
||||||
|
// forward.
|
||||||
|
//
|
||||||
// Subject should already be q/b-word-decoded.
|
// Subject should already be q/b-word-decoded.
|
||||||
//
|
//
|
||||||
// If allowNull is true, base subjects with a \0 can be returned. If not set,
|
// If allowNull is true, base subjects with a \0 can be returned. If not set,
|
||||||
|
|
|
@ -9,9 +9,9 @@ import (
|
||||||
type Writer struct {
|
type Writer struct {
|
||||||
writer io.Writer
|
writer io.Writer
|
||||||
|
|
||||||
HaveBody bool // Body is optional. ../rfc/5322:343
|
HaveBody bool // Body is optional in a message. ../rfc/5322:343
|
||||||
Has8bit bool // Whether a byte with the high/8bit has been read. So whether this is 8BITMIME instead of 7BIT.
|
Has8bit bool // Whether a byte with the high/8bit has been read. So whether this needs SMTP 8BITMIME instead of 7BIT.
|
||||||
Size int64
|
Size int64 // Number of bytes written, may be different from bytes read due to LF to CRLF conversion.
|
||||||
|
|
||||||
tail [3]byte // For detecting header/body-separating crlf.
|
tail [3]byte // For detecting header/body-separating crlf.
|
||||||
// todo: should be parsing headers here, as we go
|
// todo: should be parsing headers here, as we go
|
||||||
|
@ -22,7 +22,9 @@ func NewWriter(w io.Writer) *Writer {
|
||||||
return &Writer{writer: w, tail: [3]byte{0, '\r', '\n'}}
|
return &Writer{writer: w, tail: [3]byte{0, '\r', '\n'}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write implements io.Writer.
|
// Write implements io.Writer, and writes buf as message to the Writer's underlying
|
||||||
|
// io.Writer. It converts bare new lines (LF) to carriage returns with new lines
|
||||||
|
// (CRLF).
|
||||||
func (w *Writer) Write(buf []byte) (int, error) {
|
func (w *Writer) Write(buf []byte) (int, error) {
|
||||||
origtail := w.tail
|
origtail := w.tail
|
||||||
|
|
||||||
|
|
37
mtasts/examples_test.go
Normal file
37
mtasts/examples_test.go
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
package mtasts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/mtasts"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleGet() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
|
||||||
|
// Get for example.org does a DNS TXT lookup at _mta-sts.example.org.
|
||||||
|
// If the record exists, the policy is fetched from https://mta-sts.<domain>/.well-known/mta-sts.txt, and parsed.
|
||||||
|
record, policy, policyText, err := mtasts.Get(ctx, slog.Default(), resolver, dns.Domain{ASCII: "example.org"})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("looking up mta-sts record and fetching policy: %v", err)
|
||||||
|
if !errors.Is(err, mtasts.ErrDNS) {
|
||||||
|
log.Printf("domain does not implement mta-sts")
|
||||||
|
}
|
||||||
|
// Continuing, we may have a record but not a policy.
|
||||||
|
} else {
|
||||||
|
log.Printf("domain implements mta-sts")
|
||||||
|
}
|
||||||
|
if record != nil {
|
||||||
|
log.Printf("mta-sts DNS record: %#v", record)
|
||||||
|
}
|
||||||
|
if policy != nil {
|
||||||
|
log.Printf("mta-sts policy: %#v", policy)
|
||||||
|
log.Printf("mta-sts policy text:\n%s", policyText)
|
||||||
|
}
|
||||||
|
}
|
|
@ -72,7 +72,7 @@ type Mode string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ModeEnforce Mode = "enforce" // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made.
|
ModeEnforce Mode = "enforce" // Policy must be followed, i.e. deliveries must fail if a TLS connection cannot be made.
|
||||||
ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLS-RPT.
|
ModeTesting Mode = "testing" // In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT.
|
||||||
ModeNone Mode = "none" // In case MTA-STS is not or no longer implemented.
|
ModeNone Mode = "none" // In case MTA-STS is not or no longer implemented.
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
18
publicsuffix/examples_test.go
Normal file
18
publicsuffix/examples_test.go
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
package publicsuffix_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/publicsuffix"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleLookup() {
|
||||||
|
// Lookup the organizational domain for sub.example.org.
|
||||||
|
orgDom := publicsuffix.Lookup(context.Background(), slog.Default(), dns.Domain{ASCII: "sub.example.org"})
|
||||||
|
fmt.Println(orgDom)
|
||||||
|
// Output: example.org
|
||||||
|
}
|
49
ratelimit/examples_test.go
Normal file
49
ratelimit/examples_test.go
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleLimiter() {
|
||||||
|
// Make a new rate limit that has maxima per minute, hour and day. The maxima are
|
||||||
|
// tracked per ipmasked1 (ipv4 /32 or ipv6 /64), ipmasked2 (ipv4 /26 or ipv6 /48)
|
||||||
|
// and ipmasked3 (ipv4 /21 or ipv6 /32).
|
||||||
|
//
|
||||||
|
// It is common to allow short bursts (with a narrow window), but not allow a high
|
||||||
|
// sustained rate (with wide window).
|
||||||
|
limit := ratelimit.Limiter{
|
||||||
|
WindowLimits: []ratelimit.WindowLimit{
|
||||||
|
{Window: time.Minute, Limits: [...]int64{2, 3, 4}},
|
||||||
|
{Window: time.Hour, Limits: [...]int64{4, 6, 8}},
|
||||||
|
{Window: 24 * time.Hour, Limits: [...]int64{20, 40, 60}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tm, _ := time.Parse(time.RFC3339, "2006-01-02T15:04:05Z")
|
||||||
|
|
||||||
|
fmt.Println("1:", limit.Add(net.ParseIP("127.0.0.1"), tm, 1)) // Success.
|
||||||
|
fmt.Println("2:", limit.Add(net.ParseIP("127.0.0.1"), tm, 1)) // Success.
|
||||||
|
fmt.Println("3:", limit.Add(net.ParseIP("127.0.0.1"), tm, 1)) // Failure, too many from same ip.
|
||||||
|
fmt.Println("4:", limit.Add(net.ParseIP("127.0.0.2"), tm, 1)) // Success, different IP, though nearby.
|
||||||
|
fmt.Println("5:", limit.Add(net.ParseIP("127.0.0.2"), tm, 1)) // Failure, hits ipmasked2 check.
|
||||||
|
fmt.Println("6:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(time.Minute), 1)) // Success, in next minute.
|
||||||
|
fmt.Println("7:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(2*time.Minute), 1)) // Success, in another minute.
|
||||||
|
fmt.Println("8:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(3*time.Minute), 1)) // Failure, hitting hourly window for ipmasked1.
|
||||||
|
limit.Reset(net.ParseIP("127.0.0.1"), tm.Add(3*time.Minute))
|
||||||
|
fmt.Println("9:", limit.Add(net.ParseIP("127.0.0.1"), tm.Add(3*time.Minute), 1)) // Success.
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// 1: true
|
||||||
|
// 2: true
|
||||||
|
// 3: false
|
||||||
|
// 4: true
|
||||||
|
// 5: false
|
||||||
|
// 6: true
|
||||||
|
// 7: true
|
||||||
|
// 8: false
|
||||||
|
// 9: true
|
||||||
|
}
|
39
sasl/sasl.go
39
sasl/sasl.go
|
@ -12,16 +12,28 @@ import (
|
||||||
"github.com/mjl-/mox/scram"
|
"github.com/mjl-/mox/scram"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client is a SASL client
|
// Client is a SASL client.
|
||||||
|
//
|
||||||
|
// A SASL client can be used for authentication in IMAP, SMTP and other protocols.
|
||||||
|
// A client and server exchange messages in step lock. In IMAP and SMTP, these
|
||||||
|
// messages are encoded with base64. Each SASL mechanism has predefined steps, but
|
||||||
|
// the transaction can be aborted by either side at any time. An IMAP or SMTP
|
||||||
|
// client must choose a SASL mechanism, instantiate a SASL client, and call Next
|
||||||
|
// with a nil parameter. The resulting data must be written to the server, properly
|
||||||
|
// encoded. The client must then read the response from the server and feed it to
|
||||||
|
// the SASL client, which will return more data to send, or an error.
|
||||||
type Client interface {
|
type Client interface {
|
||||||
// Name as used in SMTP AUTH, e.g. PLAIN, CRAM-MD5, SCRAM-SHA-256.
|
// Name as used in SMTP or IMAP authentication, e.g. PLAIN, CRAM-MD5,
|
||||||
// cleartextCredentials indicates if credentials are exchanged in clear text, which influences whether they are logged.
|
// SCRAM-SHA-256. cleartextCredentials indicates if credentials are exchanged in
|
||||||
|
// clear text, which can be used to decide if the exchange is logged.
|
||||||
Info() (name string, cleartextCredentials bool)
|
Info() (name string, cleartextCredentials bool)
|
||||||
|
|
||||||
// Next is called for each step of the SASL communication. The first call has a nil
|
// Next must be called for each step of the SASL transaction. The first call has a
|
||||||
// fromServer and serves to get a possible "initial response" from the client. If
|
// nil fromServer and serves to get a possible "initial response" from the client
|
||||||
// the client sends its final message it indicates so with last. Returning an error
|
// to the server. When last is true, the message from client to server is the last
|
||||||
// aborts the authentication attempt.
|
// one, and the server must send a verdict. If err is set, the transaction must be
|
||||||
|
// aborted.
|
||||||
|
//
|
||||||
// For the first toServer ("initial response"), a nil toServer indicates there is
|
// For the first toServer ("initial response"), a nil toServer indicates there is
|
||||||
// no data, which is different from a non-nil zero-length toServer.
|
// no data, which is different from a non-nil zero-length toServer.
|
||||||
Next(fromServer []byte) (toServer []byte, last bool, err error)
|
Next(fromServer []byte) (toServer []byte, last bool, err error)
|
||||||
|
@ -35,6 +47,9 @@ type clientPlain struct {
|
||||||
var _ Client = (*clientPlain)(nil)
|
var _ Client = (*clientPlain)(nil)
|
||||||
|
|
||||||
// NewClientPlain returns a client for SASL PLAIN authentication.
|
// NewClientPlain returns a client for SASL PLAIN authentication.
|
||||||
|
//
|
||||||
|
// PLAIN is specified in RFC 4616, The PLAIN Simple Authentication and Security
|
||||||
|
// Layer (SASL) Mechanism.
|
||||||
func NewClientPlain(username, password string) Client {
|
func NewClientPlain(username, password string) Client {
|
||||||
return &clientPlain{username, password, 0}
|
return &clientPlain{username, password, 0}
|
||||||
}
|
}
|
||||||
|
@ -61,6 +76,7 @@ type clientLogin struct {
|
||||||
var _ Client = (*clientLogin)(nil)
|
var _ Client = (*clientLogin)(nil)
|
||||||
|
|
||||||
// NewClientLogin returns a client for the obsolete SASL LOGIN authentication.
|
// NewClientLogin returns a client for the obsolete SASL LOGIN authentication.
|
||||||
|
//
|
||||||
// See https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
|
// See https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
|
||||||
func NewClientLogin(username, password string) Client {
|
func NewClientLogin(username, password string) Client {
|
||||||
return &clientLogin{username, password, 0}
|
return &clientLogin{username, password, 0}
|
||||||
|
@ -90,6 +106,9 @@ type clientCRAMMD5 struct {
|
||||||
var _ Client = (*clientCRAMMD5)(nil)
|
var _ Client = (*clientCRAMMD5)(nil)
|
||||||
|
|
||||||
// NewClientCRAMMD5 returns a client for SASL CRAM-MD5 authentication.
|
// NewClientCRAMMD5 returns a client for SASL CRAM-MD5 authentication.
|
||||||
|
//
|
||||||
|
// CRAM-MD5 is specified in RFC 2195, IMAP/POP AUTHorize Extension for Simple
|
||||||
|
// Challenge/Response.
|
||||||
func NewClientCRAMMD5(username, password string) Client {
|
func NewClientCRAMMD5(username, password string) Client {
|
||||||
return &clientCRAMMD5{username, password, 0}
|
return &clientCRAMMD5{username, password, 0}
|
||||||
}
|
}
|
||||||
|
@ -160,11 +179,17 @@ type clientSCRAMSHA struct {
|
||||||
var _ Client = (*clientSCRAMSHA)(nil)
|
var _ Client = (*clientSCRAMSHA)(nil)
|
||||||
|
|
||||||
// NewClientSCRAMSHA1 returns a client for SASL SCRAM-SHA-1 authentication.
|
// NewClientSCRAMSHA1 returns a client for SASL SCRAM-SHA-1 authentication.
|
||||||
|
//
|
||||||
|
// SCRAM-SHA-1 is specified in RFC 5802, Salted Challenge Response Authentication
|
||||||
|
// Mechanism (SCRAM) SASL and GSS-API Mechanisms.
|
||||||
func NewClientSCRAMSHA1(username, password string) Client {
|
func NewClientSCRAMSHA1(username, password string) Client {
|
||||||
return &clientSCRAMSHA{username, password, "SCRAM-SHA-1", 0, nil}
|
return &clientSCRAMSHA{username, password, "SCRAM-SHA-1", 0, nil}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClientSCRAMSHA256 returns a client for SASL SCRAM-SHA-256 authentication.
|
// NewClientSCRAMSHA256 returns a client for SASL SCRAM-SHA-256 authentication.
|
||||||
|
//
|
||||||
|
// SCRAM-SHA-256 is specified in RFC 7677, SCRAM-SHA-256 and SCRAM-SHA-256-PLUS
|
||||||
|
// Simple Authentication and Security Layer (SASL) Mechanisms.
|
||||||
func NewClientSCRAMSHA256(username, password string) Client {
|
func NewClientSCRAMSHA256(username, password string) Client {
|
||||||
return &clientSCRAMSHA{username, password, "SCRAM-SHA-256", 0, nil}
|
return &clientSCRAMSHA{username, password, "SCRAM-SHA-256", 0, nil}
|
||||||
}
|
}
|
||||||
|
|
70
scram/examples_test.go
Normal file
70
scram/examples_test.go
Normal file
|
@ -0,0 +1,70 @@
|
||||||
|
package scram_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/scram"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
// Prepare credentials.
|
||||||
|
//
|
||||||
|
// The client normally remembers the password and uses it during authentication.
|
||||||
|
//
|
||||||
|
// The server sets the iteration count, generates a salt and uses the password once
|
||||||
|
// to generate salted password hash. The salted password hash is used to
|
||||||
|
// authenticate the client during authentication.
|
||||||
|
iterations := 4096
|
||||||
|
salt := scram.MakeRandom()
|
||||||
|
password := "test1234"
|
||||||
|
saltedPassword := scram.SaltPassword(sha256.New, password, salt, iterations)
|
||||||
|
|
||||||
|
check := func(err error, msg string) {
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("%s: %s", msg, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make a new client for authenticating user mjl with SCRAM-SHA-256.
|
||||||
|
username := "mjl"
|
||||||
|
authz := ""
|
||||||
|
client := scram.NewClient(sha256.New, username, authz)
|
||||||
|
clientFirst, err := client.ClientFirst()
|
||||||
|
check(err, "client.ClientFirst")
|
||||||
|
|
||||||
|
// Instantia a new server with the initial message from the client.
|
||||||
|
server, err := scram.NewServer(sha256.New, []byte(clientFirst))
|
||||||
|
check(err, "NewServer")
|
||||||
|
|
||||||
|
// Generate first message from server to client, with a challenge.
|
||||||
|
serverFirst, err := server.ServerFirst(iterations, salt)
|
||||||
|
check(err, "server.ServerFirst")
|
||||||
|
|
||||||
|
// Continue at client with first message from server, resulting in message from
|
||||||
|
// client to server.
|
||||||
|
clientFinal, err := client.ServerFirst([]byte(serverFirst), password)
|
||||||
|
check(err, "client.ServerFirst")
|
||||||
|
|
||||||
|
// Continue at server with message from client.
|
||||||
|
// The server authenticates the client in this step.
|
||||||
|
serverFinal, err := server.Finish([]byte(clientFinal), saltedPassword)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("server does not accept client credentials")
|
||||||
|
} else {
|
||||||
|
fmt.Println("server has accepted client credentials")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, the client verifies that the server knows the salted password hash.
|
||||||
|
err = client.ServerFinal([]byte(serverFinal))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("client does not accept server")
|
||||||
|
} else {
|
||||||
|
fmt.Println("client has accepted server")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output:
|
||||||
|
// server has accepted client credentials
|
||||||
|
// client has accepted server
|
||||||
|
}
|
|
@ -2,7 +2,8 @@
|
||||||
//
|
//
|
||||||
// SCRAM-SHA-256 and SCRAM-SHA-1 allow a client to authenticate to a server using a
|
// SCRAM-SHA-256 and SCRAM-SHA-1 allow a client to authenticate to a server using a
|
||||||
// password without handing plaintext password over to the server. The client also
|
// password without handing plaintext password over to the server. The client also
|
||||||
// verifies the server knows (a derivative of) the password.
|
// verifies the server knows (a derivative of) the password. Both the client and
|
||||||
|
// server side are implemented.
|
||||||
package scram
|
package scram
|
||||||
|
|
||||||
// todo: test with messages that contains extensions
|
// todo: test with messages that contains extensions
|
||||||
|
@ -88,8 +89,8 @@ func SaltPassword(h func() hash.Hash, password string, salt []byte, iterations i
|
||||||
return pbkdf2.Key([]byte(password), salt, iterations, h().Size(), h)
|
return pbkdf2.Key([]byte(password), salt, iterations, h().Size(), h)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HMAC returns the hmac with key over msg.
|
// hmac0 returns the hmac with key over msg.
|
||||||
func HMAC(h func() hash.Hash, key []byte, msg string) []byte {
|
func hmac0(h func() hash.Hash, key []byte, msg string) []byte {
|
||||||
mac := hmac.New(h, key)
|
mac := hmac.New(h, key)
|
||||||
mac.Write([]byte(msg))
|
mac.Write([]byte(msg))
|
||||||
return mac.Sum(nil)
|
return mac.Sum(nil)
|
||||||
|
@ -211,19 +212,19 @@ func (s *Server) Finish(clientFinal []byte, saltedPassword []byte) (serverFinal
|
||||||
|
|
||||||
msg := s.clientFirstBare + "," + s.serverFirst + "," + s.clientFinalWithoutProof
|
msg := s.clientFirstBare + "," + s.serverFirst + "," + s.clientFinalWithoutProof
|
||||||
|
|
||||||
clientKey := HMAC(s.h, saltedPassword, "Client Key")
|
clientKey := hmac0(s.h, saltedPassword, "Client Key")
|
||||||
h := s.h()
|
h := s.h()
|
||||||
h.Write(clientKey)
|
h.Write(clientKey)
|
||||||
storedKey := h.Sum(nil)
|
storedKey := h.Sum(nil)
|
||||||
|
|
||||||
clientSig := HMAC(s.h, storedKey, msg)
|
clientSig := hmac0(s.h, storedKey, msg)
|
||||||
xor(clientSig, clientKey) // Now clientProof.
|
xor(clientSig, clientKey) // Now clientProof.
|
||||||
if !bytes.Equal(clientSig, proof) {
|
if !bytes.Equal(clientSig, proof) {
|
||||||
return "e=" + string(ErrInvalidProof), ErrInvalidProof
|
return "e=" + string(ErrInvalidProof), ErrInvalidProof
|
||||||
}
|
}
|
||||||
|
|
||||||
serverKey := HMAC(s.h, saltedPassword, "Server Key")
|
serverKey := hmac0(s.h, saltedPassword, "Server Key")
|
||||||
serverSig := HMAC(s.h, serverKey, msg)
|
serverSig := hmac0(s.h, serverKey, msg)
|
||||||
return fmt.Sprintf("v=%s", base64.StdEncoding.EncodeToString(serverSig)), nil
|
return fmt.Sprintf("v=%s", base64.StdEncoding.EncodeToString(serverSig)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -321,11 +322,11 @@ func (c *Client) ServerFirst(serverFirst []byte, password string) (clientFinal s
|
||||||
c.authMessage = c.clientFirstBare + "," + c.serverFirst + "," + c.clientFinalWithoutProof
|
c.authMessage = c.clientFirstBare + "," + c.serverFirst + "," + c.clientFinalWithoutProof
|
||||||
|
|
||||||
c.saltedPassword = SaltPassword(c.h, password, salt, iterations)
|
c.saltedPassword = SaltPassword(c.h, password, salt, iterations)
|
||||||
clientKey := HMAC(c.h, c.saltedPassword, "Client Key")
|
clientKey := hmac0(c.h, c.saltedPassword, "Client Key")
|
||||||
h := c.h()
|
h := c.h()
|
||||||
h.Write(clientKey)
|
h.Write(clientKey)
|
||||||
storedKey := h.Sum(nil)
|
storedKey := h.Sum(nil)
|
||||||
clientSig := HMAC(c.h, storedKey, c.authMessage)
|
clientSig := hmac0(c.h, storedKey, c.authMessage)
|
||||||
xor(clientSig, clientKey) // Now clientProof.
|
xor(clientSig, clientKey) // Now clientProof.
|
||||||
clientProof := clientSig
|
clientProof := clientSig
|
||||||
|
|
||||||
|
@ -350,8 +351,8 @@ func (c *Client) ServerFinal(serverFinal []byte) (rerr error) {
|
||||||
p.xtake("v=")
|
p.xtake("v=")
|
||||||
verifier := p.xbase64()
|
verifier := p.xbase64()
|
||||||
|
|
||||||
serverKey := HMAC(c.h, c.saltedPassword, "Server Key")
|
serverKey := hmac0(c.h, c.saltedPassword, "Server Key")
|
||||||
serverSig := HMAC(c.h, serverKey, c.authMessage)
|
serverSig := hmac0(c.h, serverKey, c.authMessage)
|
||||||
if !bytes.Equal(verifier, serverSig) {
|
if !bytes.Equal(verifier, serverSig) {
|
||||||
return fmt.Errorf("incorrect server signature")
|
return fmt.Errorf("incorrect server signature")
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,29 @@
|
||||||
// Package smtpclient is an SMTP client, used by the queue for sending outgoing messages.
|
// Package smtpclient is an SMTP client, for submitting to an SMTP server or
|
||||||
|
// delivering from a queue.
|
||||||
|
//
|
||||||
|
// Email clients can submit a message to SMTP server, after which the server is
|
||||||
|
// responsible for delivery to the final destination. A submission client
|
||||||
|
// typically connects with TLS, and PKIX-verifies the server's certificate. The
|
||||||
|
// client then authenticates using a SASL mechanism.
|
||||||
|
//
|
||||||
|
// Email servers manage a message queue, from which they will try to deliver
|
||||||
|
// messages. In case of temporary failures, the message is kept in the queue and
|
||||||
|
// tried again later. For delivery, no authentication is done. TLS is opportunistic
|
||||||
|
// by default (TLS certificates not verified), but TLS and certificate verification
|
||||||
|
// can be opted into by domains by specifying an MTA-STS policy for the domain, or
|
||||||
|
// DANE TLSA records for their MX hosts.
|
||||||
|
//
|
||||||
|
// Delivering a message from a queue would involve:
|
||||||
|
// 1. Looking up an MTA-STS policy, through a cache.
|
||||||
|
// 2. Resolving the MX targets for a domain, through smtpclient.GatherDestinations,
|
||||||
|
// and for each destination try delivery through:
|
||||||
|
// 3. Looking up IP addresses for the destination, with smtpclient.GatherIPs.
|
||||||
|
// 4. Looking up TLSA records for DANE, in case of authentic DNS responses
|
||||||
|
// (DNSSEC), with smtpclient.GatherTLSA.
|
||||||
|
// 5. Dialing the MX target with smtpclient.Dial.
|
||||||
|
// 6. Initializing a SMTP session with smtpclient.New, with proper TLS
|
||||||
|
// configuration based on discovered MTA-STS and DANE policies, and finally calling
|
||||||
|
// client.Deliver.
|
||||||
package smtpclient
|
package smtpclient
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -201,22 +226,28 @@ type Opts struct {
|
||||||
// returned on which eventually Close must be called. Otherwise an error is
|
// returned on which eventually Close must be called. Otherwise an error is
|
||||||
// returned and the caller is responsible for closing the connection.
|
// returned and the caller is responsible for closing the connection.
|
||||||
//
|
//
|
||||||
// Connecting to the correct host is outside the scope of the client. The queue
|
// Connecting to the correct host for delivery can be done using the Gather
|
||||||
// managing outgoing messages decides which host to deliver to, taking multiple MX
|
// functions, and with Dial. The queue managing outgoing messages typically decides
|
||||||
// records with preferences, other DNS records, MTA-STS, retries and special
|
// which host to deliver to, taking multiple MX records with preferences, other DNS
|
||||||
// cases into account.
|
// records, MTA-STS, retries and special cases into account.
|
||||||
//
|
//
|
||||||
// tlsMode indicates if and how TLS may/must (not) be used. tlsVerifyPKIX
|
// tlsMode indicates if and how TLS may/must (not) be used.
|
||||||
// indicates if TLS certificates must be validated against the PKIX/WebPKI
|
//
|
||||||
// certificate authorities (if TLS is done). DANE-verification is done when
|
// tlsVerifyPKIX indicates if TLS certificates must be validated against the
|
||||||
// opts.DANERecords is not nil. TLS verification errors will be ignored if
|
// PKIX/WebPKI certificate authorities (if TLS is done).
|
||||||
// opts.IgnoreTLSVerification is set. If TLS is done, PKIX verification is
|
//
|
||||||
// always performed for tracking the results for TLS reporting, but if
|
// DANE-verification is done when opts.DANERecords is not nil.
|
||||||
// tlsVerifyPKIX is false, the verification result does not affect the
|
//
|
||||||
// connection. At the time of writing, delivery of email on the internet is done
|
// TLS verification errors will be ignored if opts.IgnoreTLSVerification is set.
|
||||||
// with opportunistic TLS without PKIX verification by default. Recipient domains
|
//
|
||||||
// can opt-in to PKIX verification by publishing an MTA-STS policy, or opt-in to
|
// If TLS is done, PKIX verification is always performed for tracking the results
|
||||||
// DANE verification by publishing DNSSEC-protected TLSA records in DNS.
|
// for TLS reporting, but if tlsVerifyPKIX is false, the verification result does
|
||||||
|
// not affect the connection.
|
||||||
|
//
|
||||||
|
// At the time of writing, delivery of email on the internet is done with
|
||||||
|
// opportunistic TLS without PKIX verification by default. Recipient domains can
|
||||||
|
// opt-in to PKIX verification by publishing an MTA-STS policy, or opt-in to DANE
|
||||||
|
// verification by publishing DNSSEC-protected TLSA records in DNS.
|
||||||
func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode, tlsVerifyPKIX bool, ehloHostname, remoteHostname dns.Domain, opts Opts) (*Client, error) {
|
func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode, tlsVerifyPKIX bool, ehloHostname, remoteHostname dns.Domain, opts Opts) (*Client, error) {
|
||||||
ensureResult := func(r *tlsrpt.Result) *tlsrpt.Result {
|
ensureResult := func(r *tlsrpt.Result) *tlsrpt.Result {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
|
|
|
@ -41,8 +41,9 @@ type Dialer interface {
|
||||||
// Dial connects to host by dialing ips, taking previous attempts in dialedIPs into
|
// Dial connects to host by dialing ips, taking previous attempts in dialedIPs into
|
||||||
// accounts (for greylisting, blocklisting and ipv4/ipv6).
|
// accounts (for greylisting, blocklisting and ipv4/ipv6).
|
||||||
//
|
//
|
||||||
// If the previous attempt used IPv4, this attempt will use IPv6 (in case one of
|
// If the previous attempt used IPv4, this attempt will use IPv6 (useful in case
|
||||||
// the IPs is in a DNSBL).
|
// one of the IPs is in a DNSBL).
|
||||||
|
//
|
||||||
// The second attempt for an address family we prefer the same IP as earlier, to
|
// The second attempt for an address family we prefer the same IP as earlier, to
|
||||||
// increase our chances if remote is doing greylisting.
|
// increase our chances if remote is doing greylisting.
|
||||||
//
|
//
|
||||||
|
|
58
smtpclient/examples_test.go
Normal file
58
smtpclient/examples_test.go
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
package smtpclient_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/sasl"
|
||||||
|
"github.com/mjl-/mox/smtpclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleClient() {
|
||||||
|
// Submit a message to an SMTP server, with authentication. The SMTP server is
|
||||||
|
// responsible for getting the message delivered.
|
||||||
|
|
||||||
|
// Make TCP connection to submission server.
|
||||||
|
conn, err := net.Dial("tcp", "submit.example.org:465")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("dial submission server: %v", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Initialize the SMTP session, with a EHLO, STARTTLS and authentication.
|
||||||
|
// Verify the server TLS certificate with PKIX/WebPKI.
|
||||||
|
ctx := context.Background()
|
||||||
|
tlsVerifyPKIX := true
|
||||||
|
opts := smtpclient.Opts{
|
||||||
|
Auth: []sasl.Client{
|
||||||
|
// Prefer strongest authentication mechanism, allow up to older CRAM-MD5.
|
||||||
|
sasl.NewClientSCRAMSHA256("mjl", "test1234"),
|
||||||
|
sasl.NewClientSCRAMSHA1("mjl", "test1234"),
|
||||||
|
sasl.NewClientCRAMMD5("mjl", "test1234"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
localname := dns.Domain{ASCII: "localhost"}
|
||||||
|
remotename := dns.Domain{ASCII: "submit.example.org"}
|
||||||
|
client, err := smtpclient.New(ctx, slog.Default(), conn, smtpclient.TLSImmediate, tlsVerifyPKIX, localname, remotename, opts)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("initialize smtp to submission server: %v", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
// Send the message to the server, which will add it to its queue.
|
||||||
|
req8bitmime := false // ASCII-only, so 8bitmime not required.
|
||||||
|
reqSMTPUTF8 := false // No UTF-8 headers, so smtputf8 not required.
|
||||||
|
requireTLS := false // Not supported by most servers at the time of writing.
|
||||||
|
msg := "From: <mjl@example.org>\r\nTo: <other@example.org>\r\nSubject: hi\r\n\r\nnice to test you.\r\n"
|
||||||
|
err = client.Deliver(ctx, "mjl@example.org", "other@example.com", int64(len(msg)), strings.NewReader(msg), req8bitmime, reqSMTPUTF8, requireTLS)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("submit message to smtp server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message has been submitted.
|
||||||
|
}
|
|
@ -42,11 +42,11 @@ var (
|
||||||
// expandedNextHopAuthentic indicates if the DNS records after following CNAMEs were
|
// expandedNextHopAuthentic indicates if the DNS records after following CNAMEs were
|
||||||
// DNSSEC secure.
|
// DNSSEC secure.
|
||||||
//
|
//
|
||||||
// These authentic flags are used by DANE, to determine where to look up TLSA
|
// These authentic results are needed for DANE, to determine where to look up TLSA
|
||||||
// records, and which names to allow in the remote TLS certificate. If MX records
|
// records, and which names to allow in the remote TLS certificate. If MX records
|
||||||
// were found, both the original and expanded next-hops must be authentic for DANE
|
// were found, both the original and expanded next-hops must be authentic for DANE
|
||||||
// to apply. For a non-IP with no MX records found, the authentic result can be
|
// to be option. For a non-IP with no MX records found, the authentic result can
|
||||||
// used to decide which of the names to use as TLSA base domain.
|
// be used to decide which of the names to use as TLSA base domain.
|
||||||
func GatherDestinations(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, origNextHop dns.IPDomain) (haveMX, origNextHopAuthentic, expandedNextHopAuthentic bool, expandedNextHop dns.Domain, hosts []dns.IPDomain, permanent bool, err error) {
|
func GatherDestinations(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, origNextHop dns.IPDomain) (haveMX, origNextHopAuthentic, expandedNextHopAuthentic bool, expandedNextHop dns.Domain, hosts []dns.IPDomain, permanent bool, err error) {
|
||||||
// ../rfc/5321:3824
|
// ../rfc/5321:3824
|
||||||
|
|
||||||
|
|
61
spf/examples_test.go
Normal file
61
spf/examples_test.go
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
package spf_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/smtp"
|
||||||
|
"github.com/mjl-/mox/spf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleVerify() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
|
||||||
|
args := spf.Args{
|
||||||
|
// IP from SMTP session.
|
||||||
|
RemoteIP: net.ParseIP("1.2.3.4"),
|
||||||
|
|
||||||
|
// Based on "MAIL FROM" in SMTP session.
|
||||||
|
MailFromLocalpart: smtp.Localpart("user"),
|
||||||
|
MailFromDomain: dns.Domain{ASCII: "sendingdomain.example.com"},
|
||||||
|
|
||||||
|
// From HELO/EHLO in SMTP session.
|
||||||
|
HelloDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "mx.example.com"}},
|
||||||
|
|
||||||
|
// LocalIP and LocalHostname should be set, they may be used when evaluating macro's.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup SPF record and evaluate against IP and domain in args.
|
||||||
|
received, domain, explanation, authentic, err := spf.Verify(ctx, slog.Default(), resolver, args)
|
||||||
|
|
||||||
|
// received.Result is always set, regardless of err.
|
||||||
|
switch received.Result {
|
||||||
|
case spf.StatusNone:
|
||||||
|
log.Printf("no useful spf result, domain probably has no spf record")
|
||||||
|
case spf.StatusNeutral:
|
||||||
|
log.Printf("spf has no statement on ip, with \"?\" qualifier")
|
||||||
|
case spf.StatusPass:
|
||||||
|
log.Printf("ip is authorized")
|
||||||
|
case spf.StatusFail:
|
||||||
|
log.Printf("ip is not authorized, with \"-\" qualifier")
|
||||||
|
case spf.StatusSoftfail:
|
||||||
|
log.Printf("ip is probably not authorized, with \"~\" qualifier, softfail")
|
||||||
|
case spf.StatusTemperror:
|
||||||
|
log.Printf("temporary error, possibly dns lookup failure, try again soon")
|
||||||
|
case spf.StatusPermerror:
|
||||||
|
log.Printf("permanent error, possibly invalid spf records, later attempts likely have the same result")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error: %v", err)
|
||||||
|
}
|
||||||
|
if explanation != "" {
|
||||||
|
log.Printf("explanation from remote about spf result: %s", explanation)
|
||||||
|
}
|
||||||
|
log.Printf("result is for domain %s", domain) // mailfrom or ehlo/ehlo.
|
||||||
|
log.Printf("dns lookups dnssec-protected: %v", authentic)
|
||||||
|
}
|
|
@ -83,8 +83,8 @@ func quotedString(s string) string {
|
||||||
return w.String()
|
return w.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Header returns a Received-SPF header line including trailing crlf that can
|
// Header returns a Received-SPF header including trailing crlf that can be
|
||||||
// be prepended to an incoming message.
|
// prepended to an incoming message.
|
||||||
func (r Received) Header() string {
|
func (r Received) Header() string {
|
||||||
// ../rfc/7208:2043
|
// ../rfc/7208:2043
|
||||||
w := &message.HeaderWriter{}
|
w := &message.HeaderWriter{}
|
||||||
|
|
|
@ -117,7 +117,7 @@ var timeNow = time.Now
|
||||||
|
|
||||||
// Lookup looks up and parses an SPF TXT record for domain.
|
// Lookup looks up and parses an SPF TXT record for domain.
|
||||||
//
|
//
|
||||||
// authentic indicates if the DNS results were DNSSEC-verified.
|
// Authentic indicates if the DNS results were DNSSEC-verified.
|
||||||
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, authentic bool, rerr error) {
|
func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain) (rstatus Status, rtxt string, rrecord *Record, authentic bool, rerr error) {
|
||||||
log := mlog.New("spf", elog)
|
log := mlog.New("spf", elog)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
@ -186,7 +186,7 @@ func Lookup(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domai
|
||||||
// Verify takes the maximum number of 10 DNS requests into account, and the maximum
|
// Verify takes the maximum number of 10 DNS requests into account, and the maximum
|
||||||
// of 2 lookups resulting in no records ("void lookups").
|
// of 2 lookups resulting in no records ("void lookups").
|
||||||
//
|
//
|
||||||
// authentic indicates if the DNS results were DNSSEC-verified.
|
// Authentic indicates if the DNS results were DNSSEC-verified.
|
||||||
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, authentic bool, rerr error) {
|
func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, args Args) (received Received, domain dns.Domain, explanation string, authentic bool, rerr error) {
|
||||||
log := mlog.New("spf", elog)
|
log := mlog.New("spf", elog)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
@ -869,7 +869,7 @@ func expandIP(ip net.IP) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
|
// validateDNS checks if a DNS name is valid. Must not end in dot. This does not
|
||||||
// check valid host names, e.g. _ is allows in DNS but not in a host name.
|
// check valid host names, e.g. _ is allowed in DNS but not in a host name.
|
||||||
func validateDNS(s string) error {
|
func validateDNS(s string) error {
|
||||||
// ../rfc/7208:800
|
// ../rfc/7208:800
|
||||||
// note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.
|
// note: we are not checking for max 253 bytes length, because one of the callers may be chopping off labels to "correct" the name.
|
||||||
|
|
|
@ -1,4 +1,10 @@
|
||||||
// Package subjectpass implements a mechanism for reject an incoming message with a challenge to include a token in a next delivery attempt.
|
// Package subjectpass implements a mechanism for reject an incoming message with a challenge to include a token in a next delivery attempt.
|
||||||
|
//
|
||||||
|
// An SMTP server can reject a message with instructions to send another
|
||||||
|
// message, this time including a special token. The sender will receive a DSN,
|
||||||
|
// which will include the error message with instructions. By sending the
|
||||||
|
// message again with the token, as instructed, the SMTP server can recognize
|
||||||
|
// the token, verify it, and accept the message.
|
||||||
package subjectpass
|
package subjectpass
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
@ -38,7 +44,9 @@ var Explanation = "Your message resembles spam. If your email is legitimate, ple
|
||||||
|
|
||||||
// Generate generates a token that is valid for "mailFrom", starting from "tm"
|
// Generate generates a token that is valid for "mailFrom", starting from "tm"
|
||||||
// and signed with "key".
|
// and signed with "key".
|
||||||
// The token is of the form: (pass:<signeddata>)
|
//
|
||||||
|
// The token is of the form: (pass:<signeddata>). Instructions to the sender should
|
||||||
|
// be to include this token in the Subject header of a new message.
|
||||||
func Generate(elog *slog.Logger, mailFrom smtp.Address, key []byte, tm time.Time) string {
|
func Generate(elog *slog.Logger, mailFrom smtp.Address, key []byte, tm time.Time) string {
|
||||||
log := mlog.New("subjectpass", elog)
|
log := mlog.New("subjectpass", elog)
|
||||||
|
|
||||||
|
|
68
tlsrpt/examples_test.go
Normal file
68
tlsrpt/examples_test.go
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
package tlsrpt_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
|
||||||
|
"github.com/mjl-/mox/dns"
|
||||||
|
"github.com/mjl-/mox/tlsrpt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleLookup() {
|
||||||
|
ctx := context.Background()
|
||||||
|
resolver := dns.StrictResolver{}
|
||||||
|
domain, err := dns.ParseDomain("domain.example")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parsing domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lookup TLSRPT record in DNS, and parse it.
|
||||||
|
record, txt, err := tlsrpt.Lookup(ctx, slog.Default(), resolver, domain)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("looking up tlsrpt record: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("TLSRPT record: %s", txt)
|
||||||
|
log.Printf("Parsed: %v", record)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ExampleParseMessage() {
|
||||||
|
// Message, as received over SMTP.
|
||||||
|
msg := `From: <tlsrpt@mail.sender.example.com>
|
||||||
|
To: <mts-sts-tlsrpt@example.net>
|
||||||
|
Subject: Report Domain: example.net
|
||||||
|
Report-ID: <735ff.e317+bf22029@example.net>
|
||||||
|
TLS-Report-Domain: example.net
|
||||||
|
TLS-Report-Submitter: mail.sender.example.com
|
||||||
|
MIME-Version: 1.0
|
||||||
|
Content-Type: application/tlsrpt+gzip
|
||||||
|
Content-Transfer-Encoding: base64
|
||||||
|
Content-Disposition: attachment;
|
||||||
|
filename="mail.sender.example!example.com!1013662812!1013749130.json.gz"
|
||||||
|
|
||||||
|
H4sIAPZreGUAA51UbW/aMBD+DL/Cyr5NdeokJIVI0za1aPswdRWgiXWqImMbai2JI9tBMMR/n52Y
|
||||||
|
lwkx2KQocXx3vud57s6bbscTcoFL/gtrLkpY4oJ5KfDuRVHhcg2n3o1xoVgzKHG5sLZNt9PxlMZS
|
||||||
|
Q7uveRsRoiCBqAdRMEEobZ5nG9zxWEnPeYZRGg/M8+x1O1ubiYhSY6IhL+fC+iqtoGSVkJqXiw/E
|
||||||
|
oVr5bIWLKmcNutYOObUBMUriXnhHYBjRCPbuCIazhCE46CUMI9YnvVkbVYmcE86UCfphUFpWbnPt
|
||||||
|
SO7/oV5XzKFpKB0sSksDzJ1h95dMKiNkCsaT8TJw3h2vEJSlQDNleRx2Vyl46xeY5/6O2vqYWuuE
|
||||||
|
VxlemOh+0kPIa3Zf/kRBhTmjtAjPHWNSwVeh9BHSs4nbDPa9bYI9VRcFlkeyaKFxDlVNCFNqXpul
|
||||||
|
+dr2IaIubY44CpObY9+5SVVLduIYoego0c6LMm1Wag924yBLpupc78tBmGmLOSe2O9mq4pLRvWrK
|
||||||
|
dJ2RGhYaQ161bYeClM76KZ4RarozCNP0UCDJCOPLJqJVajcJxSq4VCELm9ETbgFCjUNL7hyJZpJ0
|
||||||
|
rmAptJG0sr38bzyiK3mEl3gcYneZIh/5QRD5cXKJrEG188CUcnuZmLLbMZZFc7XYA1+1rlR6e9tO
|
||||||
|
rPJP5tlZMhsH3gNOwTvgJhoQAEEYARq9AWOn2aPQ451iwLtC7CXOOW1vOtdrfxE6GPT9OPBNGf0k
|
||||||
|
vEak/jVVgDNMftbVf/ZUdGy3oyIZVo2dNudPYzTIvmXD0Sh7Gn2dfs+ePk4+Z1+Gj5/MZzi9Hw4f
|
||||||
|
hg9Oqa4bc7N46W67vwF2Eq+hDAYAAA==
|
||||||
|
`
|
||||||
|
msg = strings.ReplaceAll(msg, "\n", "\r\n")
|
||||||
|
|
||||||
|
// Parse the email message, and the TLSRPT report within.
|
||||||
|
report, err := tlsrpt.ParseMessage(slog.Default(), strings.NewReader(msg))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("parsing tlsrpt report in message: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("report: %#v", report)
|
||||||
|
}
|
|
@ -173,6 +173,14 @@ func FetchChangelog(ctx context.Context, elog *slog.Logger, baseURL string, base
|
||||||
|
|
||||||
// Check checks for an updated version through DNS and fetches a
|
// Check checks for an updated version through DNS and fetches a
|
||||||
// changelog if so.
|
// changelog if so.
|
||||||
|
//
|
||||||
|
// Check looks up a TXT record at _updates.<domain>, and parses the record. If the
|
||||||
|
// latest version is more recent than lastKnown, an update is available, and Check
|
||||||
|
// will fetch the signed changes since lastKnown, verify the signatures, and
|
||||||
|
// return the changelog. The latest version and parsed DNS record is returned
|
||||||
|
// regardless of whether a new version was found. A non-nil changelog is only
|
||||||
|
// returned when a new version was found and a changelog could be fetched and
|
||||||
|
// verified.
|
||||||
func Check(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain, lastKnown Version, changelogBaseURL string, pubKey []byte) (rversion Version, rrecord *Record, changelog *Changelog, rerr error) {
|
func Check(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, domain dns.Domain, lastKnown Version, changelogBaseURL string, pubKey []byte) (rversion Version, rrecord *Record, changelog *Changelog, rerr error) {
|
||||||
log := mlog.New("updates", elog)
|
log := mlog.New("updates", elog)
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
|
@ -97,18 +97,18 @@
|
||||||
"Structs": [
|
"Structs": [
|
||||||
{
|
{
|
||||||
"Name": "Domain",
|
"Name": "Domain",
|
||||||
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.",
|
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups. The strings do not have a\ntrailing dot. When using with StrictResolver, add the trailing dot.",
|
||||||
"Fields": [
|
"Fields": [
|
||||||
{
|
{
|
||||||
"Name": "ASCII",
|
"Name": "ASCII",
|
||||||
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.",
|
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Unicode",
|
"Name": "Unicode",
|
||||||
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain.",
|
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
|
|
@ -1267,18 +1267,18 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Domain",
|
"Name": "Domain",
|
||||||
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.",
|
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups. The strings do not have a\ntrailing dot. When using with StrictResolver, add the trailing dot.",
|
||||||
"Fields": [
|
"Fields": [
|
||||||
{
|
{
|
||||||
"Name": "ASCII",
|
"Name": "ASCII",
|
||||||
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.",
|
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Unicode",
|
"Name": "Unicode",
|
||||||
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain.",
|
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
@ -1767,7 +1767,7 @@
|
||||||
"Fields": [
|
"Fields": [
|
||||||
{
|
{
|
||||||
"Name": "Version",
|
"Name": "Version",
|
||||||
"Docs": "\"v=DMARC1\"",
|
"Docs": "\"v=DMARC1\", fixed.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
@ -1788,7 +1788,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "AggregateReportAddresses",
|
"Name": "AggregateReportAddresses",
|
||||||
"Docs": "Optional, for \"rua=\".",
|
"Docs": "Optional, for \"rua=\". Destination addresses for aggregate reports.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"[]",
|
"[]",
|
||||||
"URI"
|
"URI"
|
||||||
|
@ -1796,7 +1796,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "FailureReportAddresses",
|
"Name": "FailureReportAddresses",
|
||||||
"Docs": "Optional, for \"ruf=\"",
|
"Docs": "Optional, for \"ruf=\". Destination addresses for failure reports.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"[]",
|
"[]",
|
||||||
"URI"
|
"URI"
|
||||||
|
@ -1804,21 +1804,21 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "ADKIM",
|
"Name": "ADKIM",
|
||||||
"Docs": "\"r\" (default) for relaxed or \"s\" for simple. For \"adkim=\".",
|
"Docs": "Alignment: \"r\" (default) for relaxed or \"s\" for simple. For \"adkim=\".",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"Align"
|
"Align"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "ASPF",
|
"Name": "ASPF",
|
||||||
"Docs": "\"r\" (default) for relaxed or \"s\" for simple. For \"aspf=\".",
|
"Docs": "Alignment: \"r\" (default) for relaxed or \"s\" for simple. For \"aspf=\".",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"Align"
|
"Align"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "AggregateReportingInterval",
|
"Name": "AggregateReportingInterval",
|
||||||
"Docs": "Default 86400. For \"ri=\"",
|
"Docs": "In seconds, default 86400. For \"ri=\"",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"int32"
|
"int32"
|
||||||
]
|
]
|
||||||
|
@ -1833,7 +1833,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "ReportingFormat",
|
"Name": "ReportingFormat",
|
||||||
"Docs": "\"afrf\" (default). Ffor \"rf=\".",
|
"Docs": "\"afrf\" (default). For \"rf=\".",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"[]",
|
"[]",
|
||||||
"string"
|
"string"
|
||||||
|
@ -1841,7 +1841,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Percentage",
|
"Name": "Percentage",
|
||||||
"Docs": "Between 0 and 100, default 100. For \"pct=\".",
|
"Docs": "Between 0 and 100, default 100. For \"pct=\". Policy applies randomly to this percentage of messages.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"int32"
|
"int32"
|
||||||
]
|
]
|
||||||
|
@ -4206,7 +4206,7 @@
|
||||||
{
|
{
|
||||||
"Name": "ModeTesting",
|
"Name": "ModeTesting",
|
||||||
"Value": "testing",
|
"Value": "testing",
|
||||||
"Docs": "In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLS-RPT."
|
"Docs": "In case TLS cannot be negotiated, plain SMTP can be used, but failures must be reported, e.g. with TLSRPT."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "ModeNone",
|
"Name": "ModeNone",
|
||||||
|
|
|
@ -981,18 +981,18 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Domain",
|
"Name": "Domain",
|
||||||
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups.",
|
"Docs": "Domain is a domain name, with one or more labels, with at least an ASCII\nrepresentation, and for IDNA non-ASCII domains a unicode representation.\nThe ASCII string must be used for DNS lookups. The strings do not have a\ntrailing dot. When using with StrictResolver, add the trailing dot.",
|
||||||
"Fields": [
|
"Fields": [
|
||||||
{
|
{
|
||||||
"Name": "ASCII",
|
"Name": "ASCII",
|
||||||
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.",
|
"Docs": "A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"Name": "Unicode",
|
"Name": "Unicode",
|
||||||
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain.",
|
"Docs": "Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.",
|
||||||
"Typewords": [
|
"Typewords": [
|
||||||
"string"
|
"string"
|
||||||
]
|
]
|
||||||
|
|
|
@ -120,10 +120,11 @@ export interface MessageAddress {
|
||||||
|
|
||||||
// Domain is a domain name, with one or more labels, with at least an ASCII
|
// Domain is a domain name, with one or more labels, with at least an ASCII
|
||||||
// representation, and for IDNA non-ASCII domains a unicode representation.
|
// representation, and for IDNA non-ASCII domains a unicode representation.
|
||||||
// The ASCII string must be used for DNS lookups.
|
// The ASCII string must be used for DNS lookups. The strings do not have a
|
||||||
|
// trailing dot. When using with StrictResolver, add the trailing dot.
|
||||||
export interface Domain {
|
export interface Domain {
|
||||||
ASCII: string // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case.
|
ASCII: string // A non-unicode domain, e.g. with A-labels (xn--...) or NR-LDH (non-reserved letters/digits/hyphens) labels. Always in lower case. No trailing dot.
|
||||||
Unicode: string // Name as U-labels. Empty if this is an ASCII-only domain.
|
Unicode: string // Name as U-labels. Empty if this is an ASCII-only domain. No trailing dot.
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubmitMessage is an email message to be sent to one or more recipients.
|
// SubmitMessage is an email message to be sent to one or more recipients.
|
||||||
|
|
Loading…
Reference in a new issue