diff -Nru chasquid-0.05/chasquid.go chasquid-0.06/chasquid.go --- chasquid-0.05/chasquid.go 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/chasquid.go 2018-07-22 10:15:40.000000000 +0000 @@ -1,12 +1,11 @@ -// chasquid is an SMTP (email) server. -// -// It aims to be easy to configure and maintain for a small mail server, at -// the expense of flexibility and functionality. +// chasquid is an SMTP (email) server, with a focus on simplicity, security, +// and ease of operation. // // See https://blitiri.com.ar/p/chasquid for more details. package main import ( + "context" "expvar" "flag" "fmt" @@ -25,6 +24,7 @@ "blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/smtpsrv" + "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/userdb" "blitiri.com.ar/go/log" "blitiri.com.ar/go/systemd" @@ -146,12 +146,18 @@ dinfo := s.InitDomainInfo(conf.DataDir + "/domaininfo") + stsCache, err := sts.NewCache(conf.DataDir + "/sts-cache") + if err != nil { + log.Fatalf("Failed to initialize STS cache: %v", err) + } + go stsCache.PeriodicallyRefresh(context.Background()) + localC := &courier.Procmail{ Binary: conf.MailDeliveryAgentBin, Args: conf.MailDeliveryAgentArgs, Timeout: 30 * time.Second, } - remoteC := &courier.SMTP{Dinfo: dinfo} + remoteC := &courier.SMTP{Dinfo: dinfo, STSCache: stsCache} s.InitQueue(conf.DataDir+"/queue", localC, remoteC) // Load the addresses and listeners. diff -Nru chasquid-0.05/cmd/smtp-check/smtp-check.go chasquid-0.06/cmd/smtp-check/smtp-check.go --- chasquid-0.05/cmd/smtp-check/smtp-check.go 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/cmd/smtp-check/smtp-check.go 2018-07-22 10:15:40.000000000 +0000 @@ -5,12 +5,15 @@ package main import ( + "context" "crypto/tls" "flag" "log" "net" "net/smtp" + "time" + "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/tlsconst" "blitiri.com.ar/go/spf" @@ -37,6 +40,21 @@ log.Fatalf("IDNA conversion failed: %v", err) } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + log.Printf("=== STS policy") + policy, err := sts.UncheckedFetch(ctx, domain) + if err != nil { + log.Printf("Not available (%s)", err) + } else { + log.Printf("Parsed contents: [%+v]\n", *policy) + if err := policy.Check(); err != nil { + log.Fatalf("Invalid: %v", err) + } + log.Printf("OK") + } + mxs, err := net.LookupMX(domain) if err != nil { log.Fatalf("MX lookup: %v", err) @@ -86,6 +104,13 @@ c.Close() } + if policy != nil { + if !policy.MXIsAllowed(mx.Host) { + log.Fatalf("NOT allowed by STS policy") + } + log.Printf("Allowed by policy") + } + log.Printf("") } diff -Nru chasquid-0.05/debian/changelog chasquid-0.06/debian/changelog --- chasquid-0.05/debian/changelog 2018-06-05 00:51:33.000000000 +0000 +++ chasquid-0.06/debian/changelog 2018-07-22 11:10:39.000000000 +0000 @@ -1,3 +1,10 @@ +chasquid (0.06-1) unstable; urgency=medium + + * New upstream release + * Update Standards-Version to 4.1.5 (no changes) + + -- Alberto Bertogli Sun, 22 Jul 2018 12:10:39 +0100 + chasquid (0.05-1) unstable; urgency=medium [ Alexandre Viau ] diff -Nru chasquid-0.05/debian/control chasquid-0.06/debian/control --- chasquid-0.05/debian/control 2018-06-05 00:51:33.000000000 +0000 +++ chasquid-0.06/debian/control 2018-07-22 11:10:39.000000000 +0000 @@ -15,7 +15,7 @@ golang-golang-x-net-dev, golang-golang-x-text-dev, golang-goprotobuf-dev, -Standards-Version: 4.1.4 +Standards-Version: 4.1.5 Homepage: https://blitiri.com.ar/p/chasquid Vcs-Browser: https://salsa.debian.org/go-team/packages/chasquid Vcs-Git: https://salsa.debian.org/go-team/packages/chasquid.git diff -Nru chasquid-0.05/docs/howto.md chasquid-0.06/docs/howto.md --- chasquid-0.05/docs/howto.md 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/howto.md 2018-07-22 10:15:40.000000000 +0000 @@ -194,7 +194,7 @@ to the [dovecot documentation](https://wiki.dovecot.org/BasicConfiguration) for the details. -You can also add chasquid-specific users with `chasquid-util add-user`. +You can also add chasquid-specific users with `chasquid-util user-add`. ## Additional domains diff -Nru chasquid-0.05/docs/man/chasquid.1 chasquid-0.06/docs/man/chasquid.1 --- chasquid-0.05/docs/man/chasquid.1 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/man/chasquid.1 2018-07-22 10:15:40.000000000 +0000 @@ -129,7 +129,7 @@ .\" ======================================================================== .\" .IX Title "chasquid 1" -.TH chasquid 1 "2018-04-02" "" "" +.TH chasquid 1 "2018-07-22" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l @@ -141,10 +141,8 @@ \&\fBchasquid\fR [\fIoptions\fR...] .SH "DESCRIPTION" .IX Header "DESCRIPTION" -chasquid is an \s-1SMTP\s0 (email) server. -.PP -It aims to be easy to configure and maintain for a small mail server, at the -expense of flexibility and functionality. +chasquid is an \s-1SMTP\s0 (email) server with a focus on simplicity, security, and +ease of operation. .PP It's written in Go, and distributed under the Apache license 2.0. .SH "OPTIONS" diff -Nru chasquid-0.05/docs/man/chasquid.1.pod chasquid-0.06/docs/man/chasquid.1.pod --- chasquid-0.05/docs/man/chasquid.1.pod 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/man/chasquid.1.pod 2018-07-22 10:15:40.000000000 +0000 @@ -8,10 +8,8 @@ =head1 DESCRIPTION -chasquid is an SMTP (email) server. - -It aims to be easy to configure and maintain for a small mail server, at the -expense of flexibility and functionality. +chasquid is an SMTP (email) server with a focus on simplicity, security, and +ease of operation. It's written in Go, and distributed under the Apache license 2.0. diff -Nru chasquid-0.05/docs/man/chasquid.conf.5 chasquid-0.06/docs/man/chasquid.conf.5 --- chasquid-0.05/docs/man/chasquid.conf.5 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/man/chasquid.conf.5 2018-07-22 10:15:40.000000000 +0000 @@ -129,7 +129,7 @@ .\" ======================================================================== .\" .IX Title "chasquid.conf 5" -.TH chasquid.conf 5 "2018-06-04" "" "" +.TH chasquid.conf 5 "2018-06-06" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l @@ -220,7 +220,7 @@ databases will be authenticated via dovecot. Default: \f(CW\*(C`false\*(C'\fR. .Sp The path to dovecot's auth sockets is autodetected, but can be manually -overriden using the \f(CW\*(C`dovecot_userdb_path\*(C'\fR and \f(CW\*(C`dovecot_client_path\*(C'\fR if +overridden using the \f(CW\*(C`dovecot_userdb_path\*(C'\fR and \f(CW\*(C`dovecot_client_path\*(C'\fR if needed. .SH "SEE ALSO" .IX Header "SEE ALSO" diff -Nru chasquid-0.05/docs/man/chasquid.conf.5.pod chasquid-0.06/docs/man/chasquid.conf.5.pod --- chasquid-0.05/docs/man/chasquid.conf.5.pod 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/man/chasquid.conf.5.pod 2018-07-22 10:15:40.000000000 +0000 @@ -101,7 +101,7 @@ databases will be authenticated via dovecot. Default: C. The path to dovecot's auth sockets is autodetected, but can be manually -overriden using the C and C if +overridden using the C and C if needed. =back diff -Nru chasquid-0.05/docs/man/generate.sh chasquid-0.06/docs/man/generate.sh --- chasquid-0.05/docs/man/generate.sh 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/man/generate.sh 2018-07-22 10:15:40.000000000 +0000 @@ -12,6 +12,14 @@ SECTION=${OUT##*.} NAME=${OUT%.*} + # If it has not changed in git, set the mtime to the last commit that + # touched the file. + CHANGED=$( git status --porcelain -- "$IN" | wc -l ) + if [ $CHANGED -eq 0 ]; then + GIT_MTIME=$( git log --pretty=%at -n1 -- "$IN" ) + touch -d "@$GIT_MTIME" "$IN" + fi + podchecker $IN pod2man --section=$SECTION --name=$NAME \ --release "" --center "" \ diff -Nru chasquid-0.05/docs/man/smtp-check.1 chasquid-0.06/docs/man/smtp-check.1 --- chasquid-0.05/docs/man/smtp-check.1 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/docs/man/smtp-check.1 2018-07-22 10:15:40.000000000 +0000 @@ -129,7 +129,7 @@ .\" ======================================================================== .\" .IX Title "smtp-check 1" -.TH smtp-check 1 "2018-04-03" "" "" +.TH smtp-check 1 "2018-04-02" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l diff -Nru chasquid-0.05/internal/courier/smtp.go chasquid-0.06/internal/courier/smtp.go --- chasquid-0.05/internal/courier/smtp.go 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/internal/courier/smtp.go 2018-07-22 10:15:40.000000000 +0000 @@ -1,6 +1,7 @@ package courier import ( + "context" "crypto/tls" "expvar" "flag" @@ -13,6 +14,7 @@ "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/smtp" + "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/trace" ) @@ -36,11 +38,15 @@ var ( tlsCount = expvar.NewMap("chasquid/smtpOut/tlsCount") slcResults = expvar.NewMap("chasquid/smtpOut/securityLevelChecks") + + stsSecurityModes = expvar.NewMap("chasquid/smtpOut/sts/mode") + stsSecurityResults = expvar.NewMap("chasquid/smtpOut/sts/security") ) // SMTP delivers remote mail via outgoing SMTP. type SMTP struct { - Dinfo *domaininfo.DB + Dinfo *domaininfo.DB + STSCache *sts.PolicyCache } // Deliver an email. On failures, returns an error, and whether or not it is @@ -82,7 +88,14 @@ a.helloDomain, _ = os.Hostname() } + a.stsPolicy = s.fetchSTSPolicy(a.tr, a.toDomain) + for _, mx := range mxs { + if a.stsPolicy != nil && !a.stsPolicy.MXIsAllowed(mx) { + a.tr.Printf("%q skipped as per MTA-STA policy", mx) + continue + } + var permanent bool err, permanent = a.deliver(mx) if err == nil { @@ -108,6 +121,8 @@ toDomain string helloDomain string + stsPolicy *sts.Policy + tr *trace.Trace } @@ -175,6 +190,18 @@ } slcResults.Add("pass", 1) + if a.stsPolicy != nil && a.stsPolicy.Mode == sts.Enforce { + // The connection MUST be validated TLS. + // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-4.2 + if secLevel != domaininfo.SecLevel_TLS_SECURE { + stsSecurityResults.Add("fail", 1) + return a.tr.Errorf("invalid security level (%v) for STS policy", + secLevel), false + } + stsSecurityResults.Add("pass", 1) + a.tr.Debugf("STS policy: connection is using valid TLS") + } + if err = c.MailAndRcpt(a.from, a.to); err != nil { return a.tr.Errorf("MAIL+RCPT %v", err), smtp.IsPermanent(err) } @@ -199,6 +226,25 @@ return nil, false } +func (s *SMTP) fetchSTSPolicy(tr *trace.Trace, domain string) *sts.Policy { + if s.STSCache == nil { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + policy, err := s.STSCache.Fetch(ctx, domain) + if err != nil { + return nil + } + + tr.Debugf("got STS policy") + stsSecurityModes.Add(string(policy.Mode), 1) + + return policy +} + func lookupMXs(tr *trace.Trace, domain string) ([]string, error) { domain, err := idna.ToASCII(domain) if err != nil { @@ -239,8 +285,8 @@ // This case is explicitly covered by the SMTP RFC. // https://tools.ietf.org/html/rfc5321#section-5.1 - // Cap the list of MXs to 5 hosts, to keep delivery attempt times sane - // and prevent abuse. + // Cap the list of MXs to 5 hosts, to keep delivery attempt times + // sane and prevent abuse. if len(mxs) > 5 { mxs = mxs[:5] } diff -Nru chasquid-0.05/internal/courier/smtp_test.go chasquid-0.06/internal/courier/smtp_test.go --- chasquid-0.05/internal/courier/smtp_test.go 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/internal/courier/smtp_test.go 2018-07-22 10:15:40.000000000 +0000 @@ -35,7 +35,7 @@ t.Fatal(err) } - return &SMTP{dinfo}, dir + return &SMTP{dinfo, nil}, dir } // Fake server, to test SMTP out. diff -Nru chasquid-0.05/internal/sts/sts.go chasquid-0.06/internal/sts/sts.go --- chasquid-0.05/internal/sts/sts.go 1970-01-01 00:00:00.000000000 +0000 +++ chasquid-0.06/internal/sts/sts.go 2018-07-22 10:15:40.000000000 +0000 @@ -0,0 +1,510 @@ +// Package sts implements the MTA-STS (Strict Transport Security), based on +// the current draft, https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18. +// +// This is an EXPERIMENTAL implementation for now. +// +// Note that "report" mode is not supported. +// +package sts + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "expvar" + "fmt" + "io" + "io/ioutil" + "mime" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "time" + + "blitiri.com.ar/go/chasquid/internal/safeio" + "blitiri.com.ar/go/chasquid/internal/trace" + + "golang.org/x/net/context/ctxhttp" + "golang.org/x/net/idna" +) + +// Exported variables. +var ( + cacheFetches = expvar.NewInt("chasquid/sts/cache/fetches") + cacheHits = expvar.NewInt("chasquid/sts/cache/hits") + cacheExpired = expvar.NewInt("chasquid/sts/cache/expired") + + cacheIOErrors = expvar.NewInt("chasquid/sts/cache/ioErrors") + cacheFailedFetch = expvar.NewInt("chasquid/sts/cache/failedFetch") + cacheInvalid = expvar.NewInt("chasquid/sts/cache/invalid") + + cacheMarshalErrors = expvar.NewInt("chasquid/sts/cache/marshalErrors") + cacheUnmarshalErrors = expvar.NewInt("chasquid/sts/cache/unmarshalErrors") + + cacheRefreshCycles = expvar.NewInt("chasquid/sts/cache/refreshCycles") + cacheRefreshes = expvar.NewInt("chasquid/sts/cache/refreshes") + cacheRefreshErrors = expvar.NewInt("chasquid/sts/cache/refreshErrors") +) + +// Policy represents a parsed policy. +// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.2 +// The json annotations are used for serializing for caching purposes. +type Policy struct { + Version string `json:"version"` + Mode Mode `json:"mode"` + MXs []string `json:"mx"` + MaxAge time.Duration `json:"max_age"` +} + +// The Mode of a policy. Valid values (according to the standard) are +// constants below. +type Mode string + +// Valid modes. +const ( + Enforce = Mode("enforce") + Testing = Mode("testing") + None = Mode("none") +) + +// parsePolicy parses a text representation of the policy (as specified in the +// RFC), and returns the corresponding Policy structure. +func parsePolicy(raw []byte) (*Policy, error) { + p := &Policy{} + + scanner := bufio.NewScanner(bytes.NewReader(raw)) + for scanner.Scan() { + sp := strings.SplitN(scanner.Text(), ":", 2) + if len(sp) != 2 { + continue + } + + key := strings.TrimSpace(sp[0]) + value := strings.TrimSpace(sp[1]) + + // Only care for the keys we recognize. + switch key { + case "version": + p.Version = value + case "mode": + p.Mode = Mode(value) + case "max_age": + // On error, p.MaxAge will be 0 which is invalid. + maxAge, _ := strconv.Atoi(value) + p.MaxAge = time.Duration(maxAge) * time.Second + case "mx": + p.MXs = append(p.MXs, value) + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + + return p, nil +} + +// Check errors. +var ( + ErrUnknownVersion = errors.New("unknown policy version") + ErrInvalidMaxAge = errors.New("invalid max_age") + ErrInvalidMode = errors.New("invalid mode") + ErrInvalidMX = errors.New("invalid mx") +) + +// Fetch errors. +var ( + ErrInvalidMediaType = errors.New("invalid HTTP media type") +) + +// Check that the policy contents are valid. +func (p *Policy) Check() error { + if p.Version != "STSv1" { + return ErrUnknownVersion + } + if p.MaxAge <= 0 { + return ErrInvalidMaxAge + } + + if p.Mode != Enforce && p.Mode != Testing && p.Mode != None { + return ErrInvalidMode + } + + // "mx" field is required, and the policy is invalid if it's not present. + // https://mailarchive.ietf.org/arch/msg/uta/Omqo1Bw6rJbrTMl2Zo69IJr35Qo + if len(p.MXs) == 0 { + return ErrInvalidMX + } + + return nil +} + +// MXIsAllowed checks if the given MX is allowed, according to the policy. +// https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-4.1 +func (p *Policy) MXIsAllowed(mx string) bool { + if p.Mode != Enforce { + return true + } + + for _, pattern := range p.MXs { + if matchDomain(mx, pattern) { + return true + } + } + + return false +} + +// UncheckedFetch fetches and parses the policy, but does NOT check it. +// This can be useful for debugging and troubleshooting, but you should always +// call Check on the policy before using it. +func UncheckedFetch(ctx context.Context, domain string) (*Policy, error) { + // Convert the domain to ascii form, as httpGet does not support IDNs in + // any other way. + domain, err := idna.ToASCII(domain) + if err != nil { + return nil, err + } + + ok, err := hasSTSRecord(domain) + if err != nil { + return nil, err + } + if !ok { + return nil, fmt.Errorf("MTA-STS TXT record missing") + } + + url := urlForDomain(domain) + rawPolicy, err := httpGet(ctx, url) + if err != nil { + return nil, err + } + + return parsePolicy(rawPolicy) +} + +// Fake URL for testing purposes, so we can do more end-to-end tests, +// including the HTTP fetching code. +var fakeURLForTesting string + +func urlForDomain(domain string) string { + if fakeURLForTesting != "" { + return fakeURLForTesting + "/" + domain + } + + // URL composed from the domain, as explained in: + // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.3 + // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.2 + return "https://mta-sts." + domain + "/.well-known/mta-sts.txt" +} + +// Fetch a policy for the given domain. Note this results in various network +// lookups and HTTPS GETs, so it can be slow. +// The returned policy is parsed and sanity-checked (using Policy.Check), so +// it should be safe to use. +func Fetch(ctx context.Context, domain string) (*Policy, error) { + p, err := UncheckedFetch(ctx, domain) + if err != nil { + return nil, err + } + + err = p.Check() + if err != nil { + return nil, err + } + + return p, nil +} + +// httpGet performs an HTTP GET of the given URL, using the context and +// rejecting redirects, as per the standard. +func httpGet(ctx context.Context, url string) ([]byte, error) { + client := &http.Client{ + // We MUST NOT follow redirects, see + // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.3 + CheckRedirect: rejectRedirect, + } + + resp, err := ctxhttp.Get(ctx, client, url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP response status code: %v", resp.StatusCode) + } + + // Media type must be "text/plain" to guard against cases where webservers + // allow untrusted users to host non-text content (like HTML or images) at + // a user-defined path. + // https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-3.2 + mt, _, err := mime.ParseMediaType(resp.Header.Get("Content-type")) + if err != nil { + return nil, fmt.Errorf("HTTP media type error: %v", err) + } + if mt != "text/plain" { + return nil, ErrInvalidMediaType + } + + // Read but up to 10k; policies should be way smaller than that, and + // having a limit prevents abuse/accidents with very large replies. + return ioutil.ReadAll(&io.LimitedReader{R: resp.Body, N: 10 * 1024}) +} + +var errRejectRedirect = errors.New("redirects not allowed in MTA-STS") + +func rejectRedirect(req *http.Request, via []*http.Request) error { + return errRejectRedirect +} + +// matchDomain checks if the domain matches the given pattern, according to +// from https://tools.ietf.org/html/draft-ietf-uta-mta-sts-18#section-4.1 +// (based on https://tools.ietf.org/html/rfc6125#section-6.4). +func matchDomain(domain, pattern string) bool { + domain, dErr := domainToASCII(domain) + pattern, pErr := domainToASCII(pattern) + if dErr != nil || pErr != nil { + // Domains should already have been checked and normalized by the + // caller, exposing this is not worth the API complexity in this case. + return false + } + + // Simplify the case of a literal match. + if domain == pattern { + return true + } + + // For wildcards, skip the first part of the domain and match the rest. + // Note that if the pattern is malformed this might fail, but we are ok + // with that. + if strings.HasPrefix(pattern, "*.") { + parts := strings.SplitN(domain, ".", 2) + if len(parts) > 1 && parts[1] == pattern[2:] { + return true + } + } + + return false +} + +// domainToASCII converts the domain to ASCII form, similar to idna.ToASCII +// but with some preprocessing convenient for our use cases. +func domainToASCII(domain string) (string, error) { + domain = strings.TrimSuffix(domain, ".") + domain = strings.ToLower(domain) + return idna.ToASCII(domain) +} + +// Function that we override for testing purposes. +// In the future we will override net.DefaultResolver, but we don't do that +// yet for backwards compatibility. +var lookupTXT = net.LookupTXT + +// hasSTSRecord checks if there is a valid MTA-STS TXT record for the domain. +// We don't do full parsing and don't care about the "id=" field, as it is +// unused in this implementation. +func hasSTSRecord(domain string) (bool, error) { + txts, err := lookupTXT("_mta-sts." + domain) + if err != nil { + return false, err + } + + for _, txt := range txts { + if strings.HasPrefix(txt, "v=STSv1;") { + return true, nil + } + } + + return false, nil +} + +// PolicyCache is a caching layer for fetching policies. +// +// Policies are cached by domain, and stored in a single directory. +// The files will have as mtime the time when the policy expires, this makes +// the store simpler, as it can avoid keeping additional metadata. +// +// There is no in-memory caching. This may be added in the future, but for +// now disk is good enough for our purposes. +type PolicyCache struct { + dir string + + sync.Mutex +} + +// NewCache creates an instance of PolicyCache using the given directory as +// backing storage. The directory will be created if it does not exist. +func NewCache(dir string) (*PolicyCache, error) { + c := &PolicyCache{ + dir: dir, + } + err := os.MkdirAll(dir, 0770) + return c, err +} + +const pathPrefix = "pol:" + +func (c *PolicyCache) domainPath(domain string) string { + // We assume the domain is well formed, sanity check just in case. + if strings.Contains(domain, "/") { + panic("domain contains slash") + } + + return c.dir + "/" + pathPrefix + domain +} + +var errExpired = errors.New("cache entry expired") + +func (c *PolicyCache) load(domain string) (*Policy, error) { + fname := c.domainPath(domain) + + fi, err := os.Stat(fname) + if err != nil { + return nil, err + } + if time.Since(fi.ModTime()) > 0 { + cacheExpired.Add(1) + return nil, errExpired + } + + data, err := ioutil.ReadFile(fname) + if err != nil { + cacheIOErrors.Add(1) + return nil, err + } + + p := &Policy{} + err = json.Unmarshal(data, p) + if err != nil { + cacheUnmarshalErrors.Add(1) + return nil, err + } + + // The policy should always be valid, as we marshalled it ourselves; + // however, check it just to be safe. + if err := p.Check(); err != nil { + cacheInvalid.Add(1) + return nil, fmt.Errorf( + "%s unmarshalled invalid policy %v: %v", domain, p, err) + } + + return p, nil +} + +func (c *PolicyCache) store(domain string, p *Policy) error { + data, err := json.Marshal(p) + if err != nil { + cacheMarshalErrors.Add(1) + return fmt.Errorf("%s failed to marshal policy %v, error: %v", + domain, p, err) + } + + // Change the modification time to the future, when the policy expires. + // load will check for this to detect expired cache entries, see above for + // the details. + expires := time.Now().Add(p.MaxAge) + chTime := func(fname string) error { + return os.Chtimes(fname, expires, expires) + } + + fname := c.domainPath(domain) + err = safeio.WriteFile(fname, data, 0640, chTime) + if err != nil { + cacheIOErrors.Add(1) + } + return err +} + +// Fetch a policy for the given domain, using the cache. +func (c *PolicyCache) Fetch(ctx context.Context, domain string) (*Policy, error) { + cacheFetches.Add(1) + tr := trace.New("STSCache.Fetch", domain) + defer tr.Finish() + + p, err := c.load(domain) + if err == nil { + tr.Debugf("cache hit: %v", p) + cacheHits.Add(1) + return p, nil + } + + p, err = Fetch(ctx, domain) + if err != nil { + tr.Debugf("failed to fetch: %v", err) + cacheFailedFetch.Add(1) + return nil, err + } + tr.Debugf("fetched: %v", p) + + // We could do this asynchronously, as we got the policy to give to the + // caller. However, to make troubleshooting easier and the cost of storing + // entries easier to track down, we store synchronously. + // Note that even if the store returns an error, we pass on the policy: at + // this point we rather use the policy even if we couldn't store it in the + // cache. + err = c.store(domain, p) + if err != nil { + tr.Errorf("failed to store: %v", err) + } else { + tr.Debugf("stored") + } + + return p, nil +} + +// PeriodicallyRefresh the cache, by re-fetching all entries. +func (c *PolicyCache) PeriodicallyRefresh(ctx context.Context) { + for ctx.Err() == nil { + c.refresh(ctx) + cacheRefreshCycles.Add(1) + + // Wait 10 minutes between passes; this is a background refresh and + // there's no need to poke the servers very often. + time.Sleep(10 * time.Minute) + } +} + +func (c *PolicyCache) refresh(ctx context.Context) { + tr := trace.New("STSCache.Refresh", c.dir) + defer tr.Finish() + + entries, err := ioutil.ReadDir(c.dir) + if err != nil { + tr.Errorf("failed to list directory %q: %v", c.dir, err) + return + } + tr.Debugf("%d entries", len(entries)) + + for _, e := range entries { + if !strings.HasPrefix(e.Name(), pathPrefix) { + continue + } + domain := e.Name()[len(pathPrefix):] + cacheRefreshes.Add(1) + tr.Debugf("%v: refreshing", domain) + + fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + p, err := Fetch(fetchCtx, domain) + cancel() + if err != nil { + tr.Debugf("%v: failed to fetch: %v", domain, err) + cacheRefreshErrors.Add(1) + continue + } + tr.Debugf("%v: fetched", domain) + + err = c.store(domain, p) + if err != nil { + tr.Errorf("%v: failed to store: %v", domain, err) + } else { + tr.Debugf("%v: stored", domain) + } + } + + tr.Debugf("refresh done") +} diff -Nru chasquid-0.05/internal/sts/sts_test.go chasquid-0.06/internal/sts/sts_test.go --- chasquid-0.05/internal/sts/sts_test.go 1970-01-01 00:00:00.000000000 +0000 +++ chasquid-0.06/internal/sts/sts_test.go 2018-07-22 10:15:40.000000000 +0000 @@ -0,0 +1,573 @@ +package sts + +import ( + "context" + "expvar" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "testing" + "time" + + "blitiri.com.ar/go/chasquid/internal/testlib" +) + +// Override the lookup function to control its results. +var txtResults = map[string][]string{ + "dom1": nil, + "dom2": {}, + "dom3": {"abc", "def"}, + "dom4": {"abc", "v=STSv1; id=blah;"}, + + // Matching policyForDomain below. + "_mta-sts.domain.com": {"v=STSv1; id=blah;"}, + "_mta-sts.policy404": {"v=STSv1; id=blah;"}, + "_mta-sts.version99": {"v=STSv1; id=blah;"}, +} +var errTest = fmt.Errorf("error for testing purposes") +var txtErrors = map[string]error{ + "_mta-sts.domErr": errTest, +} + +func testLookupTXT(domain string) ([]string, error) { + return txtResults[domain], txtErrors[domain] +} + +// Test policy for each of the requested domains. Will be served by the test +// HTTP server. +var policyForDomain = map[string]string{ + // domain.com -> valid, with reasonable policy. + "domain.com": ` + version: STSv1 + mode: enforce + mx: *.mail.domain.com + max_age: 3600 + `, + + // version99 -> invalid policy (unknown version). + "version99": ` + version: STSv99 + mode: enforce + mx: *.mail.version99 + max_age: 999 + `, +} + +func testHTTPHandler(w http.ResponseWriter, r *http.Request) { + // For testing, the domain in the path (see urlForDomain). + policy, ok := policyForDomain[r.URL.Path[1:]] + if !ok { + http.Error(w, "not found", 404) + return + } + fmt.Fprintln(w, policy) + return +} + +func TestMain(m *testing.M) { + lookupTXT = testLookupTXT + + // Create a test HTTP server, used by the more end-to-end tests. + httpServer := httptest.NewServer(http.HandlerFunc(testHTTPHandler)) + + fakeURLForTesting = httpServer.URL + os.Exit(m.Run()) +} + +func TestParsePolicy(t *testing.T) { + const pol1 = ` + version: STSv1 + mode: enforce + mx: *.mail.example.com + max_age: 123456 +` + p, err := parsePolicy([]byte(pol1)) + if err != nil { + t.Errorf("failed to parse policy: %v", err) + } + + t.Logf("pol1: %+v", p) +} + +func TestCheckPolicy(t *testing.T) { + validPs := []Policy{ + {Version: "STSv1", Mode: "enforce", MaxAge: 1 * time.Hour, + MXs: []string{"mx1", "mx2"}}, + {Version: "STSv1", Mode: "testing", MaxAge: 1 * time.Hour, + MXs: []string{"mx1"}}, + {Version: "STSv1", Mode: "none", MaxAge: 1 * time.Hour, + MXs: []string{"mx1"}}, + } + for i, p := range validPs { + if err := p.Check(); err != nil { + t.Errorf("%d policy %v failed check: %v", i, p, err) + } + } + + invalid := []struct { + p Policy + expected error + }{ + {Policy{Version: "STSv2"}, ErrUnknownVersion}, + {Policy{Version: "STSv1"}, ErrInvalidMaxAge}, + {Policy{Version: "STSv1", MaxAge: 1, Mode: "blah"}, ErrInvalidMode}, + {Policy{Version: "STSv1", MaxAge: 1, Mode: "enforce"}, ErrInvalidMX}, + {Policy{Version: "STSv1", MaxAge: 1, Mode: "enforce", MXs: []string{}}, + ErrInvalidMX}, + } + for i, c := range invalid { + if err := c.p.Check(); err != c.expected { + t.Errorf("%d policy %v check: expected %v, got %v", i, c.p, + c.expected, err) + } + } +} + +func TestMatchDomain(t *testing.T) { + cases := []struct { + domain, pattern string + expected bool + }{ + {"lalala", "lalala", true}, + {"a.b.", "a.b", true}, + {"a.b", "a.b.", true}, + {"abc.com", "*.com", true}, + + {"abc.com", "abc.*.com", false}, + {"abc.com", "x.abc.com", false}, + {"x.abc.com", "*.*.com", false}, + {"abc.def.com", "abc.*.com", false}, + + {"ñaca.com", "ñaca.com", true}, + {"Ñaca.com", "ñaca.com", true}, + {"ñaca.com", "Ñaca.com", true}, + {"x.ñaca.com", "x.xn--aca-6ma.com", true}, + {"x.naca.com", "x.xn--aca-6ma.com", false}, + + // Triggers errors in domainToASCII. + {strings.Repeat("x", 65536) + "\uff00", "x.com", false}, + + // Examples from the RFC. + {"mail.example.com", "*.example.com", true}, + {"example.com", "*.example.com", false}, + {"foo.bar.example.com", "*.example.com", false}, + + // Missing "*" (invalid, seen in the wild). + {"aa.b.cc.com", ".aa.b.cc.com", false}, + {"zz.aa.b.cc.com", ".aa.b.cc.com", false}, + {"zz.aa.b.cc.com", "*.aa.b.cc.com", true}, + } + + for _, c := range cases { + if r := matchDomain(c.domain, c.pattern); r != c.expected { + t.Errorf("matchDomain(%q, %q) = %v, expected %v", + c.domain, c.pattern, r, c.expected) + } + } +} + +func TestMXIsAllowed(t *testing.T) { + p := Policy{Version: "STSv1", Mode: "enforce", MaxAge: 1 * time.Hour, + MXs: []string{"mx1", "mx2"}} + if p.MXIsAllowed("notamx") { + t.Errorf("notamx should not be allowed") + } + if !p.MXIsAllowed("mx1") { + t.Errorf("mx1 should be allowed") + } + if !p.MXIsAllowed("mx2") { + t.Errorf("mx2 should be allowed") + } + + p = Policy{Version: "STSv1", Mode: "testing", MaxAge: 1 * time.Hour, + MXs: []string{"mx1"}} + if !p.MXIsAllowed("notamx") { + t.Errorf("notamx should be allowed (policy not enforced)") + } +} + +func TestFetch(t *testing.T) { + // Note the data "fetched" for each domain comes from policyForDomain, + // defined in TestMain above. See httpGet for more details. + + // Normal fetch, all valid. + p, err := Fetch(context.Background(), "domain.com") + if err != nil { + t.Errorf("failed to fetch policy: %v", err) + } + t.Logf("domain.com: %+v", p) + + // Domain without a policy (HTTP get fails). + p, err = Fetch(context.Background(), "policy404") + if err == nil { + t.Errorf("fetched unknown policy: %v", p) + } + t.Logf("policy404: got error as expected: %v", err) + + // Domain with an invalid policy (unknown version). + p, err = Fetch(context.Background(), "version99") + if err != ErrUnknownVersion { + t.Errorf("expected error %v, got %v (and policy: %v)", + ErrUnknownVersion, err, p) + } + t.Logf("version99: got expected error: %v", err) + + // Error fetching TXT record for this domain. + p, err = Fetch(context.Background(), "domErr") + if err != errTest { + t.Errorf("expected error %v, got %v (and policy: %v)", + errTest, err, p) + } + t.Logf("domErr: got expected error: %v", err) +} + +func TestPolicyTooBig(t *testing.T) { + // Construct a valid but very large JSON as a policy. + raw := `{"version": "STSv1", "mode": "enforce", "mx": [` + for i := 0; i < 2000; i++ { + raw += fmt.Sprintf("\"mx%d\", ", i) + } + raw += `"mxlast"], "max_age": 100}` + policyForDomain["toobig"] = raw + + _, err := Fetch(context.Background(), "toobig") + if err == nil { + t.Errorf("fetch worked, but should have failed") + } + t.Logf("got error as expected: %v", err) +} + +// Tests for the policy cache. + +func expvarMustEq(t *testing.T, name string, v *expvar.Int, expected int) { + // TODO: Use v.Value once we drop support of Go 1.7. + value, _ := strconv.Atoi(v.String()) + if value != expected { + t.Errorf("%s is %d, expected %d", name, value, expected) + } +} + +func TestCacheBasics(t *testing.T) { + dir := testlib.MustTempDir(t) + c, err := NewCache(dir) + if err != nil { + t.Fatal(err) + } + + // Note the data "fetched" for each domain comes from policyForDomain, + // defined in TestMain above. See httpGet for more details. + + // Reset the expvar counters that we use to validate hits, misses, etc. + cacheFetches.Set(0) + cacheHits.Set(0) + + ctx := context.Background() + + // Fetch domain.com, check we get a reasonable policy, and that it's a + // cache miss. + p, err := c.Fetch(ctx, "domain.com") + if err != nil || p.Check() != nil || p.MXs[0] != "*.mail.domain.com" { + t.Errorf("unexpected fetch result - policy = %v ; error = %v", p, err) + } + t.Logf("cache fetched domain.com: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 1) + expvarMustEq(t, "cacheHits", cacheHits, 0) + + // Fetch domain.com again, this time we should see a cache hit. + p, err = c.Fetch(ctx, "domain.com") + if err != nil || p.Check() != nil || p.MXs[0] != "*.mail.domain.com" { + t.Errorf("unexpected fetch result - policy = %v ; error = %v", p, err) + } + t.Logf("cache fetched domain.com: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 2) + expvarMustEq(t, "cacheHits", cacheHits, 1) + + // Simulate an expired cache entry by changing the mtime of domain.com's + // entry to the past. + expires := time.Now().Add(-1 * time.Minute) + os.Chtimes(c.domainPath("domain.com"), expires, expires) + + // Do a third fetch, check that we don't get a cache hit. + p, err = c.Fetch(ctx, "domain.com") + if err != nil || p.Check() != nil || p.MXs[0] != "*.mail.domain.com" { + t.Errorf("unexpected fetch result - policy = %v ; error = %v", p, err) + } + t.Logf("cache fetched domain.com: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 3) + expvarMustEq(t, "cacheHits", cacheHits, 1) + + // Fetch for a domain without policy. + p, err = c.Fetch(ctx, "domErr") + if err == nil || p != nil { + t.Errorf("expected failure, got: policy = %v ; error = %v", p, err) + } + t.Logf("cache fetched domErr: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 4) + expvarMustEq(t, "cacheHits", cacheHits, 1) + expvarMustEq(t, "cacheFailedFetch", cacheFailedFetch, 1) + + if !t.Failed() { + os.RemoveAll(dir) + } +} + +// Test how the cache behaves when the files are corrupt. +func TestCacheBadData(t *testing.T) { + dir := testlib.MustTempDir(t) + c, err := NewCache(dir) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + cacheUnmarshalErrors.Set(0) + cacheInvalid.Set(0) + + cases := []string{ + // Case 1: A file with invalid json, which will fail unmarshalling. + "this is not valid json", + + // Case 2: A file with a parseable but invalid policy. + `{"version": "STSv1", "mode": "INVALID", "mx": ["mx"], "max_age": 1}`, + } + + for _, badContent := range cases { + // Reset the expvar counters that we use to validate hits, misses, etc. + cacheFetches.Set(0) + cacheHits.Set(0) + + // Fetch domain.com, should result in the file being added to the + // cache. + p, err := c.Fetch(ctx, "domain.com") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + t.Logf("cache fetched domain.com: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 1) + expvarMustEq(t, "cacheHits", cacheHits, 0) + + // Edit the file, filling it with the bad content for this case. + fname := c.domainPath("domain.com") + mustRewriteAndChtime(t, fname, badContent) + + // We now expect Fetch to fall back to getting the policy from the + // network (in our case, from policyForDomain). + p, err = c.Fetch(ctx, "domain.com") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + t.Logf("cache fetched domain.com: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 2) + expvarMustEq(t, "cacheHits", cacheHits, 0) + + // And now the file should be fine, resulting in a cache hit. + p, err = c.Fetch(ctx, "domain.com") + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + t.Logf("cache fetched domain.com: %v", p) + expvarMustEq(t, "cacheFetches", cacheFetches, 3) + expvarMustEq(t, "cacheHits", cacheHits, 1) + + // Remove the file, to start with a clean slate for the next case. + os.Remove(fname) + } + + expvarMustEq(t, "cacheUnmarshalErrors", cacheUnmarshalErrors, 1) + expvarMustEq(t, "cacheInvalid", cacheInvalid, 1) + + if !t.Failed() { + os.RemoveAll(dir) + } +} + +func mustFetch(t *testing.T, c *PolicyCache, ctx context.Context, d string) *Policy { + p, err := c.Fetch(ctx, d) + if err != nil { + t.Fatalf("Fetch %q failed: %v", d, err) + } + t.Logf("Fetch %q: %v", d, p) + return p +} + +func mustRewriteAndChtime(t *testing.T, fname, content string) { + testlib.Rewrite(t, fname, content) + + // Advance the expiration time to the future, so the rewritten policy is + // not considered expired. + expires := time.Now().Add(10 * time.Second) + err := os.Chtimes(fname, expires, expires) + if err != nil { + t.Fatalf("failed to chtime %q to the past: %v", fname, err) + } +} + +func TestCacheRefresh(t *testing.T) { + dir := testlib.MustTempDir(t) + c, err := NewCache(dir) + if err != nil { + t.Fatal(err) + } + + ctx := context.Background() + + txtResults["_mta-sts.refresh-test"] = []string{"v=STSv1; id=blah;"} + policyForDomain["refresh-test"] = ` + version: STSv1 + mode: enforce + mx: mx + max_age: 100` + p := mustFetch(t, c, ctx, "refresh-test") + if p.MaxAge != 100*time.Second { + t.Fatalf("policy.MaxAge is %v, expected 100s", p.MaxAge) + } + + // Change the "published" policy, check that we see the old version at + // fetch (should be cached), and a new version after a refresh. + policyForDomain["refresh-test"] = ` + version: STSv1 + mode: enforce + mx: mx + max_age: 200` + + p = mustFetch(t, c, ctx, "refresh-test") + if p.MaxAge != 100*time.Second { + t.Fatalf("policy.MaxAge is %v, expected 100s", p.MaxAge) + } + + // Launch background refreshes, and wait for one to complete. + // TODO: change to cacheRefreshCycles.Value once we drop support for Go + // 1.7. + cacheRefreshCycles.Set(0) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + go c.PeriodicallyRefresh(ctx) + for cacheRefreshCycles.String() == "0" { + time.Sleep(5 * time.Millisecond) + } + + p = mustFetch(t, c, ctx, "refresh-test") + if p.MaxAge != 200*time.Second { + t.Fatalf("policy.MaxAge is %v, expected 200s", p.MaxAge) + } + + if !t.Failed() { + os.RemoveAll(dir) + } +} + +func TestCacheSlashSafe(t *testing.T) { + dir := testlib.MustTempDir(t) + c, err := NewCache(dir) + if err != nil { + t.Fatal(err) + } + + defer func() { + if r := recover(); r != nil { + t.Logf("recovered: %v", r) + } else { + t.Fatalf("check did not panic as expected") + } + }() + + c.domainPath("a/b") +} + +func TestURLForDomain(t *testing.T) { + // This function will behave differently if fakeURLForTesting is set, so + // temporarily unset it. + oldURL := fakeURLForTesting + fakeURLForTesting = "" + defer func() { fakeURLForTesting = oldURL }() + + got := urlForDomain("a-test-domain") + expected := "https://mta-sts.a-test-domain/.well-known/mta-sts.txt" + if got != expected { + t.Errorf("got %q, expected %q", got, expected) + } +} + +func TestHasSTSRecord(t *testing.T) { + txtResults["_mta-sts.dom1"] = nil + txtResults["_mta-sts.dom2"] = []string{} + txtResults["_mta-sts.dom3"] = []string{"abc", "def"} + txtResults["_mta-sts.dom4"] = []string{"abc", "v=STSv1; id=blah;"} + + cases := []struct { + domain string + ok bool + err error + }{ + {"", false, nil}, + {"dom1", false, nil}, + {"dom2", false, nil}, + {"dom3", false, nil}, + {"dom4", true, nil}, + {"domErr", false, errTest}, + } + for _, c := range cases { + ok, err := hasSTSRecord(c.domain) + if ok != c.ok || err != c.err { + t.Errorf("%s: expected {%v, %v}, got {%v, %v}", c.domain, + c.ok, c.err, ok, err) + } + } +} + +func TestHTTPGet(t *testing.T) { + // Basic test, it should work. + srv1 := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(policyForDomain["domain.com"])) + })) + defer srv1.Close() + + ctx := context.Background() + raw, err := httpGet(ctx, srv1.URL) + if err != nil { + t.Errorf("GET failed: got %q, %v", raw, err) + } + + // Test that redirects are rejected. + srv2 := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, fakeURLForTesting, http.StatusMovedPermanently) + })) + defer srv2.Close() + + raw, err = httpGet(ctx, srv2.URL) + if err == nil { + t.Errorf("redirect allowed, should have failed: got %q, %v", raw, err) + } + + // Content type != text/plain should be rejected. + srv3 := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/json") + w.Write([]byte(policyForDomain["domain.com"])) + })) + defer srv3.Close() + + raw, err = httpGet(ctx, srv3.URL) + if err != ErrInvalidMediaType { + t.Errorf("content type != text/plain was allowed: got %q, %v", raw, err) + } + + // Invalid (unparseable) media type. + srv4 := httptest.NewServer( + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "invalid/content/type") + w.Write([]byte(policyForDomain["domain.com"])) + })) + defer srv4.Close() + + raw, err = httpGet(ctx, srv4.URL) + if err == nil || err == ErrInvalidMediaType { + t.Errorf("invalid content type was allowed: got %q, %v", raw, err) + } +} diff -Nru chasquid-0.05/README.md chasquid-0.06/README.md --- chasquid-0.05/README.md 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/README.md 2018-07-22 10:15:40.000000000 +0000 @@ -35,7 +35,7 @@ * Tracking of per-domain TLS support, prevents connection downgrading. * Multiple TLS certificates. * Easy integration with [Let's Encrypt]. - * [SPF] checking. + * [SPF] and [MTA-STS] checking. [SMTPUTF8]: https://en.wikipedia.org/wiki/Extended_SMTP#SMTPUTF8 @@ -43,6 +43,7 @@ [Let's Encrypt]: https://letsencrypt.org [Dovecot]: https://dovecot.org [SPF]: https://en.wikipedia.org/wiki/Sender_Policy_Framework +[MTA-STS]: https://datatracker.ietf.org/doc/draft-ietf-uta-mta-sts/ [Debian]: https://debian.org [Ubuntu]: https://ubuntu.com diff -Nru chasquid-0.05/test/t-11-dovecot/config/dovecot.conf.in chasquid-0.06/test/t-11-dovecot/config/dovecot.conf.in --- chasquid-0.05/test/t-11-dovecot/config/dovecot.conf.in 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/test/t-11-dovecot/config/dovecot.conf.in 2018-07-22 10:15:40.000000000 +0000 @@ -41,6 +41,35 @@ chroot = } +# In dovecot 2.3 these services want to change the group owner of the files, +# so override it manually to our effective group. +# This is backwards-compatible with dovecot 2.2. +# TODO: once we stop supporting dovecot 2.2 for tests, we can set +# default_internal_group and remove these settings. +service imap-hibernate { + unix_listener imap-hibernate { + group = $GROUP + } +} +service stats { + unix_listener stats { + group = $GROUP + } + unix_listener stats-writer { + group = $GROUP + } +} +service dict { + unix_listener dict { + group = $GROUP + } +} +service dict-async { + unix_listener dict-async { + group = $GROUP + } +} + # Turn on debugging information, to help troubleshooting issues. auth_verbose = yes auth_debug = yes diff -Nru chasquid-0.05/test/t-11-dovecot/run.sh chasquid-0.06/test/t-11-dovecot/run.sh --- chasquid-0.05/test/t-11-dovecot/run.sh 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/test/t-11-dovecot/run.sh 2018-07-22 10:15:40.000000000 +0000 @@ -25,6 +25,7 @@ mkdir -p $ROOT $ROOT/run rm -f $ROOT/dovecot.log +export GROUP=$(id -g -n) envsubst < config/dovecot.conf.in > $ROOT/dovecot.conf cp -f config/passwd $ROOT/passwd diff -Nru chasquid-0.05/UPGRADING.md chasquid-0.06/UPGRADING.md --- chasquid-0.05/UPGRADING.md 2018-06-04 22:45:18.000000000 +0000 +++ chasquid-0.06/UPGRADING.md 2018-07-22 10:15:40.000000000 +0000 @@ -5,6 +5,20 @@ backwards-incompatible ways. This should be rare and will be avoided if possible. +## 0.05 → 0.06 + +No backwards-incompatible changes. + + +## 0.04 → 0.05 + +No backwards-incompatible changes. + + +## 0.03 → 0.04 + +No backwards-incompatible changes. + ## 0.02 → 0.03