mirror of
https://github.com/mjl-/mox.git
synced 2025-04-21 21:40:01 +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
apidiff
autotls
dane
dmarc
dns
dnsbl
iprev
message
mtasts
publicsuffix
ratelimit
sasl
scram
smtpclient
spf
subjectpass
tlsrpt
updates
webaccount
webadmin
webmail
|
@ -44,6 +44,7 @@ Below are the incompatible changes between v0.0.8 and v0.0.9, per package.
|
|||
# sasl
|
||||
|
||||
# scram
|
||||
- HMAC: removed
|
||||
|
||||
# smtp
|
||||
|
||||
|
|
|
@ -36,7 +36,7 @@ func TestAutotls(t *testing.T) {
|
|||
if err := m.HostPolicy(context.Background(), "mox.example"); err == nil || !errors.Is(err, errHostNotAllowed) {
|
||||
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()
|
||||
if !reflect.DeepEqual(l, []dns.Domain{{ASCII: "mox.example"}}) {
|
||||
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")
|
||||
}
|
||||
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 {
|
||||
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
|
||||
// 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
|
||||
// for a "next-hop domain", the "original next-hop domain" (domain of an email
|
||||
// address to deliver to) is also a valid name, as is the "CNAME-expanded original
|
||||
// next-hop domain", bringing the potential total allowed names to four (if CNAMEs
|
||||
// are followed for the MX hosts).
|
||||
// domain name is also a valid hostname. With SMTP, additionally for hosts found in
|
||||
// MX records for a "next-hop domain", the "original next-hop domain" (domain of an
|
||||
// email address to deliver to) is also a valid name, as is the "CNAME-expanded
|
||||
// original next-hop domain", bringing the potential total allowed names to four
|
||||
// (if CNAMEs are followed for the MX hosts).
|
||||
package dane
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
// and verifies the remote certificate against the TLSA records. If
|
||||
// verification succeeds, the verified record is returned.
|
||||
// and verifies the remote certificate against the TLSA records. If verification
|
||||
// succeeds, the verified record is returned.
|
||||
//
|
||||
// Different protocols require different usages. For example, SMTP with STARTTLS
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// record and a nil error.
|
||||
// If there is no match, then in the typical case false, a zero record value and a
|
||||
// nil error is returned.
|
||||
// If there is no match, then in the typical case Verify returns: false, a zero
|
||||
// record value and a nil error.
|
||||
// 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
|
||||
// (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) {
|
||||
log := mlog.New("dane", elog)
|
||||
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.
|
||||
type Result struct {
|
||||
// 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
|
||||
// Result of DMARC validation. A message can fail validation, but still
|
||||
// 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.
|
||||
//
|
||||
// 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)
|
||||
start := time.Now()
|
||||
defer func() {
|
||||
log.Debugx("dmarc lookup result", rerr,
|
||||
slog.Any("fromdomain", from),
|
||||
slog.Any("fromdomain", msgFrom),
|
||||
slog.Any("status", status),
|
||||
slog.Any("domain", domain),
|
||||
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
|
||||
domain = from
|
||||
domain = msgFrom
|
||||
status, record, txt, authentic, err := lookupRecord(ctx, resolver, domain)
|
||||
if status != StatusNone {
|
||||
return status, domain, record, txt, authentic, err
|
||||
}
|
||||
if record == nil {
|
||||
// ../rfc/7489:761 ../rfc/7489:1377
|
||||
domain = publicsuffix.Lookup(ctx, log.Logger, from)
|
||||
if domain == from {
|
||||
domain = publicsuffix.Lookup(ctx, log.Logger, msgFrom)
|
||||
if domain == msgFrom {
|
||||
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
|
||||
// against the message (for inclusion in Authentication-Result headers).
|
||||
//
|
||||
// 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) {
|
||||
// useResult indicates if the result should be applied in a policy decision,
|
||||
// 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)
|
||||
start := time.Now()
|
||||
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)
|
||||
log.Debugx("dmarc verify result", result.Err,
|
||||
slog.Any("fromdomain", from),
|
||||
slog.Any("fromdomain", msgFrom),
|
||||
slog.Any("dkimresults", dkimResults),
|
||||
slog.Any("spfresult", spfResult),
|
||||
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)))
|
||||
}()
|
||||
|
||||
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 {
|
||||
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"
|
||||
// from reject to quarantine if this message was sampled out.
|
||||
// ../rfc/7489:1446 ../rfc/7489:1024
|
||||
if recordDomain != from && record.SubdomainPolicy != PolicyEmpty {
|
||||
if recordDomain != msgFrom && record.SubdomainPolicy != PolicyEmpty {
|
||||
result.Reject = record.SubdomainPolicy != PolicyNone
|
||||
} else {
|
||||
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: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
|
||||
}
|
||||
|
||||
|
@ -299,7 +301,7 @@ func Verify(ctx context.Context, elog *slog.Logger, resolver dns.Resolver, from
|
|||
continue
|
||||
}
|
||||
// ../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
|
||||
result.AlignedDKIMPass = true
|
||||
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.
|
||||
//
|
||||
// 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) {
|
||||
return parseRecord(s, true)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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
|
||||
type Record struct {
|
||||
Version string // "v=DMARC1"
|
||||
Version string // "v=DMARC1", fixed.
|
||||
Policy DMARCPolicy // Required, for "p=".
|
||||
SubdomainPolicy DMARCPolicy // Like policy but for subdomains. Optional, for "sp=".
|
||||
AggregateReportAddresses []URI // Optional, for "rua=".
|
||||
FailureReportAddresses []URI // Optional, for "ruf="
|
||||
ADKIM Align // "r" (default) for relaxed or "s" for simple. For "adkim=".
|
||||
ASPF Align // "r" (default) for relaxed or "s" for simple. For "aspf=".
|
||||
AggregateReportingInterval int // Default 86400. For "ri="
|
||||
AggregateReportAddresses []URI // Optional, for "rua=". Destination addresses for aggregate reports.
|
||||
FailureReportAddresses []URI // Optional, for "ruf=". Destination addresses for failure reports.
|
||||
ADKIM Align // Alignment: "r" (default) for relaxed or "s" for simple. For "adkim=".
|
||||
ASPF Align // Alignment: "r" (default) for relaxed or "s" for simple. For "aspf=".
|
||||
AggregateReportingInterval int // In seconds, default 86400. For "ri="
|
||||
FailureReportingOptions []string // "0" (default), "1", "d", "s". For "fo=".
|
||||
ReportingFormat []string // "afrf" (default). Ffor "rf=".
|
||||
Percentage int // Between 0 and 100, default 100. For "pct=".
|
||||
ReportingFormat []string // "afrf" (default). For "rf=".
|
||||
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.
|
||||
|
|
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
|
||||
// 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 {
|
||||
// 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
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
@ -68,7 +69,8 @@ func (d Domain) String() string {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
if d.Unicode == "" {
|
||||
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
|
||||
// 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
|
||||
// to explicitly check for zero entries.
|
||||
// The adns resolver (just like the Go resolver) returns an IsNotFound error for
|
||||
// both cases, there is no need to explicitly check for zero entries.
|
||||
func IsNotFound(err error) bool {
|
||||
var dnsErr *adns.DNSError
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
|
||||
import (
|
||||
|
@ -21,7 +34,7 @@ var (
|
|||
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.
|
||||
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.
|
||||
var (
|
||||
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
|
||||
|
|
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
|
||||
// header buffer "header", while using "scratch" as temporary space, prevent lots
|
||||
// of unneeded allocations when only a few headers are needed.
|
||||
// header buffer "header". It uses "scratch" as temporary space, which can be
|
||||
// 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) {
|
||||
// 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())
|
||||
}
|
||||
|
||||
// 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
|
||||
// already). For unknown or missing character sets/encodings, the original reader
|
||||
// is returned.
|
||||
|
|
|
@ -4,8 +4,10 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
// NeedsQuotedPrintable returns whether text should be encoded with
|
||||
// quoted-printable. If not, it can be included as 7bit or 8bit encoding.
|
||||
// NeedsQuotedPrintable returns whether text, with crlf-separated lines, should be
|
||||
// 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 {
|
||||
// ../rfc/2045:1025
|
||||
for _, line := range strings.Split(text, "\r\n") {
|
||||
|
|
|
@ -9,6 +9,9 @@ import (
|
|||
// always required to match to an existing thread, both if
|
||||
// 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.
|
||||
//
|
||||
// If allowNull is true, base subjects with a \0 can be returned. If not set,
|
||||
|
|
|
@ -9,9 +9,9 @@ import (
|
|||
type Writer struct {
|
||||
writer io.Writer
|
||||
|
||||
HaveBody bool // Body is optional. ../rfc/5322:343
|
||||
Has8bit bool // Whether a byte with the high/8bit has been read. So whether this is 8BITMIME instead of 7BIT.
|
||||
Size int64
|
||||
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 needs SMTP 8BITMIME instead of 7BIT.
|
||||
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.
|
||||
// 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'}}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 (
|
||||
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.
|
||||
)
|
||||
|
||||
|
|
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"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
// Name as used in SMTP AUTH, e.g. PLAIN, CRAM-MD5, SCRAM-SHA-256.
|
||||
// cleartextCredentials indicates if credentials are exchanged in clear text, which influences whether they are logged.
|
||||
// Name as used in SMTP or IMAP authentication, e.g. PLAIN, CRAM-MD5,
|
||||
// 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)
|
||||
|
||||
// Next is called for each step of the SASL communication. The first call has a nil
|
||||
// fromServer and serves to get a possible "initial response" from the client. If
|
||||
// the client sends its final message it indicates so with last. Returning an error
|
||||
// aborts the authentication attempt.
|
||||
// Next must be called for each step of the SASL transaction. The first call has a
|
||||
// nil fromServer and serves to get a possible "initial response" from the client
|
||||
// to the server. When last is true, the message from client to server is the last
|
||||
// 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
|
||||
// no data, which is different from a non-nil zero-length toServer.
|
||||
Next(fromServer []byte) (toServer []byte, last bool, err error)
|
||||
|
@ -35,6 +47,9 @@ type clientPlain struct {
|
|||
var _ Client = (*clientPlain)(nil)
|
||||
|
||||
// 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 {
|
||||
return &clientPlain{username, password, 0}
|
||||
}
|
||||
|
@ -61,6 +76,7 @@ type clientLogin struct {
|
|||
var _ Client = (*clientLogin)(nil)
|
||||
|
||||
// NewClientLogin returns a client for the obsolete SASL LOGIN authentication.
|
||||
//
|
||||
// See https://datatracker.ietf.org/doc/html/draft-murchison-sasl-login-00
|
||||
func NewClientLogin(username, password string) Client {
|
||||
return &clientLogin{username, password, 0}
|
||||
|
@ -90,6 +106,9 @@ type clientCRAMMD5 struct {
|
|||
var _ Client = (*clientCRAMMD5)(nil)
|
||||
|
||||
// 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 {
|
||||
return &clientCRAMMD5{username, password, 0}
|
||||
}
|
||||
|
@ -160,11 +179,17 @@ type clientSCRAMSHA struct {
|
|||
var _ Client = (*clientSCRAMSHA)(nil)
|
||||
|
||||
// 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 {
|
||||
return &clientSCRAMSHA{username, password, "SCRAM-SHA-1", 0, nil}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
// 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
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// HMAC returns the hmac with key over msg.
|
||||
func HMAC(h func() hash.Hash, key []byte, msg string) []byte {
|
||||
// hmac0 returns the hmac with key over msg.
|
||||
func hmac0(h func() hash.Hash, key []byte, msg string) []byte {
|
||||
mac := hmac.New(h, key)
|
||||
mac.Write([]byte(msg))
|
||||
return mac.Sum(nil)
|
||||
|
@ -211,19 +212,19 @@ func (s *Server) Finish(clientFinal []byte, saltedPassword []byte) (serverFinal
|
|||
|
||||
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.Write(clientKey)
|
||||
storedKey := h.Sum(nil)
|
||||
|
||||
clientSig := HMAC(s.h, storedKey, msg)
|
||||
clientSig := hmac0(s.h, storedKey, msg)
|
||||
xor(clientSig, clientKey) // Now clientProof.
|
||||
if !bytes.Equal(clientSig, proof) {
|
||||
return "e=" + string(ErrInvalidProof), ErrInvalidProof
|
||||
}
|
||||
|
||||
serverKey := HMAC(s.h, saltedPassword, "Server Key")
|
||||
serverSig := HMAC(s.h, serverKey, msg)
|
||||
serverKey := hmac0(s.h, saltedPassword, "Server Key")
|
||||
serverSig := hmac0(s.h, serverKey, msg)
|
||||
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.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.Write(clientKey)
|
||||
storedKey := h.Sum(nil)
|
||||
clientSig := HMAC(c.h, storedKey, c.authMessage)
|
||||
clientSig := hmac0(c.h, storedKey, c.authMessage)
|
||||
xor(clientSig, clientKey) // Now clientProof.
|
||||
clientProof := clientSig
|
||||
|
||||
|
@ -350,8 +351,8 @@ func (c *Client) ServerFinal(serverFinal []byte) (rerr error) {
|
|||
p.xtake("v=")
|
||||
verifier := p.xbase64()
|
||||
|
||||
serverKey := HMAC(c.h, c.saltedPassword, "Server Key")
|
||||
serverSig := HMAC(c.h, serverKey, c.authMessage)
|
||||
serverKey := hmac0(c.h, c.saltedPassword, "Server Key")
|
||||
serverSig := hmac0(c.h, serverKey, c.authMessage)
|
||||
if !bytes.Equal(verifier, serverSig) {
|
||||
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
|
||||
|
||||
import (
|
||||
|
@ -201,22 +226,28 @@ type Opts struct {
|
|||
// returned on which eventually Close must be called. Otherwise an error is
|
||||
// returned and the caller is responsible for closing the connection.
|
||||
//
|
||||
// Connecting to the correct host is outside the scope of the client. The queue
|
||||
// managing outgoing messages decides which host to deliver to, taking multiple MX
|
||||
// records with preferences, other DNS records, MTA-STS, retries and special
|
||||
// cases into account.
|
||||
// Connecting to the correct host for delivery can be done using the Gather
|
||||
// functions, and with Dial. The queue managing outgoing messages typically decides
|
||||
// which host to deliver to, taking multiple MX records with preferences, other DNS
|
||||
// records, MTA-STS, retries and special cases into account.
|
||||
//
|
||||
// tlsMode indicates if and how TLS may/must (not) be used. tlsVerifyPKIX
|
||||
// indicates if TLS certificates must be validated against the PKIX/WebPKI
|
||||
// certificate authorities (if TLS is done). DANE-verification is done when
|
||||
// opts.DANERecords is not nil. TLS verification errors will be ignored if
|
||||
// opts.IgnoreTLSVerification is set. If TLS is done, PKIX verification is
|
||||
// always performed for tracking the results 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.
|
||||
// tlsMode indicates if and how TLS may/must (not) be used.
|
||||
//
|
||||
// tlsVerifyPKIX indicates if TLS certificates must be validated against the
|
||||
// PKIX/WebPKI certificate authorities (if TLS is done).
|
||||
//
|
||||
// DANE-verification is done when opts.DANERecords is not nil.
|
||||
//
|
||||
// TLS verification errors will be ignored if opts.IgnoreTLSVerification is set.
|
||||
//
|
||||
// If TLS is done, PKIX verification is always performed for tracking the results
|
||||
// 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) {
|
||||
ensureResult := func(r *tlsrpt.Result) *tlsrpt.Result {
|
||||
if r == nil {
|
||||
|
|
|
@ -41,8 +41,9 @@ type Dialer interface {
|
|||
// Dial connects to host by dialing ips, taking previous attempts in dialedIPs into
|
||||
// accounts (for greylisting, blocklisting and ipv4/ipv6).
|
||||
//
|
||||
// If the previous attempt used IPv4, this attempt will use IPv6 (in case one of
|
||||
// the IPs is in a DNSBL).
|
||||
// If the previous attempt used IPv4, this attempt will use IPv6 (useful in case
|
||||
// one of the IPs is in a DNSBL).
|
||||
//
|
||||
// The second attempt for an address family we prefer the same IP as earlier, to
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// used to decide which of the names to use as TLSA base domain.
|
||||
// to be option. For a non-IP with no MX records found, the authentic result can
|
||||
// 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) {
|
||||
// ../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()
|
||||
}
|
||||
|
||||
// Header returns a Received-SPF header line including trailing crlf that can
|
||||
// be prepended to an incoming message.
|
||||
// Header returns a Received-SPF header including trailing crlf that can be
|
||||
// prepended to an incoming message.
|
||||
func (r Received) Header() string {
|
||||
// ../rfc/7208:2043
|
||||
w := &message.HeaderWriter{}
|
||||
|
|
|
@ -117,7 +117,7 @@ var timeNow = time.Now
|
|||
|
||||
// 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) {
|
||||
log := mlog.New("spf", elog)
|
||||
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
|
||||
// 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) {
|
||||
log := mlog.New("spf", elog)
|
||||
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
|
||||
// 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 {
|
||||
// ../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.
|
||||
|
|
|
@ -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.
|
||||
//
|
||||
// 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
|
||||
|
||||
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"
|
||||
// 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 {
|
||||
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
|
||||
// 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) {
|
||||
log := mlog.New("updates", elog)
|
||||
start := time.Now()
|
||||
|
|
|
@ -97,18 +97,18 @@
|
|||
"Structs": [
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"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": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"string"
|
||||
]
|
||||
|
|
|
@ -1267,18 +1267,18 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"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": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"string"
|
||||
]
|
||||
|
@ -1767,7 +1767,7 @@
|
|||
"Fields": [
|
||||
{
|
||||
"Name": "Version",
|
||||
"Docs": "\"v=DMARC1\"",
|
||||
"Docs": "\"v=DMARC1\", fixed.",
|
||||
"Typewords": [
|
||||
"string"
|
||||
]
|
||||
|
@ -1788,7 +1788,7 @@
|
|||
},
|
||||
{
|
||||
"Name": "AggregateReportAddresses",
|
||||
"Docs": "Optional, for \"rua=\".",
|
||||
"Docs": "Optional, for \"rua=\". Destination addresses for aggregate reports.",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"URI"
|
||||
|
@ -1796,7 +1796,7 @@
|
|||
},
|
||||
{
|
||||
"Name": "FailureReportAddresses",
|
||||
"Docs": "Optional, for \"ruf=\"",
|
||||
"Docs": "Optional, for \"ruf=\". Destination addresses for failure reports.",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"URI"
|
||||
|
@ -1804,21 +1804,21 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
"Align"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"Align"
|
||||
]
|
||||
},
|
||||
{
|
||||
"Name": "AggregateReportingInterval",
|
||||
"Docs": "Default 86400. For \"ri=\"",
|
||||
"Docs": "In seconds, default 86400. For \"ri=\"",
|
||||
"Typewords": [
|
||||
"int32"
|
||||
]
|
||||
|
@ -1833,7 +1833,7 @@
|
|||
},
|
||||
{
|
||||
"Name": "ReportingFormat",
|
||||
"Docs": "\"afrf\" (default). Ffor \"rf=\".",
|
||||
"Docs": "\"afrf\" (default). For \"rf=\".",
|
||||
"Typewords": [
|
||||
"[]",
|
||||
"string"
|
||||
|
@ -1841,7 +1841,7 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
"int32"
|
||||
]
|
||||
|
@ -4206,7 +4206,7 @@
|
|||
{
|
||||
"Name": "ModeTesting",
|
||||
"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",
|
||||
|
|
|
@ -981,18 +981,18 @@
|
|||
},
|
||||
{
|
||||
"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": [
|
||||
{
|
||||
"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": [
|
||||
"string"
|
||||
]
|
||||
},
|
||||
{
|
||||
"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": [
|
||||
"string"
|
||||
]
|
||||
|
|
|
@ -120,10 +120,11 @@ export interface MessageAddress {
|
|||
|
||||
// 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.
|
||||
// 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 {
|
||||
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.
|
||||
Unicode: string // Name as U-labels. Empty if this is an ASCII-only 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. No trailing dot.
|
||||
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.
|
||||
|
|
Loading…
Reference in a new issue