pax_global_header00006660000000000000000000000064135724722630014525gustar00rootroot0000000000000052 comment=0df2e325ed433fe7e1bcaff203318b39732d13dd chasquid-1.2/000077500000000000000000000000001357247226300131705ustar00rootroot00000000000000chasquid-1.2/.gitignore000066400000000000000000000017331357247226300151640ustar00rootroot00000000000000 # Ignore anything beginning with a dot: these are usually temporary or # unimportant. .* # Exceptions to the rules above: files we care about that would otherwise be # excluded. !.gitignore # The binaries. /chasquid /chasquid-util /smtp-check /spf-check /mda-lmtp /dovecot-auth-cli cmd/chasquid-util/chasquid-util cmd/smtp-check/smtp-check cmd/spf-check/spf-check cmd/mda-lmtp/mda-lmtp cmd/dovecot-auth-cli/dovecot-auth-cli test/util/minidns # Test binary, generated during coverage tests. chasquid.test # Exclude any .pem files, to prevent accidentally including test keys and # certificates. *.pem # Ignore the generated corpus: we don't want to commit it to the repository by # default, to avoid size blowup. Manually added corpus will begin with "t-", # and thus not ignored. # Leave crashers not ignored, to make them easier to spot (and they should be # moved to manually-added corpus once detected). **/testdata/fuzz/corpus/[0-9a-f]* # go-fuzz build artifacts. *-fuzz.zip chasquid-1.2/.gitlab-ci.yml000066400000000000000000000020061357247226300156220ustar00rootroot00000000000000 stages: - test - docker_image # Integration test, using the module versions from the repository. integration_test: stage: test image: docker:stable services: - docker:dind script: - docker info - docker build -t chasquid-test -f test/Dockerfile . - docker run chasquid-test env - docker run chasquid-test make test # Integration test, using the latest module versions. integration_test_latest: stage: test image: docker:stable services: - docker:dind script: - docker info - docker build -t chasquid-test --build-arg GO_GET_ARGS="-u=patch" -f test/Dockerfile . - docker run chasquid-test env - docker run chasquid-test make test image_build: stage: docker_image image: docker:stable services: - docker:dind script: - docker info - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY - docker build -t $CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME -f docker/Dockerfile . - docker push $CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME chasquid-1.2/.mkdocs.yml000066400000000000000000000012031357247226300152450ustar00rootroot00000000000000# mkdocs configuration # # To test changes locally, run: # mkdocs serve -f .mkdocs.yml site_name: chasquid documentation # Point the repo to github to make it easier for users to do edits, even if # it's not the canonical location. repo_url: https://github.com/albertito/chasquid markdown_extensions: - codehilite: guess_lang: false - attr_list theme: readthedocs nav: - Home: index.md - How-to: howto.md - Install: install.md - Manpages: man/index.md - All: - aliases.md - hooks.md - dovecot.md - dkim.md - docker.md - flow.md - monitoring.md - sec-levels.md - tests.md - relnotes.md chasquid-1.2/.travis.yml000066400000000000000000000016041357247226300153020ustar00rootroot00000000000000# Configuration for https://travis-ci.org/ language: go go_import_path: blitiri.com.ar/go/chasquid dist: trusty sudo: false go: # Check against the version in Debian stable. - 1.11 - stable - master # This is needed because the repository has a Makefile, so travis won't invoke # "go get" by default. install: - go get blitiri.com.ar/go/chasquid - go get blitiri.com.ar/go/chasquid/cmd/chasquid-util - go get blitiri.com.ar/go/chasquid/cmd/mda-lmtp - go get blitiri.com.ar/go/chasquid/cmd/smtp-check - go get blitiri.com.ar/go/chasquid/cmd/spf-check script: - make all - go test ./... - go test -race ./... notifications: email: on_success: change on_failure: always irc: channels: - "ircs://chat.freenode.net:7070/#chasquid" use_notice: true on_success: change on_failure: always chasquid-1.2/LICENSE000066400000000000000000000263141357247226300142030ustar00rootroot00000000000000 Copyright 2016 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. chasquid-1.2/Makefile000066400000000000000000000025651357247226300146400ustar00rootroot00000000000000 ifndef VERSION VERSION = `git describe --always --long --dirty` endif # https://wiki.debian.org/ReproducibleBuilds/TimestampsProposal ifndef SOURCE_DATE_EPOCH SOURCE_DATE_EPOCH = `git log -1 --format=%ct` endif default: chasquid all: chasquid chasquid-util smtp-check spf-check mda-lmtp dovecot-auth-cli chasquid: go build -ldflags="\ -X main.version=${VERSION} \ -X main.sourceDateTs=${SOURCE_DATE_EPOCH} \ " ${GOFLAGS} chasquid-util: go build ${GOFLAGS} ./cmd/chasquid-util/ smtp-check: go build ${GOFLAGS} ./cmd/smtp-check/ spf-check: go build ${GOFLAGS} ./cmd/spf-check/ mda-lmtp: go build ${GOFLAGS} ./cmd/mda-lmtp/ dovecot-auth-cli: go build ${GOFLAGS} ./cmd/dovecot-auth-cli/ test: go test ${GOFLAGS} ./... setsid -w ./test/run.sh setsid -w ./test/stress.sh setsid -w ./cmd/chasquid-util/test.sh setsid -w ./cmd/mda-lmtp/test.sh setsid -w ./cmd/dovecot-auth-cli/test.sh install-binaries: chasquid chasquid-util smtp-check mda-lmtp mkdir -p /usr/local/bin/ cp -a chasquid chasquid-util smtp-check mda-lmtp /usr/local/bin/ install-config-skeleton: if ! [ -d /etc/chasquid ] ; then cp -arv etc / ; fi if ! [ -d /var/lib/chasquid ]; then \ mkdir -v /var/lib/chasquid; \ chmod -v 0700 /var/lib/chasquid ; \ chown -v mail:mail /var/lib/chasquid ; \ fi .PHONY: chasquid test \ chasquid-util smtp-check spf-check mda-lmtp dovecot-auth-cli chasquid-1.2/README.md000066400000000000000000000061601357247226300144520ustar00rootroot00000000000000 # chasquid [chasquid](https://blitiri.com.ar/p/chasquid) is an SMTP (email) server with a focus on simplicity, security, and ease of operation. It is designed mainly for individuals and small groups. It's written in [Go](https://golang.org), and distributed under the [Apache license 2.0](http://en.wikipedia.org/wiki/Apache_License). [![Travis-CI status](https://travis-ci.org/albertito/chasquid.svg?branch=master)](https://travis-ci.org/albertito/chasquid) [![Gitlab CI status](https://gitlab.com/albertito/chasquid/badges/master/pipeline.svg)](https://gitlab.com/albertito/chasquid/pipelines) [![Go Report Card](https://goreportcard.com/badge/github.com/albertito/chasquid)](https://goreportcard.com/report/github.com/albertito/chasquid) [![Coverage](https://img.shields.io/badge/coverage-next-brightgreen.svg)](https://blitiri.com.ar/p/chasquid/coverage.html) [![Docs](https://img.shields.io/badge/docs-reference-blue.svg)](https://blitiri.com.ar/p/chasquid/docs/) [![Freenode](https://img.shields.io/badge/chat-freenode-blue.svg)](https://webchat.freenode.net/#chasquid) ## Features * Easy * Easy to configure. * Hard to mis-configure in ways that are harmful or insecure (e.g. no open relay, or clear-text authentication). * [Monitoring] HTTP server, with exported variables and tracing to help debugging. * Integrated with [Debian] and [Ubuntu]. * Supports using [Dovecot] for authentication. * Useful * Multiple/virtual domains, with per-domain users and aliases. * Suffix dropping (`user+something@domain` → `user@domain`). * [Hooks] for integration with greylisting, anti-virus, anti-spam, and DKIM/DMARC. * International usernames ([SMTPUTF8]) and domain names ([IDNA]). * Secure * [Tracking] of per-domain TLS support, prevents connection downgrading. * Multiple TLS certificates. * Easy integration with [Let's Encrypt]. * [SPF] and [MTA-STS] checking. [Debian]: https://debian.org [Dovecot]: https://blitiri.com.ar/p/chasquid/docs/dovecot/ [Hooks]: https://blitiri.com.ar/p/chasquid/docs/hooks/ [IDNA]: https://en.wikipedia.org/wiki/Internationalized_domain_name [Let's Encrypt]: https://letsencrypt.org [MTA-STS]: https://tools.ietf.org/html/rfc8461 [Monitoring]: https://blitiri.com.ar/p/chasquid/docs/monitoring/ [SMTPUTF8]: https://en.wikipedia.org/wiki/Extended_SMTP#SMTPUTF8 [SPF]: https://en.wikipedia.org/wiki/Sender_Policy_Framework [Tracking]: https://blitiri.com.ar/p/chasquid/docs/sec-levels/ [Ubuntu]: https://ubuntu.com ## Documentation The [how-to guide](https://blitiri.com.ar/p/chasquid/docs/howto/) and the [installation guide](https://blitiri.com.ar/p/chasquid/docs/install/) are the best starting points on how to install, configure and run chasquid. You will find [all documentation here](https://blitiri.com.ar/p/chasquid/docs/). ## Contact If you have any questions, comments or patches please send them to the [mailing list](https://groups.google.com/forum/#!forum/chasquid), chasquid@googlegroups.com. To subscribe, send an email to chasquid+subscribe@googlegroups.com. You can also reach out via IRC, `#chasquid` on [freenode](https://freenode.net/). chasquid-1.2/chasquid.go000066400000000000000000000225621357247226300153270ustar00rootroot00000000000000// 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" "html/template" "io/ioutil" "math/rand" "net" "os" "path/filepath" "strconv" "time" "blitiri.com.ar/go/chasquid/internal/config" "blitiri.com.ar/go/chasquid/internal/courier" "blitiri.com.ar/go/chasquid/internal/dovecot" "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" "net/http" _ "net/http/pprof" ) // Command-line flags. var ( configDir = flag.String("config_dir", "/etc/chasquid", "configuration directory") showVer = flag.Bool("version", false, "show version and exit") ) // Build information, overridden at build time using // -ldflags="-X main.version=blah". var ( version = "undefined" sourceDateTs = "0" ) var ( versionVar = expvar.NewString("chasquid/version") sourceDate time.Time sourceDateVar = expvar.NewString("chasquid/sourceDateStr") sourceDateTsVar = expvar.NewInt("chasquid/sourceDateTimestamp") ) func main() { flag.Parse() log.Init() parseVersionInfo() if *showVer { fmt.Printf("chasquid %s (source date: %s)\n", version, sourceDate) return } log.Infof("chasquid starting (version %s)", version) // Seed the PRNG, just to prevent for it to be totally predictable. rand.Seed(time.Now().UnixNano()) conf, err := config.Load(*configDir + "/chasquid.conf") if err != nil { log.Fatalf("Error reading config: %v", err) } config.LogConfig(conf) // Change to the config dir. // This allow us to use relative paths for configuration directories. // It also can be useful in unusual environments and for testing purposes, // where paths inside the configuration itself could be relative, and this // fixes the point of reference. os.Chdir(*configDir) initMailLog(conf.MailLogPath) if conf.MonitoringAddress != "" { launchMonitoringServer(conf.MonitoringAddress) } s := smtpsrv.NewServer() s.Hostname = conf.Hostname s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024 s.HookPath = "hooks/" s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters) if conf.DovecotAuth { loadDovecot(s, conf.DovecotUserdbPath, conf.DovecotClientPath) } // Load certificates from "certs//{fullchain,privkey}.pem". // The structure matches letsencrypt's, to make it easier for that case. log.Infof("Loading certificates") for _, info := range mustReadDir("certs/") { name := info.Name() dir := filepath.Join("certs/", name) if fi, err := os.Stat(dir); err == nil && !fi.IsDir() { // Skip non-directories. continue } log.Infof(" %s", name) certPath := filepath.Join(dir, "fullchain.pem") if _, err := os.Stat(certPath); os.IsNotExist(err) { continue } keyPath := filepath.Join(dir, "privkey.pem") if _, err := os.Stat(keyPath); os.IsNotExist(err) { continue } err := s.AddCerts(certPath, keyPath) if err != nil { log.Fatalf(" %v", err) } } // Load domains from "domains/". log.Infof("Domain config paths:") for _, info := range mustReadDir("domains/") { domain, err := normalize.Domain(info.Name()) if err != nil { log.Fatalf("Invalid name %+q: %v", info.Name(), err) } dir := filepath.Join("domains", info.Name()) loadDomain(domain, dir, s) } // Always include localhost as local domain. // This can prevent potential trouble if we were to accidentally treat it // as a remote domain (for loops, alias resolutions, etc.). s.AddDomain("localhost") 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, STSCache: stsCache} s.InitQueue(conf.DataDir+"/queue", localC, remoteC) // Load the addresses and listeners. systemdLs, err := systemd.Listeners() if err != nil { log.Fatalf("Error getting systemd listeners: %v", err) } naddr := loadAddresses(s, conf.SmtpAddress, systemdLs["smtp"], smtpsrv.ModeSMTP) naddr += loadAddresses(s, conf.SubmissionAddress, systemdLs["submission"], smtpsrv.ModeSubmission) naddr += loadAddresses(s, conf.SubmissionOverTlsAddress, systemdLs["submission_tls"], smtpsrv.ModeSubmissionTLS) if naddr == 0 { log.Fatalf("No address to listen on") } s.ListenAndServe() } func loadAddresses(srv *smtpsrv.Server, addrs []string, ls []net.Listener, mode smtpsrv.SocketMode) int { naddr := 0 for _, addr := range addrs { // The "systemd" address indicates we get listeners via systemd. if addr == "systemd" { srv.AddListeners(ls, mode) naddr += len(ls) } else { srv.AddAddr(addr, mode) naddr++ } } if naddr == 0 { log.Errorf("Warning: No %v addresses/listeners", mode) log.Errorf("If using systemd, check that you named the sockets") } return naddr } func initMailLog(path string) { var err error if path == "" { maillog.Default, err = maillog.NewSyslog() } else { os.MkdirAll(filepath.Dir(path), 0775) var f *os.File f, err = os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0664) maillog.Default = maillog.New(f) } if err != nil { log.Fatalf("Error opening mail log: %v", err) } } // Helper to load a single domain configuration into the server. func loadDomain(name, dir string, s *smtpsrv.Server) { log.Infof(" %s", name) s.AddDomain(name) if _, err := os.Stat(dir + "/users"); err == nil { log.Infof(" adding users") udb, err := userdb.Load(dir + "/users") if err != nil { log.Errorf(" error: %v", err) } else { s.AddUserDB(name, udb) } } log.Infof(" adding aliases") err := s.AddAliasesFile(name, dir+"/aliases") if err != nil { log.Errorf(" error: %v", err) } } func loadDovecot(s *smtpsrv.Server, userdb, client string) { a := dovecot.Autodetect(userdb, client) if a == nil { log.Errorf("Dovecot autodetection failed, no dovecot fallback") return } if a != nil { s.SetAuthFallback(a) log.Infof("Fallback authenticator: %v", a) if err := a.Check(); err != nil { log.Errorf("Failed dovecot authenticator check: %v", err) } } } // Read a directory, which must have at least some entries. func mustReadDir(path string) []os.FileInfo { dirs, err := ioutil.ReadDir(path) if err != nil { log.Fatalf("Error reading %q directory: %v", path, err) } if len(dirs) == 0 { log.Fatalf("No entries found in %q", path) } return dirs } func parseVersionInfo() { versionVar.Set(version) sdts, err := strconv.ParseInt(sourceDateTs, 10, 0) if err != nil { panic(err) } sourceDate = time.Unix(sdts, 0) sourceDateVar.Set(sourceDate.Format("2006-01-02 15:04:05 -0700")) sourceDateTsVar.Set(sdts) } func launchMonitoringServer(addr string) { log.Infof("Monitoring HTTP server listening on %s", addr) indexData := struct { Version string SourceDate time.Time StartTime time.Time }{ Version: version, SourceDate: sourceDate, StartTime: time.Now(), } http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } if err := monitoringHTMLIndex.Execute(w, indexData); err != nil { log.Infof("monitoring handler error: %v", err) } }) flags := dumpFlags() http.HandleFunc("/debug/flags", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(flags)) }) go http.ListenAndServe(addr, nil) } // Static index for the monitoring website. var monitoringHTMLIndex = template.Must(template.New("index").Funcs( template.FuncMap{"since": time.Since}).Parse( ` chasquid monitoring

chasquid monitoring

chasquid {{.Version}}
source date {{.SourceDate.Format "2006-01-02 15:04:05 -0700"}}

started {{.StartTime.Format "Mon, 2006-01-02 15:04:05 -0700"}}
up since {{.StartTime | since}}

`)) // dumpFlags to a string, for troubleshooting purposes. func dumpFlags() string { s := "" visited := make(map[string]bool) // Print set flags first, then the rest. flag.Visit(func(f *flag.Flag) { s += fmt.Sprintf("-%s=%s\n", f.Name, f.Value.String()) visited[f.Name] = true }) s += "\n" flag.VisitAll(func(f *flag.Flag) { if !visited[f.Name] { s += fmt.Sprintf("-%s=%s\n", f.Name, f.Value.String()) } }) return s } chasquid-1.2/cmd/000077500000000000000000000000001357247226300137335ustar00rootroot00000000000000chasquid-1.2/cmd/chasquid-util/000077500000000000000000000000001357247226300165075ustar00rootroot00000000000000chasquid-1.2/cmd/chasquid-util/chasquid-util.go000066400000000000000000000170721357247226300216210ustar00rootroot00000000000000// chasquid-util is a command-line utility for chasquid-related operations. // // Don't include it in the coverage build. // +build !coverage package main import ( "fmt" "io/ioutil" "net/url" "os" "path/filepath" "syscall" "bytes" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/config" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/userdb" "github.com/docopt/docopt-go" "github.com/golang/protobuf/proto" "golang.org/x/crypto/ssh/terminal" ) // Usage, which doubles as parameter definitions thanks to docopt. const usage = ` Usage: chasquid-util [options] user-add [--password=] chasquid-util [options] user-remove chasquid-util [options] authenticate [--password=] chasquid-util [options] check-userdb chasquid-util [options] aliases-resolve
chasquid-util [options] domaininfo-remove chasquid-util [options] print-config chasquid-util [options] aliases-add Options: -C --configdir= Configuration directory ` // Command-line arguments. var args map[string]interface{} // Globals, loaded from top-level options. var ( configDir = "/etc/chasquid" ) func main() { args, _ = docopt.Parse(usage, nil, true, "", false) // Load globals. if d, ok := args["--configdir"].(string); ok { configDir = d } commands := map[string]func(){ "user-add": userAdd, "user-remove": userRemove, "authenticate": authenticate, "check-userdb": checkUserDB, "aliases-resolve": aliasesResolve, "print-config": printConfig, "domaininfo-remove": domaininfoRemove, "aliases-add": aliasesAdd, } for cmd, f := range commands { if args[cmd].(bool) { f() } } } // Fatalf prints the given message, then exits the program with an error code. func Fatalf(s string, arg ...interface{}) { fmt.Printf(s+"\n", arg...) os.Exit(1) } func userDBForDomain(domain string) string { if domain == "" { domain = args[""].(string) } return configDir + "/domains/" + domain + "/users" } func userDBFromArgs(create bool) (string, string, *userdb.DB) { username := args[""].(string) user, domain := envelope.Split(username) if domain == "" { Fatalf("Domain missing, username should be of the form 'user@domain'") } db, err := userdb.Load(userDBForDomain(domain)) if err != nil { if create && os.IsNotExist(err) { fmt.Println("Creating database") os.MkdirAll(filepath.Dir(userDBForDomain(domain)), 0755) } else { Fatalf("Error loading database: %v", err) } } user, err = normalize.User(user) if err != nil { Fatalf("Error normalizing user: %v", err) } return user, domain, db } // chasquid-util check-userdb func checkUserDB() { _, err := userdb.Load(userDBForDomain("")) if err != nil { Fatalf("Error loading database: %v", err) } fmt.Println("Database loaded") } // chasquid-util user-add [--password=] func userAdd() { user, _, db := userDBFromArgs(true) password := getPassword() err := db.AddUser(user, password) if err != nil { Fatalf("Error adding user: %v", err) } err = db.Write() if err != nil { Fatalf("Error writing database: %v", err) } fmt.Println("Added user") } // chasquid-util authenticate [--password=] func authenticate() { user, _, db := userDBFromArgs(false) password := getPassword() ok := db.Authenticate(user, password) if ok { fmt.Println("Authentication succeeded") } else { Fatalf("Authentication failed") } } func getPassword() string { password, ok := args["--password"].(string) if ok { return password } fmt.Printf("Password: ") p1, err := terminal.ReadPassword(syscall.Stdin) fmt.Printf("\n") if err != nil { Fatalf("Error reading password: %v\n", err) } fmt.Printf("Confirm password: ") p2, err := terminal.ReadPassword(syscall.Stdin) fmt.Printf("\n") if err != nil { Fatalf("Error reading password: %v", err) } if !bytes.Equal(p1, p2) { Fatalf("Passwords don't match") } return string(p1) } // chasquid-util user-remove func userRemove() { user, _, db := userDBFromArgs(false) present := db.RemoveUser(user) if !present { Fatalf("Unknown user") } err := db.Write() if err != nil { Fatalf("Error writing database: %v", err) } fmt.Println("Removed user") } // chasquid-util aliases-resolve
func aliasesResolve() { conf, err := config.Load(configDir + "/chasquid.conf") if err != nil { Fatalf("Error reading config") } os.Chdir(configDir) r := aliases.NewResolver() r.SuffixSep = conf.SuffixSeparators r.DropChars = conf.DropCharacters domainDirs, err := ioutil.ReadDir("domains/") if err != nil { Fatalf("Error reading domains/ directory: %v", err) } if len(domainDirs) == 0 { Fatalf("No domains found in config") } for _, info := range domainDirs { name := info.Name() aliasfile := "domains/" + name + "/aliases" r.AddDomain(name) err := r.AddAliasesFile(name, aliasfile) if err == nil { fmt.Printf("%s: loaded %q\n", name, aliasfile) } else if err != nil && os.IsNotExist(err) { fmt.Printf("%s: no aliases file\n", name) } else { fmt.Printf("%s: error loading %q: %v\n", name, aliasfile, err) } } rcpts, err := r.Resolve(args["
"].(string)) if err != nil { Fatalf("Error resolving: %v", err) } for _, rcpt := range rcpts { fmt.Printf("%v %s\n", rcpt.Type, rcpt.Addr) } } // chasquid-util print-config func printConfig() { conf, err := config.Load(configDir + "/chasquid.conf") if err != nil { Fatalf("Error reading config") } fmt.Println(proto.MarshalTextString(conf)) } // chasquid-util domaininfo-remove func domaininfoRemove() { domain := args[""].(string) conf, err := config.Load(configDir + "/chasquid.conf") if err != nil { Fatalf("Error reading config") } // File for the corresponding domain. // Note this is making some assumptions about the data layout and // protoio's storage structure, so it will need adjustment if they change. file := conf.DataDir + "/domaininfo/s:" + url.QueryEscape(domain) err = os.Remove(file) if err != nil { Fatalf("Error removing file: %v", err) } } // chasquid-util aliases-add func aliasesAdd() { source := args[""].(string) target := args[""].(string) user, domain := envelope.Split(source) if domain == "" { Fatalf("Domain required in source address") } // Ensure the domain exists. if _, err := os.Stat(filepath.Join(configDir, "domains", domain)); os.IsNotExist(err) { Fatalf("Domain doesn't exist") } conf, err := config.Load(configDir + "/chasquid.conf") if err != nil { Fatalf("Error reading config") } os.Chdir(configDir) // Setup alias resolver. r := aliases.NewResolver() r.SuffixSep = conf.SuffixSeparators r.DropChars = conf.DropCharacters r.AddDomain(domain) aliasesFilePath := filepath.Join("domains", domain, "aliases") if err := r.AddAliasesFile(domain, aliasesFilePath); err != nil { Fatalf("%s: error loading %q: %v", domain, aliasesFilePath, err) } // Check for existing entry. if _, ok := r.Exists(source); ok { Fatalf("There's already an entry for %v", source) } // Append the new entry. aliasesFile, err := os.OpenFile(aliasesFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) if err != nil { Fatalf("Couldn't open %s: %v", aliasesFilePath, err) } _, err = fmt.Fprintf(aliasesFile, "%s: %s\n", user, target) if err != nil { Fatalf("Couldn't write to %s: %v", aliasesFilePath, err) } aliasesFile.Close() fmt.Println("Added alias") } chasquid-1.2/cmd/chasquid-util/test.sh000077500000000000000000000040001357247226300200170ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../../test/util/lib.sh init go build || exit 1 function r() { ./chasquid-util -C .config "$@" } function check_userdb() { if ! r check-userdb domain > /dev/null; then echo check-userdb failed exit 1 fi } mkdir -p .config/domains/domain/ .data/domaininfo rm -f .config/chasquid.conf echo 'data_dir: ".data"' >> .config/chasquid.conf if ! r print-config > /dev/null; then echo print-config failed exit 1 fi if ! r user-add user@domain --password=passwd > /dev/null; then echo user-add failed exit 1 fi check_userdb if ! r authenticate user@domain --password=passwd > /dev/null; then echo authenticate failed exit 1 fi if r authenticate user@domain --password=abcd > /dev/null; then echo authenticate with bad password worked exit 1 fi if ! r user-remove user@domain > /dev/null; then echo user-remove failed exit 1 fi check_userdb if r authenticate user@domain --password=passwd > /dev/null; then echo authenticate for removed user worked exit 1 fi touch '.data/domaininfo/s:dom%C3%A1in' if ! r domaininfo-remove domáin; then echo domaininfo-remove failed exit 1 fi if [ -f '.data/domaininfo/s:dom%C3%A1in' ]; then echo domaininfo-remove did not remove file exit 1 fi echo "alias: user@somewhere" > .config/domains/domain/aliases A=$(r aliases-resolve alias@domain | grep somewhere) if [ "$A" != "(email) user@somewhere" ]; then echo aliases-resolve failed echo output: "$A" exit 1 fi C=$(r print-config | grep hostname) if [ "$C" != "hostname: \"$HOSTNAME\"" ]; then echo print-config failed echo output: "$C" exit 1 fi if r aliases-add alias2@domain target > /dev/null; then A=$(grep alias2 .config/domains/domain/aliases) if [ "$A" != "alias2: target" ]; then echo aliases-add failed echo output: "$A" exit 1 fi fi if r aliases-add alias2@domain target > /dev/null; then echo aliases-add on existing alias worked exit 1 fi if r aliases-add alias3@notexist target > /dev/null; then echo aliases-add on non-existing domain worked exit 1 fi success chasquid-1.2/cmd/dovecot-auth-cli/000077500000000000000000000000001357247226300171025ustar00rootroot00000000000000chasquid-1.2/cmd/dovecot-auth-cli/.gitignore000066400000000000000000000000551357247226300210720ustar00rootroot00000000000000*.log dovecot-auth-cli dovecot-auth-cli.test chasquid-1.2/cmd/dovecot-auth-cli/coverage_test.go000066400000000000000000000010161357247226300222610ustar00rootroot00000000000000// This package is tested externally (see test.sh). // However, we need this to do coverage tests. // // See coverage_test.go for the details, this is the same horrible hack. // // +build coveragebin package main import ( "os" "os/signal" "syscall" "testing" ) func TestRunMain(t *testing.T) { done := make(chan bool) signals := make(chan os.Signal, 1) go func() { <-signals done <- true }() signal.Notify(signals, os.Interrupt, os.Kill, syscall.SIGTERM) go func() { main() done <- true }() <-done } chasquid-1.2/cmd/dovecot-auth-cli/coverage_wrapper000077500000000000000000000003171357247226300223640ustar00rootroot00000000000000#!/bin/bash # # Wrapper for dovecot-auth-cli to run when we build it in coverage mode. exec ./dovecot-auth-cli.test -test.run ^TestRunMain$ \ -test.coverprofile="$COVER_DIR/test-`date +%s.%N`.out" \ "$@" chasquid-1.2/cmd/dovecot-auth-cli/dovecot-auth-cli.go000066400000000000000000000011171357247226300226000ustar00rootroot00000000000000// CLI used for testing the dovecot authentication package. // // NOT for production use. // +build !coverage package main import ( "flag" "fmt" "blitiri.com.ar/go/chasquid/internal/dovecot" ) func main() { flag.Parse() a := dovecot.NewAuth(flag.Arg(0)+"-userdb", flag.Arg(0)+"-client") var ok bool var err error switch flag.Arg(1) { case "exists": ok, err = a.Exists(flag.Arg(2)) case "auth": ok, err = a.Authenticate(flag.Arg(2), flag.Arg(3)) default: fmt.Printf("unknown subcommand\n") } if ok { fmt.Printf("yes\n") return } fmt.Printf("no: %v\n", err) } chasquid-1.2/cmd/dovecot-auth-cli/test.sh000077500000000000000000000012211357247226300204140ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../../test/util/lib.sh init # Build the binary once, so we can use it and launch it in chamuyero scripts. # Otherwise, we not only spend time rebuilding it over and over, but also "go # run" masks the exit code, which is something we care about. if [ "${COVER_DIR}" != "" ]; then go test -covermode=count -coverpkg=../../... -c \ $GOFLAGS -tags="coveragebin $GOTAGS" cp coverage_wrapper dovecot-auth-cli else go build $GOFLAGS -tags="$GOTAGS" dovecot-auth-cli.go fi for i in *.cmy; do if ! chamuyero $i > $i.log 2>&1 ; then echo "# Test $i failed, log follows" cat $i.log exit 1 fi done success exit 0 chasquid-1.2/cmd/dovecot-auth-cli/test_auth_bad_proto.cmy000066400000000000000000000014461357247226300236520ustar00rootroot00000000000000 # Break the handhake early. client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client <- VERSION 1 1 client <~ CPID # We are supposed to send the handshake here. client close c <- no: error receiving handshake: EOF c wait 0 # Break before sending the final response. client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= # We're supposed to send the OK/FAIL here. client close c <- no: error receiving response: EOF c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_auth_error.cmy000066400000000000000000000006711357247226300230310ustar00rootroot00000000000000 client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= client -> OTHER c <~ no: invalid response c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_auth_no.cmy000066400000000000000000000006571357247226300223200ustar00rootroot00000000000000 client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= client -> FAIL 1 c <- no: c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_auth_yes.cmy000066400000000000000000000006471357247226300225030ustar00rootroot00000000000000 client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= client -> OK 1 c <- yes c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_exists_bad_proto.cmy000066400000000000000000000012221357247226300242200ustar00rootroot00000000000000 # Invalid version userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 0 c <~ no: error receiving version c wait 0 # No SPID (send "NOSPID" instead userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> NOSPID c <~ no: error receiving SPID: c wait 0 # Break before sending the final response. userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb close c <- no: error receiving response: EOF c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_exists_error.cmy000066400000000000000000000003651357247226300234070ustar00rootroot00000000000000 userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb -> OTHER c <~ no: invalid response: c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_exists_notfound.cmy000066400000000000000000000003561357247226300241120ustar00rootroot00000000000000 userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb -> NOTFOUND 1 c <- no: c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_exists_yes.cmy000066400000000000000000000004241357247226300230520ustar00rootroot00000000000000 userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb -> USER 1 username system_groups_user=blah uid=10 gid=10 c <- yes c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_missing_socket.cmy000066400000000000000000000003311357247226300236710ustar00rootroot00000000000000 c = ./dovecot-auth-cli .missingsocket exists username c <~ no: dial unix .missingsocket-userdb c wait 0 c = ./dovecot-auth-cli .missingsocket auth username password c <~ no: dial unix .missingsocket-client c wait 0 chasquid-1.2/cmd/dovecot-auth-cli/test_wrong_command.cmy000066400000000000000000000001221357247226300235000ustar00rootroot00000000000000 c = ./dovecot-auth-cli .missingsocket something c <- unknown subcommand c wait 0 chasquid-1.2/cmd/mda-lmtp/000077500000000000000000000000001357247226300154465ustar00rootroot00000000000000chasquid-1.2/cmd/mda-lmtp/.gitignore000066400000000000000000000000171357247226300174340ustar00rootroot00000000000000mda-lmtp *.log chasquid-1.2/cmd/mda-lmtp/mda-lmtp.go000066400000000000000000000057461357247226300175240ustar00rootroot00000000000000// mda-lmtp is a very basic MDA that uses LMTP to do the delivery. // // See the usage below for details. // // +build !coverage package main import ( "flag" "fmt" "io" "net" "net/textproto" "os" "strings" ) // Command-line flags var ( fromwhom = flag.String("f", "", "Whom the message is from") recipient = flag.String("d", "", "Recipient") addrNetwork = flag.String("addr_network", "", "Network of the LMTP address (e.g. unix or tcp)") addr = flag.String("addr", "", "LMTP server address") ) func usage() { fmt.Fprintf(os.Stderr, ` mda-lmtp is a very basic MDA that uses LMTP to do the mail delivery. It takes command line arguments similar to maildrop or procmail, reads an email via standard input, and sends it over the given LMTP server. Supports connecting to LMTP servers over UNIX sockets and TCP. It can be used when your mail server does not support LMTP directly. Example of use: $ mda-lmtp --addr localhost:1234 -f juan@casa -d jose < email Flags: `) flag.PrintDefaults() } // Exit with EX_TEMPFAIL. func tempExit(format string, args ...interface{}) { fmt.Printf(format+"\n", args...) // 75 = EX_TEMPFAIL "temporary failure" exit code (sysexits.h). os.Exit(75) } func main() { flag.Usage = usage flag.Parse() if *addr == "" { fmt.Printf("No LMTP server address given (use --addr)\n") os.Exit(2) } // Try to autodetect the network if it's missing. if *addrNetwork == "" { *addrNetwork = "tcp" if strings.HasPrefix(*addr, "/") { *addrNetwork = "unix" } } conn, err := net.Dial(*addrNetwork, *addr) if err != nil { tempExit("Error connecting to (%s, %s): %v", *addrNetwork, *addr, err) } tc := textproto.NewConn(conn) // Expect the hello from the server. _, _, err = tc.ReadResponse(220) if err != nil { tempExit("Server greeting error: %v", err) } hostname, err := os.Hostname() if err != nil { tempExit("Could not get hostname: %v", err) } if *fromwhom == "<>" { *fromwhom = "" } if *recipient == "<>" { *recipient = "" } cmd(tc, 250, "LHLO %s", hostname) cmd(tc, 250, "MAIL FROM:<%s>", *fromwhom) cmd(tc, 250, "RCPT TO:<%s>", *recipient) cmd(tc, 354, "DATA") w := tc.DotWriter() _, err = io.Copy(w, os.Stdin) w.Close() if err != nil { tempExit("Error writing DATA: %v", err) } // This differs from SMTP: here we get one reply per recipient, with the // result of the delivery. Since we deliver to only one recipient, read // one code. _, _, err = tc.ReadResponse(250) if err != nil { tempExit("Delivery failed remotely: %v", err) } cmd(tc, 221, "QUIT") tc.Close() } // cmd sends a command and checks it matched the expected code. func cmd(conn *textproto.Conn, expectCode int, format string, args ...interface{}) { id, err := conn.Cmd(format, args...) if err != nil { tempExit("Sent %q, got %v", fmt.Sprintf(format, args...), err) } conn.StartResponse(id) defer conn.EndResponse(id) _, _, err = conn.ReadResponse(expectCode) if err != nil { tempExit("Sent %q, got %v", fmt.Sprintf(format, args...), err) } } chasquid-1.2/cmd/mda-lmtp/test-email000066400000000000000000000000371357247226300174350ustar00rootroot00000000000000Subject: test This is a test. chasquid-1.2/cmd/mda-lmtp/test.sh000077500000000000000000000006621357247226300167700ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../../test/util/lib.sh init # Build the binary once, so we can use it and launch it in chamuyero scripts. # Otherwise, we not only spend time rebuilding it over and over, but also "go # run" masks the exit code, which is something we care about. go build for i in *.cmy; do if ! chamuyero $i > $i.log 2>&1 ; then echo "# Test $i failed, log follows" cat $i.log exit 1 fi done success chasquid-1.2/cmd/mda-lmtp/test_tcp_null.cmy000066400000000000000000000006231357247226300210400ustar00rootroot00000000000000 nc tcp_listen localhost:14932 mda |= ./mda-lmtp --addr=localhost:14932 -f "<>" -d "<>" < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM:<> nc -> 250 Aja nc <- RCPT TO:<> nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.2/cmd/mda-lmtp/test_tcp_success.cmy000066400000000000000000000006271357247226300215420ustar00rootroot00000000000000 nc tcp_listen localhost:14932 mda |= ./mda-lmtp --addr=localhost:14932 -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.2/cmd/mda-lmtp/test_unix_failure.cmy000066400000000000000000000006731357247226300217170ustar00rootroot00000000000000 nc unix_listen .test-sock mda = ./mda-lmtp --addr=.test-sock --addr_network=unix \ -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 452 Nananana mda <- Delivery failed remotely: 452 Nananana mda wait 75 chasquid-1.2/cmd/mda-lmtp/test_unix_success.cmy000066400000000000000000000006541357247226300217370ustar00rootroot00000000000000 nc unix_listen .test-sock mda |= ./mda-lmtp --addr=.test-sock --addr_network=unix \ -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.2/cmd/smtp-check/000077500000000000000000000000001357247226300157715ustar00rootroot00000000000000chasquid-1.2/cmd/smtp-check/smtp-check.go000066400000000000000000000053561357247226300203670ustar00rootroot00000000000000// smtp-check is a command-line too for checking SMTP setups. // // +build !coverage package main import ( "context" "crypto/tls" "flag" "fmt" "log" "net" "net/smtp" "time" "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/tlsconst" "blitiri.com.ar/go/spf" "golang.org/x/net/idna" ) var ( port = flag.String("port", "smtp", "port to use for connecting to the MX servers") skipTLSCheck = flag.Bool("skip_tls_check", false, "skip TLS check (useful if connections are blocked)") ) func main() { flag.Parse() domain := flag.Arg(0) if domain == "" { log.Fatal("Use: smtp-check ") } domain, err := idna.ToASCII(domain) if err != nil { 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") } log.Printf("") mxs, err := net.LookupMX(domain) if err != nil { log.Fatalf("MX lookup: %v", err) } if len(mxs) == 0 { log.Fatalf("MX lookup returned no results") } errs := []error{} for _, mx := range mxs { log.Printf("=== MX: %2d %s", mx.Pref, mx.Host) ips, err := net.LookupIP(mx.Host) if err != nil { log.Fatal(err) } for _, ip := range ips { result, err := spf.CheckHostWithSender(ip, domain, "test@"+domain) log.Printf("SPF %v for %v: %v", result, ip, err) if result != spf.Pass { errs = append(errs, fmt.Errorf("%s: SPF failed (%v)", mx.Host, ip)) } } if *skipTLSCheck { log.Printf("TLS check skipped") } else { c, err := smtp.Dial(mx.Host + ":" + *port) if err != nil { log.Fatal(err) } config := &tls.Config{ // Expect the server to have a certificate valid for the MX // we're connecting to. ServerName: mx.Host, } err = c.StartTLS(config) if err != nil { log.Printf("TLS error: %v", err) errs = append(errs, fmt.Errorf("%s: TLS failed", mx.Host)) } else { cstate, _ := c.TLSConnectionState() log.Printf("TLS OK: %s - %s", tlsconst.VersionName(cstate.Version), tlsconst.CipherSuiteName(cstate.CipherSuite)) } c.Close() } if policy != nil { if !policy.MXIsAllowed(mx.Host) { log.Printf("NOT allowed by STS policy") errs = append(errs, fmt.Errorf("%s: STS failed", mx.Host)) } log.Printf("Allowed by policy") } log.Printf("") } if len(errs) == 0 { log.Printf("=== Success") } else { log.Printf("=== FAILED") for _, err := range errs { log.Printf("%v", err) } log.Fatal("") } } chasquid-1.2/cmd/spf-check/000077500000000000000000000000001357247226300155765ustar00rootroot00000000000000chasquid-1.2/cmd/spf-check/spf-check.go000066400000000000000000000005571357247226300177770ustar00rootroot00000000000000// Command line tool for playing with the SPF library. // // Not for use in production, just development and experimentation. // +build !coverage package main import ( "flag" "fmt" "net" "blitiri.com.ar/go/spf" ) func main() { flag.Parse() r, err := spf.CheckHostWithSender( net.ParseIP(flag.Arg(0)), "", flag.Arg(1)) fmt.Println(r) fmt.Println(err) } chasquid-1.2/coverage_test.go000066400000000000000000000014551357247226300163560ustar00rootroot00000000000000// Test file used to build a coverage-enabled chasquid binary. // // Go lacks support for properly building a coverage binary, it can only build // coverage test binaries. As a workaround, we have a test that just runs // main. We then build a binary of this test, which we use instead of chasquid // in integration tests. // // This is hacky and horrible. // // The test has a build label so it's not accidentally executed during normal // "go test" invocations. // +build coveragebin package main import ( "os" "os/signal" "syscall" "testing" ) func TestRunMain(t *testing.T) { done := make(chan bool) signals := make(chan os.Signal, 1) go func() { <-signals done <- true }() signal.Notify(signals, os.Interrupt, os.Kill, syscall.SIGTERM) go func() { main() done <- true }() <-done } chasquid-1.2/dnsoverride.go000066400000000000000000000015611357247226300160460ustar00rootroot00000000000000// Support for overriding DNS lookups, for testing purposes. // This is only used in tests, when the "dnsoverride" tag is active. // It requires Go >= 1.8. // // +build dnsoverride package main import ( "context" "flag" "net" "time" ) var ( dnsAddr = flag.String("testing__dns_addr", "127.0.0.1:9053", "DNS server address to use, for testing purposes only") ) var dialer = &net.Dialer{ // We're going to talk to localhost, so have a short timeout so we fail // fast. Otherwise the callers might hang indefinitely when trying to // dial the DNS server. Timeout: 2 * time.Second, } func dial(ctx context.Context, network, address string) (net.Conn, error) { return dialer.DialContext(ctx, network, *dnsAddr) } func init() { // Override the resolver to talk with our local server for testing. net.DefaultResolver.PreferGo = true net.DefaultResolver.Dial = dial } chasquid-1.2/docker/000077500000000000000000000000001357247226300144375ustar00rootroot00000000000000chasquid-1.2/docker/Dockerfile000066400000000000000000000052751357247226300164420ustar00rootroot00000000000000# Docker file for creating a container that will run chasquid and Dovecot. # # THIS IS EXPERIMENTAL AND LIKELY TO CHANGE. # # This is not recommended for serious installations, you're probably better # off following the documentation and setting the server up manually. # # See the README.md file for more details. # Build the binaries. FROM golang:latest as build WORKDIR /go/src/blitiri.com.ar/go/chasquid COPY . . RUN go get -d ./... RUN go install ./... # Create the image. FROM debian:stable # Make debconf/frontend non-interactive, to avoid distracting output about the # lack of $TERM. ENV DEBIAN_FRONTEND noninteractive # Install the packages we need. # This includes chasquid, which sets up good defaults. RUN apt-get update -q RUN apt-get install -y -q \ chasquid \ dovecot-lmtpd dovecot-imapd dovecot-pop3d \ dovecot-sieve dovecot-managesieved \ acl sudo certbot # Copy the binaries. This overrides the debian packages with the ones we just # built above. COPY --from=build /go/bin/chasquid /usr/bin/ COPY --from=build /go/bin/chasquid-util /usr/bin/ COPY --from=build /go/bin/smtp-check /usr/bin/ COPY --from=build /go/bin/mda-lmtp /usr/bin/ # Let chasquid bind privileged ports, so we can run it as its own user. RUN setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/chasquid # Copy docker-specific configurations. COPY docker/dovecot.conf /etc/dovecot/dovecot.conf COPY docker/chasquid.conf /etc/chasquid/chasquid.conf # Copy utility scripts. COPY docker/add-user.sh / COPY docker/entrypoint.sh / # chasquid: SMTP, submission, submission+tls. EXPOSE 25 465 587 # dovecot: POP3s, IMAPs, managesieve. EXPOSE 993 995 4190 # http for letsencrypt/certbot. EXPOSE 80 443 # Store emails and chasquid databases in an external volume, to be mounted at # /data, so they're independent from the image itself. VOLUME /data # Put some directories where we expect persistent user data into /data. RUN rmdir /etc/chasquid/domains/ RUN ln -sf /data/chasquid/domains/ /etc/chasquid/domains RUN rm -rf /etc/letsencrypt/ RUN ln -sf /data/letsencrypt/ /etc/letsencrypt # Give the chasquid user access to the necessary configuration. RUN setfacl -R -m u:chasquid:rX /etc/chasquid/ RUN mv /etc/chasquid/certs/ /etc/chasquid/certs-orig RUN ln -s /etc/letsencrypt/live/ /etc/chasquid/certs # NOTE: Set AUTO_CERTS="example.com example.org" to automatically obtain and # renew certificates upon startup, via Letsencrypt. You're agreeing to their # ToS by setting this variable, so please review them carefully. # CERTS_EMAIL should be set to your email address so letsencrypt can send you # critical notifications. # Custom entry point that does some configuration checks and ensures # letsencrypt is properly set up. ENTRYPOINT ["/entrypoint.sh"] chasquid-1.2/docker/README.md000066400000000000000000000044401357247226300157200ustar00rootroot00000000000000 # Docker chasquid comes with a Dockerfile to create a container running [chasquid], [dovecot], and managed certificates with [Let's Encrypt]. **IT IS EXPERIMENTAL AND LIKELY TO BREAK** The more traditional setup is **highly recommended**, see the [how-to](howto.md) documentation for more details. [chasquid]: https://blitiri.com.ar/p/chasquid [dovecot]: https://dovecot.org [Let's Encrypt]: https://letsencrypt.org ## Images There are [pre-built images at gitlab registry](https://gitlab.com/albertito/chasquid/container_registry). They are automatically built, and tagged with the corresponding branch name. Use the *master* tag for a stable version. If, instead, you want to build the image yourself, just run: ```sh $ docker build -t chasquid -f docker/Dockerfile . ``` ## Running First, pull the image into your target machine: ```sh $ docker pull registry.gitlab.com/albertito/chasquid:master ``` You will need a data volume to store persistent data, outside the image. This will contain the mailboxes, user databases, etc. ```sh $ docker volume create chasquid-data ``` To add your first user to the image: ``` $ docker run \ --mount source=chasquid-data,target=/data \ -it --entrypoint=/add-user.sh \ registry.gitlab.com/albertito/chasquid:master Email (full user@domain format): pepe@example.com Password: pepe@example.com added to /data/dovecot/users ``` Upon startup, the image will obtain a TLS certificate for you using [Let's Encrypt](https://letsencrypt.com/). You need to tell it the domain(s) to get a certificate from by setting the `AUTO_CERTS` variable. Because certificates expire, you should restart the container every week or so. Certificates will be renewed automatically upon startup if needed. In order for chasquid to get access to the source IP address, you will need to use host networking, or create a custom docker network that does IP forwarding and not proxying. Finally, start the container: ```sh $ docker run -e AUTO_CERTS=mail.yourdomain.com \ --mount source=chasquid-data,target=/data \ --network host \ registry.gitlab.com/albertito/chasquid:master ``` ## Debugging To get a shell on the running container for debugging, you can use `docker ps` to find the container ID, and then `docker exec -it CONTAINERID /bin/bash` to open a shell on the running container. chasquid-1.2/docker/add-user.sh000077500000000000000000000017001357247226300165000ustar00rootroot00000000000000#!/bin/bash # # Creates a user. If it exists, updates the password. # # Note this is not robust, it's only for convenience on extremely simple # setups. set -e read -p "Email (full user@domain format): " EMAIL if ! echo "${EMAIL}" | grep -q @; then echo "Error: email should have '@'." exit 1 fi read -p "Password: " -s PASSWORD echo DOMAIN=$(echo echo "${EMAIL}" | cut -d '@' -f 2) # If the domain doesn't exist in chasquid's config, create it. mkdir -p "/data/chasquid/domains/${DOMAIN}/" # Encrypt password. ENCPASS=$(doveadm pw -u "${EMAIL}" -p "${PASSWORD}") # Edit dovecot users: remove user if it exits. mkdir -p /data/dovecot if grep -q "^${EMAIL}:" /data/dovecot/users; then cp /data/dovecot/users /data/dovecot/users.old cat /data/dovecot/users.old | grep -v "^${EMAIL}:" \ > /data/dovecot/users fi # Edit dovecot users: add user. echo "${EMAIL}:${ENCPASS}::::" >> /data/dovecot/users echo "${EMAIL} added to /data/dovecot/users" chasquid-1.2/docker/chasquid.conf000066400000000000000000000012661357247226300171140ustar00rootroot00000000000000 # Listening addresses. smtp_address: ":25" submission_address: ":587" submission_over_tls_address: ":465" # Monitoring HTTP server only bound to localhost, just in case. monitoring_address: "127.0.0.1:1099" # Auth against dovecot. dovecot_auth: true # Use mda-lmtp to talk to dovecot. mail_delivery_agent_bin: "/usr/bin/mda-lmtp" mail_delivery_agent_args: "--addr" mail_delivery_agent_args: "/run/dovecot/lmtp" mail_delivery_agent_args: "-f" mail_delivery_agent_args: "%from%" mail_delivery_agent_args: "-d" mail_delivery_agent_args: "%to%" # Store data in the container volume. data_dir: "/data/chasquid/data" # Mail log to the container volume. mail_log_path: "/data/chasquid/mail.log" chasquid-1.2/docker/dovecot.conf000066400000000000000000000040461357247226300167550ustar00rootroot00000000000000 # # Logging # log_path = /data/dovecot/dovecot.log # # Email storage # # Store emails in /data/mail/home/domain/user, which will be inside the # container's volume. mail_home = /data/mail/home/%d/%n # Use Dovecot's native format. mail_location = mdbox:~/mdbox # User and group used to store and access mailboxes. mail_uid = dovecot mail_gid = dovecot # As we're using virtual mailboxes, the system user will be "dovecot", which # has uid in the 100-500 range. By default using uids <500 is blocked, so we # need to explicitly lower the value to allow storage of mail as "dovecot". first_valid_uid = 100 first_valid_gid = 100 # # Authentication # # Static file, in /data/dovecot/users. auth_mechanisms = plain passdb { driver = passwd-file args = scheme=CRYPT username_format=%u /data/dovecot/users } userdb { driver = passwd-file args = /data/dovecot/users } # # TLS # # TLS is mandatory. # The entrypoint generates auto-ssl.conf, with all the certificates. ssl = required !include_try /etc/dovecot/auto-ssl.conf # Only allow TLS 1.2 and up. ssl_min_protocol = TLSv1.2 # # Protocols # protocols = lmtp imap pop3 sieve # # IMAP # service imap-login { inet_listener imap { # Disable plain text IMAP, just in case. port = 0 } inet_listener imaps { port = 993 ssl = yes } } service imap { } # # POP3 # service pop3-login { inet_listener pop3 { # Disable plain text POP3, just in case. port = 0 } inet_listener pop3s { port = 995 ssl = yes } } service pop3 { } # # Sieve/managesieve # service managesieve-login { } service managesieve { } protocol sieve { } plugin { sieve = file:~/sieve;active=~/.dovecot.sieve } # # Internal services # service auth { unix_listener auth-userdb { } # Grant chasquid access to request user authentication. unix_listener auth-chasquid-userdb { mode = 0660 user = chasquid } unix_listener auth-chasquid-client { mode = 0660 user = chasquid } } service auth-worker { } dict { } service lmtp { # This is used by mda-lmtp. unix_listener lmtp { } } chasquid-1.2/docker/entrypoint.sh000077500000000000000000000062031357247226300172120ustar00rootroot00000000000000#!/bin/bash # # Script that is used as a Docker entrypoint. # set -e if ! grep -q data /proc/mounts; then echo "/data is not mounted." echo "Check that the /data volume is set up correctly." exit 1 fi # Create the directory structure if it's not there. # Some of these directories are symlink targets, see the Dockerfile. mkdir -p /data/chasquid mkdir -p /data/letsencrypt mkdir -p /data/chasquid mkdir -p /data/chasquid/domains mkdir -p /data/dovecot # Set up the certificates for the requested domains. if [ "$AUTO_CERTS" != "" ]; then # If we were given an email to use for letsencrypt, use it. Otherwise # continue without one. MAIL_OPTS="--register-unsafely-without-email" if [ "$CERTS_MAIL" != "" ]; then MAIL_OPTS="-m $CERTS_MAIL" fi for DOMAIN in $(echo $AUTO_CERTS); do # If it has never been set up, then do so. if ! [ -e /etc/letsencrypt/live/$DOMAIN/fullchain.pem ]; then certbot certonly \ --non-interactive \ --standalone \ --agree-tos \ $MAIL_OPTS \ -d $DOMAIN else echo "$DOMAIN certificate already set up." fi done # Renew on startup, since the container won't have cron facilities. # Note this requires you to restart every week or so, to make sure # your certificate does not expire. certbot renew fi CERT_DOMAINS="" for i in $(ls /etc/letsencrypt/live/); do if [ -e "/etc/letsencrypt/live/$i/fullchain.pem" ]; then CERT_DOMAINS="$CERT_DOMAINS $i" fi done # We need one domain to use as a default - pick the last one. ONE_DOMAIN=$i # Check that there's at least once certificate at this point. if [ "$CERT_DOMAINS" == "" ]; then echo "No certificates found." echo echo "Set AUTO_CERTS='example.com' to automatically get one." exit 1 fi # Give chasquid access to the certificates. # Dovecot does not need this as it reads them as root. setfacl -R -m u:chasquid:rX /etc/letsencrypt/{live,archive} # Give chasquid access to the data directory. mkdir -p /data/chasquid/data chown -R chasquid /data/chasquid/ # Give dovecot access to the mailbox home. mkdir -p /data/mail/ chown dovecot:dovecot /data/mail/ # Generate the dovecot ssl configuration based on all the certificates we have. # The default goes first because dovecot complains otherwise. echo "# Autogenerated by entrypoint.sh" > /etc/dovecot/auto-ssl.conf cat >> /etc/dovecot/auto-ssl.conf <> /etc/dovecot/auto-ssl.conf # Pick the default domain as default hostname for chasquid. This is only used # in plain text sessions and on very rare cases, and it's mostly for aesthetic # purposes. echo "hostname: '$ONE_DOMAIN'" >> /etc/chasquid/chasquid.conf # Start the services: dovecot in background, chasquid in foreground. start-stop-daemon --start --quiet --pidfile /run/dovecot.pid \ --exec /usr/sbin/dovecot -- -c /etc/dovecot/dovecot.conf sudo -u chasquid -g chasquid /usr/bin/chasquid $CHASQUID_FLAGS chasquid-1.2/docs/000077500000000000000000000000001357247226300141205ustar00rootroot00000000000000chasquid-1.2/docs/aliases.md000066400000000000000000000052451357247226300160710ustar00rootroot00000000000000 # Aliases [chasquid] supports [email aliases], which is a mechanism to redirect mail from one account to others. ## File format The aliases are configured per-domain, on a text file named `aliases` within the domain directory. So like `/etc/chasquid/domains/example.com/aliases`. The format is very similar to the one used by classic MTAs (sendmail, exim, postfix), but not identical. ### Comments Lines beginning with `#` are considered comments, and are ignored. ### Email aliases To create email aliases, where mail to a user are redirected to other addresses, write lines of the form `user: address, address, ...`. The user should not have the domain specified, as it is implicit by the location of the file. The domain in target addresses is optional, and defaults to the user domain if not present. For example: ``` # Redirect mail to pepe@ to jose@ on the same domain. pepe: jose # Redirect mail to flowers@ to the indvidual flowers. flowers: rose@backgarden, lilly@pond ``` User names cannot contain spaces, ":" or commas, for parsing reasons. This is a tradeoff between flexibility and keeping the file format easy to edit for people. User names will be normalized internally to lower-case. UTF-8 is allowed and fully supported. ### Pipe aliases A pipe alias is of the form `user: | command`, and causes mail to be sent as standard input to the given command. The command can have space-separated arguments (no quotes or escaping expansion will be performed). For example: ``` # Mail to user@ will be piped to this command for delivery. user: | /usr/bin/email-handler --work # Mail to null@ will be piped to "cat", effectively discarding the email. null: | cat ``` ## Processing Aliases files are read upon start-up and refreshed every 30 seconds, so changes to them don't require a daemon restart. The resolver will perform lookups recursively, until it finds all the final recipients. There are recursion limits to avoid alias loops. If the limit (10 levels) is reached, the entire resolution will fail. Commands are given 30s to run, after which it will be killed and the execution will fail. If the command exits with an error (non-0 exit code), the delivery will be considered failed. The `chasquid-util` command-line tool can be used to check and resolve aliases. ## Hooks There are two hooks that allow more sophisticated aliases resolution: `alias-exists` and `alias-resolve`. If they exist, they are invoked as part of the resolution process and the results are merged with the file-based resolution results. See the [hooks](hooks.md) documentation for more details. [chasquid]: https://blitiri.com.ar/p/chasquid [email aliases]: https://en.wikipedia.org/wiki/Email_alias chasquid-1.2/docs/dkim.md000066400000000000000000000020071357247226300153650ustar00rootroot00000000000000 # DKIM integration [chasquid] supports generating [DKIM] signatures via the [hooks](hooks.md) mechanism. ## Signing The example hook in this repository contains an example of integration with [driusan/dkim](https://github.com/driusan/dkim) tools, and assumes the following: - The [selector](https://tools.ietf.org/html/rfc6376#section-3.1) for a domain can be found in the file `domains/$DOMAIN/dkim_selector`. - The private key to use for signing can be found in the file `certs/$DOMAIN/dkim_privkey.pem`. Only authenticated email will be signed. ## Verification Verifying signatures is technically supported as well, and can be done in the same hook. However, it's not recommended for SMTP servers to reject mail on verification failures ([source 1](https://tools.ietf.org/html/rfc6376#section-6.3), [source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)), so it is not included in the example. [chasquid]: https://blitiri.com.ar/p/chasquid [DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail chasquid-1.2/docs/docker.md000077700000000000000000000000001357247226300206452../docker/README.mdustar00rootroot00000000000000chasquid-1.2/docs/dovecot.md000066400000000000000000000024271357247226300161120ustar00rootroot00000000000000 # Dovecot integration As of version 0.04 (2018-02), [chasquid] has integration with [dovecot] for authenticating users. This means that chasquid can ask dovecot to authenticate users, instead/in addition to having its own per-domain user databases. ## Configuring dovecot The following needs to be added to the Dovecot configuration, usually in `/etc/dovecot/conf.d/10-master.conf`: ``` service auth { unix_listener auth-chasquid-userdb { mode = 0660 user = chasquid } unix_listener auth-chasquid-client { mode = 0660 user = chasquid } } ``` If chasquid is running under a different user, adjust the `user = ` lines accordingly. This lets chasquid issue authentication requests to dovecot. Authentication requests sent by chasquid to dovecot will use the fully-qualified user form, `user@domain`. ## Configuring chasquid Add the following line to `/etc/chasquid/chasquid.conf`: ``` dovecot_auth: true ``` That should be it, because chasquid will "autodetect" the full path to the dovecot sockets, by looking in the usual places (tested in Debian, Ubuntu, and CentOS). If chasquid can't find them, the paths can be set with the `dovecot_userdb_path` and `dovecot_client_path` options. [dovecot]: https://dovecot.org [chasquid]: https://blitiri.com.ar/p/chasquid chasquid-1.2/docs/flow.md000066400000000000000000000033221357247226300154110ustar00rootroot00000000000000 # Message flows This document explains at a high level some parts of chasquid's message processing, in particular how messages flow through the system. ## Message reception - Client connects to chasquid on the smtp or submission ports, and issues HELO/EHLO. - Client optionally performs STARTTLS. - Client optionally performs AUTH. - Check that this is done over TLS. - Client sends MAIL FROM. - Check SPF. - Check connection security level. - Client sends one or more RCPT TO. - If the destination is remote, then the user must have authenticated. - If the destination is local, check that the user exists. - Client sends DATA. - Client sends actual data, and ends it with '.' - Run the post-data hook. If the hook fails, return an error. - Parse the data contents to perform loop detection. - Add the required headers (Received, SPF results, post-data hook output). - Put it in the queue and reply success. ## Queue processing Before accepting a message: - Create a (pseudo) random internal ID for it. - For each recipient, use the alias database to expand it, add the results to the list of final recipients (which may not be email). - Save the resulting envelope (with the final recipients) to disk. Queue processing runs asynchronously, there's a goroutine for each message which does, in a loop: - For each recipient which we have not delivered yet: - Attempt delivery. - Write to disk the results. - If there are mails still pending, wait for some time (incrementally). - When all the recipients have completed delivery, or enough time has passed: - If all were successful, remove from the queue. - If some failed, send a delivery status notification back to the sender. chasquid-1.2/docs/hooks.md000066400000000000000000000050751357247226300155740ustar00rootroot00000000000000 # Hooks chasquid supports some functionality via hooks, which are binaries that get executed at specific points in time during delivery. They are optional, and will be skipped if they don't exist. ## Post-DATA hook After completion of DATA, but before accepting the mail for queueing, chasquid will run the command at `$config_dir/hooks/post-data`. The contents of the mail will be written to the command's stdin, and the environment is detailed below. If the exit status is 0, chasquid will move forward processing the command, and its stdout should contain headers which will be added to contents of the email (at the top). Otherwise, chasquid will respond with an error, and the last line of stdout will be passed back to the client as the error message. If the exit status is 20 the error code will be permanent, otherwise it will be temporary. This hook can be used to block based on contents, for example to check for spam or virus. See `etc/hooks/post-data` for an example. ### Environment This hook will run as the chasquid user, so be careful about permissions and privileges. The environment will contain the following variables: - USER - SHELL - PATH - PWD - REMOTE_ADDR - MAIL_FROM - RCPT_TO (space separated) - AUTH_AS (empty if not completed AUTH) - ON_TLS (0 if not, 1 if yes) - FROM_LOCAL_DOMAIN (0 if not, 1 if yes) - SPF_PASS (0 if not, 1 if yes) There is a 1 minute timeout for hook execution. It will be run at the config directory. ## Alias resolve hook When an alias needs to be resolved, chasquid will run the command at `$config_dir/hooks/alias-resolve` (if the file exists). The address to resolve will be passed as the single argument. The output of the command will be parsed as if it was the right-hand side of the aliases configuration file (see [Aliases](aliases.md) for more details). Results are appended to the results of the file-based alias resolution. If there is no alias for the address, the hook should just exit successfuly without emitting any output. There is a 5 second timeout for hook execution. If the hook exits with an error, including timeout, delivery will fail. ## Alias exists hook When chasquid needs to check whether an alias exists or not, it will run the command at `$config_dir/hooks/alias-exists` (if the file exists). The address to check will be passed as the single argument. If the commands exits successfuly (exit code 0), then the alias exists; any other exit code signals that the alias does not exist. There is a 5 second timeout for hook execution. If the hook times out, the alias will be assumed not to exist. chasquid-1.2/docs/howto.md000066400000000000000000000160271357247226300156100ustar00rootroot00000000000000 # chasquid how-to guide This is a practical guide for setting up an email server for personal or small groups use. It does not contain many explanations, but includes links to more detailed references where possible. While a lot of the contents are generic, for simplicity it will use: - [Debian] as base operating system ([Ubuntu] also works) - [Dovecot] for [POP3]+[IMAP] - [chasquid] for [SMTP] - [Let's Encrypt] for [TLS] certificates [Debian]: https://debian.org [Ubuntu]: https://ubuntu.com [Dovecot]: https://dovecot.org [chasquid]: https://blitiri.com.ar/p/chasquid [Let's Encrypt]: https://letsencrypt.org [POP3]: https://en.wikipedia.org/wiki/Post_Office_Protocol [IMAP]: https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol [SMTP]: https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol [TLS]: https://en.wikipedia.org/wiki/Transport_Layer_Security ## Example data This guide will use the following data for illustration purposes, replace them with your own where appropriate. - Domain name: `example.com`. - IPv4 address of your mail server: `198.51.100.7`. - IPv6 address of your mail server: `2001:db8::7`. Note IPv6 is optional but highly encouraged, and supported by most providers. ## Getting a server You first need to have a server to use. This could be an existing one (for example, if you already have one where you host HTTP), doesn't have to be exclusive for email. In this guide we will use a separate server, mostly for clarity. For small groups the size of the server does not matter, any small VPS (virtual private server) will do just fine. Specifically for hosting email servers, there are some things to check when selecting a provider: - Make sure they allow traffic on TCP port 25 (SMTP). While almost all VPS and dedicated server providers are fine, some "cloud" providers (like Google Cloud) block port 25, which is used for sending and receiving mails. - Once you get a server, make sure the IP address is not listed in any [blackhole lists]. There are many services to check them, for example the one from [the Anti-Abuse project]. Remember to update your server regularly, setting up [unattended upgrades] is highly recommended. [the Anti-Abuse project]: http://www.anti-abuse.org/multi-rbl-check/ [blackhole lists]: https://en.wikipedia.org/wiki/DNSBL [unattended upgrades]: https://wiki.debian.org/UnattendedUpgrades ## DNS Set up the following DNS records for `example.com`. This is usually done either in your DNS server, or in the user interface of your DNS provider. ``` ; Assign "mail.example.com" to the server's IP addresses. ; Replace these with the ones for your server. mail A 198.51.100.7 mail AAAA 2001:db8::7 ; The mail server for example.com is mail.example.com. @ MX 10 mail ; Use SPF to say that the servers in "MX" above are allowed to send email ; for this domain, and nobody else. @ TXT "v=spf1 mx -all" ``` Finally, you should go to your server provider and configure the "reverse DNS" (also known as "PTR") for the IP addresses to be to "mail.example.com". This is important, as some spam checkers will consider it a factor. *References: [A record](https://en.wikipedia.org/wiki/A_record), [MX record](https://en.wikipedia.org/wiki/MX_record), [Sender Policy Framework (SPF)](https://en.wikipedia.org/wiki/Sender_Policy_Framework).* ## TLS certificate [TLS] certificates are needed to send and receive email securely. [letsencrypt] will provide us with a free certificate, which needs to be renewed every 90 days, so the following relies on automatic renewal. Note `certbot` is the recommended letsencrypt command line client. ```shell sudo apt install certbot acl # Obtain a TLS certificate for mail.example.com. sudo certbot certonly --standalone -d mail.example.com # Give chasquid access to the certificates. # Dovecot does not need this as it reads them as root. sudo setfacl -R -m u:chasquid:rX /etc/letsencrypt/{live,archive} # Automatically restart the daemons after each certificate renewal. sudo mkdir -p /etc/letsencrypt/renewal-hooks/post cat <. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .if !\nF .nr F 0 .if \nF>0 \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} .\} .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "chasquid-util 1" .TH chasquid-util 1 "2018-05-20" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" chasquid\-util \- chasquid management tool .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBchasquid-util\fR [\fIoptions\fR] user-add \fIuser@domain\fR [\-\-password=\fIpassword\fR] .PP \&\fBchasquid-util\fR [\fIoptions\fR] user-remove \fIuser@domain\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] authenticate \fIuser@domain\fR [\-\-password=\fIpassword\fR] .PP \&\fBchasquid-util\fR [\fIoptions\fR] check-userdb \fIdomain\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] aliases-resolve \fIaddr\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] domaininfo-remove \fIdomain\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] print-config .SH "DESCRIPTION" .IX Header "DESCRIPTION" chasquid-util is a command-line utility for \fIchasquid\fR\|(1) operations. .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fBuser-add\fR \fIuser@domain\fR [\-\-password=\fIpassword\fR]" 8 .IX Item "user-add user@domain [--password=password]" Add a new user to the domain. .IP "\fBuser-remove\fR \fIuser@domain\fR" 8 .IX Item "user-remove user@domain" Remove the user from the domain. .IP "\fBauthenticate\fR \fIuser@domain\fR [\-\-password=\fIpassword\fR]" 8 .IX Item "authenticate user@domain [--password=password]" Check the user's password. .IP "\fBcheck-userdb\fR \fIdomain\fR" 8 .IX Item "check-userdb domain" Check the integrity of the domain's users database. .IP "\fBaliases-resolve\fR \fIaddr\fR" 8 .IX Item "aliases-resolve addr" Resolve the given address. .IP "\fBdomaininfo-remove\fR \fIdomain\fR" 8 .IX Item "domaininfo-remove domain" Remove the domain information entry. This can be used to manually allow a security level downgrade. .IP "\fBprint-config\fR" 8 .IX Item "print-config" Parse and print the configuration in a human-readable way. .IP "\fB\-C\fR or \fB\-\-configdir=" 8 .IX Item "-C or --configdir=" Configuration directory. .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fIchasquid\fR\|(1) chasquid-1.2/docs/man/chasquid-util.1.pod000066400000000000000000000025001357247226300203070ustar00rootroot00000000000000=head1 NAME chasquid-util - chasquid management tool =head1 SYNOPSIS B [I] user-add I [--password=I] B [I] user-remove I B [I] authenticate I [--password=I] B [I] check-userdb I B [I] aliases-resolve I B [I] domaininfo-remove I B [I] print-config =head1 DESCRIPTION chasquid-util is a command-line utility for chasquid(1) operations. =head1 OPTIONS =over 8 =item B I [--password=I] Add a new user to the domain. =item B I Remove the user from the domain. =item B I [--password=I] Check the user's password. =item B I Check the integrity of the domain's users database. =item B I Resolve the given address. =item B I Remove the domain information entry. This can be used to manually allow a security level downgrade. =item B Parse and print the configuration in a human-readable way. =item B<-C> or B<--configdir=> Configuration directory. =back =head1 SEE ALSO chasquid(1) chasquid-1.2/docs/man/chasquid.1000066400000000000000000000151361357247226300165640ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.09 (Pod::Simple 3.35) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .if !\nF .nr F 0 .if \nF>0 \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} .\} .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "chasquid 1" .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 .nh .SH "NAME" chasquid \- SMTP (email) server .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBchasquid\fR [\fIoptions\fR...] .SH "DESCRIPTION" .IX Header "DESCRIPTION" 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" .IX Header "OPTIONS" .IP "\fB\-config_dir\fR \fIdir\fR" 8 .IX Item "-config_dir dir" configuration directory (default \fI/etc/chasquid\fR) .IP "\fB\-alsologtostderr\fR" 8 .IX Item "-alsologtostderr" also log to stderr, in addition to the file .IP "\fB\-logfile\fR \fIfile\fR" 8 .IX Item "-logfile file" file to log to (enables logtime) .IP "\fB\-logtime\fR" 8 .IX Item "-logtime" include the time when writing the log to stderr .IP "\fB\-logtosyslog\fR \fItag\fR" 8 .IX Item "-logtosyslog tag" log to syslog, with the given tag .IP "\fB\-v\fR \fIlevel\fR" 8 .IX Item "-v level" verbosity level (1 = debug) .IP "\fB\-version\fR" 8 .IX Item "-version" show version and exit .SH "FILES" .IX Header "FILES" The daemon's configuration is by default in \fI/etc/chasquid/\fR, and can be changed with the \fI\-config_dir\fR flag. .PP Inside that directory, the daemon expects the following structure: .IP "\fIchasquid.conf\fR" 8 .IX Item "chasquid.conf" Main config file, see \fIchasquid.conf\fR\|(5). .IP "\fIdomains/\fR" 8 .IX Item "domains/" Per-domain configuration. .IP "\fIdomains/example.com/\fR" 8 .IX Item "domains/example.com/" Domain-specific configuration. Can be empty. .IP "\fIdomains/example.com/users\fR" 8 .IX Item "domains/example.com/users" User and password database for this domain. .IP "\fIdomains/example.com/aliases\fR" 8 .IX Item "domains/example.com/aliases" Aliases for the domain. .IP "\fIcerts/\fR" 8 .IX Item "certs/" Certificates to use, one directory per pair. .IP "\fIcerts/mx.example.com/\fR" 8 .IX Item "certs/mx.example.com/" Certificates for this domain. .IP "\fIcerts/mx.example.com/fullchain.pem\fR" 8 .IX Item "certs/mx.example.com/fullchain.pem" Certificate (full chain). .IP "\fIcerts/mx.example.com/privkey.pem\fR" 8 .IX Item "certs/mx.example.com/privkey.pem" Private key. .PP Note the \fIcerts/\fR directory layout matches the one from \fIcertbot\fR\|(1) (client for Let's Encrypt \s-1CA\s0), so you can just symlink \fIcerts/\fR to \&\fI/etc/letsencrypt/live\fR. .PP Make sure the user you use to run chasquid under (\*(L"mail\*(R" in the example config) can access the certificates and private keys. .SH "CONTACT" .IX Header "CONTACT" Main website . .PP If you have any questions, comments or patches please send them to the mailing list, \f(CW\*(C`chasquid@googlegroups.com\*(C'\fR. To subscribe, send an email to \&\f(CW\*(C`chasquid+subscribe@googlegroups.com\*(C'\fR. .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fIchasquid\-util\fR\|(1), \fIchasquid.conf\fR\|(5) chasquid-1.2/docs/man/chasquid.1.pod000066400000000000000000000041431357247226300173410ustar00rootroot00000000000000=head1 NAME chasquid - SMTP (email) server =head1 SYNOPSIS B [I...] =head1 DESCRIPTION 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. =head1 OPTIONS =over 8 =item B<-config_dir> I configuration directory (default F) =item B<-alsologtostderr> also log to stderr, in addition to the file =item B<-logfile> I file to log to (enables logtime) =item B<-logtime> include the time when writing the log to stderr =item B<-logtosyslog> I log to syslog, with the given tag =item B<-v> I verbosity level (1 = debug) =item B<-version> show version and exit =back =head1 FILES The daemon's configuration is by default in F, and can be changed with the I<-config_dir> flag. Inside that directory, the daemon expects the following structure: =over 8 =item F Main config file, see chasquid.conf(5). =item F Per-domain configuration. =item F Domain-specific configuration. Can be empty. =item F User and password database for this domain. =item F Aliases for the domain. =item F Certificates to use, one directory per pair. =item F Certificates for this domain. =item F Certificate (full chain). =item F Private key. =back Note the F directory layout matches the one from certbot(1) (client for Let's Encrypt CA), so you can just symlink F to F. Make sure the user you use to run chasquid under ("mail" in the example config) can access the certificates and private keys. =head1 CONTACT L
. If you have any questions, comments or patches please send them to the mailing list, C. To subscribe, send an email to C. =head1 SEE ALSO chasquid-util(1), chasquid.conf(5) chasquid-1.2/docs/man/chasquid.conf.5000066400000000000000000000207361357247226300175160ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.10 (Pod::Simple 3.35) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .nr rF 0 .if \n(.g .if rF .nr rF 1 .if (\n(rF:(\n(.g==0)) \{\ . if \nF \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} . \} .\} .rr rF .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "chasquid.conf 5" .TH chasquid.conf 5 "2019-07-15" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" chasquid.conf(5) \-\- chasquid configuration file .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBchasquid.conf\fR\|(5) is \fBchasquid\fR\|(1)'s main configuration file. .SH "DESCRIPTION" .IX Header "DESCRIPTION" The file is in protocol buffers' text format. .PP Comments start with \f(CW\*(C`#\*(C'\fR. Empty lines are allowed. Values are of the form \&\f(CW\*(C`key: value\*(C'\fR. Values can be strings (quoted), integers, or booleans (\f(CW\*(C`true\*(C'\fR or \&\f(CW\*(C`false\*(C'\fR). .PP Some values might be repeated, for example the listening addresses. .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fBhostname\fR (string):" 8 .IX Item "hostname (string):" Default hostname to use when saying hello. This is used to say hello to clients, for aesthetic purposes. Default: the system's hostname. .IP "\fBmax_data_size_mb\fR (int):" 8 .IX Item "max_data_size_mb (int):" Maximum email size, in megabytes. Default: 50. .IP "\fBsmtp_address\fR (repeated string):" 8 .IX Item "smtp_address (repeated string):" Addresses to listen on for \s-1SMTP\s0 (usually port 25). Default: \*(L"systemd\*(R", which means systemd passes sockets to us. systemd sockets must be named with \&\fBFileDescriptorName=smtp\fR. .IP "\fBsubmission_address\fR (repeated string):" 8 .IX Item "submission_address (repeated string):" Addresses to listen on for submission (usually port 587). Default: \*(L"systemd\*(R", which means systemd passes sockets to us. systemd sockets must be named with \&\fBFileDescriptorName=submission\fR. .IP "\fBsubmission_over_tls_address\fR (repeated string):" 8 .IX Item "submission_over_tls_address (repeated string):" Addresses to listen on for submission-over-TLS (usually port 465). Default: \&\*(L"systemd\*(R", which means systemd passes sockets to us. systemd sockets must be named with \fBFileDescriptorName=submission_tls\fR. .IP "\fBmonitoring_address\fR (string):" 8 .IX Item "monitoring_address (string):" Address for the monitoring \s-1HTTP\s0 server. Do \s-1NOT\s0 expose this to the public internet. Default: no monitoring server. .IP "\fBmail_delivery_agent_bin\fR (string):" 8 .IX Item "mail_delivery_agent_bin (string):" Mail delivery agent (\s-1MDA,\s0 also known as \s-1LDA\s0) to use. This should point to the binary to use to deliver email to local users. The content of the email will be passed via stdin. If it exits unsuccessfully, we assume the mail was not delivered. Default: \fImaildrop\fR. .IP "\fBmail_delivery_agent_args\fR (repeated string):" 8 .IX Item "mail_delivery_agent_args (repeated string):" Command line arguments for the mail delivery agent. One per argument. Some replacements will be done. .Sp On an email sent from marsnik@mars to venera@venus: .Sp .Vb 6 \& %from% \-> from address (marsnik@mars) \& %from_user% \-> from user (marsnik) \& %from_domain% \-> from domain (mars) \& %to% \-> to address (venera@venus) \& %to_user% \-> to user (venera) \& %to_domain% \-> to domain (venus) .Ve .Sp Default: \f(CW"\-f", "%from%", "\-d", "%to_user%"\fR (adequate for procmail and maildrop). .IP "\fBdata_dir\fR (string):" 8 .IX Item "data_dir (string):" Directory where we store our persistent data. Default: \&\fI/var/lib/chasquid\fR. .IP "\fBsuffix_separators\fR (string):" 8 .IX Item "suffix_separators (string):" Suffix separator, to perform suffix removal of local users. For example, if you set this to \f(CW\*(C`\-+\*(C'\fR, email to local user \f(CW\*(C`user\-blah\*(C'\fR and \&\f(CW\*(C`user+blah\*(C'\fR will be delivered to \f(CW\*(C`user\*(C'\fR. Including \f(CW\*(C`+\*(C'\fR is strongly encouraged, as it is assumed for email forwarding. Default: \f(CW\*(C`+\*(C'\fR. .IP "\fBdrop_characters\fR (string):" 8 .IX Item "drop_characters (string):" Characters to drop from the user part on local emails. For example, if you set this to \f(CW\*(C`._\*(C'\fR, email to local user \f(CW\*(C`u.se_r\*(C'\fR will be delivered to \&\f(CW\*(C`user\*(C'\fR. Default: \f(CW\*(C`.\*(C'\fR. .IP "\fBmail_log_path\fR (string):" 8 .IX Item "mail_log_path (string):" Path where to write the mail log to. If \f(CW\*(C`\*(C'\fR, log using the syslog (at \f(CW\*(C`MAIL|INFO\*(C'\fR priority). Default: \f(CW\*(C`\*(C'\fR. .IP "\fBdovecot_auth\fR (bool):" 8 .IX Item "dovecot_auth (bool):" Enable dovecot authentication. If true, users that are not found in chasquid's 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 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" \&\fBchasquid\fR\|(1) chasquid-1.2/docs/man/chasquid.conf.5.pod000066400000000000000000000070371357247226300202760ustar00rootroot00000000000000=head1 NAME chasquid.conf(5) -- chasquid configuration file =head1 SYNOPSIS chasquid.conf(5) is chasquid(1)'s main configuration file. =head1 DESCRIPTION The file is in protocol buffers' text format. Comments start with C<#>. Empty lines are allowed. Values are of the form C. Values can be strings (quoted), integers, or booleans (C or C). Some values might be repeated, for example the listening addresses. =head1 OPTIONS =over 8 =item B (string): Default hostname to use when saying hello. This is used to say hello to clients, for aesthetic purposes. Default: the system's hostname. =item B (int): Maximum email size, in megabytes. Default: 50. =item B (repeated string): Addresses to listen on for SMTP (usually port 25). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with B. =item B (repeated string): Addresses to listen on for submission (usually port 587). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with B. =item B (repeated string): Addresses to listen on for submission-over-TLS (usually port 465). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with B. =item B (string): Address for the monitoring HTTP server. Do NOT expose this to the public internet. Default: no monitoring server. =item B (string): Mail delivery agent (MDA, also known as LDA) to use. This should point to the binary to use to deliver email to local users. The content of the email will be passed via stdin. If it exits unsuccessfully, we assume the mail was not delivered. Default: F. =item B (repeated string): Command line arguments for the mail delivery agent. One per argument. Some replacements will be done. On an email sent from marsnik@mars to venera@venus: %from% -> from address (marsnik@mars) %from_user% -> from user (marsnik) %from_domain% -> from domain (mars) %to% -> to address (venera@venus) %to_user% -> to user (venera) %to_domain% -> to domain (venus) Default: C<"-f", "%from%", "-d", "%to_user%"> (adequate for procmail and maildrop). =item B (string): Directory where we store our persistent data. Default: F. =item B (string): Suffix separator, to perform suffix removal of local users. For example, if you set this to C<-+>, email to local user C and C will be delivered to C. Including C<+> is strongly encouraged, as it is assumed for email forwarding. Default: C<+>. =item B (string): Characters to drop from the user part on local emails. For example, if you set this to C<._>, email to local user C will be delivered to C. Default: C<.>. =item B (string): Path where to write the mail log to. If C<< >>, log using the syslog (at C priority). Default: C<< >>. =item B (bool): Enable dovecot authentication. If true, users that are not found in chasquid's databases will be authenticated via dovecot. Default: C. The path to dovecot's auth sockets is autodetected, but can be manually overridden using the C and C if needed. =back =head1 SEE ALSO chasquid(1) chasquid-1.2/docs/man/generate.sh000077500000000000000000000011121357247226300170170ustar00rootroot00000000000000#!/bin/bash # # Convert pod files to manual pages, using pod2man. # # Assumes files are named like: # .
.pod set -e for IN in *.pod; do OUT=$( basename $IN .pod ) 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 "" \ $IN $OUT done chasquid-1.2/docs/man/index.md000066400000000000000000000025221357247226300163250ustar00rootroot00000000000000# Manual pages From the latest Debian package: - [chasquid(1)](https://manpages.debian.org/unstable/chasquid/chasquid.1): the main binary. - [chasquid.conf(5)](https://manpages.debian.org/unstable/chasquid/chasquid.conf.5): the configuration file and structure. - [chasquid-util(1)](https://manpages.debian.org/unstable/chasquid/chasquid-util.1): the command-line utility helper. - [mda-lmtp(1)](https://manpages.debian.org/unstable/chasquid/mda-lmtp.1): helper to integrate with LMTP delivery. - [smtp-check(1)](https://manpages.debian.org/unstable/chasquid/smtp-check.1): SMTP setup checker. From the development branch (more likely to change, but useful when doing development or running experimental versions): - [chasquid(1)](https://github.com/albertito/chasquid/blob/next/docs/man/chasquid.1.pod): the main binary. - [chasquid.conf(5)](https://github.com/albertito/chasquid/blob/next/docs/man/chasquid.conf.5.pod): the configuration file and structure. - [chasquid-util(1)](https://github.com/albertito/chasquid/blob/next/docs/man/chasquid-util.1.pod): the command-line utility helper. - [mda-lmtp(1)](https://github.com/albertito/chasquid/blob/next/docs/man/mda-lmtp.1.pod): helper to integrate with LMTP delivery. - [smtp-check(1)](https://github.com/albertito/chasquid/blob/next/docs/man/smtp-check.1.pod): SMTP setup checker. chasquid-1.2/docs/man/mda-lmtp.1000066400000000000000000000124671357247226300165020ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.09 (Pod::Simple 3.35) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .if !\nF .nr F 0 .if \nF>0 \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} .\} .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "mda-lmtp 1" .TH mda-lmtp 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 .nh .SH "NAME" mda\-lmtp \- MDA that uses LMTP to do the mail delivery .SH "SYNOPSIS" .IX Header "SYNOPSIS" mda-lmtp [\fB\-addr_network\fR \fInet\fR] \&\fB\-addr\fR \fIaddr\fR \&\fB\-d\fR \fIrecipient\fR \&\fB\-f\fR \fIfrom\fR .SH "DESCRIPTION" .IX Header "DESCRIPTION" mda-lmtp is a very basic \s-1MDA\s0 that uses \s-1LMTP\s0 to do the mail delivery. .PP It takes command line arguments similar to maildrop or procmail, reads an email via standard input, and sends it over the given \s-1LMTP\s0 server. Supports connecting to \s-1LMTP\s0 servers over \s-1UNIX\s0 sockets and \s-1TCP.\s0 .PP It can be used when your mail server does not support \s-1LMTP\s0 directly. .SH "EXAMPLE" .IX Header "EXAMPLE" \&\fBmda-lmtp\fR \fI\-\-addr=localhost:1234\fR \fI\-f juan@casa\fR \fI\-d jose\fR < email .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fB\-addr\fR \fIaddress\fR" 8 .IX Item "-addr address" \&\s-1LMTP\s0 server address to connect to. .IP "\fB\-addr_network\fR \fInetwork\fR" 8 .IX Item "-addr_network network" Network of the \s-1LMTP\s0 address (e.g. \fIunix\fR or \fItcp\fR). If not present, it will be autodetected from the address itself. .IP "\fB\-d\fR \fIrecipient\fR" 8 .IX Item "-d recipient" Recipient. .IP "\fB\-f\fR \fIfrom\fR" 8 .IX Item "-f from" Whom the message is from. .SH "RETURN VALUES" .IX Header "RETURN VALUES" .IP "\fB0\fR" 8 .IX Item "0" success .IP "\fB75\fR" 8 .IX Item "75" temporary failure .IP "\fIother\fR" 8 .IX Item "other" permanent failures (usually indicate misconfiguration) .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fIchasquid\fR\|(1) chasquid-1.2/docs/man/mda-lmtp.1.pod000066400000000000000000000022051357247226300172500ustar00rootroot00000000000000 =head1 NAME mda-lmtp - MDA that uses LMTP to do the mail delivery =head1 SYNOPSIS mda-lmtp [B<-addr_network> I] B<-addr> I B<-d> I B<-f> I =head1 DESCRIPTION mda-lmtp is a very basic MDA that uses LMTP to do the mail delivery. It takes command line arguments similar to maildrop or procmail, reads an email via standard input, and sends it over the given LMTP server. Supports connecting to LMTP servers over UNIX sockets and TCP. It can be used when your mail server does not support LMTP directly. =head1 EXAMPLE B I<--addr=localhost:1234> I<-f juan@casa> I<-d jose> < email =head1 OPTIONS =over 8 =item B<-addr> I
LMTP server address to connect to. =item B<-addr_network> I Network of the LMTP address (e.g. I or I). If not present, it will be autodetected from the address itself. =item B<-d> I Recipient. =item B<-f> I Whom the message is from. =back =head1 RETURN VALUES =over 8 =item B<0> success =item B<75> temporary failure =item I permanent failures (usually indicate misconfiguration) =back =head1 SEE ALSO chasquid(1) chasquid-1.2/docs/man/smtp-check.1000066400000000000000000000106461357247226300170220ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.09 (Pod::Simple 3.35) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .if !\nF .nr F 0 .if \nF>0 \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} .\} .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "smtp-check 1" .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 .nh .SH "NAME" smtp\-check \- SMTP setup checker .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBsmtp-check\fR [\-port \fIport\fR] [\-skip_tls_check] \fIdomain\fR .SH "DESCRIPTION" .IX Header "DESCRIPTION" smtp-check is a command-line too for checking \s-1SMTP\s0 setups (\s-1DNS\s0 records, \s-1TLS\s0 certificates, \s-1SPF,\s0 etc.). .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fB\-port\fR \fIport\fR:" 8 .IX Item "-port port:" Port to use for connecting to the \s-1MX\s0 servers. .IP "\fB\-skip_tls_check\fR:" 8 .IX Item "-skip_tls_check:" Skip \s-1TLS\s0 check (useful if connections are blocked). .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fIchasquid\fR\|(1) chasquid-1.2/docs/man/smtp-check.1.pod000066400000000000000000000007131357247226300175750ustar00rootroot00000000000000=head1 NAME smtp-check - SMTP setup checker =head1 SYNOPSIS B [-port I] [-skip_tls_check] I =head1 DESCRIPTION smtp-check is a command-line too for checking SMTP setups (DNS records, TLS certificates, SPF, etc.). =head1 OPTIONS =over 8 =item B<-port> I: Port to use for connecting to the MX servers. =item B<-skip_tls_check>: Skip TLS check (useful if connections are blocked). =back =head1 SEE ALSO chasquid(1) chasquid-1.2/docs/monitoring.md000066400000000000000000000205431357247226300166330ustar00rootroot00000000000000 # Monitoring chasquid includes an HTTP server for monitoring purposes, which for security it is not enabled by default. You can use the `monitoring_address` configuration option to enable it. Then just browse the address and human-friendly links to various monitoring and debugging tools should appear. These include: - Command-line flags. - [Traces](https://godoc.org/golang.org/x/net/trace) of both short and long lived requests (sampled). - State of the queue. - State of goroutines. - [Exported variables](#variables). - Profiling endpoints, for use with `go tool pprof` or similar tools. ## Variables chasquid exports some variables for monitoring, via the standard [expvar](https://golang.org/pkg/expvar/) package, which can be useful for whitebox monitoring. They're accessible over the monitoring http server, at `/debug/vars` (default endpoint for expvars). *Note these are still subject to change, although breaking changes will be avoided whenever possible, and will be noted in the [release notes](relnotes.md).* List of exported variables: - **chasquid/aliases/hookResults** (hook result -> counter): count of aliases hook results, by hook and result. - **chasquid/queue/deliverAttempts** (recipient type -> counter): attempts to deliver mail, by recipient type (pipe/local email/remote email). - **chasquid/queue/dsnQueued** (counter): count of DSNs that we generated (queued). - **chasquid/queue/itemsWritten** (counter): count of items the queue wrote to disk. - **chasquid/queue/putCount** (counter): number of envelopes put in the queue. - **chasquid/smtpIn/commandCount** (map of command -> count): count of SMTP commands received, by command. Note that for unknown commands we use `unknown`. - **chasquid/smtpIn/hookResults** (result -> counter): count of hook invocations, by result. - **chasquid/smtpIn/loopsDetected** (counter): count of email loops detected. - **chasquid/smtpIn/responseCodeCount** (code -> counter): count of response codes returned to incoming SMTP connections, by result code. - **chasquid/smtpIn/securityLevelChecks** (result -> counter): count of security level checks on incoming connections, by result. - **chasquid/smtpIn/spfResultCount** (result -> counter): count of SPF checks, by result. - **chasquid/smtpIn/tlsCount** (tls status -> counter): count of TLS statuses (plain/tls) for incoming SMTP connections. - **chasquid/smtpOut/securityLevelChecks** (result -> counter): count of security level checks on outgoing connections, by result. - **chasquid/smtpOut/sts/mode** (mode -> counter): count of STS checks on outgoing connections, by mode (enforce/testing). - **chasquid/smtpOut/sts/security** (result -> counter): count of STS security checks on outgoing connections, by result (pass/fail). - **chasquid/smtpOut/tlsCount** (status -> counter): count of TLS status (insecure TLS/secure TLS/plain) on outgoing connections. - **chasquid/sourceDateStr** (string): timestamp when the binary was built, in human readable format. - **chasquid/sourceDateTimestamp** (int): timestamp when the binary was built, in seconds since epoch. - **chasquid/sts/cache/expired** (counter): count of expired entries in the STS cache. - **chasquid/sts/cache/failedFetch** (counter): count of failed fetches in the STS cache. - **chasquid/sts/cache/fetches** (counter): count of total fetches in the STS cache. - **chasquid/sts/cache/hits** (counter): count of hits in the STS cache. - **chasquid/sts/cache/invalid** (counter): count of invalid policies in the STS cache. - **chasquid/sts/cache/ioErrors** (counter): count of I/O errors when reading/writing as part of keeping the STS cache. - **chasquid/sts/cache/marshalErrors** (counter): count of marshaling errors as part of keeping the STS cache. - **chasquid/sts/cache/refreshCycles** (counter): count of STS cache refresh cycles. - **chasquid/sts/cache/refreshErrors** (counter): count of STS cache refresh errors. - **chasquid/sts/cache/refreshes** (counter): count of STS cache refreshes. - **chasquid/sts/cache/unmarshalErrors** (counter): count of unmarshaling errors as part of keeping the STS cache. - **chasquid/version** (string): version string. ## Prometheus To monitor chasquid using [Prometheus](https://prometheus.io), you can use the [prometheus-expvar-exporter](https://blitiri.com.ar/git/r/prometheus-expvar-exporter/b/master/t/f=README.md.html) with the following configuration: ```toml # Address to listen on. Prometheus should be told to scrape this. listen_addr = ":8000" [chasquid] # Replace with the address of chasquid's monitoring server. url = "http://localhost:1099/debug/vars" # Metrics are auto-imported, but some can't be; in particular the ones with # labels need explicit definitions here. m.aliases_hook_results.expvar ="chasquid/aliases/hookResults" m.aliases_hook_results.help ="aliases hook results" m.aliases_hook_results.label_name ="result" m.deliver_attempts.expvar = "chasquid/queue/deliverAttempts" m.deliver_attempts.help = "attempts to deliver mail" m.deliver_attempts.label_name = "recipient_type" m.dsn_queued.expvar = "chasquid/queue/dsnQueued" m.dsn_queued.help = "DSN queued" m.items_written.expvar = "chasquid/queue/itemsWritten" m.items_written.help = "items written" m.queue_puts.expvar = "chasquid/queue/putCount" m.queue_puts.help = "chasquid/queue/putCount" m.smtpin_commands.expvar = "chasquid/smtpIn/commandCount" m.smtpin_commands.help = "incoming SMTP command count" m.smtpin_commands.label_name = "command" m.smtp_hook_results.expvar = "chasquid/smtpIn/hookResults" m.smtp_hook_results.help = "hook invocation results" m.smtp_hook_results.label_name = "result" m.loops_detected.expvar = "chasquid/smtpIn/loopsDetected" m.loops_detected.help = "loops detected" m.smtp_response_codes.expvar = "chasquid/smtpIn/responseCodeCount" m.smtp_response_codes.help = "response codes returned to SMTP commands" m.smtp_response_codes.label_name = "code" m.in_sec_level_checks.expvar = "chasquid/smtpIn/securityLevelChecks" m.in_sec_level_checks.help = "incoming security level check results" m.in_sec_level_checks.label_name = "result" m.spf_results.expvar = "chasquid/smtpIn/spfResultCount" m.spf_results.help = "SPF result count" m.spf_results.label_name = "result" m.in_tls_usage.expvar = "chasquid/smtpIn/tlsCount" m.in_tls_usage.help = "count of TLS usage in incoming connections" m.in_tls_usage.label_name = "status" m.out_sec_level_checks.expvar = "chasquid/smtpOut/securityLevelChecks" m.out_sec_level_checks.help = "outgoing security level check results" m.out_sec_level_checks.label_name = "result" m.sts_modes.expvar = "chasquid/smtpOut/sts/mode" m.sts_modes.help = "STS checks on outgoing connections, by mode" m.sts_modes.label_name = "mode" m.sts_security.expvar = "chasquid/smtpOut/sts/security" m.sts_security.help = "STS security checks on outgoing connections, by result" m.sts_security.label_name = "result" m.out_tls_usage.expvar = "chasquid/smtpOut/tlsCount" m.out_tls_usage.help = "count of TLS usage in outgoing connections" m.out_tls_usage.label_name = "status" m.sts_cache_expired.expvar = "chasquid/sts/cache/expired" m.sts_cache_expired.help = "expired entries in the STS cache" m.sts_cache_failed_fetch.expvar = "chasquid/sts/cache/failedFetch" m.sts_cache_failed_fetch.help = "failed fetches in the STS cache" m.sts_cache_fetches.expvar = "chasquid/sts/cache/fetches" m.sts_cache_fetches.help = "total fetches in the STS cache" m.sts_cache_hits.expvar = "chasquid/sts/cache/hits" m.sts_cache_hits.help = "hits in the STS cache" m.sts_cache_invalid.expvar = "chasquid/sts/cache/invalid" m.sts_cache_invalid.help = "invalid policies in the STS cache" m.sts_cache_io_errors.expvar = "chasquid/sts/cache/ioErrors" m.sts_cache_io_errors.help = "I/O errors when maintaining STS cache" m.sts_cache_marshal_errors.expvar = "chasquid/sts/cache/marshalErrors" m.sts_cache_marshal_errors.help = "marshalling errors when maintaining STS cache" m.sts_cache_refresh_cycles.expvar = "chasquid/sts/cache/refreshCycles" m.sts_cache_refresh_cycles.help = "STS cache refresh cycles" m.sts_cache_refresh_errors.expvar = "chasquid/sts/cache/refreshErrors" m.sts_cache_refresh_errors.help = "STS cache refresh errors" m.sts_cache_refreshes.expvar = "chasquid/sts/cache/refreshes" m.sts_cache_refreshes.help = "count of STS cache refreshes" m.sts_cache_unmarshal_errors.expvar = "chasquid/sts/cache/unmarshalErrors" m.sts_cache_unmarshal_errors.help = "unmarshalling errors in STS cache" ``` chasquid-1.2/docs/relnotes.md000066400000000000000000000053361357247226300163040ustar00rootroot00000000000000 # Release notes This file contains notes for each release, summarizing changes and explicitly noting backward-incompatible changes or known security issues. ## 1.2 (2019-12-06) Security fixes: - DoS through memory exhaustion due to not limiting the line length (on both incoming and outgoing connections). Thanks to Max Mazurov (fox.cpp@disroot.org) for the initial report. Release notes: - Fix handling of excessive long lines on incoming and outgoing connections. - Better error codes when DATA size exceeded the maximum. - New documentation sections (monitoring, release notes). - Many miscellaneous test improvements. ## 1.1 (2019-10-26) - Added hooks for aliases resolution. - Added rspamd integration in the default post-data hook. - Added chasquid-util aliases-add subcommand. - Expanded SPF support. - Documentation and test improvements. - Minor bug fixes. ## 1.0 (2019-07-15) No backwards-incompatible changes. No more are expected within this major version. - Fixed a bug on early connection deadline handling. - Make DSN tidier, especially in handling multi-line errors. - Miscellaneous test improvements. ## 0.07 (2019-01-19) No backwards-incompatible changes. - Send enhanced status codes. - Internationalized Delivery Status Notifications (DSN). - Miscellaneous test improvements. - DKIM integration examples and test. ## 0.06 (2018-07-22) No backwards-incompatible changes. - New MTA-STS (Strict Transport Security) checking. ## 0.05 (2018-06-05) No backwards-incompatible changes. - Lots of new tests. - Added a how-to and manual pages. - Periodic reload of domaininfo, support removing entries manually. - Dovecot auth support no longer considered experimental. ## 0.04 (2018-02-10) No backwards-incompatible changes. - Add Dovecot authentication support (experimental). - Miscellaneous bug fixes to mda-lmtp and tests. ## 0.03 (2017-07-15) **Backwards-incompatible changes:** - The default MTA binary has changed. It's now maildrop by default. If you relied on procmail being the default, add the following to `/etc/chasquid/chasquid.conf`: `mail_delivery_agent_bin: "procmail"`. - chasquid now listens on a third port, submission-on-TLS. If using systemd, copy the `etc/systemd/system/chasquid-submission_tls.socket` file to `/etc/systemd/system/`, and start it. Release notes: - Support submission (directly) over TLS (submissions/smtps/port 465). - Change the default MDA binary to `maildrop`. - Add a very basic MDA that uses LMTP to do the mail delivery. ## 0.02 (2017-03-03) No backwards-incompatible changes. - Improved configuration checks and safeguards. - Fall back through the MX list on errors. - Experimental MTA-STS implementation (disabled by default). ## 0.01 (2016-11-03) Initial release. chasquid-1.2/docs/sec-levels.md000066400000000000000000000141751357247226300165140ustar00rootroot00000000000000 # Security level checks chasquid tracks per-domain TLS support, and uses it to prevent connection downgrading. Incoming and outgoing connections are tracked independently, but the principle of operation is the same: once a domain shows it can establish secure connections, chasquid will reject lower-security connections from/to its servers. This is very different from other MTAs, and has some tradeoffs. ## Outgoing connections An outgoing connection has one of 3 security levels, which are (in order): 1. Plain: connection is plain-text (the server does not support TLS). 2. TLS insecure: TLS connection established, but the certificate itself was not valid. 3. TLS secure: TLS connection established, with a valid certificate. When establishing an outgoing connection, chasquid will always attempt to negotiate up to the *TLS secure* level. After the negotiation, it will compare which level it got, with the previously recorded value for this domain: * If the connection level is lower than the recorded value, then the connection will be dropped, and the delivery will fail (with a transient failure). The delivery will be retried as usual (using other MXs if available, and repeat after some delay). * If the connection level is the same as the recorded value, then the connection will proceed. * If the connection level is higher, chasquid will record this new value, and proceed. If there is no previously recorded value for this domain, a *plain* level is assumed. ### Certificate validation A certificate is considered valid if it satisfies all of the following conditions: 1. The certificate is properly signed by one of the system roots. 2. The name used to contact the server (e.g. the name from the MX record) is declared in the certificate. This is the standard method used in other services such as HTTPS; however, there is no standard to do certificate validation on SMTP. chasquid chooses to implement validation this way, which is also consistent with MTA-STS and HTTPS, but it is not universally agreed upon. It's also why the "TLS insecure" state exists, instead of the connection being rejected directly. ### Tradeoffs Almost all other MTAs do TLS negotiation but accept *all* certificates, even self-signed or expired ones. chasquid operates differently, as described above. The main advantage is that, *with domains where secure connections were previously established*, chasquid will detect connection downgrading (caused by malicious interception such as STARTTLS blocking, as well as misconfiguration such as incorrectly configured or expired certificates), and avoid communicating insecurely. The main disadvantage is that if a domain changes the configuration to a lower security level, chasquid will fail the delivery (returning a message to the sender explaining why). Because there is no formal standard for TLS certificate validation, and most MTAs will deliver email in this situation, the domain owners might not see this as a problem and thus require [manual intervention](#manual-override) on the chasquid side to explicitly allow it. ### MTA-STS [MTA-STS](https://tools.ietf.org/html/rfc8461) is a relatively new standard which defines a mechanism enabling mail service providers to declare their ability to receive TLS connections, amongst other things. It is supported by chasquid, out of the box, and in practice it means that for domains that advertise MTA-STS support, the *secure* level will be enforced even if the domain was previously unknown. ## Incoming connections Incoming connections from authenticated users are always done over TLS (chasquid will never accept authentication over plaintext connections). This section applies only to incoming connections from other SMTP servers. An incoming connection from another SMTP server is first checked through [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework). If the result of the check is negative (fail, softfail, neutral, or error), then the following is skipped. This prevents a malicious agent from raising the level and interfering with legitimate plaintext delivery. After the SPF check has passed, the connection is assigned one of the 2 security levels, which are (in order): 1. Plain: connection is plain-text (client did not do TLS negotiation). 2. TLS client: connection is over TLS. At this point, chasquid will compare the level with the previously recorded value for this domain: * If the connection level is lower than the recorded value, then the connection is rejected with an SMTP error. * If the connection level is the same as the recorded value, then the connection is allowed. * If the connection level is higher, chasquid will record this new value, and the connection is allowed. If there is no previously recorded value for this domain, a *plain* level is assumed. ### Tradeoffs Almost all other MTAs accept server to server connections regardless of the security level, because there is no way for a domain to advertise that it will always negotiate TLS when sending email. chasquid operates differently, assuming that once a server negotiates TLS, it will always attempt to do so. The main advantage is that, *with domains that had previously used TLS for incoming connections*, chasquid will detect connection downgrading (caused by malicious interception such as STARTTLS blocking), and avoid communicating insecurely. The main disadvantage is that if a domain changes the configuration and is unable to negotiate TLS, chasquid will reject the connection and not receive incoming email from this server. This is unusual nowadays, but because other MTAs will accept the connection anyway, domain owners might not even notice there is a problem, and might require [manual intervention](#manual-override) on the chasquid side to explicitly allow it. ## Accepting lower security levels {#manual-override} If a domain changes its configuration to a lower security level and is causing chasquid to fail delivery, you can use `chasquid-util domaininfo-remove ` to make the server forget about that domain. Then, the next time there is a connection, there is no high security expectation so it will proceed just fine, regardless of the level that was negotiated. chasquid-1.2/docs/tests.md000077700000000000000000000000001357247226300202502../test/README.mdustar00rootroot00000000000000chasquid-1.2/etc/000077500000000000000000000000001357247226300137435ustar00rootroot00000000000000chasquid-1.2/etc/chasquid/000077500000000000000000000000001357247226300155445ustar00rootroot00000000000000chasquid-1.2/etc/chasquid/README000066400000000000000000000016221357247226300164250ustar00rootroot00000000000000 This directory contains chasquid's configuration. - chasquid.conf Main config file. - domains/ Domains' data. - example.com/ - users User and password database for the domain. - aliases Aliases for the domain. ... - certs/ Certificates to use, one dir per pair. - example.com/ - fullchain.pem Certificate (full chain). - privkey.pem Private key. ... Note the certs/ directory matches certbot's structure, so if you use it you can just symlink to /etc/letsencrypt/live. You need at least one certificate, or the server will refuse to start. Ideally there should be a certificate for each DNS name pointing to you. Make sure the user you use to run chasquid under ("mail" in the example systemd files) can access the certificates and private keys. The user databases can be created and edited with the chasquid-util tool. chasquid-1.2/etc/chasquid/certs000077700000000000000000000000001357247226300227512/etc/letsencrypt/live/ustar00rootroot00000000000000chasquid-1.2/etc/chasquid/chasquid.conf000066400000000000000000000057031357247226300202210ustar00rootroot00000000000000 # Default hostname to use when saying hello. # This is used to say hello to clients, for aesthetic purposes. # Default: the system's hostname. #hostname: "mx.example.com" # Maximum email size, in megabytes. # Default: 50. #max_data_size_mb: 50 # Addresses to listen on for SMTP (usually port 25). # Default: "systemd", which means systemd passes sockets to us. # systemd sockets must be named with "FileDescriptorName=smtp". #smtp_address: "systemd" #smtp_address: ":25" # Addresses to listen on for submission (usually port 587). # Default: "systemd", which means systemd passes sockets to us. # systemd sockets must be named with "FileDescriptorName=submission". #submission_address: "systemd" #submission_address: ":587" # Address for the monitoring http server. # Do NOT expose this to the public internet. # Default: no monitoring http server. #monitoring_address: "127.0.0.1:1099" # Mail delivery agent (MDA, also known as LDA) to use. # This should point to the binary to use to deliver email to local users. # The content of the email will be passed via stdin. # If it exits unsuccessfully, we assume the mail was not delivered. # Default: "maildrop". #mail_delivery_agent_bin: "maildrop" # Command line arguments for the mail delivery agent. One per argument. # Some replacements will be done. # On an email sent from marsnik@mars to venera@venus: # - %from% -> from address (marsnik@mars) # - %from_user% -> from user (marsnik) # - %from_domain% -> from domain (mars) # - %to% -> to address (venera@venus) # - %to_user% -> to user (venera) # - %to_domain% -> to domain (venus) # # Default: "-f", "%from%", "-d", "%to_user%" (adequate for procmail and # maildrop). #mail_delivery_agent_args: "-f" #mail_delivery_agent_args: "%from%" #mail_delivery_agent_args: "-d" #mail_delivery_agent_args: "%to_user%" # Directory where we store our persistent data. # Default: "/var/lib/chasquid" #data_dir: "/var/lib/chasquid" # Suffix separator, to perform suffix removal of local users. # For example, if you set this to "-+", email to local user # "user-blah" and "user+blah" will be delivered to "user". # Including "+" is strongly encouraged, as it is assumed for email # forwarding. # Default: "+". #suffix_separators: "+" # Characters to drop from the user part on local emails. # For example, if you set this to "._", email to local user # "u.se_r" will be delivered to "user". # Default: ".". #drop_characters: "." # Path where to write the mail log to. # If "", log using the syslog (at MAIL|INFO priority). # Default: #mail_log_path: "" # Enable dovecot authentication. # If set to true, users not found in chasquid's user databases will be # authenticated via dovecot. # Default: false #dovecot_auth: false # Dovecot userdb and client socket paths. # Most of the time this is not needed, as chasquid will auto-detect their # location by searching standard paths. # Default: "" (autodetect) #dovecot_userdb_path: "" #dovecot_client_path: "" chasquid-1.2/etc/chasquid/domains/000077500000000000000000000000001357247226300171765ustar00rootroot00000000000000chasquid-1.2/etc/chasquid/domains/.gitignore000066400000000000000000000000001357247226300211540ustar00rootroot00000000000000chasquid-1.2/etc/chasquid/hooks/000077500000000000000000000000001357247226300166675ustar00rootroot00000000000000chasquid-1.2/etc/chasquid/hooks/post-data000077500000000000000000000047521357247226300205210ustar00rootroot00000000000000#!/bin/bash # # This file is an example post-data hook that will run standard filtering # utilities if they are available. # # - greylist (from greylistd) to do greylisting. # - spamc (from Spamassassin) to filter spam. # - rspamc (from rspamd) to filter spam. # - clamdscan (from ClamAV) to filter virus. # - dkimsign (from driusan/dkim) to do DKIM signing. # # If it exits with code 20, it will be considered a permanent error. # Otherwise, temporary. set -e # Note greylistd needs you to add the user to the "greylist" group: # usermod -a -G greylist mail if [ "$AUTH_AS" == "" ] && [ "$SPF_PASS" == "0" ] && \ command -v greylist >/dev/null && \ groups | grep -q greylist; then REMOTE_IP=$(echo "$REMOTE_ADDR" | rev | cut -d : -f 2- | rev) if ! greylist update "$REMOTE_IP" "$MAIL_FROM" 1>&2; then echo "greylisted, please try again" exit 75 # temporary error fi echo "X-Greylist: pass" fi TF="$(mktemp --tmpdir post-data-XXXXXXXXXX)" trap 'rm "$TF"' EXIT # Save the message to the temporary file, so we can pass it on to the various # filters. cat > "$TF" if command -v spamc >/dev/null; then if ! SL=$(spamc -c - < "$TF") ; then echo "spam detected" exit 20 # permanent fi echo "X-Spam-Score: $SL" fi if command -v rspamc >/dev/null; then ACTION=$( rspamc < "$TF" 2>/dev/null | grep Action: | cut -d " " -f 2- ) case "$ACTION" in greylist) echo "greylisted, please try again" exit 75 # temporary error ;; reject) echo "spam detected" exit 20 # permanent error ;; esac echo "X-Spam-Action:" "$ACTION" fi if command -v clamdscan >/dev/null; then if ! clamdscan --no-summary --infected - < "$TF" 1>&2 ; then echo "virus detected" exit 20 # permanent fi echo "X-Virus-Scanned: pass" fi # DKIM sign with https://github.com/driusan/dkim. # # Do it only if all the following are true: # - User has authenticated. # - dkimsign binary exists. # - domains/$DOMAIN/dkim_selector file exists. # - certs/$DOMAIN/dkim_privkey.pem file exists. # # Note this has not been thoroughly tested, so might need further adjustments. if [ "$AUTH_AS" != "" ] && command -v dkimsign; then DOMAIN=$( echo "$MAIL_FROM" | cut -d '@' -f 2 ) if [ -f "domains/$DOMAIN/dkim_selector" ] \ && [ -f "certs/$DOMAIN/dkim_privkey.pem" ]; then dkimsign -n -hd \ -key "certs/$DOMAIN/dkim_privkey.pem" \ -s $(cat "domains/$DOMAIN/dkim_selector") \ -d "$DOMAIN" \ < "$TF" fi fi chasquid-1.2/etc/systemd/000077500000000000000000000000001357247226300154335ustar00rootroot00000000000000chasquid-1.2/etc/systemd/system/000077500000000000000000000000001357247226300167575ustar00rootroot00000000000000chasquid-1.2/etc/systemd/system/chasquid-smtp.socket000066400000000000000000000002471357247226300227560ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (SMTP sockets) [Socket] ListenStream=25 FileDescriptorName=smtp Service=chasquid.service [Install] WantedBy=chasquid.target chasquid-1.2/etc/systemd/system/chasquid-submission.socket000066400000000000000000000002641357247226300241650ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (submission sockets) [Socket] ListenStream=587 FileDescriptorName=submission Service=chasquid.service [Install] WantedBy=chasquid.target chasquid-1.2/etc/systemd/system/chasquid-submission_tls.socket000066400000000000000000000003011357247226300250370ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (submission over TLS sockets) [Socket] ListenStream=465 FileDescriptorName=submission_tls Service=chasquid.service [Install] WantedBy=chasquid.target chasquid-1.2/etc/systemd/system/chasquid.service000066400000000000000000000006261357247226300221460ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (service) Requires=chasquid-smtp.socket \ chasquid-submission.socket \ chasquid-submission_tls.socket [Service] ExecStart=/usr/local/bin/chasquid \ # -v=3 \ # --log_dir=/var/log/chasquid/ \ # --alsologtostderr \ Type=simple Restart=always User=mail Group=mail # Simple security measures just in case. ProtectSystem=full [Install] WantedBy=multi-user.target chasquid-1.2/go.mod000066400000000000000000000007711357247226300143030ustar00rootroot00000000000000module blitiri.com.ar/go/chasquid go 1.13 require ( blitiri.com.ar/go/log v0.0.0-20171003035348-6cd06f6ca2f8 blitiri.com.ar/go/spf v0.0.0-20191018194539-a683815bdae8 blitiri.com.ar/go/systemd v0.0.0-20171003041308-cdc4fd023aa4 github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 github.com/golang/protobuf v1.3.2 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 golang.org/x/text v0.3.2 gopkg.in/yaml.v2 v2.2.4 // indirect ) chasquid-1.2/go.sum000066400000000000000000000053761357247226300143360ustar00rootroot00000000000000blitiri.com.ar/go/log v0.0.0-20171003035348-6cd06f6ca2f8 h1:1lsgqZmMh8DYLb/ZaBeSSNTaKiMBe9QC/1XOkupUNLU= blitiri.com.ar/go/log v0.0.0-20171003035348-6cd06f6ca2f8/go.mod h1:xOW3xCYp3dEVSQWNKiiKIqBtjVN4cinE+0HypCpGC+E= blitiri.com.ar/go/spf v0.0.0-20191018194539-a683815bdae8 h1:faoLmiOKc/iC8/jn9HN8hVbWBrgeVweZmdPeJgk5fWc= blitiri.com.ar/go/spf v0.0.0-20191018194539-a683815bdae8/go.mod h1:Mpik83f+Vc8wPdRrnGR5cV50NsENy+caW+42Kz4huCs= blitiri.com.ar/go/systemd v0.0.0-20171003041308-cdc4fd023aa4 h1:ceTBe2TiHNkhA7q/TAHyukC5Jc1iUb7On/++f1Mszwk= blitiri.com.ar/go/systemd v0.0.0-20171003041308-cdc4fd023aa4/go.mod h1:FmDkVlYnOzDHOhtSwtLHh6z9WVVx+aPjrHkPtfA3qhI= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815 h1:bWDMxwH3px2JBh6AyO7hdCn/PkvCZXii8TGj7sbtEbQ= github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE= github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582 h1:p9xBe/w/OzkeYVKm234g55gMdD1nSIooTir5kV11kfA= golang.org/x/net v0.0.0-20191014212845-da9a3fd4c582/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d h1:+R4KGOnez64A81RvjARKc4UT5/tI9ujCIVX+P5KiHuI= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= chasquid-1.2/internal/000077500000000000000000000000001357247226300150045ustar00rootroot00000000000000chasquid-1.2/internal/aliases/000077500000000000000000000000001357247226300164255ustar00rootroot00000000000000chasquid-1.2/internal/aliases/aliases.go000066400000000000000000000261161357247226300204030ustar00rootroot00000000000000// Package aliases implements an email aliases resolver. // // The resolver can parse many files for different domains, and perform // lookups to resolve the aliases. // // // File format // // It generally follows the traditional aliases format used by sendmail and // exim. // // The file can contain lines of the form: // // user: address, address // user: | command // // Lines starting with "#" are ignored, as well as empty lines. // User names cannot contain spaces, ":" or commas, for parsing reasons. This // is a tradeoff between flexibility and keeping the file format easy to edit // for people. // // User names will be normalized internally to lower-case. // // Usually there will be one database per domain, and there's no need to // include the "@" in the user (in this case, "@" will be forbidden). // // // Recipients // // Recipients can be of different types: // - Email: the usual user@domain we all know and love, this is the default. // - Pipe: if the right side starts with "| ", the rest of the line specifies // a command to pipe the email through. // Command and arguments are space separated. No quoting, escaping, or // replacements of any kind. // // // Lookups // // The resolver will perform lookups recursively, until it finds all the final // recipients. // // There are recursion limits to avoid alias loops. If the limit is reached, // the entire resolution will fail. // // // Suffix removal // // The resolver can also remove suffixes from emails, and drop characters // completely. This can be used to turn "user+blah@domain" into "user@domain", // and "us.er@domain" into "user@domain". // // Both are optional, and the characters configurable globally. package aliases import ( "bufio" "context" "expvar" "fmt" "io" "os" "os/exec" "strings" "sync" "time" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/trace" ) // Exported variables. var ( hookResults = expvar.NewMap("chasquid/aliases/hookResults") ) // Recipient represents a single recipient, after resolving aliases. // They don't have any special interface, the callers will do a type switch // anyway. type Recipient struct { Addr string Type RType } // RType represents a recipient type, see the contants below for valid values. type RType string // Valid recipient types. const ( EMAIL RType = "(email)" PIPE RType = "(pipe)" ) var ( // ErrRecursionLimitExceeded is returned when the resolving lookup // exceeded the recursion limit. Usually caused by aliases loops. ErrRecursionLimitExceeded = fmt.Errorf("recursion limit exceeded") // How many levels of recursions we allow during lookups. // We don't expect much recursion, so keeping this low to catch errors // quickly. recursionLimit = 10 ) // Resolver represents the aliases resolver. type Resolver struct { // Suffix separator, to perform suffix removal. SuffixSep string // Characters to drop from the user part. DropChars string // Path to resolve and exist hooks. ExistsHook string ResolveHook string // Map of domain -> alias files for that domain. // We keep track of them for reloading purposes. files map[string][]string domains map[string]bool // Map of address -> aliases. aliases map[string][]Recipient // Mutex protecting the structure. mu sync.Mutex } // NewResolver returns a new, empty Resolver. func NewResolver() *Resolver { return &Resolver{ files: map[string][]string{}, domains: map[string]bool{}, aliases: map[string][]Recipient{}, } } // Resolve the given address, returning the list of corresponding recipients // (if any). func (v *Resolver) Resolve(addr string) ([]Recipient, error) { return v.resolve(0, addr) } // Exists check that the address exists in the database. // It returns the cleaned address, and a boolean indicating the result. // The clean address can be used to look it up in other databases, even if it // doesn't exist. func (v *Resolver) Exists(addr string) (string, bool) { v.mu.Lock() addr = v.cleanIfLocal(addr) _, ok := v.aliases[addr] v.mu.Unlock() if ok { return addr, true } return addr, v.runExistsHook(addr) } func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) { if rcount >= recursionLimit { return nil, ErrRecursionLimitExceeded } // Drop suffixes and chars to get the "clean" address before resolving. // This also means that we will return the clean version if there's no // match, which our callers can rely upon. addr = v.cleanIfLocal(addr) // Lookup in the aliases database. v.mu.Lock() rcpts := v.aliases[addr] v.mu.Unlock() // Augment with the hook results. hr, err := v.runResolveHook(addr) if err != nil { return nil, err } rcpts = append(rcpts, hr...) if len(rcpts) == 0 { return []Recipient{{addr, EMAIL}}, nil } ret := []Recipient{} for _, r := range rcpts { // Only recurse for email recipients. if r.Type != EMAIL { ret = append(ret, r) continue } ar, err := v.resolve(rcount+1, r.Addr) if err != nil { return nil, err } ret = append(ret, ar...) } return ret, nil } func (v *Resolver) cleanIfLocal(addr string) string { user, domain := envelope.Split(addr) if !v.domains[domain] { return addr } user = removeAllAfter(user, v.SuffixSep) user = removeChars(user, v.DropChars) user, _ = normalize.User(user) return user + "@" + domain } // AddDomain to the resolver, registering its existence. func (v *Resolver) AddDomain(domain string) { v.mu.Lock() v.domains[domain] = true v.mu.Unlock() } // AddAliasesFile to the resolver. The file will be parsed, and an error // returned if it does not exist or parse correctly. func (v *Resolver) AddAliasesFile(domain, path string) error { // We unconditionally add the domain and file on our list. // Even if the file does not exist now, it may later. This makes it be // consider when doing Reload. // Adding it to the domains mean that we will do drop character and suffix // manipulation even if there are no aliases for it. v.mu.Lock() v.files[domain] = append(v.files[domain], path) v.domains[domain] = true v.mu.Unlock() aliases, err := parseFile(domain, path) if os.IsNotExist(err) { return nil } if err != nil { return err } // Add the aliases to the resolver, overriding any previous values. v.mu.Lock() for addr, rs := range aliases { v.aliases[addr] = rs } v.mu.Unlock() return nil } // AddAliasForTesting adds an alias to the resolver, for testing purposes. // Not for use in production code. func (v *Resolver) AddAliasForTesting(addr, rcpt string, rType RType) { v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, rType}) } // Reload aliases files for all known domains. func (v *Resolver) Reload() error { newAliases := map[string][]Recipient{} for domain, paths := range v.files { for _, path := range paths { aliases, err := parseFile(domain, path) if os.IsNotExist(err) { continue } if err != nil { return fmt.Errorf("error parsing %q: %v", path, err) } // Add the aliases to the resolver, overriding any previous values. for addr, rs := range aliases { newAliases[addr] = rs } } } v.mu.Lock() v.aliases = newAliases v.mu.Unlock() return nil } func parseFile(domain, path string) (map[string][]Recipient, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() aliases, err := parseReader(domain, f) if err != nil { return nil, fmt.Errorf("reading %q: %v", path, err) } return aliases, nil } func parseReader(domain string, r io.Reader) (map[string][]Recipient, error) { aliases := map[string][]Recipient{} scanner := bufio.NewScanner(r) for i := 1; scanner.Scan(); i++ { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") { continue } sp := strings.SplitN(line, ":", 2) if len(sp) != 2 { continue } addr, rawalias := strings.TrimSpace(sp[0]), strings.TrimSpace(sp[1]) if len(addr) == 0 || len(rawalias) == 0 { continue } if strings.Contains(addr, "@") { // It's invalid for lhs addresses to contain @ (for now). continue } addr = addr + "@" + domain addr, _ = normalize.Addr(addr) rs := parseRHS(rawalias, domain) aliases[addr] = rs } return aliases, scanner.Err() } func parseRHS(rawalias, domain string) []Recipient { if len(rawalias) == 0 { return nil } if rawalias[0] == '|' { cmd := strings.TrimSpace(rawalias[1:]) if cmd == "" { // A pipe alias without a command is invalid. return nil } return []Recipient{{cmd, PIPE}} } rs := []Recipient{} for _, a := range strings.Split(rawalias, ",") { a = strings.TrimSpace(a) if a == "" { continue } // Addresses with no domain get the current one added, so it's // easier to share alias files. if !strings.Contains(a, "@") { a = a + "@" + domain } a, _ = normalize.Addr(a) rs = append(rs, Recipient{a, EMAIL}) } return rs } // removeAllAfter removes everything from s that comes after the separators, // including them. func removeAllAfter(s, seps string) string { for _, c := range strings.Split(seps, "") { if c == "" { continue } i := strings.Index(s, c) if i == -1 { continue } s = s[:i] } return s } // removeChars removes the runes in "chars" from s. func removeChars(s, chars string) string { for _, c := range strings.Split(chars, "") { s = strings.Replace(s, c, "", -1) } return s } func (v *Resolver) runResolveHook(addr string) ([]Recipient, error) { if v.ResolveHook == "" { hookResults.Add("resolve:notset", 1) return nil, nil } // TODO: check if the file is executable. if _, err := os.Stat(v.ResolveHook); os.IsNotExist(err) { hookResults.Add("resolve:skip", 1) return nil, nil } // TODO: this should be done via a context propagated all the way through. tr := trace.New("Hook.Alias-Resolve", addr) defer tr.Finish() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, v.ResolveHook, addr) outb, err := cmd.Output() out := string(outb) tr.Debugf("stdout: %q", out) if err != nil { hookResults.Add("resolve:fail", 1) tr.Error(err) return nil, err } // Extract recipients from the output. // Same format as the right hand side of aliases file, see parseRHS. domain := envelope.DomainOf(addr) raw := strings.TrimSpace(out) rs := parseRHS(raw, domain) tr.Debugf("recipients: %v", rs) hookResults.Add("resolve:success", 1) return rs, nil } func (v *Resolver) runExistsHook(addr string) bool { if v.ExistsHook == "" { hookResults.Add("exists:notset", 1) return false } // TODO: check if the file is executable. if _, err := os.Stat(v.ExistsHook); os.IsNotExist(err) { hookResults.Add("exists:skip", 1) return false } // TODO: this should be done via a context propagated all the way through. tr := trace.New("Hook.Alias-Exists", addr) defer tr.Finish() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, v.ExistsHook, addr) err := cmd.Run() if err != nil { tr.Debugf("not exists: %v", err) hookResults.Add("exists:false", 1) return false } tr.Debugf("exists") hookResults.Add("exists:true", 1) return true } chasquid-1.2/internal/aliases/aliases_test.go000066400000000000000000000177341357247226300214500ustar00rootroot00000000000000package aliases import ( "io/ioutil" "os" "reflect" "testing" ) type Cases []struct { addr string expect []Recipient } func (cases Cases) check(t *testing.T, r *Resolver) { for _, c := range cases { got, err := r.Resolve(c.addr) if err != nil { t.Errorf("case %q, got error: %v", c.addr, err) continue } if !reflect.DeepEqual(got, c.expect) { t.Errorf("case %q, got %+v, expected %+v", c.addr, got, c.expect) } } } func mustExist(t *testing.T, r *Resolver, addrs ...string) { for _, addr := range addrs { if _, ok := r.Exists(addr); !ok { t.Errorf("address %q does not exist, it should", addr) } } } func mustNotExist(t *testing.T, r *Resolver, addrs ...string) { for _, addr := range addrs { if _, ok := r.Exists(addr); ok { t.Errorf("address %q exists, it should not", addr) } } } func TestBasic(t *testing.T) { resolver := NewResolver() resolver.aliases = map[string][]Recipient{ "a@b": {{"c@d", EMAIL}, {"e@f", EMAIL}}, "e@f": {{"cmd", PIPE}}, "cmd": {{"x@y", EMAIL}}, // it's a trap! } cases := Cases{ {"a@b", []Recipient{{"c@d", EMAIL}, {"cmd", PIPE}}}, {"e@f", []Recipient{{"cmd", PIPE}}}, {"x@y", []Recipient{{"x@y", EMAIL}}}, } cases.check(t, resolver) mustExist(t, resolver, "a@b", "e@f", "cmd") mustNotExist(t, resolver, "x@y") } func TestAddrRewrite(t *testing.T) { resolver := NewResolver() resolver.AddDomain("def") resolver.AddDomain("p-q.com") resolver.aliases = map[string][]Recipient{ "abc@def": {{"x@y", EMAIL}}, "ñoño@def": {{"x@y", EMAIL}}, "recu@def": {{"ab+cd@p-q.com", EMAIL}}, } resolver.DropChars = ".~" resolver.SuffixSep = "-+" cases := Cases{ {"abc@def", []Recipient{{"x@y", EMAIL}}}, {"a.b.c@def", []Recipient{{"x@y", EMAIL}}}, {"a~b~c@def", []Recipient{{"x@y", EMAIL}}}, {"a.b~c@def", []Recipient{{"x@y", EMAIL}}}, {"abc-ñaca@def", []Recipient{{"x@y", EMAIL}}}, {"abc-ñaca@def", []Recipient{{"x@y", EMAIL}}}, {"abc-xyz@def", []Recipient{{"x@y", EMAIL}}}, {"abc+xyz@def", []Recipient{{"x@y", EMAIL}}}, {"abc-x.y+z@def", []Recipient{{"x@y", EMAIL}}}, {"ñ.o~ño-ñaca@def", []Recipient{{"x@y", EMAIL}}}, // Don't mess with the domain, even if it's known. {"a.bc-ñaca@p-q.com", []Recipient{{"abc@p-q.com", EMAIL}}}, // Clean the right hand side too (if it's a local domain). {"recu+blah@def", []Recipient{{"ab@p-q.com", EMAIL}}}, // We should not mess with emails for domains we don't know. {"xy@z.com", []Recipient{{"xy@z.com", EMAIL}}}, {"x.y@z.com", []Recipient{{"x.y@z.com", EMAIL}}}, {"x-@y-z.com", []Recipient{{"x-@y-z.com", EMAIL}}}, {"x+blah@y", []Recipient{{"x+blah@y", EMAIL}}}, } cases.check(t, resolver) } func TestExistsRewrite(t *testing.T) { resolver := NewResolver() resolver.AddDomain("def") resolver.AddDomain("p-q.com") resolver.aliases = map[string][]Recipient{ "abc@def": {{"x@y", EMAIL}}, "ñoño@def": {{"x@y", EMAIL}}, "recu@def": {{"ab+cd@p-q.com", EMAIL}}, } resolver.DropChars = ".~" resolver.SuffixSep = "-+" mustExist(t, resolver, "abc@def", "a.bc+blah@def", "ño.ño@def") mustNotExist(t, resolver, "abc@d.ef", "nothere@def") cases := []struct { addr string expectAddr string expectExists bool }{ {"abc@def", "abc@def", true}, {"abc+blah@def", "abc@def", true}, {"a.b~c@def", "abc@def", true}, {"a.bc+blah@def", "abc@def", true}, {"a.bc@unknown", "a.bc@unknown", false}, {"x.yz@def", "xyz@def", false}, {"x.yz@d.ef", "x.yz@d.ef", false}, } for _, c := range cases { addr, exists := resolver.Exists(c.addr) if addr != c.expectAddr { t.Errorf("%q: expected addr %q, got %q", c.addr, c.expectAddr, addr) } if exists != c.expectExists { t.Errorf("%q: expected exists %v, got %v", c.addr, c.expectExists, exists) } } } func TestTooMuchRecursion(t *testing.T) { resolver := Resolver{} resolver.aliases = map[string][]Recipient{ "a@b": {{"c@d", EMAIL}}, "c@d": {{"a@b", EMAIL}}, } rs, err := resolver.Resolve("a@b") if err != ErrRecursionLimitExceeded { t.Errorf("expected ErrRecursionLimitExceeded, got %v", err) } if rs != nil { t.Errorf("expected nil recipients, got %+v", rs) } } func mustWriteFile(t *testing.T, content string) string { f, err := ioutil.TempFile("", "aliases_test") if err != nil { t.Fatalf("failed to get temp file: %v", err) } defer f.Close() _, err = f.WriteString(content) if err != nil { t.Fatalf("failed to write temp file: %v", err) } return f.Name() } func TestAddFile(t *testing.T) { cases := []struct { contents string expected []Recipient }{ {"\n", []Recipient{{"a@dom", EMAIL}}}, {" # Comment\n", []Recipient{{"a@dom", EMAIL}}}, {":\n", []Recipient{{"a@dom", EMAIL}}}, {"a: \n", []Recipient{{"a@dom", EMAIL}}}, {"a@dom: b@c \n", []Recipient{{"a@dom", EMAIL}}}, {"a: b\n", []Recipient{{"b@dom", EMAIL}}}, {"a:b\n", []Recipient{{"b@dom", EMAIL}}}, {"a : b \n", []Recipient{{"b@dom", EMAIL}}}, {"a : b, \n", []Recipient{{"b@dom", EMAIL}}}, {"a: |cmd\n", []Recipient{{"cmd", PIPE}}}, {"a:|cmd\n", []Recipient{{"cmd", PIPE}}}, {"a:| cmd \n", []Recipient{{"cmd", PIPE}}}, {"a :| cmd \n", []Recipient{{"cmd", PIPE}}}, {"a: | cmd arg1 arg2\n", []Recipient{{"cmd arg1 arg2", PIPE}}}, {"a: c@d, e@f, g\n", []Recipient{{"c@d", EMAIL}, {"e@f", EMAIL}, {"g@dom", EMAIL}}}, // Invalid pipe aliases, should be ignored. {"a:|\n", []Recipient{{"a@dom", EMAIL}}}, {"a:| \n", []Recipient{{"a@dom", EMAIL}}}, } for _, c := range cases { fname := mustWriteFile(t, c.contents) defer os.Remove(fname) resolver := NewResolver() err := resolver.AddAliasesFile("dom", fname) if err != nil { t.Fatalf("error adding file: %v", err) } got, err := resolver.Resolve("a@dom") if err != nil { t.Errorf("case %q, got error: %v", c.contents, err) continue } if !reflect.DeepEqual(got, c.expected) { t.Errorf("case %q, got %v, expected %v", c.contents, got, c.expected) } } } const richFileContents = ` # This is a "complex" alias file, with a few tricky situations. # It is used in TestRichFile. # First some valid cases. a: b c: d@e, f, x: | command # The following is invalid, should be ignored. a@dom: x@dom # Overrides. o1: a o1: b # Check that we normalize the right hand side. aA: bB@dom-B # Finally one to make the file NOT end in \n: y: z` func TestRichFile(t *testing.T) { fname := mustWriteFile(t, richFileContents) defer os.Remove(fname) resolver := NewResolver() err := resolver.AddAliasesFile("dom", fname) if err != nil { t.Fatalf("failed to add file: %v", err) } cases := Cases{ {"a@dom", []Recipient{{"b@dom", EMAIL}}}, {"c@dom", []Recipient{{"d@e", EMAIL}, {"f@dom", EMAIL}}}, {"x@dom", []Recipient{{"command", PIPE}}}, {"o1@dom", []Recipient{{"b@dom", EMAIL}}}, {"aA@dom", []Recipient{{"bb@dom-b", EMAIL}}}, {"aa@dom", []Recipient{{"bb@dom-b", EMAIL}}}, {"y@dom", []Recipient{{"z@dom", EMAIL}}}, } cases.check(t, resolver) } func TestManyFiles(t *testing.T) { files := map[string]string{ "d1": mustWriteFile(t, "a: b\nc:d@e"), "domain2": mustWriteFile(t, "a: b\nc:d@e"), "dom3": mustWriteFile(t, "x: y, z"), "dom4": mustWriteFile(t, "a: |cmd"), // Cross-domain. "xd1": mustWriteFile(t, "a: b@xd2"), "xd2": mustWriteFile(t, "b: |cmd"), } for _, fname := range files { defer os.Remove(fname) } resolver := NewResolver() for domain, fname := range files { err := resolver.AddAliasesFile(domain, fname) if err != nil { t.Fatalf("failed to add file: %v", err) } } check := func() { cases := Cases{ {"a@d1", []Recipient{{"b@d1", EMAIL}}}, {"c@d1", []Recipient{{"d@e", EMAIL}}}, {"x@d1", []Recipient{{"x@d1", EMAIL}}}, {"a@domain2", []Recipient{{"b@domain2", EMAIL}}}, {"c@domain2", []Recipient{{"d@e", EMAIL}}}, {"x@dom3", []Recipient{{"y@dom3", EMAIL}, {"z@dom3", EMAIL}}}, {"a@dom4", []Recipient{{"cmd", PIPE}}}, {"a@xd1", []Recipient{{"cmd", PIPE}}}, } cases.check(t, resolver) } check() // Reload, and check again just in case. if err := resolver.Reload(); err != nil { t.Fatalf("failed to reload: %v", err) } check() } chasquid-1.2/internal/aliases/fuzz.go000066400000000000000000000005561357247226300177600ustar00rootroot00000000000000// Fuzz testing for package aliases. // +build gofuzz package aliases import "bytes" func Fuzz(data []byte) int { interesting := 0 aliases, _ := parseReader("domain", bytes.NewReader(data)) // Mark cases with actual aliases as more interesting. for _, rcpts := range aliases { if len(rcpts) > 0 { interesting = 1 break } } return interesting } chasquid-1.2/internal/aliases/testdata/000077500000000000000000000000001357247226300202365ustar00rootroot00000000000000chasquid-1.2/internal/aliases/testdata/fuzz/000077500000000000000000000000001357247226300212345ustar00rootroot00000000000000chasquid-1.2/internal/aliases/testdata/fuzz/corpus/000077500000000000000000000000001357247226300225475ustar00rootroot00000000000000chasquid-1.2/internal/aliases/testdata/fuzz/corpus/t-001000066400000000000000000000000001357247226300232210ustar00rootroot00000000000000chasquid-1.2/internal/aliases/testdata/fuzz/corpus/t-002000066400000000000000000000003021357247226300232270ustar00rootroot00000000000000# First some valid cases. a: b c: d@e, f, x: | command # The following is invalid, should be ignored. a@dom: x@dom # Overrides. o1: a o1: b # Finally one to make the file NOT end in \n: y: z chasquid-1.2/internal/aliases/testdata/fuzz/corpus/t-003000066400000000000000000000002441357247226300232350ustar00rootroot00000000000000 # Easy aliases. pepe: jose joan: juan # UTF-8 aliases. pitanga: ñangapirí añil: azul, índigo # Pipe aliases. tubo: | writemailto ../.data/pipe_alias_worked chasquid-1.2/internal/aliases/testdata/fuzz/corpus/t-004000066400000000000000000000000201357247226300232260ustar00rootroot00000000000000 fail: | false chasquid-1.2/internal/aliases/testdata/fuzz/corpus/t-005000066400000000000000000000000261357247226300232350ustar00rootroot00000000000000 aliasA: aliasB@srv-B chasquid-1.2/internal/auth/000077500000000000000000000000001357247226300157455ustar00rootroot00000000000000chasquid-1.2/internal/auth/auth.go000066400000000000000000000134171357247226300172430ustar00rootroot00000000000000// Package auth implements authentication services for chasquid. package auth import ( "bytes" "encoding/base64" "errors" "fmt" "math/rand" "strings" "time" "blitiri.com.ar/go/chasquid/internal/normalize" ) // Backend is the common interface for all authentication backends. type Backend interface { Authenticate(user, password string) (bool, error) Exists(user string) (bool, error) Reload() error } // NoErrorBackend is the interface for authentication backends that don't need // to emit errors. This allows backends to avoid unnecessary complexity, in // exchange for a bit more here. // They can be converted to normal Backend using WrapNoErrorBackend (defined // below). type NoErrorBackend interface { Authenticate(user, password string) bool Exists(user string) bool Reload() error } // Authenticator tracks the backends for each domain, and allows callers to // query them with a more practical API. type Authenticator struct { // Registered backends, map of domain (string) -> Backend. // Backend operations will _not_ include the domain in the username. backends map[string]Backend // Fallback backend, to use when backends[domain] (which may not exist) // did not yield a positive result. // Note that this backend gets the user with the domain included, of the // form "user@domain". Fallback Backend // How long Authenticate calls should last, approximately. // This will be applied both for successful and unsuccessful attempts. // We will increase this number by 0-20%. AuthDuration time.Duration } // NewAuthenticator returns a new Authenticator with no backends. func NewAuthenticator() *Authenticator { return &Authenticator{ backends: map[string]Backend{}, AuthDuration: 100 * time.Millisecond, } } // Register a backend to use for the given domain. func (a *Authenticator) Register(domain string, be Backend) { a.backends[domain] = be } // Authenticate the user@domain with the given password. func (a *Authenticator) Authenticate(user, domain, password string) (bool, error) { // Make sure the call takes a.AuthDuration + 0-20% regardless of the // outcome, to prevent basic timing attacks. defer func(start time.Time) { elapsed := time.Since(start) delay := a.AuthDuration - elapsed if delay > 0 { maxDelta := int64(float64(delay) * 0.2) delay += time.Duration(rand.Int63n(maxDelta)) time.Sleep(delay) } }(time.Now()) if be, ok := a.backends[domain]; ok { ok, err := be.Authenticate(user, password) if ok || err != nil { return ok, err } } if a.Fallback != nil { return a.Fallback.Authenticate(user+"@"+domain, password) } return false, nil } // Exists checks that user@domain exists. func (a *Authenticator) Exists(user, domain string) (bool, error) { if be, ok := a.backends[domain]; ok { ok, err := be.Exists(user) if ok || err != nil { return ok, err } } if a.Fallback != nil { return a.Fallback.Exists(user + "@" + domain) } return false, nil } // Reload the registered backends. func (a *Authenticator) Reload() error { msgs := []string{} for domain, be := range a.backends { err := be.Reload() if err != nil { msgs = append(msgs, fmt.Sprintf("%q: %v", domain, err)) } } if a.Fallback != nil { err := a.Fallback.Reload() if err != nil { msgs = append(msgs, fmt.Sprintf(": %v", err)) } } if len(msgs) > 0 { return errors.New(strings.Join(msgs, " ; ")) } return nil } // DecodeResponse decodes a plain auth response. // // It must be a a base64-encoded string of the form: // NUL NUL // // https://tools.ietf.org/html/rfc4954#section-4.1. // // Either both ID match, or one of them is empty. // We expect the ID to be "user@domain", which is NOT an RFC requirement but // our own. func DecodeResponse(response string) (user, domain, passwd string, err error) { buf, err := base64.StdEncoding.DecodeString(response) if err != nil { return } bufsp := bytes.SplitN(buf, []byte{0}, 3) if len(bufsp) != 3 { err = fmt.Errorf("response pieces != 3, as per RFC") return } identity := "" passwd = string(bufsp[2]) { // We don't make the distinction between the two IDs, as long as one is // empty, or they're the same. z := string(bufsp[0]) c := string(bufsp[1]) // If neither is empty, then they must be the same. if (z != "" && c != "") && (z != c) { err = fmt.Errorf("auth IDs do not match") return } if z != "" { identity = z } if c != "" { identity = c } } if identity == "" { err = fmt.Errorf("empty identity, must be in the form user@domain") return } // Identity must be in the form "user@domain". // This is NOT an RFC requirement, it's our own. idsp := strings.SplitN(identity, "@", 2) if len(idsp) != 2 { err = fmt.Errorf("identity must be in the form user@domain") return } user = idsp[0] domain = idsp[1] // Normalize the user and domain. This is so users can write the username // in their own style and still can log in. For the domain, we use IDNA // and relevant transformations to turn it to utf8 which is what we use // internally. user, err = normalize.User(user) if err != nil { return } domain, err = normalize.Domain(domain) if err != nil { return } return } // WrapNoErrorBackend wraps a NoErrorBackend, converting it into a valid // Backend. This is normally used in Auth.Register calls, to register no-error // backends. func WrapNoErrorBackend(be NoErrorBackend) Backend { return &wrapNoErrorBackend{be} } type wrapNoErrorBackend struct { be NoErrorBackend } func (w *wrapNoErrorBackend) Authenticate(user, password string) (bool, error) { return w.be.Authenticate(user, password), nil } func (w *wrapNoErrorBackend) Exists(user string) (bool, error) { return w.be.Exists(user), nil } func (w *wrapNoErrorBackend) Reload() error { return w.be.Reload() } chasquid-1.2/internal/auth/auth_test.go000066400000000000000000000156721357247226300203070ustar00rootroot00000000000000package auth import ( "encoding/base64" "fmt" "testing" "time" "blitiri.com.ar/go/chasquid/internal/dovecot" "blitiri.com.ar/go/chasquid/internal/userdb" ) func TestDecodeResponse(t *testing.T) { // Successful cases. Note we hard-code the response for extra assurance. cases := []struct { response, user, domain, passwd string }{ {"dUBkAHVAZABwYXNz", "u", "d", "pass"}, // u@d\0u@d\0pass {"dUBkAABwYXNz", "u", "d", "pass"}, // u@d\0\0pass {"AHVAZABwYXNz", "u", "d", "pass"}, // \0u@d\0pass {"dUBkAABwYXNz/w==", "u", "d", "pass\xff"}, // u@d\0\0pass\xff // "ñaca@ñeque\0\0clavaré" {"w7FhY2FAw7FlcXVlAABjbGF2YXLDqQ==", "ñaca", "ñeque", "clavaré"}, } for _, c := range cases { u, d, p, err := DecodeResponse(c.response) if err != nil { t.Errorf("Error in case %v: %v", c, err) } if u != c.user || d != c.domain || p != c.passwd { t.Errorf("Expected %q %q %q ; got %q %q %q", c.user, c.domain, c.passwd, u, d, p) } } _, _, _, err := DecodeResponse("this is not base64 encoded") if err == nil { t.Errorf("invalid base64 did not fail as expected") } failedCases := []string{ "", "\x00", "\x00\x00", "\x00\x00\x00", "\x00\x00\x00\x00", "a\x00b", "a\x00b\x00c", "a@a\x00b@b\x00pass", "a\x00a\x00pass", "\xffa@b\x00\xffa@b\x00pass", } for _, c := range failedCases { r := base64.StdEncoding.EncodeToString([]byte(c)) _, _, _, err := DecodeResponse(r) if err == nil { t.Errorf("Expected case %q to fail, but succeeded", c) } else { t.Logf("OK: %q failed with %v", c, err) } } } func TestAuthenticate(t *testing.T) { db := userdb.New("/dev/null") db.AddUser("user", "password") a := NewAuthenticator() a.Register("domain", WrapNoErrorBackend(db)) // Shorten the duration to speed up the test. This should still be long // enough for it to fail if we don't sleep intentionally. a.AuthDuration = 20 * time.Millisecond // Test the correct case first check(t, a, "user", "domain", "password", true) // Wrong password, but valid user@domain. ts := time.Now() if ok, _ := a.Authenticate("user", "domain", "invalid"); ok { t.Errorf("invalid password, but authentication succeeded") } if time.Since(ts) < a.AuthDuration { t.Errorf("authentication was too fast (invalid case)") } // Incorrect cases, where the user@domain do not exist. cases := []struct{ user, domain, password string }{ {"user", "unknown", "password"}, {"invalid", "domain", "p"}, {"invalid", "unknown", "p"}, {"user", "", "password"}, {"invalid", "", "p"}, {"", "domain", "password"}, {"", "", ""}, } for _, c := range cases { check(t, a, c.user, c.domain, c.password, false) } } func check(t *testing.T, a *Authenticator, user, domain, passwd string, expect bool) { c := fmt.Sprintf("{%s@%s %s}", user, domain, passwd) ts := time.Now() ok, err := a.Authenticate(user, domain, passwd) if time.Since(ts) < a.AuthDuration { t.Errorf("auth on %v was too fast", c) } if ok != expect { t.Errorf("auth on %v: got %v, expected %v", c, ok, expect) } if err != nil { t.Errorf("auth on %v: got error %v", c, err) } ok, err = a.Exists(user, domain) if ok != expect { t.Errorf("exists on %v: got %v, expected %v", c, ok, expect) } if err != nil { t.Errorf("exists on %v: error %v", c, err) } } func TestInterfaces(t *testing.T) { var _ NoErrorBackend = userdb.New("/dev/null") var _ Backend = dovecot.NewAuth("/dev/null", "/dev/null") } // Backend implementation for testing. type TestBE struct { users map[string]string reloadCount int nextError error } func NewTestBE() *TestBE { return &TestBE{ users: map[string]string{}, } } func (d *TestBE) add(user, password string) { d.users[user] = password } func (d *TestBE) Authenticate(user, password string) (bool, error) { if d.nextError != nil { return false, d.nextError } if validP, ok := d.users[user]; ok { return validP == password, nil } return false, nil } func (d *TestBE) Exists(user string) (bool, error) { if d.nextError != nil { return false, d.nextError } _, ok := d.users[user] return ok, nil } func (d *TestBE) Reload() error { d.reloadCount++ if d.nextError != nil { return d.nextError } return nil } func TestMultipleBackends(t *testing.T) { domain1 := NewTestBE() domain2 := NewTestBE() fallback := NewTestBE() a := NewAuthenticator() a.Register("domain1", domain1) a.Register("domain2", domain2) a.Fallback = fallback // Shorten the duration to speed up the test. This should still be long // enough for it to fail if we don't sleep intentionally. a.AuthDuration = 20 * time.Millisecond domain1.add("user1", "passwd1") domain2.add("user2", "passwd2") fallback.add("user3@fallback", "passwd3") fallback.add("user4@domain1", "passwd4") // Successful tests. cases := []struct{ user, domain, password string }{ {"user1", "domain1", "passwd1"}, {"user2", "domain2", "passwd2"}, {"user3", "fallback", "passwd3"}, {"user4", "domain1", "passwd4"}, } for _, c := range cases { check(t, a, c.user, c.domain, c.password, true) } // Unsuccessful tests (users don't exist). cases = []struct{ user, domain, password string }{ {"nobody", "domain1", "p"}, {"nobody", "domain2", "p"}, {"nobody", "fallback", "p"}, {"user3", "", "p"}, } for _, c := range cases { check(t, a, c.user, c.domain, c.password, false) } } func TestErrors(t *testing.T) { be := NewTestBE() be.add("user", "passwd") a := NewAuthenticator() a.Register("domain", be) a.AuthDuration = 0 ok, err := a.Authenticate("user", "domain", "passwd") if err != nil || !ok { t.Fatalf("failed auth") } expectedErr := fmt.Errorf("test error") be.nextError = expectedErr ok, err = a.Authenticate("user", "domain", "passwd") if ok { t.Errorf("authentication succeeded, expected error") } if err != expectedErr { t.Errorf("expected error, got %v", err) } ok, err = a.Exists("user", "domain") if ok { t.Errorf("exists succeeded, expected error") } if err != expectedErr { t.Errorf("expected error, got %v", err) } } func TestReload(t *testing.T) { be1 := NewTestBE() be2 := NewTestBE() fallback := NewTestBE() a := NewAuthenticator() a.Register("domain1", be1) a.Register("domain2", be2) a.Fallback = fallback err := a.Reload() if err != nil { t.Errorf("unexpected error reloading: %v", err) } if be1.reloadCount != 1 || be2.reloadCount != 1 || fallback.reloadCount != 1 { t.Errorf("unexpected reload counts: %d %d %d != 1 1 1", be1.reloadCount, be2.reloadCount, fallback.reloadCount) } be2.nextError = fmt.Errorf("test error") err = a.Reload() if err == nil { t.Errorf("expected error reloading, got nil") } if be1.reloadCount != 2 || be2.reloadCount != 2 || fallback.reloadCount != 2 { t.Errorf("unexpected reload counts: %d %d %d != 2 2 2", be1.reloadCount, be2.reloadCount, fallback.reloadCount) } a2 := NewAuthenticator() a2.Register("domain", WrapNoErrorBackend(userdb.New("/dev/null"))) if err = a2.Reload(); err != nil { t.Errorf("unexpected error reloading wrapped backend: %v", err) } } chasquid-1.2/internal/auth/fuzz.go000066400000000000000000000004361357247226300172750ustar00rootroot00000000000000// Fuzz testing for package aliases. // +build gofuzz package auth func Fuzz(data []byte) int { // user, domain, passwd, err := DecodeResponse(string(data)) interesting := 0 _, _, _, err := DecodeResponse(string(data)) if err == nil { interesting = 1 } return interesting } chasquid-1.2/internal/auth/testdata/000077500000000000000000000000001357247226300175565ustar00rootroot00000000000000chasquid-1.2/internal/auth/testdata/fuzz/000077500000000000000000000000001357247226300205545ustar00rootroot00000000000000chasquid-1.2/internal/auth/testdata/fuzz/corpus/000077500000000000000000000000001357247226300220675ustar00rootroot00000000000000chasquid-1.2/internal/auth/testdata/fuzz/corpus/t-001000066400000000000000000000000201357247226300225430ustar00rootroot00000000000000dUBkAHVAZABwYXNzchasquid-1.2/internal/auth/testdata/fuzz/corpus/t-002000066400000000000000000000000141357247226300225470ustar00rootroot00000000000000dUBkAABwYXNzchasquid-1.2/internal/auth/testdata/fuzz/corpus/t-003000066400000000000000000000000141357247226300225500ustar00rootroot00000000000000AHVAZABwYXNzchasquid-1.2/internal/auth/testdata/fuzz/corpus/t-004000066400000000000000000000000201357247226300225460ustar00rootroot00000000000000dUBkAABwYXNz/w==chasquid-1.2/internal/auth/testdata/fuzz/corpus/t-005000066400000000000000000000000401357247226300225510ustar00rootroot00000000000000w7FhY2FAw7FlcXVlAABjbGF2YXLDqQ==chasquid-1.2/internal/auth/testdata/fuzz/corpus/t-006000066400000000000000000000000321357247226300225530ustar00rootroot00000000000000this is not base64 encodedchasquid-1.2/internal/auth/testdata/fuzz/corpus/x-001000066400000000000000000000000521357247226300225540ustar00rootroot00000000000000̥̥̥̥̥̥ͯ̈́̈́̈́̈́̈́ͯ̈́̈́̈́̈́̈́̈́@chasquid-1.2/internal/auth/testdata/fuzz/corpus/x-002000066400000000000000000000000541357247226300225570ustar00rootroot00000000000000̥̥̥̥̥̥̥̈́ͥ̈́̈́̈́̈́̈́̈́̈́̈́̈́ͥ̓@chasquid-1.2/internal/auth/testdata/fuzz/corpus/x-003000066400000000000000000000000521357247226300225560ustar00rootroot00000000000000̥̥̥̥̥̥̈́̈́̈́̈́̈́ͯ̈́̈́̈́̈́̈́̈́̈́@chasquid-1.2/internal/auth/testdata/fuzz/corpus/x-004000066400000000000000000000000551357247226300225620ustar00rootroot00000000000000@̥̥̥̥̥̥̄̈́̈́̈́̈́̈́̈́̈́̈́ͥ̈́̈́ͥ̓chasquid-1.2/internal/config/000077500000000000000000000000001357247226300162515ustar00rootroot00000000000000chasquid-1.2/internal/config/config.go000066400000000000000000000047221357247226300200520ustar00rootroot00000000000000// Package config implements the chasquid configuration. package config // Generate the config protobuf. //go:generate protoc --go_out=. config.proto import ( "io/ioutil" "os" "blitiri.com.ar/go/log" "github.com/golang/protobuf/proto" ) // Load the config from the given file. func Load(path string) (*Config, error) { c := &Config{} buf, err := ioutil.ReadFile(path) if err != nil { log.Errorf("Failed to read config at %q", path) log.Errorf(" (%v)", err) return nil, err } err = proto.UnmarshalText(string(buf), c) if err != nil { log.Errorf("Error parsing config: %v", err) return nil, err } // Fill in defaults for anything that's missing. if c.Hostname == "" { c.Hostname, err = os.Hostname() if err != nil { log.Errorf("Could not get hostname: %v", err) return nil, err } } if c.MaxDataSizeMb == 0 { c.MaxDataSizeMb = 50 } if len(c.SmtpAddress) == 0 { c.SmtpAddress = append(c.SmtpAddress, "systemd") } if len(c.SubmissionAddress) == 0 { c.SubmissionAddress = append(c.SubmissionAddress, "systemd") } if len(c.SubmissionOverTlsAddress) == 0 { c.SubmissionOverTlsAddress = append(c.SubmissionOverTlsAddress, "systemd") } if c.MailDeliveryAgentBin == "" { c.MailDeliveryAgentBin = "maildrop" } if len(c.MailDeliveryAgentArgs) == 0 { c.MailDeliveryAgentArgs = append(c.MailDeliveryAgentArgs, "-f", "%from%", "-d", "%to_user%") } if c.DataDir == "" { c.DataDir = "/var/lib/chasquid" } if c.SuffixSeparators == "" { c.SuffixSeparators = "+" } if c.DropCharacters == "" { c.DropCharacters = "." } if c.MailLogPath == "" { c.MailLogPath = "" } return c, nil } // LogConfig logs the given configuration, in a human-friendly way. func LogConfig(c *Config) { log.Infof("Configuration:") log.Infof(" Hostname: %q", c.Hostname) log.Infof(" Max data size (MB): %d", c.MaxDataSizeMb) log.Infof(" SMTP Addresses: %v", c.SmtpAddress) log.Infof(" Submission Addresses: %v", c.SubmissionAddress) log.Infof(" Submission+TLS Addresses: %v", c.SubmissionOverTlsAddress) log.Infof(" Monitoring address: %s", c.MonitoringAddress) log.Infof(" MDA: %s %v", c.MailDeliveryAgentBin, c.MailDeliveryAgentArgs) log.Infof(" Data directory: %s", c.DataDir) log.Infof(" Suffix separators: %s", c.SuffixSeparators) log.Infof(" Drop characters: %s", c.DropCharacters) log.Infof(" Mail log: %s", c.MailLogPath) log.Infof(" Dovecot auth: %v (%q, %q)", c.DovecotAuth, c.DovecotUserdbPath, c.DovecotClientPath) } chasquid-1.2/internal/config/config.pb.go000066400000000000000000000247521357247226300204570ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // source: config.proto package config import ( fmt "fmt" proto "github.com/golang/protobuf/proto" math "math" ) // Reference imports to suppress errors if they are not otherwise used. var _ = proto.Marshal var _ = fmt.Errorf var _ = math.Inf // This is a compile-time assertion to ensure that this generated file // is compatible with the proto package it is being compiled against. // A compilation error at this line likely means your copy of the // proto package needs to be updated. const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package type Config struct { // Default hostname to use when saying hello. // This is used to say hello to clients, for aesthetic purposes. // Default: the system's hostname. Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` // Maximum email size, in megabytes. // Default: 50. MaxDataSizeMb int64 `protobuf:"varint,2,opt,name=max_data_size_mb,json=maxDataSizeMb,proto3" json:"max_data_size_mb,omitempty"` // Addresses to listen on for SMTP (usually port 25). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=smtp". SmtpAddress []string `protobuf:"bytes,3,rep,name=smtp_address,json=smtpAddress,proto3" json:"smtp_address,omitempty"` // Addresses to listen on for submission (usually port 587). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=submission". SubmissionAddress []string `protobuf:"bytes,4,rep,name=submission_address,json=submissionAddress,proto3" json:"submission_address,omitempty"` // Addresses to listen on for submission-over-TLS (usually port 465). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=submission_tls". SubmissionOverTlsAddress []string `protobuf:"bytes,5,rep,name=submission_over_tls_address,json=submissionOverTlsAddress,proto3" json:"submission_over_tls_address,omitempty"` // Address for the monitoring http server. // Do NOT expose this to the public internet. // Default: no monitoring http server. MonitoringAddress string `protobuf:"bytes,6,opt,name=monitoring_address,json=monitoringAddress,proto3" json:"monitoring_address,omitempty"` // Mail delivery agent (MDA, also known as LDA) to use. // This should point to the binary to use to deliver email to local users. // The content of the email will be passed via stdin. // If it exits unsuccessfully, we assume the mail was not delivered. // Default: "maildrop". MailDeliveryAgentBin string `protobuf:"bytes,7,opt,name=mail_delivery_agent_bin,json=mailDeliveryAgentBin,proto3" json:"mail_delivery_agent_bin,omitempty"` // Command line arguments for the mail delivery agent. One per argument. // Some replacements will be done. // On an email sent from marsnik@mars to venera@venus: // - %from% -> from address (marsnik@mars) // - %from_user% -> from user (marsnik) // - %from_domain% -> from domain (mars) // - %to% -> to address (venera@venus) // - %to_user% -> to user (venera) // - %to_domain% -> to domain (venus) // // Default: "-f", "%from%", "-d", "%to_user%" (adequate for procmail // and maildrop). MailDeliveryAgentArgs []string `protobuf:"bytes,8,rep,name=mail_delivery_agent_args,json=mailDeliveryAgentArgs,proto3" json:"mail_delivery_agent_args,omitempty"` // Directory where we store our persistent data. // Default: "/var/lib/chasquid" DataDir string `protobuf:"bytes,9,opt,name=data_dir,json=dataDir,proto3" json:"data_dir,omitempty"` // Suffix separator, to perform suffix removal of local users. // For example, if you set this to "-+", email to local user // "user-blah" and "user+blah" will be delivered to "user". // Including "+" is strongly encouraged, as it is assumed for email // forwarding. // Default: "+". SuffixSeparators string `protobuf:"bytes,10,opt,name=suffix_separators,json=suffixSeparators,proto3" json:"suffix_separators,omitempty"` // Characters to drop from the user part on local emails. // For example, if you set this to "._", email to local user // "u.se_r" will be delivered to "user". // Default: ".". DropCharacters string `protobuf:"bytes,11,opt,name=drop_characters,json=dropCharacters,proto3" json:"drop_characters,omitempty"` // Path where to write the mail log to. // If "", log using the syslog (at MAIL|INFO priority). // Default: MailLogPath string `protobuf:"bytes,12,opt,name=mail_log_path,json=mailLogPath,proto3" json:"mail_log_path,omitempty"` // Enable dovecot authentication. // Domains that don't have an user database will be authenticated via // dovecot. DovecotAuth bool `protobuf:"varint,13,opt,name=dovecot_auth,json=dovecotAuth,proto3" json:"dovecot_auth,omitempty"` // Dovecot userdb path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-userdb DovecotUserdbPath string `protobuf:"bytes,14,opt,name=dovecot_userdb_path,json=dovecotUserdbPath,proto3" json:"dovecot_userdb_path,omitempty"` // Dovecot client path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-client DovecotClientPath string `protobuf:"bytes,15,opt,name=dovecot_client_path,json=dovecotClientPath,proto3" json:"dovecot_client_path,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } func (m *Config) Reset() { *m = Config{} } func (m *Config) String() string { return proto.CompactTextString(m) } func (*Config) ProtoMessage() {} func (*Config) Descriptor() ([]byte, []int) { return fileDescriptor_3eaf2c85e69e9ea4, []int{0} } func (m *Config) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_Config.Unmarshal(m, b) } func (m *Config) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { return xxx_messageInfo_Config.Marshal(b, m, deterministic) } func (m *Config) XXX_Merge(src proto.Message) { xxx_messageInfo_Config.Merge(m, src) } func (m *Config) XXX_Size() int { return xxx_messageInfo_Config.Size(m) } func (m *Config) XXX_DiscardUnknown() { xxx_messageInfo_Config.DiscardUnknown(m) } var xxx_messageInfo_Config proto.InternalMessageInfo func (m *Config) GetHostname() string { if m != nil { return m.Hostname } return "" } func (m *Config) GetMaxDataSizeMb() int64 { if m != nil { return m.MaxDataSizeMb } return 0 } func (m *Config) GetSmtpAddress() []string { if m != nil { return m.SmtpAddress } return nil } func (m *Config) GetSubmissionAddress() []string { if m != nil { return m.SubmissionAddress } return nil } func (m *Config) GetSubmissionOverTlsAddress() []string { if m != nil { return m.SubmissionOverTlsAddress } return nil } func (m *Config) GetMonitoringAddress() string { if m != nil { return m.MonitoringAddress } return "" } func (m *Config) GetMailDeliveryAgentBin() string { if m != nil { return m.MailDeliveryAgentBin } return "" } func (m *Config) GetMailDeliveryAgentArgs() []string { if m != nil { return m.MailDeliveryAgentArgs } return nil } func (m *Config) GetDataDir() string { if m != nil { return m.DataDir } return "" } func (m *Config) GetSuffixSeparators() string { if m != nil { return m.SuffixSeparators } return "" } func (m *Config) GetDropCharacters() string { if m != nil { return m.DropCharacters } return "" } func (m *Config) GetMailLogPath() string { if m != nil { return m.MailLogPath } return "" } func (m *Config) GetDovecotAuth() bool { if m != nil { return m.DovecotAuth } return false } func (m *Config) GetDovecotUserdbPath() string { if m != nil { return m.DovecotUserdbPath } return "" } func (m *Config) GetDovecotClientPath() string { if m != nil { return m.DovecotClientPath } return "" } func init() { proto.RegisterType((*Config)(nil), "Config") } func init() { proto.RegisterFile("config.proto", fileDescriptor_3eaf2c85e69e9ea4) } var fileDescriptor_3eaf2c85e69e9ea4 = []byte{ // 409 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x6c, 0x92, 0x41, 0x8f, 0x12, 0x31, 0x14, 0xc7, 0x83, 0xb8, 0x2c, 0x14, 0xd8, 0x5d, 0xaa, 0xc6, 0xaa, 0x17, 0xdc, 0xcb, 0x92, 0x18, 0xf7, 0x62, 0x8c, 0x27, 0x0f, 0x08, 0x47, 0x8d, 0x86, 0xd5, 0x73, 0xf3, 0x66, 0xa6, 0xcc, 0x34, 0x99, 0x69, 0x27, 0xef, 0x75, 0x08, 0xf2, 0x3d, 0xfc, 0xbe, 0xa6, 0x0f, 0x18, 0x30, 0xee, 0xb1, 0xff, 0xdf, 0xef, 0xdf, 0x76, 0xde, 0x54, 0x8c, 0x52, 0xef, 0xd6, 0x36, 0xbf, 0xaf, 0xd1, 0x07, 0x7f, 0xfb, 0xe7, 0x42, 0xf4, 0x16, 0x1c, 0xc8, 0xd7, 0xa2, 0x5f, 0x78, 0x0a, 0x0e, 0x2a, 0xa3, 0x3a, 0xd3, 0xce, 0x6c, 0xb0, 0x6a, 0xd7, 0xf2, 0x4e, 0xdc, 0x54, 0xb0, 0xd5, 0x19, 0x04, 0xd0, 0x64, 0x77, 0x46, 0x57, 0x89, 0x7a, 0x32, 0xed, 0xcc, 0xba, 0xab, 0x71, 0x05, 0xdb, 0x25, 0x04, 0x78, 0xb0, 0x3b, 0xf3, 0x2d, 0x91, 0x6f, 0xc5, 0x88, 0xaa, 0x50, 0x6b, 0xc8, 0x32, 0x34, 0x44, 0xaa, 0x3b, 0xed, 0xce, 0x06, 0xab, 0x61, 0xcc, 0xe6, 0xfb, 0x48, 0xbe, 0x17, 0x92, 0x9a, 0xa4, 0xb2, 0x44, 0xd6, 0xbb, 0x56, 0x7c, 0xca, 0xe2, 0xe4, 0x44, 0x8e, 0xfa, 0x67, 0xf1, 0xe6, 0x4c, 0xf7, 0x1b, 0x83, 0x3a, 0x94, 0xd4, 0xf6, 0x2e, 0xb8, 0xa7, 0x4e, 0xca, 0xf7, 0x8d, 0xc1, 0x9f, 0x25, 0x9d, 0x9d, 0x56, 0x79, 0x67, 0x83, 0x47, 0xeb, 0xf2, 0xb6, 0xd5, 0xe3, 0xef, 0x9b, 0x9c, 0xc8, 0x51, 0xff, 0x28, 0x5e, 0x56, 0x60, 0x4b, 0x9d, 0x99, 0xd2, 0x6e, 0x0c, 0xfe, 0xd6, 0x90, 0x1b, 0x17, 0x74, 0x62, 0x9d, 0xba, 0xe4, 0xce, 0xf3, 0x88, 0x97, 0x07, 0x3a, 0x8f, 0xf0, 0x8b, 0x75, 0xf2, 0x93, 0x50, 0x8f, 0xd5, 0x00, 0x73, 0x52, 0x7d, 0xbe, 0xe1, 0x8b, 0xff, 0x7a, 0x73, 0xcc, 0x49, 0xbe, 0x12, 0x7d, 0x1e, 0x6a, 0x66, 0x51, 0x0d, 0xf8, 0x80, 0xcb, 0xb8, 0x5e, 0x5a, 0x94, 0xef, 0xc4, 0x84, 0x9a, 0xf5, 0xda, 0x6e, 0x35, 0x99, 0x1a, 0x10, 0x82, 0x47, 0x52, 0x82, 0x9d, 0x9b, 0x3d, 0x78, 0x68, 0x73, 0x79, 0x27, 0xae, 0x33, 0xf4, 0xb5, 0x4e, 0x0b, 0x40, 0x48, 0x83, 0x41, 0x52, 0x43, 0x56, 0xaf, 0x62, 0xbc, 0x68, 0x53, 0x79, 0x2b, 0xc6, 0x7c, 0xd3, 0xd2, 0xe7, 0xba, 0x86, 0x50, 0xa8, 0x11, 0x6b, 0xc3, 0x18, 0x7e, 0xf5, 0xf9, 0x0f, 0x08, 0x45, 0xfc, 0x89, 0x99, 0xdf, 0x98, 0xd4, 0x07, 0x0d, 0x4d, 0x28, 0xd4, 0x78, 0xda, 0x99, 0xf5, 0x57, 0xc3, 0x43, 0x36, 0x6f, 0x42, 0x21, 0xef, 0xc5, 0xb3, 0xa3, 0xd2, 0x90, 0xc1, 0x2c, 0xd9, 0x6f, 0x76, 0xb5, 0x9f, 0xeb, 0x01, 0xfd, 0x62, 0xc2, 0x5b, 0x9e, 0xf9, 0x69, 0x69, 0xe3, 0x6c, 0xd8, 0xbf, 0xfe, 0xc7, 0x5f, 0x30, 0x89, 0x7e, 0xd2, 0xe3, 0xe7, 0xf9, 0xe1, 0x6f, 0x00, 0x00, 0x00, 0xff, 0xff, 0x37, 0xa3, 0x19, 0x18, 0xae, 0x02, 0x00, 0x00, } chasquid-1.2/internal/config/config.proto000066400000000000000000000064631357247226300206140ustar00rootroot00000000000000 syntax = "proto3"; message Config { // Default hostname to use when saying hello. // This is used to say hello to clients, for aesthetic purposes. // Default: the system's hostname. string hostname = 1; // Maximum email size, in megabytes. // Default: 50. int64 max_data_size_mb = 2; // Addresses to listen on for SMTP (usually port 25). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=smtp". repeated string smtp_address = 3; // Addresses to listen on for submission (usually port 587). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=submission". repeated string submission_address = 4; // Addresses to listen on for submission-over-TLS (usually port 465). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=submission_tls". repeated string submission_over_tls_address = 5; // Address for the monitoring http server. // Do NOT expose this to the public internet. // Default: no monitoring http server. string monitoring_address = 6; // Mail delivery agent (MDA, also known as LDA) to use. // This should point to the binary to use to deliver email to local users. // The content of the email will be passed via stdin. // If it exits unsuccessfully, we assume the mail was not delivered. // Default: "maildrop". string mail_delivery_agent_bin = 7; // Command line arguments for the mail delivery agent. One per argument. // Some replacements will be done. // On an email sent from marsnik@mars to venera@venus: // - %from% -> from address (marsnik@mars) // - %from_user% -> from user (marsnik) // - %from_domain% -> from domain (mars) // - %to% -> to address (venera@venus) // - %to_user% -> to user (venera) // - %to_domain% -> to domain (venus) // // Default: "-f", "%from%", "-d", "%to_user%" (adequate for procmail // and maildrop). repeated string mail_delivery_agent_args = 8; // Directory where we store our persistent data. // Default: "/var/lib/chasquid" string data_dir = 9; // Suffix separator, to perform suffix removal of local users. // For example, if you set this to "-+", email to local user // "user-blah" and "user+blah" will be delivered to "user". // Including "+" is strongly encouraged, as it is assumed for email // forwarding. // Default: "+". string suffix_separators = 10; // Characters to drop from the user part on local emails. // For example, if you set this to "._", email to local user // "u.se_r" will be delivered to "user". // Default: ".". string drop_characters = 11; // Path where to write the mail log to. // If "", log using the syslog (at MAIL|INFO priority). // Default: string mail_log_path = 12; // Enable dovecot authentication. // Domains that don't have an user database will be authenticated via // dovecot. bool dovecot_auth = 13; // Dovecot userdb path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-userdb string dovecot_userdb_path = 14; // Dovecot client path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-client string dovecot_client_path = 15; } chasquid-1.2/internal/config/config_test.go000066400000000000000000000055001357247226300211040ustar00rootroot00000000000000package config import ( "io" "io/ioutil" "os" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/log" ) func mustCreateConfig(t *testing.T, contents string) (string, string) { tmpDir := testlib.MustTempDir(t) confStr := []byte(contents) err := ioutil.WriteFile(tmpDir+"/chasquid.conf", confStr, 0600) if err != nil { t.Fatalf("Failed to write tmp config: %v", err) } return tmpDir, tmpDir + "/chasquid.conf" } func TestEmptyConfig(t *testing.T) { tmpDir, path := mustCreateConfig(t, "") defer testlib.RemoveIfOk(t, tmpDir) c, err := Load(path) if err != nil { t.Fatalf("error loading empty config: %v", err) } // Test the default values are set. hostname, _ := os.Hostname() if c.Hostname == "" || c.Hostname != hostname { t.Errorf("invalid hostname %q, should be: %q", c.Hostname, hostname) } if c.MaxDataSizeMb != 50 { t.Errorf("max data size != 50: %d", c.MaxDataSizeMb) } if len(c.SmtpAddress) != 1 || c.SmtpAddress[0] != "systemd" { t.Errorf("unexpected address default: %v", c.SmtpAddress) } if len(c.SubmissionAddress) != 1 || c.SubmissionAddress[0] != "systemd" { t.Errorf("unexpected address default: %v", c.SubmissionAddress) } if c.MonitoringAddress != "" { t.Errorf("monitoring address is set: %v", c.MonitoringAddress) } testLogConfig(c) } func TestFullConfig(t *testing.T) { confStr := ` hostname: "joust" smtp_address: ":1234" smtp_address: ":5678" monitoring_address: ":1111" max_data_size_mb: 26 ` tmpDir, path := mustCreateConfig(t, confStr) defer testlib.RemoveIfOk(t, tmpDir) c, err := Load(path) if err != nil { t.Fatalf("error loading non-existent config: %v", err) } if c.Hostname != "joust" { t.Errorf("hostname %q != 'joust'", c.Hostname) } if c.MaxDataSizeMb != 26 { t.Errorf("max data size != 26: %d", c.MaxDataSizeMb) } if len(c.SmtpAddress) != 2 || c.SmtpAddress[0] != ":1234" || c.SmtpAddress[1] != ":5678" { t.Errorf("different address: %v", c.SmtpAddress) } if c.MonitoringAddress != ":1111" { t.Errorf("monitoring address %q != ':1111;", c.MonitoringAddress) } testLogConfig(c) } func TestErrorLoading(t *testing.T) { c, err := Load("/does/not/exist") if err == nil { t.Fatalf("loaded a non-existent config: %v", c) } } func TestBrokenConfig(t *testing.T) { tmpDir, path := mustCreateConfig( t, " this is not a valid protobuf") defer testlib.RemoveIfOk(t, tmpDir) c, err := Load(path) if err == nil { t.Fatalf("loaded an invalid config: %v", c) } } // Run LogConfig, overriding the default logger first. This exercises the // code, we don't yet validate the output, but it is an useful sanity check. func testLogConfig(c *Config) { l := log.New(nopWCloser{ioutil.Discard}) log.Default = l LogConfig(c) } type nopWCloser struct { io.Writer } func (nopWCloser) Close() error { return nil } chasquid-1.2/internal/courier/000077500000000000000000000000001357247226300164545ustar00rootroot00000000000000chasquid-1.2/internal/courier/courier.go000066400000000000000000000006561357247226300204620ustar00rootroot00000000000000// Package courier implements various couriers for delivering messages. package courier // Courier delivers mail to a single recipient. // It is implemented by different couriers, for both local and remote // recipients. type Courier interface { // Deliver mail to a recipient. Return the error (if any), and whether it // is permanent (true) or transient (false). Deliver(from string, to string, data []byte) (error, bool) } chasquid-1.2/internal/courier/procmail.go000066400000000000000000000054631357247226300206210ustar00rootroot00000000000000package courier import ( "bytes" "context" "fmt" "os/exec" "strings" "syscall" "time" "unicode" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/trace" ) var ( errTimeout = fmt.Errorf("operation timed out") ) // Procmail delivers local mail by executing a local binary, like procmail or // maildrop. It is named after procmail just for reference, it works with any // binary that: // - Receives the email to deliver via stdin. // - Exits with code EX_TEMPFAIL (75) for transient issues. type Procmail struct { Binary string // Path to the binary. Args []string // Arguments to pass. Timeout time.Duration // Timeout for each invocation. } // Deliver an email. On failures, returns an error, and whether or not it is // permanent. func (p *Procmail) Deliver(from string, to string, data []byte) (error, bool) { tr := trace.New("Courier.Procmail", to) defer tr.Finish() // Sanitize, just in case. from = sanitizeForProcmail(from) to = sanitizeForProcmail(to) tr.Debugf("%s -> %s", from, to) // Prepare the command, replacing the necessary arguments. replacer := strings.NewReplacer( "%from%", from, "%from_user%", envelope.UserOf(from), "%from_domain%", envelope.DomainOf(from), "%to%", to, "%to_user%", envelope.UserOf(to), "%to_domain%", envelope.DomainOf(to), ) args := []string{} for _, a := range p.Args { args = append(args, replacer.Replace(a)) } tr.Debugf("%s %q", p.Binary, args) ctx, cancel := context.WithTimeout(context.Background(), p.Timeout) defer cancel() cmd := exec.CommandContext(ctx, p.Binary, args...) cmd.Stdin = bytes.NewReader(data) output, err := cmd.CombinedOutput() if ctx.Err() == context.DeadlineExceeded { return tr.Error(errTimeout), false } if err != nil { // Determine if the error is permanent or not. // Default to permanent, but error code 75 is transient by general // convention (/usr/include/sysexits.h), and commonly relied upon. permanent := true if exiterr, ok := err.(*exec.ExitError); ok { if status, ok := exiterr.Sys().(syscall.WaitStatus); ok { permanent = status.ExitStatus() != 75 } } err = tr.Errorf("procmail failed: %v - %q", err, string(output)) return err, permanent } tr.Debugf("delivered") return nil, false } // sanitizeForProcmail cleans the string, removing characters that could be // problematic considering we will run an external command. // // The server does not rely on this to do substitution or proper filtering, // that's done at a different layer; this is just for defense in depth. func sanitizeForProcmail(s string) string { valid := func(r rune) rune { switch { case unicode.IsSpace(r), unicode.IsControl(r), strings.ContainsRune("/;\"'\\|*&$%()[]{}`!", r): return rune(-1) default: return r } } return strings.Map(valid, s) } chasquid-1.2/internal/courier/procmail_test.go000066400000000000000000000064431357247226300216570ustar00rootroot00000000000000package courier import ( "bytes" "io/ioutil" "os" "testing" "time" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestProcmail(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) p := Procmail{ Binary: "tee", Args: []string{dir + "/%to_user%"}, Timeout: 1 * time.Minute, } err, _ := p.Deliver("from@x", "to@local", []byte("data")) if err != nil { t.Fatalf("Deliver: %v", err) } data, err := ioutil.ReadFile(dir + "/to") if err != nil || !bytes.Equal(data, []byte("data")) { t.Errorf("Invalid data: %q - %v", string(data), err) } } func TestProcmailTimeout(t *testing.T) { p := Procmail{"/bin/sleep", []string{"1"}, 100 * time.Millisecond} err, permanent := p.Deliver("from", "to@local", []byte("data")) if err != errTimeout { t.Errorf("Unexpected error: %v", err) } if permanent { t.Errorf("expected transient, got permanent") } } func TestProcmailBadCommandLine(t *testing.T) { // Non-existent binary. p := Procmail{"thisdoesnotexist", nil, 1 * time.Minute} err, permanent := p.Deliver("from", "to", []byte("data")) if err == nil { t.Errorf("unexpected success for non-existent binary") } if !permanent { t.Errorf("expected permanent, got transient") } // Incorrect arguments. p = Procmail{"cat", []string{"--fail_unknown_option"}, 1 * time.Minute} err, _ = p.Deliver("from", "to", []byte("data")) if err == nil { t.Errorf("unexpected success for incorrect arguments") } } // Test that local delivery failures are considered permanent or not // according to the exit code. func TestExitCode(t *testing.T) { // TODO: This can happen when building under unusual circumstances, such // as Debian package building. Are they reasonable enough for us to keep // this? if _, err := os.Stat("../../test/util/exitcode"); os.IsNotExist(err) { t.Skipf("util/exitcode not found, running from outside repo?") } cases := []struct { cmd string args []string expectPermanent bool }{ {"does/not/exist", nil, true}, {"../../test/util/exitcode", []string{"1"}, true}, {"../../test/util/exitcode", []string{"75"}, false}, } for _, c := range cases { p := &Procmail{c.cmd, c.args, 5 * time.Second} err, permanent := p.Deliver("from", "to", []byte("data")) if err == nil { t.Errorf("%q: pipe delivery worked, expected failure", c.cmd) } if c.expectPermanent != permanent { t.Errorf("%q: permanent expected=%v, got=%v", c.cmd, c.expectPermanent, permanent) } } } func TestSanitize(t *testing.T) { cases := []struct{ v, expected string }{ // These are the same. {"thisisfine", "thisisfine"}, {"ñaca", "ñaca"}, {"123-456_789", "123-456_789"}, {"123+456~789", "123+456~789"}, // These have problematic characters that get dropped. {"with spaces", "withspaces"}, {"with/slash", "withslash"}, {"quote';andsemicolon", "quoteandsemicolon"}, {"a;b", "ab"}, {`"test"`, "test"}, // Interesting cases taken from // http://www.user.uni-hannover.de/nhtcapri/bidirectional-text.html // We allow them, they're the same on both sides. {"١٩٩٩–١٢–٣١", "١٩٩٩–١٢–٣١"}, {"موزه‌ها", "موزه\u200cها"}, } for _, c := range cases { out := sanitizeForProcmail(c.v) if out != c.expected { t.Errorf("%q: expected %q, got %q", c.v, c.expected, out) } } } chasquid-1.2/internal/courier/smtp.go000066400000000000000000000172711357247226300177760ustar00rootroot00000000000000package courier import ( "context" "crypto/tls" "expvar" "flag" "net" "os" "time" "golang.org/x/net/idna" "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" ) var ( // Timeouts for SMTP delivery. smtpDialTimeout = 1 * time.Minute smtpTotalTimeout = 10 * time.Minute // Port for outgoing SMTP. // Tests can override this. smtpPort = flag.String("testing__outgoing_smtp_port", "25", "port to use for outgoing SMTP connections, ONLY FOR TESTING") // Allow overriding of net.LookupMX for testing purposes. // TODO: replace this with proper lookup interception once it is supported // by Go. netLookupMX = net.LookupMX ) // Exported variables. 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 STSCache *sts.PolicyCache } // Deliver an email. On failures, returns an error, and whether or not it is // permanent. func (s *SMTP) Deliver(from string, to string, data []byte) (error, bool) { a := &attempt{ courier: s, from: from, to: to, toDomain: envelope.DomainOf(to), data: data, tr: trace.New("Courier.SMTP", to), } defer a.tr.Finish() a.tr.Debugf("%s -> %s", from, to) // smtp.Client.Mail will add the <> for us when the address is empty. if a.from == "<>" { a.from = "" } mxs, err := lookupMXs(a.tr, a.toDomain) if err != nil || len(mxs) == 0 { // Note this is considered a permanent error. // This is in line with what other servers (Exim) do. However, the // downside is that temporary DNS issues can affect delivery, so we // have to make sure we try hard enough on the lookup above. return a.tr.Errorf("Could not find mail server: %v", err), true } // Issue an EHLO with a valid domain; otherwise, some servers like postfix // will complain. a.helloDomain, err = idna.ToASCII(envelope.DomainOf(from)) if err != nil { return a.tr.Errorf("Sender domain not IDNA compliant: %v", err), true } if a.helloDomain == "" { // This can happen when sending bounces. Last resort. 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 { return nil, false } if permanent { return err, true } a.tr.Errorf("%q returned transient error: %v", mx, err) } // We exhausted all MXs failed to deliver, try again later. return a.tr.Errorf("all MXs returned transient failures (last: %v)", err), false } type attempt struct { courier *SMTP from string to string data []byte toDomain string helloDomain string stsPolicy *sts.Policy tr *trace.Trace } func (a *attempt) deliver(mx string) (error, bool) { // Do we use insecure TLS? // Set as fallback when retrying. insecure := false secLevel := domaininfo.SecLevel_PLAIN retry: conn, err := net.DialTimeout("tcp", mx+":"+*smtpPort, smtpDialTimeout) if err != nil { return a.tr.Errorf("Could not dial: %v", err), false } defer conn.Close() conn.SetDeadline(time.Now().Add(smtpTotalTimeout)) c, err := smtp.NewClient(conn, mx) if err != nil { return a.tr.Errorf("Error creating client: %v", err), false } if err = c.Hello(a.helloDomain); err != nil { return a.tr.Errorf("Error saying hello: %v", err), false } if ok, _ := c.Extension("STARTTLS"); ok { config := &tls.Config{ ServerName: mx, InsecureSkipVerify: insecure, } err = c.StartTLS(config) if err != nil { // Unfortunately, many servers use self-signed certs, so if we // fail verification we just try again without validating. if insecure { tlsCount.Add("tls:failed", 1) return a.tr.Errorf("TLS error: %v", err), false } insecure = true a.tr.Debugf("TLS error, retrying insecurely") goto retry } if config.InsecureSkipVerify { a.tr.Debugf("Insecure - using TLS, but cert does not match %s", mx) tlsCount.Add("tls:insecure", 1) secLevel = domaininfo.SecLevel_TLS_INSECURE } else { tlsCount.Add("tls:secure", 1) a.tr.Debugf("Secure - using TLS") secLevel = domaininfo.SecLevel_TLS_SECURE } } else { tlsCount.Add("plain", 1) a.tr.Debugf("Insecure - NOT using TLS") } if !a.courier.Dinfo.OutgoingSecLevel(a.toDomain, secLevel) { // We consider the failure transient, so transient misconfigurations // do not affect deliveries. slcResults.Add("fail", 1) return a.tr.Errorf("Security level check failed (level:%s)", secLevel), false } slcResults.Add("pass", 1) if a.stsPolicy != nil && a.stsPolicy.Mode == sts.Enforce { // The connection MUST be validated by TLS. // https://tools.ietf.org/html/rfc8461#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) } w, err := c.Data() if err != nil { return a.tr.Errorf("DATA %v", err), smtp.IsPermanent(err) } _, err = w.Write(a.data) if err != nil { return a.tr.Errorf("DATA writing: %v", err), smtp.IsPermanent(err) } err = w.Close() if err != nil { return a.tr.Errorf("DATA closing %v", err), smtp.IsPermanent(err) } c.Quit() a.tr.Debugf("done") 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 { return nil, err } mxs := []string{} mxRecords, err := netLookupMX(domain) if err != nil { // There was an error. It could be that the domain has no MX, in which // case we have to fall back to A, or a bigger problem. // Unfortunately, go's API doesn't let us easily distinguish between // them. For now, if the error is permanent, we assume it's because // there was no MX and fall back, otherwise we return. // TODO: Find a better way to do this. dnsErr, ok := err.(*net.DNSError) if !ok { tr.Debugf("MX lookup error: %v", err) return nil, err } else if dnsErr.Temporary() { tr.Debugf("temporary DNS error: %v", dnsErr) return nil, err } // Permanent error, we assume MX does not exist and fall back to A. tr.Debugf("failed to resolve MX for %s, falling back to A", domain) mxs = []string{domain} } else { // Convert the DNS records to a plain string slice. They're already // sorted by priority. for _, r := range mxRecords { mxs = append(mxs, r.Host) } } // Note that mxs could be empty; in that case we do NOT fall back to A. // 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. if len(mxs) > 5 { mxs = mxs[:5] } tr.Debugf("MXs: %v", mxs) return mxs, nil } chasquid-1.2/internal/courier/smtp_test.go000066400000000000000000000152601357247226300210310ustar00rootroot00000000000000package courier import ( "bufio" "fmt" "net" "net/textproto" "strings" "sync" "testing" "time" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/trace" ) // This domain will cause idna.ToASCII to fail. var invalidDomain = "test " + strings.Repeat("x", 65536) + "\uff00" // Override the netLookupMX function, to return controlled results for // testing. var testMX = map[string][]*net.MX{} var testMXErr = map[string]error{} func init() { netLookupMX = func(name string) ([]*net.MX, error) { return testMX[name], testMXErr[name] } } func newSMTP(t *testing.T) (*SMTP, string) { dir := testlib.MustTempDir(t) dinfo, err := domaininfo.New(dir) if err != nil { t.Fatal(err) } return &SMTP{dinfo, nil}, dir } // Fake server, to test SMTP out. func fakeServer(t *testing.T, responses map[string]string) (string, *sync.WaitGroup) { l, err := net.Listen("tcp", "localhost:0") if err != nil { t.Fatalf("fake server listen: %v", err) } wg := &sync.WaitGroup{} wg.Add(1) go func() { defer wg.Done() defer l.Close() c, err := l.Accept() if err != nil { t.Fatalf("fake server accept: %v", err) } defer c.Close() t.Logf("fakeServer got connection") r := textproto.NewReader(bufio.NewReader(c)) c.Write([]byte(responses["_welcome"])) for { line, err := r.ReadLine() if err != nil { t.Logf("fakeServer exiting: %v\n", err) return } t.Logf("fakeServer read: %q\n", line) c.Write([]byte(responses[line])) if line == "DATA" { _, err = r.ReadDotBytes() if err != nil { t.Logf("fakeServer exiting: %v\n", err) return } c.Write([]byte(responses["_DATA"])) } } }() return l.Addr().String(), wg } func TestSMTP(t *testing.T) { // Shorten the total timeout, so the test fails quickly if the protocol // gets stuck. smtpTotalTimeout = 5 * time.Second responses := map[string]string{ "_welcome": "220 welcome\n", "EHLO me": "250 ehlo ok\n", "MAIL FROM:": "250 mail ok\n", "RCPT TO:": "250 rcpt ok\n", "DATA": "354 send data\n", "_DATA": "250 data ok\n", "QUIT": "250 quit ok\n", } addr, wg := fakeServer(t, responses) host, port, _ := net.SplitHostPort(addr) // Put a non-existing host first, so we check that if the first host // doesn't work, we try with the rest. // The host we use is invalid, to avoid having to do an actual network // lookup whick makes the test more hermetic. This is a hack, ideally we // would be able to override the default resolver, but Go does not // implement that yet. testMX["to"] = []*net.MX{ {Host: ":::", Pref: 10}, {Host: host, Pref: 20}, } *smtpPort = port s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) err, _ := s.Deliver("me@me", "to@to", []byte("data")) if err != nil { t.Errorf("deliver failed: %v", err) } wg.Wait() } func TestSMTPErrors(t *testing.T) { // Shorten the total timeout, so the test fails quickly if the protocol // gets stuck. smtpTotalTimeout = 1 * time.Second responses := []map[string]string{ // First test: hang response, should fail due to timeout. { "_welcome": "220 no newline", }, // MAIL FROM not allowed. { "_welcome": "220 mail from not allowed\n", "EHLO me": "250 ehlo ok\n", "MAIL FROM:": "501 mail error\n", }, // RCPT TO not allowed. { "_welcome": "220 rcpt to not allowed\n", "EHLO me": "250 ehlo ok\n", "MAIL FROM:": "250 mail ok\n", "RCPT TO:": "501 rcpt error\n", }, // DATA error. { "_welcome": "220 data error\n", "EHLO me": "250 ehlo ok\n", "MAIL FROM:": "250 mail ok\n", "RCPT TO:": "250 rcpt ok\n", "DATA": "554 data error\n", }, // DATA response error. { "_welcome": "220 data response error\n", "EHLO me": "250 ehlo ok\n", "MAIL FROM:": "250 mail ok\n", "RCPT TO:": "250 rcpt ok\n", "DATA": "354 send data\n", "_DATA": "551 data response error\n", }, } for _, rs := range responses { addr, wg := fakeServer(t, rs) host, port, _ := net.SplitHostPort(addr) testMX["to"] = []*net.MX{{Host: host, Pref: 10}} *smtpPort = port s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) err, _ := s.Deliver("me@me", "to@to", []byte("data")) if err == nil { t.Errorf("deliver not failed in case %q: %v", rs["_welcome"], err) } t.Logf("failed as expected: %v", err) wg.Wait() } } func TestNoMXServer(t *testing.T) { testMX["to"] = []*net.MX{} s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) err, permanent := s.Deliver("me@me", "to@to", []byte("data")) if err == nil { t.Errorf("delivery worked, expected failure") } if !permanent { t.Errorf("expected permanent failure, got transient (%v)", err) } t.Logf("got permanent failure, as expected: %v", err) } func TestTooManyMX(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = []*net.MX{ {Host: "h1", Pref: 10}, {Host: "h2", Pref: 20}, {Host: "h3", Pref: 30}, {Host: "h4", Pref: 40}, {Host: "h5", Pref: 50}, {Host: "h5", Pref: 60}, } mxs, err := lookupMXs(tr, "domain") if err != nil { t.Fatalf("unexpected error: %v", err) } if len(mxs) != 5 { t.Errorf("expected len(mxs) == 5, got: %v", mxs) } } func TestFallbackToA(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = nil testMXErr["domain"] = &net.DNSError{ Err: "no such host (test)", IsTemporary: false, } mxs, err := lookupMXs(tr, "domain") if err != nil { t.Fatalf("unexpected error: %v", err) } if !(len(mxs) == 1 && mxs[0] == "domain") { t.Errorf("expected mxs == [domain], got: %v", mxs) } } func TestTemporaryDNSerror(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = nil testMXErr["domain"] = &net.DNSError{ Err: "temp error (test)", IsTemporary: true, } mxs, err := lookupMXs(tr, "domain") if !(mxs == nil && err == testMXErr["domain"]) { t.Errorf("expected mxs == nil, err == test error, got: %v, %v", mxs, err) } } func TestMXLookupError(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = nil testMXErr["domain"] = fmt.Errorf("test error") mxs, err := lookupMXs(tr, "domain") if !(mxs == nil && err == testMXErr["domain"]) { t.Errorf("expected mxs == nil, err == test error, got: %v, %v", mxs, err) } } func TestLookupInvalidDomain(t *testing.T) { tr := trace.New("test", "test") mxs, err := lookupMXs(tr, invalidDomain) if !(mxs == nil && err != nil) { t.Errorf("expected err != nil, got: %v, %v", mxs, err) } } // TODO: Test STARTTLS negotiation. chasquid-1.2/internal/domaininfo/000077500000000000000000000000001357247226300171275ustar00rootroot00000000000000chasquid-1.2/internal/domaininfo/domaininfo.go000066400000000000000000000062461357247226300216110ustar00rootroot00000000000000// Package domaininfo implements a domain information database, to keep track // of things we know about a particular domain. package domaininfo import ( "fmt" "sync" "blitiri.com.ar/go/chasquid/internal/protoio" "blitiri.com.ar/go/chasquid/internal/trace" ) // Command to generate domaininfo.pb.go. //go:generate protoc --go_out=. domaininfo.proto // DB represents the persistent domain information database. type DB struct { // Persistent store with the list of domains we know. store *protoio.Store info map[string]*Domain sync.Mutex ev *trace.EventLog } // New opens a domain information database on the given dir, creating it if // necessary. The returned database will not be loaded. func New(dir string) (*DB, error) { st, err := protoio.NewStore(dir) if err != nil { return nil, err } l := &DB{ store: st, info: map[string]*Domain{}, } l.ev = trace.NewEventLog("DomainInfo", dir) err = l.Reload() if err != nil { return nil, err } return l, nil } // Reload the database from disk. func (db *DB) Reload() error { db.Lock() defer db.Unlock() // Clear the map, in case it has data. db.info = map[string]*Domain{} ids, err := db.store.ListIDs() if err != nil { return err } for _, id := range ids { d := &Domain{} _, err := db.store.Get(id, d) if err != nil { return fmt.Errorf("error loading %q: %v", id, err) } db.info[d.Name] = d } db.ev.Debugf("loaded %d domains", len(ids)) return nil } func (db *DB) write(d *Domain) { err := db.store.Put(d.Name, d) if err != nil { db.ev.Errorf("%s error saving: %v", d.Name, err) } else { db.ev.Debugf("%s saved", d.Name) } } // IncomingSecLevel checks an incoming security level for the domain. // Returns true if allowed, false otherwise. func (db *DB) IncomingSecLevel(domain string, level SecLevel) bool { db.Lock() defer db.Unlock() d, exists := db.info[domain] if !exists { d = &Domain{Name: domain} db.info[domain] = d defer db.write(d) } if level < d.IncomingSecLevel { db.ev.Errorf("%s incoming denied: %s < %s", d.Name, level, d.IncomingSecLevel) return false } else if level == d.IncomingSecLevel { db.ev.Debugf("%s incoming allowed: %s == %s", d.Name, level, d.IncomingSecLevel) return true } else { db.ev.Printf("%s incoming level raised: %s > %s", d.Name, level, d.IncomingSecLevel) d.IncomingSecLevel = level if exists { defer db.write(d) } return true } } // OutgoingSecLevel checks an incoming security level for the domain. // Returns true if allowed, false otherwise. func (db *DB) OutgoingSecLevel(domain string, level SecLevel) bool { db.Lock() defer db.Unlock() d, exists := db.info[domain] if !exists { d = &Domain{Name: domain} db.info[domain] = d defer db.write(d) } if level < d.OutgoingSecLevel { db.ev.Errorf("%s outgoing denied: %s < %s", d.Name, level, d.OutgoingSecLevel) return false } else if level == d.OutgoingSecLevel { db.ev.Debugf("%s outgoing allowed: %s == %s", d.Name, level, d.OutgoingSecLevel) return true } else { db.ev.Printf("%s outgoing level raised: %s > %s", d.Name, level, d.OutgoingSecLevel) d.OutgoingSecLevel = level if exists { defer db.write(d) } return true } } chasquid-1.2/internal/domaininfo/domaininfo.pb.go000066400000000000000000000111061357247226300222000ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // source: domaininfo.proto package domaininfo import ( fmt "fmt" proto "github.com/golang/protobuf/proto" math "math" ) // Reference imports to suppress errors if they are not otherwise used. var _ = proto.Marshal var _ = fmt.Errorf var _ = math.Inf // This is a compile-time assertion to ensure that this generated file // is compatible with the proto package it is being compiled against. // A compilation error at this line likely means your copy of the // proto package needs to be updated. const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package type SecLevel int32 const ( // Does not do TLS. SecLevel_PLAIN SecLevel = 0 // TLS client connection (no certificate validation). SecLevel_TLS_CLIENT SecLevel = 1 // TLS, but with invalid certificates. SecLevel_TLS_INSECURE SecLevel = 2 // TLS, with valid certificates. SecLevel_TLS_SECURE SecLevel = 3 ) var SecLevel_name = map[int32]string{ 0: "PLAIN", 1: "TLS_CLIENT", 2: "TLS_INSECURE", 3: "TLS_SECURE", } var SecLevel_value = map[string]int32{ "PLAIN": 0, "TLS_CLIENT": 1, "TLS_INSECURE": 2, "TLS_SECURE": 3, } func (x SecLevel) String() string { return proto.EnumName(SecLevel_name, int32(x)) } func (SecLevel) EnumDescriptor() ([]byte, []int) { return fileDescriptor_622326b6f7a15daa, []int{0} } type Domain struct { Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Security level for mail coming from this domain (they send to us). IncomingSecLevel SecLevel `protobuf:"varint,2,opt,name=incoming_sec_level,json=incomingSecLevel,proto3,enum=domaininfo.SecLevel" json:"incoming_sec_level,omitempty"` // Security level for mail going to this domain (we send to them). OutgoingSecLevel SecLevel `protobuf:"varint,3,opt,name=outgoing_sec_level,json=outgoingSecLevel,proto3,enum=domaininfo.SecLevel" json:"outgoing_sec_level,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } func (m *Domain) Reset() { *m = Domain{} } func (m *Domain) String() string { return proto.CompactTextString(m) } func (*Domain) ProtoMessage() {} func (*Domain) Descriptor() ([]byte, []int) { return fileDescriptor_622326b6f7a15daa, []int{0} } func (m *Domain) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_Domain.Unmarshal(m, b) } func (m *Domain) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { return xxx_messageInfo_Domain.Marshal(b, m, deterministic) } func (m *Domain) XXX_Merge(src proto.Message) { xxx_messageInfo_Domain.Merge(m, src) } func (m *Domain) XXX_Size() int { return xxx_messageInfo_Domain.Size(m) } func (m *Domain) XXX_DiscardUnknown() { xxx_messageInfo_Domain.DiscardUnknown(m) } var xxx_messageInfo_Domain proto.InternalMessageInfo func (m *Domain) GetName() string { if m != nil { return m.Name } return "" } func (m *Domain) GetIncomingSecLevel() SecLevel { if m != nil { return m.IncomingSecLevel } return SecLevel_PLAIN } func (m *Domain) GetOutgoingSecLevel() SecLevel { if m != nil { return m.OutgoingSecLevel } return SecLevel_PLAIN } func init() { proto.RegisterEnum("domaininfo.SecLevel", SecLevel_name, SecLevel_value) proto.RegisterType((*Domain)(nil), "domaininfo.Domain") } func init() { proto.RegisterFile("domaininfo.proto", fileDescriptor_622326b6f7a15daa) } var fileDescriptor_622326b6f7a15daa = []byte{ // 190 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x48, 0xc9, 0xcf, 0x4d, 0xcc, 0xcc, 0xcb, 0xcc, 0x4b, 0xcb, 0xd7, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0xe2, 0x42, 0x88, 0x28, 0x2d, 0x61, 0xe4, 0x62, 0x73, 0x01, 0x73, 0x85, 0x84, 0xb8, 0x58, 0xf2, 0x12, 0x73, 0x53, 0x25, 0x18, 0x15, 0x18, 0x35, 0x38, 0x83, 0xc0, 0x6c, 0x21, 0x27, 0x2e, 0xa1, 0xcc, 0xbc, 0xe4, 0xfc, 0xdc, 0xcc, 0xbc, 0xf4, 0xf8, 0xe2, 0xd4, 0xe4, 0xf8, 0x9c, 0xd4, 0xb2, 0xd4, 0x1c, 0x09, 0x26, 0x05, 0x46, 0x0d, 0x3e, 0x23, 0x11, 0x3d, 0x24, 0x93, 0x83, 0x53, 0x93, 0x7d, 0x40, 0x72, 0x41, 0x02, 0x30, 0xf5, 0x30, 0x11, 0x90, 0x19, 0xf9, 0xa5, 0x25, 0xe9, 0xf9, 0xa8, 0x66, 0x30, 0xe3, 0x33, 0x03, 0xa6, 0x1e, 0x26, 0xa2, 0xe5, 0xce, 0xc5, 0x01, 0x37, 0x8f, 0x93, 0x8b, 0x35, 0xc0, 0xc7, 0xd1, 0xd3, 0x4f, 0x80, 0x41, 0x88, 0x8f, 0x8b, 0x2b, 0xc4, 0x27, 0x38, 0xde, 0xd9, 0xc7, 0xd3, 0xd5, 0x2f, 0x44, 0x80, 0x51, 0x48, 0x80, 0x8b, 0x07, 0xc4, 0xf7, 0xf4, 0x0b, 0x76, 0x75, 0x0e, 0x0d, 0x72, 0x15, 0x60, 0x82, 0xa9, 0x80, 0xf2, 0x99, 0x93, 0xd8, 0xc0, 0x41, 0x60, 0x0c, 0x08, 0x00, 0x00, 0xff, 0xff, 0x2c, 0x78, 0x65, 0x5b, 0x16, 0x01, 0x00, 0x00, } chasquid-1.2/internal/domaininfo/domaininfo.proto000066400000000000000000000010151357247226300223340ustar00rootroot00000000000000 syntax = "proto3"; package domaininfo; enum SecLevel { // Does not do TLS. PLAIN = 0; // TLS client connection (no certificate validation). TLS_CLIENT = 1; // TLS, but with invalid certificates. TLS_INSECURE = 2; // TLS, with valid certificates. TLS_SECURE = 3; } message Domain { string name = 1; // Security level for mail coming from this domain (they send to us). SecLevel incoming_sec_level = 2; // Security level for mail going to this domain (we send to them). SecLevel outgoing_sec_level = 3; } chasquid-1.2/internal/domaininfo/domaininfo_test.go000066400000000000000000000056141357247226300226460ustar00rootroot00000000000000package domaininfo import ( "testing" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestBasic(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } if !db.IncomingSecLevel("d1", SecLevel_PLAIN) { t.Errorf("new domain as plain not allowed") } if !db.IncomingSecLevel("d1", SecLevel_TLS_SECURE) { t.Errorf("increment to tls-secure not allowed") } if db.IncomingSecLevel("d1", SecLevel_TLS_INSECURE) { t.Errorf("decrement to tls-insecure was allowed") } // Check that it was added to the store and a new db sees it. db2, err := New(dir) if err != nil { t.Fatal(err) } if db2.IncomingSecLevel("d1", SecLevel_TLS_INSECURE) { t.Errorf("decrement to tls-insecure was allowed in new DB") } } func TestNewDomain(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } cases := []struct { domain string level SecLevel }{ {"plain", SecLevel_PLAIN}, {"insecure", SecLevel_TLS_INSECURE}, {"secure", SecLevel_TLS_SECURE}, } for _, c := range cases { if !db.IncomingSecLevel(c.domain, c.level) { t.Errorf("domain %q not allowed (in) at %s", c.domain, c.level) } if !db.OutgoingSecLevel(c.domain, c.level) { t.Errorf("domain %q not allowed (out) at %s", c.domain, c.level) } } } func TestProgressions(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } cases := []struct { domain string lvl SecLevel ok bool }{ {"pisis", SecLevel_PLAIN, true}, {"pisis", SecLevel_TLS_INSECURE, true}, {"pisis", SecLevel_TLS_SECURE, true}, {"pisis", SecLevel_TLS_INSECURE, false}, {"pisis", SecLevel_TLS_SECURE, true}, {"ssip", SecLevel_TLS_SECURE, true}, {"ssip", SecLevel_TLS_SECURE, true}, {"ssip", SecLevel_TLS_INSECURE, false}, {"ssip", SecLevel_PLAIN, false}, } for i, c := range cases { if ok := db.IncomingSecLevel(c.domain, c.lvl); ok != c.ok { t.Errorf("%2d %q in attempt for %s failed: got %v, expected %v", i, c.domain, c.lvl, ok, c.ok) } if ok := db.OutgoingSecLevel(c.domain, c.lvl); ok != c.ok { t.Errorf("%2d %q out attempt for %s failed: got %v, expected %v", i, c.domain, c.lvl, ok, c.ok) } } } func TestErrors(t *testing.T) { // Non-existent directory. _, err := New("/doesnotexists") if err == nil { t.Error("could create a DB on a non-existent directory") } // Corrupt/invalid file. dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } if !db.IncomingSecLevel("d1", SecLevel_TLS_SECURE) { t.Errorf("increment to tls-secure not allowed") } testlib.Rewrite(t, dir+"/s:d1", "invalid-text-protobuf-contents") err = db.Reload() if err == nil { t.Errorf("no error when reloading db with invalid file") } } chasquid-1.2/internal/dovecot/000077500000000000000000000000001357247226300164475ustar00rootroot00000000000000chasquid-1.2/internal/dovecot/dovecot.go000066400000000000000000000157761357247226300204610ustar00rootroot00000000000000// Package dovecot implements functions to interact with Dovecot's // authentication service. // // In particular, it supports doing user authorization, and checking if a user // exists. It is a very basic implementation, with only the minimum needed to // cover chasquid's needs. // // https://wiki.dovecot.org/Design/AuthProtocol // https://wiki.dovecot.org/Services#auth package dovecot import ( "encoding/base64" "errors" "fmt" "net" "net/textproto" "os" "strings" "time" "unicode" ) // DefaultTimeout to use. We expect Dovecot to be quite fast, but don't want // to hang forever if something gets stuck. const DefaultTimeout = 5 * time.Second var ( errUsernameNotSafe = errors.New("username not safe (contains spaces)") ) var defaultUserdbPaths = []string{ "/var/run/dovecot/auth-chasquid-userdb", "/var/run/dovecot/auth-userdb", } var defaultClientPaths = []string{ "/var/run/dovecot/auth-chasquid-client", "/var/run/dovecot/auth-client", } // Auth represents a particular Dovecot auth service to use. type Auth struct { userdbAddr string clientAddr string // Timeout for connection and I/O operations (applies on each call). // Set to DefaultTimeout by NewAuth. Timeout time.Duration } // NewAuth returns a new connection against Dovecot authentication service. It // takes the addresses of userdb and client sockets (usually paths as // configured in dovecot). func NewAuth(userdb, client string) *Auth { return &Auth{ userdbAddr: userdb, clientAddr: client, Timeout: DefaultTimeout, } } // String representation of this Auth, for human consumption. func (a *Auth) String() string { return fmt.Sprintf("DovecotAuth(%q, %q)", a.userdbAddr, a.clientAddr) } // Check to see if this auth is valid (but may not be working). func (a *Auth) Check() error { // We intentionally don't connect or complete any handshakes because // dovecot may not be up yet, even thought it may be configured properly. // Just check that the addresses are valid sockets. if !isUnixSocket(a.userdbAddr) { return fmt.Errorf("userdb is not an unix socket") } if !isUnixSocket(a.clientAddr) { return fmt.Errorf("client is not an unix socket") } return nil } // Exists returns true if the user exists, false otherwise. func (a *Auth) Exists(user string) (bool, error) { if !isUsernameSafe(user) { return false, errUsernameNotSafe } conn, err := a.dial("unix", a.userdbAddr) if err != nil { return false, err } defer conn.Close() // Dovecot greets us with version and server pid. // VERSION\t\t // SPID\t err = expect(conn, "VERSION\t1") if err != nil { return false, fmt.Errorf("error receiving version: %v", err) } err = expect(conn, "SPID\t") if err != nil { return false, fmt.Errorf("error receiving SPID: %v", err) } // Send our version, and then the request. err = write(conn, "VERSION\t1\t1\n") if err != nil { return false, err } err = write(conn, fmt.Sprintf("USER\t1\t%s\tservice=smtp\n", user)) if err != nil { return false, err } // Get the response, and we're done. resp, err := conn.ReadLine() if err != nil { return false, fmt.Errorf("error receiving response: %v", err) } else if strings.HasPrefix(resp, "USER\t1\t") { return true, nil } else if strings.HasPrefix(resp, "NOTFOUND\t") { return false, nil } return false, fmt.Errorf("invalid response: %q", resp) } // Authenticate returns true if the password is valid for the user, false // otherwise. func (a *Auth) Authenticate(user, passwd string) (bool, error) { if !isUsernameSafe(user) { return false, errUsernameNotSafe } conn, err := a.dial("unix", a.clientAddr) if err != nil { return false, err } defer conn.Close() // Send our version, and then our PID. err = write(conn, fmt.Sprintf("VERSION\t1\t1\nCPID\t%d\n", os.Getpid())) if err != nil { return false, err } // Read the server-side handshake. We don't care about the contents // really, so just read all lines until we see the DONE. for { resp, err := conn.ReadLine() if err != nil { return false, fmt.Errorf("error receiving handshake: %v", err) } if resp == "DONE" { break } } // We only support PLAIN authentication, so construct the request. // Note we set the "secured" option, with the assumpition that we got the // password via a secure channel (like TLS). This is always true for // chasquid by design, and simplifies the API. // TODO: does dovecot handle utf8 domains well? do we need to encode them // in IDNA first? resp := base64.StdEncoding.EncodeToString( []byte(fmt.Sprintf("%s\x00%s\x00%s", user, user, passwd))) err = write(conn, fmt.Sprintf( "AUTH\t1\tPLAIN\tservice=smtp\tsecured\tno-penalty\tnologin\tresp=%s\n", resp)) if err != nil { return false, err } // Get the response, and we're done. resp, err = conn.ReadLine() if err != nil { return false, fmt.Errorf("error receiving response: %v", err) } else if strings.HasPrefix(resp, "OK\t1") { return true, nil } else if strings.HasPrefix(resp, "FAIL\t1") { return false, nil } return false, fmt.Errorf("invalid response: %q", resp) } // Reload the authenticator. It's a no-op for dovecot, but it is needed to // conform with the auth.Backend interface. func (a *Auth) Reload() error { return nil } func (a *Auth) dial(network, addr string) (*textproto.Conn, error) { nc, err := net.DialTimeout(network, addr, a.Timeout) if err != nil { return nil, err } nc.SetDeadline(time.Now().Add(a.Timeout)) return textproto.NewConn(nc), nil } func expect(conn *textproto.Conn, prefix string) error { resp, err := conn.ReadLine() if err != nil { return err } if !strings.HasPrefix(resp, prefix) { return fmt.Errorf("got %q", resp) } return nil } func write(conn *textproto.Conn, msg string) error { _, err := conn.W.Write([]byte(msg)) if err != nil { return err } return conn.W.Flush() } // isUsernameSafe to use in the dovecot protocol? // Unfotunately dovecot's protocol is not very robust wrt. whitespace, // so we need to be careful. func isUsernameSafe(user string) bool { for _, r := range user { if unicode.IsSpace(r) { return false } } return true } // Autodetect where the dovecot authentication paths are, and return an Auth // instance for them. If any of userdb or client are != "", they will be used // and not autodetected. func Autodetect(userdb, client string) *Auth { // If both are given, no need to autodtect. if userdb != "" && client != "" { return NewAuth(userdb, client) } var userdbs, clients []string if userdb != "" { userdbs = append(userdbs, userdb) } if client != "" { clients = append(clients, client) } if len(userdbs) == 0 { userdbs = append(userdbs, defaultUserdbPaths...) } if len(clients) == 0 { clients = append(clients, defaultClientPaths...) } // Go through each possiblity, return the first auth that works. for _, u := range userdbs { for _, c := range clients { a := NewAuth(u, c) if a.Check() == nil { return a } } } return nil } func isUnixSocket(path string) bool { fi, err := os.Stat(path) if err != nil { return false } return fi.Mode()&os.ModeSocket != 0 } chasquid-1.2/internal/dovecot/dovecot_test.go000066400000000000000000000075131357247226300215060ustar00rootroot00000000000000package dovecot // The dovecot package is mainly tested via integration/external tests using // the dovecot-auth-cli tool. See cmd/dovecot-auth-cli for more details. // The tests here are more narrow and only test specific functionality that is // easier to cover from Go. import ( "net" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestUsernameNotSafe(t *testing.T) { a := NewAuth("/tmp/nothing", "/tmp/nothing") cases := []string{ "a b", " ab", "ab ", "a\tb", "a\t", " ", "\t", "\t "} for _, c := range cases { ok, err := a.Authenticate(c, "passwd") if ok || err != errUsernameNotSafe { t.Errorf("Authenticate(%q, _): got %v, %v", c, ok, err) } ok, err = a.Exists(c) if ok || err != errUsernameNotSafe { t.Errorf("Exists(%q): got %v, %v", c, ok, err) } } } func TestAutodetect(t *testing.T) { // If we give both parameters to autodetect, it should return a new Auth // using them, even if they're not valid. a := Autodetect("uDoesNotExist", "cDoesNotExist") if a == nil { t.Errorf("Autodetection with two params failed") } else if *a != *NewAuth("uDoesNotExist", "cDoesNotExist") { t.Errorf("Autodetection with two params: got %v", a) } // We override the default paths, so we can point the "defaults" to our // test environment as needed. defaultUserdbPaths = []string{"/dev/null"} defaultClientPaths = []string{"/dev/null"} // Autodetect failure: no valid sockets on the list. a = Autodetect("", "") if a != nil { t.Errorf("Autodetection worked with only /dev/null, got %v", a) } // Create a temporary directory, and two sockets on it. dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) userdb := dir + "/userdb" client := dir + "/client" uL := mustListen(t, userdb) cL := mustListen(t, client) defaultUserdbPaths = append(defaultUserdbPaths, userdb) defaultClientPaths = append(defaultClientPaths, client) // Autodetect should work fine against open sockets. a = Autodetect("", "") if a == nil { t.Errorf("Autodetection failed (open sockets)") } else if a.userdbAddr != userdb || a.clientAddr != client { t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}", userdb, client, a.userdbAddr, a.clientAddr) } // Close the two sockets, and re-do the test from above: Autodetect should // work fine against closed sockets. // We need to tell Go to keep the socket files around explicitly, as the // default is to delete them since they were creeated by the net library. uL.SetUnlinkOnClose(false) uL.Close() cL.SetUnlinkOnClose(false) cL.Close() a = Autodetect("", "") if a == nil { t.Errorf("Autodetection failed (closed sockets)") } else if a.userdbAddr != userdb || a.clientAddr != client { t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}", userdb, client, a.userdbAddr, a.clientAddr) } // Autodetect should pick the suggestions passed as parameters (if // possible). defaultUserdbPaths = []string{"/dev/null"} defaultClientPaths = []string{"/dev/null", client} a = Autodetect(userdb, "") if a == nil { t.Errorf("Autodetection failed (single parameter)") } else if a.userdbAddr != userdb || a.clientAddr != client { t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}", userdb, client, a.userdbAddr, a.clientAddr) } } func TestReload(t *testing.T) { // Make sure Reload does not fail. a := Auth{} if err := a.Reload(); err != nil { t.Errorf("Reload failed") } } func mustListen(t *testing.T, path string) *net.UnixListener { addr, err := net.ResolveUnixAddr("unix", path) if err != nil { t.Fatalf("failed to resolve unix addr %q: %v", path, err) } l, err := net.ListenUnix("unix", addr) if err != nil { t.Fatalf("failed to listen on %q: %v", path, err) } return l } func TestNotASocket(t *testing.T) { if isUnixSocket("/doesnotexist") { t.Errorf("isUnixSocket(/doesnotexist) returned true") } } chasquid-1.2/internal/envelope/000077500000000000000000000000001357247226300166215ustar00rootroot00000000000000chasquid-1.2/internal/envelope/envelope.go000066400000000000000000000023071357247226300207670ustar00rootroot00000000000000// Package envelope implements functions related to handling email envelopes // (basically tuples of (from, to, data). package envelope import ( "fmt" "strings" "blitiri.com.ar/go/chasquid/internal/set" ) // Split an user@domain address into user and domain. func Split(addr string) (string, string) { ps := strings.SplitN(addr, "@", 2) if len(ps) != 2 { return addr, "" } return ps[0], ps[1] } // UserOf user@domain returns user. func UserOf(addr string) string { user, _ := Split(addr) return user } // DomainOf user@domain returns domain. func DomainOf(addr string) string { _, domain := Split(addr) return domain } // DomainIn checks that the domain of the address is on the given set. func DomainIn(addr string, locals *set.String) bool { domain := DomainOf(addr) if domain == "" { return true } return locals.Has(domain) } // AddHeader adds (prepends) a MIME header to the message. func AddHeader(data []byte, k, v string) []byte { if len(v) > 0 { // If the value contains newlines, indent them properly. if v[len(v)-1] == '\n' { v = v[:len(v)-1] } v = strings.Replace(v, "\n", "\n\t", -1) } header := []byte(fmt.Sprintf("%s: %s\n", k, v)) return append(header, data...) } chasquid-1.2/internal/envelope/envelope_test.go000066400000000000000000000031601357247226300220240ustar00rootroot00000000000000package envelope import ( "testing" "blitiri.com.ar/go/chasquid/internal/set" ) func TestSplit(t *testing.T) { cases := []struct { addr, user, domain string }{ {"lalala@lelele", "lalala", "lelele"}, } for _, c := range cases { if user := UserOf(c.addr); user != c.user { t.Errorf("%q: expected user %q, got %q", c.addr, c.user, user) } if domain := DomainOf(c.addr); domain != c.domain { t.Errorf("%q: expected domain %q, got %q", c.addr, c.domain, domain) } } } func TestDomainIn(t *testing.T) { ls := set.NewString("domain1", "domain2") cases := []struct { addr string in bool }{ {"u@domain1", true}, {"u@domain2", true}, {"u@domain3", false}, {"u", true}, } for _, c := range cases { if in := DomainIn(c.addr, ls); in != c.in { t.Errorf("%q: expected %v, got %v", c.addr, c.in, in) } } } func TestAddHeader(t *testing.T) { cases := []struct { data, k, v, expected string }{ {"", "Key", "Value", "Key: Value\n"}, {"data", "Key", "Value", "Key: Value\ndata"}, {"data", "Key", "Value\n", "Key: Value\ndata"}, {"data", "Key", "L1\nL2", "Key: L1\n\tL2\ndata"}, {"data", "Key", "L1\nL2\n", "Key: L1\n\tL2\ndata"}, // Degenerate cases: we don't expect to ever produce these, and the // output is admittedly not nice, but they should at least not cause // chasquid to crash. {"data", "Key", "", "Key: \ndata"}, {"data", "", "", ": \ndata"}, {"", "", "", ": \n"}, } for i, c := range cases { got := string(AddHeader([]byte(c.data), c.k, c.v)) if got != c.expected { t.Errorf("%d (%q -> %q): expected %q, got %q", i, c.k, c.v, c.expected, got) } } } chasquid-1.2/internal/maillog/000077500000000000000000000000001357247226300164305ustar00rootroot00000000000000chasquid-1.2/internal/maillog/maillog.go000066400000000000000000000077731357247226300204210ustar00rootroot00000000000000// Package maillog implements a log specifically for email. package maillog import ( "fmt" "io" "io/ioutil" "log/syslog" "net" "sync" "time" "blitiri.com.ar/go/chasquid/internal/trace" "blitiri.com.ar/go/log" ) // Global event logs. var ( authLog = trace.NewEventLog("Authentication", "Incoming SMTP") ) // A writer that prepends timing information. type timedWriter struct { w io.Writer } // Write the given buffer, prepending timing information. func (t timedWriter) Write(b []byte) (int, error) { fmt.Fprintf(t.w, "%s ", time.Now().Format("2006-01-02 15:04:05.000000")) return t.w.Write(b) } // Logger contains a backend used to log data to, such as a file or syslog. // It implements various user-friendly methods for logging mail information to // it. type Logger struct { w io.Writer once sync.Once } // New creates a new Logger which will write messages to the given writer. func New(w io.Writer) *Logger { return &Logger{w: timedWriter{w}} } // NewSyslog creates a new Logger which will write messages to syslog. func NewSyslog() (*Logger, error) { w, err := syslog.New(syslog.LOG_INFO|syslog.LOG_MAIL, "chasquid") if err != nil { return nil, err } l := &Logger{w: w} return l, nil } func (l *Logger) printf(format string, args ...interface{}) { _, err := fmt.Fprintf(l.w, format, args...) if err != nil { l.once.Do(func() { log.Errorf("failed to write to maillog: %v", err) log.Errorf("(will not report this again)") }) } } // Listening logs that the daemon is listening on the given address. func (l *Logger) Listening(a string) { l.printf("daemon listening on %s\n", a) } // Auth logs an authentication request. func (l *Logger) Auth(netAddr net.Addr, user string, successful bool) { res := "succeeded" if !successful { res = "failed" } msg := fmt.Sprintf("%s auth %s for %s\n", netAddr, res, user) l.printf(msg) authLog.Debugf(msg) } // Rejected logs that we've rejected an email. func (l *Logger) Rejected(netAddr net.Addr, from string, to []string, err string) { if from != "" { from = fmt.Sprintf(" from=%s", from) } toStr := "" if len(to) > 0 { toStr = fmt.Sprintf(" to=%v", to) } l.printf("%s rejected%s%s - %v\n", netAddr, from, toStr, err) } // Queued logs that we have queued an email. func (l *Logger) Queued(netAddr net.Addr, from string, to []string, id string) { l.printf("%s from=%s queued ip=%s to=%v\n", id, from, netAddr, to) } // SendAttempt logs that we have attempted to send an email. func (l *Logger) SendAttempt(id, from, to string, err error, permanent bool) { if err == nil { l.printf("%s from=%s to=%s sent\n", id, from, to) } else { t := "(temporary)" if permanent { t = "(permanent)" } l.printf("%s from=%s to=%s failed %s: %v\n", id, from, to, t, err) } } // QueueLoop logs that we have completed a queue loop. func (l *Logger) QueueLoop(id, from string, nextDelay time.Duration) { if nextDelay > 0 { l.printf("%s from=%s completed loop, next in %v\n", id, from, nextDelay) } else { l.printf("%s from=%s all done\n", id, from) } } // Default logger, used in the following top-level functions. var Default = New(ioutil.Discard) // Listening logs that the daemon is listening on the given address. func Listening(a string) { Default.Listening(a) } // Auth logs an authentication request. func Auth(netAddr net.Addr, user string, successful bool) { Default.Auth(netAddr, user, successful) } // Rejected logs that we've rejected an email. func Rejected(netAddr net.Addr, from string, to []string, err string) { Default.Rejected(netAddr, from, to, err) } // Queued logs that we have queued an email. func Queued(netAddr net.Addr, from string, to []string, id string) { Default.Queued(netAddr, from, to, id) } // SendAttempt logs that we have attempted to send an email. func SendAttempt(id, from, to string, err error, permanent bool) { Default.SendAttempt(id, from, to, err, permanent) } // QueueLoop logs that we have completed a queue loop. func QueueLoop(id, from string, nextDelay time.Duration) { Default.QueueLoop(id, from, nextDelay) } chasquid-1.2/internal/maillog/maillog_test.go000066400000000000000000000104201357247226300214370ustar00rootroot00000000000000package maillog import ( "bytes" "fmt" "io" "net" "strings" "testing" "time" "blitiri.com.ar/go/log" ) var netAddr = &net.TCPAddr{ IP: net.ParseIP("1.2.3.4"), Port: 4321, } func expect(t *testing.T, buf *bytes.Buffer, s string) { if strings.Contains(buf.String(), s) { return } t.Errorf("buffer mismatch:") t.Errorf(" expected to contain: %q", s) t.Errorf(" got: %q", buf.String()) } func TestLogger(t *testing.T) { buf := &bytes.Buffer{} l := New(buf) l.Listening("1.2.3.4:4321") expect(t, buf, "daemon listening on 1.2.3.4:4321") buf.Reset() l.Auth(netAddr, "user@domain", false) expect(t, buf, "1.2.3.4:4321 auth failed for user@domain") buf.Reset() l.Auth(netAddr, "user@domain", true) expect(t, buf, "1.2.3.4:4321 auth succeeded for user@domain") buf.Reset() l.Rejected(netAddr, "from", []string{"to1", "to2"}, "error") expect(t, buf, "1.2.3.4:4321 rejected from=from to=[to1 to2] - error") buf.Reset() l.Queued(netAddr, "from", []string{"to1", "to2"}, "qid") expect(t, buf, "qid from=from queued ip=1.2.3.4:4321 to=[to1 to2]") buf.Reset() l.SendAttempt("qid", "from", "to", nil, false) expect(t, buf, "qid from=from to=to sent") buf.Reset() l.SendAttempt("qid", "from", "to", fmt.Errorf("error"), false) expect(t, buf, "qid from=from to=to failed (temporary): error") buf.Reset() l.SendAttempt("qid", "from", "to", fmt.Errorf("error"), true) expect(t, buf, "qid from=from to=to failed (permanent): error") buf.Reset() l.QueueLoop("qid", "from", 17*time.Second) expect(t, buf, "qid from=from completed loop, next in 17s") buf.Reset() l.QueueLoop("qid", "from", 0) expect(t, buf, "qid from=from all done") buf.Reset() } // Test that the default actions go reasonably to the default logger. // Unfortunately this is almost the same as TestLogger. func TestDefault(t *testing.T) { buf := &bytes.Buffer{} Default = New(buf) Listening("1.2.3.4:4321") expect(t, buf, "daemon listening on 1.2.3.4:4321") buf.Reset() Auth(netAddr, "user@domain", false) expect(t, buf, "1.2.3.4:4321 auth failed for user@domain") buf.Reset() Auth(netAddr, "user@domain", true) expect(t, buf, "1.2.3.4:4321 auth succeeded for user@domain") buf.Reset() Rejected(netAddr, "from", []string{"to1", "to2"}, "error") expect(t, buf, "1.2.3.4:4321 rejected from=from to=[to1 to2] - error") buf.Reset() Queued(netAddr, "from", []string{"to1", "to2"}, "qid") expect(t, buf, "qid from=from queued ip=1.2.3.4:4321 to=[to1 to2]") buf.Reset() SendAttempt("qid", "from", "to", nil, false) expect(t, buf, "qid from=from to=to sent") buf.Reset() SendAttempt("qid", "from", "to", fmt.Errorf("error"), false) expect(t, buf, "qid from=from to=to failed (temporary): error") buf.Reset() SendAttempt("qid", "from", "to", fmt.Errorf("error"), true) expect(t, buf, "qid from=from to=to failed (permanent): error") buf.Reset() QueueLoop("qid", "from", 17*time.Second) expect(t, buf, "qid from=from completed loop, next in 17s") buf.Reset() QueueLoop("qid", "from", 0) expect(t, buf, "qid from=from all done") buf.Reset() } // io.Writer that fails all write operations, for testing. type failedWriter struct{} func (w *failedWriter) Write(p []byte) (int, error) { return 0, fmt.Errorf("test error") } // nopCloser adds a Close method to an io.Writer, to turn it into a // io.WriteCloser. This is the equivalent of ioutil.NopCloser but for // io.Writer. type nopCloser struct { io.Writer } func (nopCloser) Close() error { return nil } // Test that we complain (only once) when we can't log. func TestFailedLogger(t *testing.T) { // Set up a test logger, that will write to a buffer for us to check. buf := &bytes.Buffer{} log.Default = log.New(nopCloser{io.Writer(buf)}) // Set up a maillog that will use a writer which always fail, to trigger // the condition. failedw := &failedWriter{} l := New(failedw) // Log something, which should fail. Then verify that the error message // appears in the log. l.printf("123 testing") s := buf.String() if !strings.Contains(s, "failed to write to maillog: test error") { t.Errorf("log did not contain expected message. Log: %#v", s) } // Further attempts should not generate any other errors. buf.Reset() l.printf("123 testing") s = buf.String() if s != "" { t.Errorf("expected second attempt to not log, but log had: %#v", s) } } chasquid-1.2/internal/normalize/000077500000000000000000000000001357247226300170045ustar00rootroot00000000000000chasquid-1.2/internal/normalize/fuzz.go000066400000000000000000000002731357247226300203330ustar00rootroot00000000000000// Fuzz testing for package normalize. // +build gofuzz package normalize func Fuzz(data []byte) int { s := string(data) User(s) Domain(s) Addr(s) DomainToUnicode(s) return 0 } chasquid-1.2/internal/normalize/normalize.go000066400000000000000000000036461357247226300213440ustar00rootroot00000000000000// Package normalize contains functions to normalize usernames, domains and // addresses. package normalize import ( "strings" "blitiri.com.ar/go/chasquid/internal/envelope" "golang.org/x/net/idna" "golang.org/x/text/secure/precis" "golang.org/x/text/unicode/norm" ) // User normalizes an username using PRECIS. // On error, it will also return the original username to simplify callers. func User(user string) (string, error) { norm, err := precis.UsernameCaseMapped.String(user) if err != nil { return user, err } return norm, nil } // Domain normalizes a DNS domain into a cleaned UTF-8 form. // On error, it will also return the original domain to simplify callers. func Domain(domain string) (string, error) { // For now, we just convert them to lower case and make sure it's in NFC // form for consistency. // There are other possible transformations (like nameprep) but for our // purposes these should be enough. // https://tools.ietf.org/html/rfc5891#section-5.2 // https://blog.golang.org/normalization d, err := idna.ToUnicode(domain) if err != nil { return domain, err } d = norm.NFC.String(d) d = strings.ToLower(d) return d, nil } // Addr normalizes an email address, applying User and Domain to its // respective components. // On error, it will also return the original address to simplify callers. func Addr(addr string) (string, error) { user, domain := envelope.Split(addr) user, err := User(user) if err != nil { return addr, err } domain, err = Domain(domain) if err != nil { return addr, err } return user + "@" + domain, nil } // DomainToUnicode takes an address with an ASCII domain, and convert it to // Unicode as per IDNA, including basic normalization. // The user part is unchanged. func DomainToUnicode(addr string) (string, error) { if addr == "<>" { return addr, nil } user, domain := envelope.Split(addr) domain, err := Domain(domain) return user + "@" + domain, err } chasquid-1.2/internal/normalize/normalize_test.go000066400000000000000000000060351357247226300223760ustar00rootroot00000000000000package normalize import "testing" func TestUser(t *testing.T) { valid := []struct{ user, norm string }{ {"ÑAndÚ", "ñandú"}, {"Pingüino", "pingüino"}, } for _, c := range valid { nu, err := User(c.user) if nu != c.norm { t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm) } if err != nil { t.Errorf("%q error: %v", c.user, err) } } invalid := []string{ "á é", "a\te", "x ", "x\xa0y", "x\x85y", "x\vy", "x\fy", "x\ry", "henry\u2163", "\u265a", "\u00b9", } for _, u := range invalid { nu, err := User(u) if err == nil { t.Errorf("expected User(%+q) to fail, but did not", u) } if nu != u { t.Errorf("%+q failed norm, but returned %+q", u, nu) } } } func TestDomain(t *testing.T) { valid := []struct{ user, norm string }{ {"ÑAndÚ", "ñandú"}, {"Pingüino", "pingüino"}, {"xn--aca-6ma", "ñaca"}, {"xn--lca", "ñ"}, // Punycode is for 'Ñ'. {"e\u0301", "é"}, // Transform to NFC form. } for _, c := range valid { nu, err := Domain(c.user) if nu != c.norm { t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm) } if err != nil { t.Errorf("%q error: %v", c.user, err) } } invalid := []string{"xn---", "xn--xyz-ñ"} for _, u := range invalid { nu, err := Domain(u) if err == nil { t.Errorf("expected Domain(%+q) to fail, but did not", u) } if nu != u { t.Errorf("%+q failed norm, but returned %+q", u, nu) } } } func TestAddr(t *testing.T) { valid := []struct{ user, norm string }{ {"ÑAndÚ@pampa", "ñandú@pampa"}, {"Pingüino@patagonia", "pingüino@patagonia"}, {"pe\u0301@le\u0301a", "pé@léa"}, // Transform to NFC form. } for _, c := range valid { nu, err := Addr(c.user) if nu != c.norm { t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm) } if err != nil { t.Errorf("%q error: %v", c.user, err) } } invalid := []string{ "á é@i", "henry\u2163@throne", "a@xn---", } for _, u := range invalid { nu, err := Addr(u) if err == nil { t.Errorf("expected Addr(%+q) to fail, but did not", u) } if nu != u { t.Errorf("%+q failed norm, but returned %+q", u, nu) } } } func TestDomainToUnicode(t *testing.T) { valid := []struct{ domain, expected string }{ {"<>", "<>"}, {"a@b", "a@b"}, {"a@Ñ", "a@ñ"}, {"xn--lca@xn--lca", "xn--lca@ñ"}, // Punycode is for 'Ñ'. {"a@e\u0301", "a@é"}, // Transform to NFC form. // Degenerate case, we don't expect to ever produce this; at least // check it does not crash. {"", "@"}, } for _, c := range valid { got, err := DomainToUnicode(c.domain) if got != c.expected { t.Errorf("DomainToUnicode(%q) = %q, expected %q", c.domain, got, c.expected) } if err != nil { t.Errorf("DomainToUnicode(%q) error: %v", c.domain, err) } } invalid := []string{"a@xn---", "a@xn--xyz-ñ"} for _, u := range invalid { got, err := DomainToUnicode(u) if err == nil { t.Errorf("expected DomainToUnicode(%+q) to fail, but did not", u) } if got != u { t.Errorf("%+q failed norm, but returned %+q", u, got) } } } chasquid-1.2/internal/normalize/testdata/000077500000000000000000000000001357247226300206155ustar00rootroot00000000000000chasquid-1.2/internal/normalize/testdata/fuzz/000077500000000000000000000000001357247226300216135ustar00rootroot00000000000000chasquid-1.2/internal/normalize/testdata/fuzz/corpus/000077500000000000000000000000001357247226300231265ustar00rootroot00000000000000chasquid-1.2/internal/normalize/testdata/fuzz/corpus/t-001000066400000000000000000000000071357247226300236070ustar00rootroot00000000000000ñandúchasquid-1.2/internal/normalize/testdata/fuzz/corpus/t-002000066400000000000000000000000071357247226300236100ustar00rootroot00000000000000ÑAndÚchasquid-1.2/internal/normalize/testdata/fuzz/corpus/t-003000066400000000000000000000000111357247226300236040ustar00rootroot00000000000000Pingüinochasquid-1.2/internal/normalize/testdata/fuzz/corpus/t-004000066400000000000000000000000121357247226300236060ustar00rootroot00000000000000pé@léachasquid-1.2/internal/normalize/testdata/fuzz/corpus/t-005000066400000000000000000000000171357247226300236140ustar00rootroot00000000000000henryⅣ@thronechasquid-1.2/internal/protoio/000077500000000000000000000000001357247226300164775ustar00rootroot00000000000000chasquid-1.2/internal/protoio/protoio.go000066400000000000000000000051451357247226300205260ustar00rootroot00000000000000// Package protoio contains I/O functions for protocol buffers. package protoio import ( "io/ioutil" "net/url" "os" "strings" "blitiri.com.ar/go/chasquid/internal/safeio" "github.com/golang/protobuf/proto" ) // ReadMessage reads a protocol buffer message from fname, and unmarshalls it // into pb. func ReadMessage(fname string, pb proto.Message) error { in, err := ioutil.ReadFile(fname) if err != nil { return err } return proto.Unmarshal(in, pb) } // ReadTextMessage reads a text format protocol buffer message from fname, and // unmarshalls it into pb. func ReadTextMessage(fname string, pb proto.Message) error { in, err := ioutil.ReadFile(fname) if err != nil { return err } return proto.UnmarshalText(string(in), pb) } // WriteMessage marshals pb and atomically writes it into fname. func WriteMessage(fname string, pb proto.Message, perm os.FileMode) error { out, err := proto.Marshal(pb) if err != nil { return err } return safeio.WriteFile(fname, out, perm) } // WriteTextMessage marshals pb in text format and atomically writes it into // fname. func WriteTextMessage(fname string, pb proto.Message, perm os.FileMode) error { out := proto.MarshalTextString(pb) return safeio.WriteFile(fname, []byte(out), perm) } /////////////////////////////////////////////////////////////// // Store represents a persistent protocol buffer message store. type Store struct { // Directory where the store is. dir string } // NewStore returns a new Store instance. It will create dir if needed. func NewStore(dir string) (*Store, error) { s := &Store{dir} err := os.MkdirAll(dir, 0770) return s, err } const storeIDPrefix = "s:" // idToFname takes a generic id and returns the corresponding file for it // (which may or may not exist). func (s *Store) idToFname(id string) string { return s.dir + "/" + storeIDPrefix + url.QueryEscape(id) } // Put a message into the store. func (s *Store) Put(id string, m proto.Message) error { return WriteTextMessage(s.idToFname(id), m, 0660) } // Get a message from the store. func (s *Store) Get(id string, m proto.Message) (bool, error) { err := ReadTextMessage(s.idToFname(id), m) if os.IsNotExist(err) { return false, nil } return err == nil, err } // ListIDs in the store. func (s *Store) ListIDs() ([]string, error) { ids := []string{} entries, err := ioutil.ReadDir(s.dir) if err != nil { return nil, err } for _, e := range entries { if !strings.HasPrefix(e.Name(), storeIDPrefix) { continue } id := e.Name()[len(storeIDPrefix):] id, err = url.QueryUnescape(id) if err != nil { continue } ids = append(ids, id) } return ids, nil } chasquid-1.2/internal/protoio/protoio_test.go000066400000000000000000000051111357247226300215560ustar00rootroot00000000000000package protoio import ( "os" "testing" "blitiri.com.ar/go/chasquid/internal/protoio/testpb" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestBin(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) pb := &testpb.M{Content: "hola"} if err := WriteMessage("f", pb, 0600); err != nil { t.Error(err) } pb2 := &testpb.M{} if err := ReadMessage("f", pb2); err != nil { t.Error(err) } if pb.Content != pb2.Content { t.Errorf("content mismatch, got %q, expected %q", pb2.Content, pb.Content) } } func TestText(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) pb := &testpb.M{Content: "hola"} if err := WriteTextMessage("f", pb, 0600); err != nil { t.Error(err) } pb2 := &testpb.M{} if err := ReadTextMessage("f", pb2); err != nil { t.Error(err) } if pb.Content != pb2.Content { t.Errorf("content mismatch, got %q, expected %q", pb2.Content, pb.Content) } } func TestStore(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) st, err := NewStore(dir + "/store") if err != nil { t.Fatalf("failed to create store: %v", err) } if ids, err := st.ListIDs(); len(ids) != 0 || err != nil { t.Errorf("expected no ids, got %v - %v", ids, err) } pb := &testpb.M{Content: "hola"} if err := st.Put("f", pb); err != nil { t.Error(err) } pb2 := &testpb.M{} if ok, err := st.Get("f", pb2); err != nil || !ok { t.Errorf("Get(f): %v - %v", ok, err) } if pb.Content != pb2.Content { t.Errorf("content mismatch, got %q, expected %q", pb2.Content, pb.Content) } if ok, err := st.Get("notexists", pb2); err != nil || ok { t.Errorf("Get(notexists): %v - %v", ok, err) } // Add an extraneous file, which ListIDs should ignore. fd, err := os.Create(dir + "/store/" + "somefile") if fd != nil { fd.Close() } if err != nil { t.Errorf("failed to create extraneous file: %v", err) } if ids, err := st.ListIDs(); len(ids) != 1 || ids[0] != "f" || err != nil { t.Errorf("expected [f], got %v - %v", ids, err) } } func TestFileErrors(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) pb := &testpb.M{Content: "hola"} if err := WriteMessage("/proc/doesnotexist", pb, 0600); err == nil { t.Errorf("write to /proc/doesnotexist worked, expected error") } if err := ReadMessage("/doesnotexist", pb); err == nil { t.Errorf("read from /doesnotexist worked, expected error") } s := &Store{dir: "/doesnotexist"} if ids, err := s.ListIDs(); !(ids == nil && err != nil) { t.Errorf("list /doesnotexist worked (%v, %v), expected error", ids, err) } } chasquid-1.2/internal/protoio/testpb/000077500000000000000000000000001357247226300200005ustar00rootroot00000000000000chasquid-1.2/internal/protoio/testpb/dummy.go000066400000000000000000000000751357247226300214640ustar00rootroot00000000000000package testpb //go:generate protoc --go_out=. testpb.proto chasquid-1.2/internal/protoio/testpb/testpb.pb.go000066400000000000000000000044471357247226300222410ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // source: testpb.proto package testpb import ( fmt "fmt" proto "github.com/golang/protobuf/proto" math "math" ) // Reference imports to suppress errors if they are not otherwise used. var _ = proto.Marshal var _ = fmt.Errorf var _ = math.Inf // This is a compile-time assertion to ensure that this generated file // is compatible with the proto package it is being compiled against. // A compilation error at this line likely means your copy of the // proto package needs to be updated. const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package type M struct { Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } func (m *M) Reset() { *m = M{} } func (m *M) String() string { return proto.CompactTextString(m) } func (*M) ProtoMessage() {} func (*M) Descriptor() ([]byte, []int) { return fileDescriptor_1b98c0ed33edeb52, []int{0} } func (m *M) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_M.Unmarshal(m, b) } func (m *M) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { return xxx_messageInfo_M.Marshal(b, m, deterministic) } func (m *M) XXX_Merge(src proto.Message) { xxx_messageInfo_M.Merge(m, src) } func (m *M) XXX_Size() int { return xxx_messageInfo_M.Size(m) } func (m *M) XXX_DiscardUnknown() { xxx_messageInfo_M.DiscardUnknown(m) } var xxx_messageInfo_M proto.InternalMessageInfo func (m *M) GetContent() string { if m != nil { return m.Content } return "" } func init() { proto.RegisterType((*M)(nil), "testpb.M") } func init() { proto.RegisterFile("testpb.proto", fileDescriptor_1b98c0ed33edeb52) } var fileDescriptor_1b98c0ed33edeb52 = []byte{ // 72 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0xe2, 0x29, 0x49, 0x2d, 0x2e, 0x29, 0x48, 0xd2, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0x62, 0x83, 0xf0, 0x94, 0x64, 0xb9, 0x18, 0x7d, 0x85, 0x24, 0xb8, 0xd8, 0x93, 0xf3, 0xf3, 0x4a, 0x52, 0xf3, 0x4a, 0x24, 0x18, 0x15, 0x18, 0x35, 0x38, 0x83, 0x60, 0xdc, 0x24, 0x36, 0xb0, 0x6a, 0x63, 0x40, 0x00, 0x00, 0x00, 0xff, 0xff, 0xcb, 0x37, 0x9b, 0x8f, 0x3d, 0x00, 0x00, 0x00, } chasquid-1.2/internal/protoio/testpb/testpb.proto000066400000000000000000000001111357247226300223570ustar00rootroot00000000000000 syntax = "proto3"; package testpb; message M { string content = 1; } chasquid-1.2/internal/queue/000077500000000000000000000000001357247226300161305ustar00rootroot00000000000000chasquid-1.2/internal/queue/dsn.go000066400000000000000000000104701357247226300172450ustar00rootroot00000000000000package queue import ( "bytes" "net/mail" "strings" "text/template" "time" ) // Maximum length of the original message to include in the DSN. // The receiver of the DSN might have a smaller message size than what we // accepted, so we truncate to a value that should be large enough to be // useful, but not problematic for modern deployments. const maxOrigMsgLen = 256 * 1024 // deliveryStatusNotification creates a delivery status notification (DSN) for // the given item, and puts it in the queue. // // References: // - https://tools.ietf.org/html/rfc3464 (DSN) // - https://tools.ietf.org/html/rfc6533 (Internationalized DSN) func deliveryStatusNotification(domainFrom string, item *Item) ([]byte, error) { info := dsnInfo{ OurDomain: domainFrom, Destination: item.From, MessageID: "chasquid-dsn-" + <-newID + "@" + domainFrom, Date: time.Now().Format(time.RFC1123Z), To: item.To, Recipients: item.Rcpt, FailedTo: map[string]string{}, } for _, rcpt := range item.Rcpt { if rcpt.Status != Recipient_SENT { info.FailedTo[rcpt.OriginalAddress] = rcpt.OriginalAddress switch rcpt.Status { case Recipient_FAILED: info.FailedRecipients = append(info.FailedRecipients, rcpt) case Recipient_PENDING: info.PendingRecipients = append(info.PendingRecipients, rcpt) } } } if len(item.Data) > maxOrigMsgLen { info.OriginalMessage = string(item.Data[:maxOrigMsgLen]) } else { info.OriginalMessage = string(item.Data) } info.OriginalMessageID = getMessageID(item.Data) info.Boundary = <-newID buf := &bytes.Buffer{} err := dsnTemplate.Execute(buf, info) return buf.Bytes(), err } func getMessageID(data []byte) string { msg, err := mail.ReadMessage(bytes.NewBuffer(data)) if err != nil { return "" } return msg.Header.Get("Message-ID") } type dsnInfo struct { OurDomain string Destination string MessageID string Date string To []string FailedTo map[string]string Recipients []*Recipient FailedRecipients []*Recipient PendingRecipients []*Recipient OriginalMessage string // Message-ID of the original message. OriginalMessageID string // MIME boundary to use to form the message. Boundary string } // indent s with the given number of spaces. func indent(sp int, s string) string { pad := strings.Repeat(" ", sp) return strings.Replace(s, "\n", "\n"+pad, -1) } var dsnTemplate = template.Must( template.New("dsn").Funcs( template.FuncMap{ "indent": indent, "trim": strings.TrimSpace, }).Parse( `From: Mail Delivery System To: <{{.Destination}}> Subject: Mail delivery failed: returning message to sender Message-ID: <{{.MessageID}}> Date: {{.Date}} In-Reply-To: {{.OriginalMessageID}} References: {{.OriginalMessageID}} X-Failed-Recipients: {{range .FailedTo}}{{.}}, {{end}} Auto-Submitted: auto-replied MIME-Version: 1.0 Content-Type: multipart/report; report-type=delivery-status; boundary="{{.Boundary}}" --{{.Boundary}} Content-Type: text/plain; charset="utf-8" Content-Disposition: inline Content-Description: Notification Content-Transfer-Encoding: 8bit Delivery of your message to the following recipient(s) failed permanently: {{range .FailedTo}} - {{.}} {{end}} Technical details: {{- range .FailedRecipients}} - "{{.Address}}" ({{.Type}}) failed permanently with error: {{.LastFailureMessage | trim | indent 4}} {{- end}} {{- range .PendingRecipients}} - "{{.Address}}" ({{.Type}}) failed repeatedly and timed out, last error: {{.LastFailureMessage | trim | indent 4}} {{- end}} --{{.Boundary}} Content-Type: message/global-delivery-status Content-Description: Delivery Report Content-Transfer-Encoding: 8bit Reporting-MTA: dns; {{.OurDomain}} {{range .FailedRecipients -}} Original-Recipient: utf-8; {{.OriginalAddress}} Final-Recipient: utf-8; {{.Address}} Action: failed Status: 5.0.0 Diagnostic-Code: smtp; {{.LastFailureMessage | trim | indent 4}} {{end -}} {{range .PendingRecipients -}} Original-Recipient: utf-8; {{.OriginalAddress}} Final-Recipient: utf-8; {{.Address}} Action: failed Status: 4.0.0 Diagnostic-Code: smtp; {{.LastFailureMessage | trim | indent 4}} {{end}} --{{.Boundary}} Content-Type: message/rfc822 Content-Description: Undelivered Message Content-Transfer-Encoding: 8bit {{.OriginalMessage}} --{{.Boundary}}-- `)) chasquid-1.2/internal/queue/dsn_test.go000066400000000000000000000120301357247226300202760ustar00rootroot00000000000000package queue import ( "fmt" "sort" "strings" "testing" ) const multilineErr = `550 5.7.1 [11:22:33:44::1] Our system has detected that this 5.7.1 message is likely unsolicited mail. To reduce the amount of spam sent 5.7.1 to BlahMail, this message has been blocked. Please visit 5.7.1 https://support.blah/mail/?p=UnsolicitedMessageError 5.7.1 for more information. a1b2c3a1b2c3a1b.123 - bsmtp` const data = `Message-ID: Data ñaca. ` func TestDSN(t *testing.T) { item := &Item{ Message: Message{ ID: <-newID, From: "from@from.org", To: []string{"ñaca@africa.org", "negra@sosa.org"}, Rcpt: []*Recipient{ mkR("poe@rcpt", Recipient_EMAIL, Recipient_FAILED, "oh! horror!", "ñaca@africa.org"), mkR("muchos@rcpt", Recipient_EMAIL, Recipient_FAILED, multilineErr, "pepe@africa.org"), mkR("newman@rcpt", Recipient_EMAIL, Recipient_PENDING, "oh! the humanity!", "ñaca@africa.org"), mkR("ant@rcpt", Recipient_EMAIL, Recipient_SENT, "", "negra@sosa.org"), }, Data: []byte(data), }, } msg, err := deliveryStatusNotification("dsnDomain", item) if err != nil { t.Error(err) } if !flexibleEq(expectedDSN, string(msg)) { t.Errorf("generated DSN different than expected") printDiff(func(s string) { t.Error(s) }, expectedDSN, string(msg)) } else { t.Log(string(msg)) } } const expectedDSN = `From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: Date: * In-Reply-To: References: X-Failed-Recipients: pepe@africa.org, ñaca@africa.org, Auto-Submitted: auto-replied MIME-Version: 1.0 Content-Type: multipart/report; report-type=delivery-status; boundary="???????????" --??????????? Content-Type: text/plain; charset="utf-8" Content-Disposition: inline Content-Description: Notification Content-Transfer-Encoding: 8bit Delivery of your message to the following recipient(s) failed permanently: - pepe@africa.org - ñaca@africa.org Technical details: - "poe@rcpt" (EMAIL) failed permanently with error: oh! horror! - "muchos@rcpt" (EMAIL) failed permanently with error: 550 5.7.1 [11:22:33:44::1] Our system has detected that this 5.7.1 message is likely unsolicited mail. To reduce the amount of spam sent 5.7.1 to BlahMail, this message has been blocked. Please visit 5.7.1 https://support.blah/mail/?p=UnsolicitedMessageError 5.7.1 for more information. a1b2c3a1b2c3a1b.123 - bsmtp - "newman@rcpt" (EMAIL) failed repeatedly and timed out, last error: oh! the humanity! --??????????? Content-Type: message/global-delivery-status Content-Description: Delivery Report Content-Transfer-Encoding: 8bit Reporting-MTA: dns; dsnDomain Original-Recipient: utf-8; ñaca@africa.org Final-Recipient: utf-8; poe@rcpt Action: failed Status: 5.0.0 Diagnostic-Code: smtp; oh! horror! Original-Recipient: utf-8; pepe@africa.org Final-Recipient: utf-8; muchos@rcpt Action: failed Status: 5.0.0 Diagnostic-Code: smtp; 550 5.7.1 [11:22:33:44::1] Our system has detected that this 5.7.1 message is likely unsolicited mail. To reduce the amount of spam sent 5.7.1 to BlahMail, this message has been blocked. Please visit 5.7.1 https://support.blah/mail/?p=UnsolicitedMessageError 5.7.1 for more information. a1b2c3a1b2c3a1b.123 - bsmtp Original-Recipient: utf-8; ñaca@africa.org Final-Recipient: utf-8; newman@rcpt Action: failed Status: 4.0.0 Diagnostic-Code: smtp; oh! the humanity! --??????????? Content-Type: message/rfc822 Content-Description: Undelivered Message Content-Transfer-Encoding: 8bit Message-ID: Data ñaca. --???????????-- ` // flexibleEq compares two strings, supporting wildcards. // Not particularly nice or robust, only useful for testing. func flexibleEq(expected, got string) bool { posG := 0 for i := 0; i < len(expected); i++ { if posG >= len(got) { return false } c := expected[i] if c == '?' { posG++ continue } else if c == '*' { for got[posG] != '\n' { posG++ } continue } else if byte(c) != got[posG] { return false } posG++ } return true } // printDiff prints the difference between the strings using the given // function. This is a _horrible_ implementation, only useful for testing. func printDiff(print func(s string), expected, got string) { lines := []string{} // expected lines and map. eM := map[string]int{} for _, l := range strings.Split(expected, "\n") { eM[l]++ lines = append(lines, l) } // got lines and map. gM := map[string]int{} for _, l := range strings.Split(got, "\n") { gM[l]++ lines = append(lines, l) } // sort the lines, to make it easier to see the differences (this works // ok when there's few, horrible when there's lots). sort.Strings(lines) // print diff of expected vs. got seen := map[string]bool{} print("E G | Line") for _, l := range lines { if !seen[l] && eM[l] != gM[l] { print(fmt.Sprintf("%2d %2d | %q", eM[l], gM[l], l)) seen[l] = true } } } chasquid-1.2/internal/queue/queue.go000066400000000000000000000302051357247226300176030ustar00rootroot00000000000000// Package queue implements our email queue. // Accepted envelopes get put in the queue, and processed asynchronously. package queue // Command to generate queue.pb.go from queue.proto. //go:generate protoc --go_out=. -I=${GOPATH}/src -I. queue.proto import ( "context" "encoding/base64" "expvar" "fmt" "math/rand" "os" "os/exec" "path/filepath" "strings" "sync" "time" "bytes" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/courier" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/protoio" "blitiri.com.ar/go/chasquid/internal/set" "blitiri.com.ar/go/chasquid/internal/trace" "blitiri.com.ar/go/log" "github.com/golang/protobuf/ptypes" "golang.org/x/net/idna" ) const ( // Maximum size of the queue; we reject emails when we hit this. maxQueueSize = 200 // Give up sending attempts after this duration. giveUpAfter = 20 * time.Hour // Prefix for item file names. // This is for convenience, versioning, and to be able to tell them apart // temporary files and other cruft. // It's important that it's outside the base64 space so it doesn't get // generated accidentally. itemFilePrefix = "m:" ) var ( errQueueFull = fmt.Errorf("Queue size too big, try again later") ) // Exported variables. var ( putCount = expvar.NewInt("chasquid/queue/putCount") itemsWritten = expvar.NewInt("chasquid/queue/itemsWritten") dsnQueued = expvar.NewInt("chasquid/queue/dsnQueued") deliverAttempts = expvar.NewMap("chasquid/queue/deliverAttempts") ) // Channel used to get random IDs for items in the queue. var newID chan string func generateNewIDs() { // The IDs are only used internally, we are ok with using a PRNG. // We create our own to avoid relying on external sources initializing it // properly. prng := rand.New(rand.NewSource(time.Now().UnixNano())) // IDs are base64(8 random bytes), but the code doesn't care. buf := make([]byte, 8) id := "" for { prng.Read(buf) id = base64.RawURLEncoding.EncodeToString(buf) newID <- id } } func init() { newID = make(chan string, 4) go generateNewIDs() } // Queue that keeps mail waiting for delivery. type Queue struct { // Items in the queue. Map of id -> Item. q map[string]*Item // Mutex protecting q. mu sync.RWMutex // Couriers to use to deliver mail. localC courier.Courier remoteC courier.Courier // Domains we consider local. localDomains *set.String // Path where we store the queue. path string // Aliases resolver. aliases *aliases.Resolver } // New creates a new Queue instance. func New(path string, localDomains *set.String, aliases *aliases.Resolver, localC, remoteC courier.Courier) *Queue { os.MkdirAll(path, 0700) return &Queue{ q: map[string]*Item{}, localC: localC, remoteC: remoteC, localDomains: localDomains, path: path, aliases: aliases, } } // Load the queue and launch the sending loops on startup. func (q *Queue) Load() error { files, err := filepath.Glob(q.path + "/" + itemFilePrefix + "*") if err != nil { return err } for _, fname := range files { item, err := ItemFromFile(fname) if err != nil { log.Errorf("error loading queue item from %q: %v", fname, err) continue } q.mu.Lock() q.q[item.ID] = item q.mu.Unlock() go item.SendLoop(q) } return nil } // Len returns the number of elements in the queue. func (q *Queue) Len() int { q.mu.RLock() defer q.mu.RUnlock() return len(q.q) } // Put an envelope in the queue. func (q *Queue) Put(from string, to []string, data []byte) (string, error) { if q.Len() >= maxQueueSize { return "", errQueueFull } putCount.Add(1) item := &Item{ Message: Message{ ID: <-newID, From: from, Data: data, }, CreatedAt: time.Now(), } for _, t := range to { item.To = append(item.To, t) rcpts, err := q.aliases.Resolve(t) if err != nil { return "", fmt.Errorf("error resolving aliases for %q: %v", t, err) } // Add the recipients (after resolving aliases); this conversion is // not very pretty but at least it's self contained. for _, aliasRcpt := range rcpts { r := &Recipient{ Address: aliasRcpt.Addr, Status: Recipient_PENDING, OriginalAddress: t, } switch aliasRcpt.Type { case aliases.EMAIL: r.Type = Recipient_EMAIL case aliases.PIPE: r.Type = Recipient_PIPE default: log.Errorf("unknown alias type %v when resolving %q", aliasRcpt.Type, t) return "", fmt.Errorf("internal error - unknown alias type") } item.Rcpt = append(item.Rcpt, r) } } err := item.WriteTo(q.path) if err != nil { return "", fmt.Errorf("failed to write item: %v", err) } q.mu.Lock() q.q[item.ID] = item q.mu.Unlock() // Begin to send it right away. go item.SendLoop(q) return item.ID, nil } // Remove an item from the queue. func (q *Queue) Remove(id string) { path := fmt.Sprintf("%s/%s%s", q.path, itemFilePrefix, id) err := os.Remove(path) if err != nil { log.Errorf("failed to remove queue file %q: %v", path, err) } q.mu.Lock() delete(q.q, id) q.mu.Unlock() } // DumpString returns a human-readable string with the current queue. // Useful for debugging purposes. func (q *Queue) DumpString() string { q.mu.RLock() defer q.mu.RUnlock() s := fmt.Sprintf("# Queue status\n\n") s += fmt.Sprintf("date: %v\n", time.Now()) s += fmt.Sprintf("length: %d\n\n", len(q.q)) for id, item := range q.q { s += fmt.Sprintf("## Item %s\n", id) item.Lock() s += fmt.Sprintf("created at: %s\n", item.CreatedAt) s += fmt.Sprintf("from: %s\n", item.From) s += fmt.Sprintf("to: %s\n", item.To) for _, rcpt := range item.Rcpt { s += fmt.Sprintf("%s %s (%s)\n", rcpt.Status, rcpt.Address, rcpt.Type) s += fmt.Sprintf(" original address: %s\n", rcpt.OriginalAddress) s += fmt.Sprintf(" last failure: %q\n", rcpt.LastFailureMessage) } item.Unlock() s += fmt.Sprintf("\n") } return s } // An Item in the queue. type Item struct { // Base the item on the protobuf message. // We will use this for serialization, so any fields below are NOT // serialized. Message // Protect the entire item. sync.Mutex // Go-friendly version of Message.CreatedAtTs. CreatedAt time.Time } // ItemFromFile loads an item from the given file. func ItemFromFile(fname string) (*Item, error) { item := &Item{} err := protoio.ReadTextMessage(fname, &item.Message) if err != nil { return nil, err } item.CreatedAt, err = ptypes.Timestamp(item.CreatedAtTs) return item, err } // WriteTo saves an item to the given directory. func (item *Item) WriteTo(dir string) error { item.Lock() defer item.Unlock() itemsWritten.Add(1) var err error item.CreatedAtTs, err = ptypes.TimestampProto(item.CreatedAt) if err != nil { return err } path := fmt.Sprintf("%s/%s%s", dir, itemFilePrefix, item.ID) return protoio.WriteTextMessage(path, &item.Message, 0600) } // SendLoop repeatedly attempts to send the item. func (item *Item) SendLoop(q *Queue) { tr := trace.New("Queue.SendLoop", item.ID) defer tr.Finish() tr.Printf("from %s", item.From) for time.Since(item.CreatedAt) < giveUpAfter { // Send to all recipients that are still pending. var wg sync.WaitGroup for _, rcpt := range item.Rcpt { if rcpt.Status != Recipient_PENDING { continue } wg.Add(1) go item.sendOneRcpt(&wg, tr, q, rcpt) } wg.Wait() // If they're all done, no need to wait. if item.countRcpt(Recipient_PENDING) == 0 { break } // TODO: Consider sending a non-final notification after 30m or so, // that some of the messages have been delayed. delay := nextDelay(item.CreatedAt) tr.Printf("waiting for %v", delay) maillog.QueueLoop(item.ID, item.From, delay) time.Sleep(delay) } // Completed to all recipients (some may not have succeeded). if item.countRcpt(Recipient_FAILED, Recipient_PENDING) > 0 && item.From != "<>" { sendDSN(tr, q, item) } tr.Printf("all done") maillog.QueueLoop(item.ID, item.From, 0) q.Remove(item.ID) } // sendOneRcpt, and update it with the results. func (item *Item) sendOneRcpt(wg *sync.WaitGroup, tr *trace.Trace, q *Queue, rcpt *Recipient) { defer wg.Done() to := rcpt.Address tr.Debugf("%s sending", to) err, permanent := item.deliver(q, rcpt) item.Lock() if err != nil { rcpt.LastFailureMessage = err.Error() if permanent { tr.Errorf("%s permanent error: %v", to, err) maillog.SendAttempt(item.ID, item.From, to, err, true) rcpt.Status = Recipient_FAILED } else { tr.Printf("%s temporary error: %v", to, err) maillog.SendAttempt(item.ID, item.From, to, err, false) } } else { tr.Printf("%s sent", to) maillog.SendAttempt(item.ID, item.From, to, nil, false) rcpt.Status = Recipient_SENT } item.Unlock() err = item.WriteTo(q.path) if err != nil { tr.Errorf("failed to write: %v", err) } } // deliver the item to the given recipient, using the couriers from the queue. // Return an error (if any), and whether it is permanent or not. func (item *Item) deliver(q *Queue, rcpt *Recipient) (err error, permanent bool) { if rcpt.Type == Recipient_PIPE { deliverAttempts.Add("pipe", 1) c := strings.Fields(rcpt.Address) if len(c) == 0 { return fmt.Errorf("empty pipe"), true } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, c[0], c[1:]...) cmd.Stdin = bytes.NewReader(item.Data) return cmd.Run(), true } // Recipient type is EMAIL. if envelope.DomainIn(rcpt.Address, q.localDomains) { deliverAttempts.Add("email:local", 1) return q.localC.Deliver(item.From, rcpt.Address, item.Data) } deliverAttempts.Add("email:remote", 1) from := item.From if !envelope.DomainIn(item.From, q.localDomains) { // We're sending from a non-local to a non-local. This should // happen only when there's an alias to forward email to a // non-local domain. In this case, using the original From is // problematic, as we may not be an authorized sender for this. // Some MTAs (like Exim) will do it anyway, others (like // gmail) will construct a special address based on the // original address. We go with the latter. // Note this assumes "+" is an alias suffix separator. // We use the IDNA version of the domain if possible, because // we can't know if the other side will support SMTPUTF8. from = fmt.Sprintf("%s+fwd_from=%s@%s", envelope.UserOf(rcpt.OriginalAddress), strings.Replace(from, "@", "=", -1), mustIDNAToASCII(envelope.DomainOf(rcpt.OriginalAddress))) } return q.remoteC.Deliver(from, rcpt.Address, item.Data) } // countRcpt counts how many recipients are in the given status. func (item *Item) countRcpt(statuses ...Recipient_Status) int { c := 0 for _, rcpt := range item.Rcpt { for _, status := range statuses { if rcpt.Status == status { c++ break } } } return c } func sendDSN(tr *trace.Trace, q *Queue, item *Item) { tr.Debugf("sending DSN") // Pick a (local) domain to send the DSN from. We should always find one, // as otherwise we're relaying. domain := "unknown" if item.From != "<>" && envelope.DomainIn(item.From, q.localDomains) { domain = envelope.DomainOf(item.From) } else { for _, rcpt := range item.Rcpt { if envelope.DomainIn(rcpt.OriginalAddress, q.localDomains) { domain = envelope.DomainOf(rcpt.OriginalAddress) break } } } msg, err := deliveryStatusNotification(domain, item) if err != nil { tr.Errorf("failed to build DSN: %v", err) return } id, err := q.Put("<>", []string{item.From}, msg) if err != nil { tr.Errorf("failed to queue DSN: %v", err) return } tr.Printf("queued DSN: %s", id) dsnQueued.Add(1) } func nextDelay(createdAt time.Time) time.Duration { var delay time.Duration since := time.Since(createdAt) switch { case since < 1*time.Minute: delay = 1 * time.Minute case since < 5*time.Minute: delay = 5 * time.Minute case since < 10*time.Minute: delay = 10 * time.Minute default: delay = 20 * time.Minute } // Perturb the delay, to avoid all queued emails to be retried at the // exact same time after a restart. delay += time.Duration(rand.Intn(60)) * time.Second return delay } func mustIDNAToASCII(s string) string { a, err := idna.ToASCII(s) if err != nil { return a } return s } chasquid-1.2/internal/queue/queue.pb.go000066400000000000000000000220261357247226300202050ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // source: queue.proto package queue import ( fmt "fmt" proto "github.com/golang/protobuf/proto" timestamp "github.com/golang/protobuf/ptypes/timestamp" math "math" ) // Reference imports to suppress errors if they are not otherwise used. var _ = proto.Marshal var _ = fmt.Errorf var _ = math.Inf // This is a compile-time assertion to ensure that this generated file // is compatible with the proto package it is being compiled against. // A compilation error at this line likely means your copy of the // proto package needs to be updated. const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package type Recipient_Type int32 const ( Recipient_EMAIL Recipient_Type = 0 Recipient_PIPE Recipient_Type = 1 ) var Recipient_Type_name = map[int32]string{ 0: "EMAIL", 1: "PIPE", } var Recipient_Type_value = map[string]int32{ "EMAIL": 0, "PIPE": 1, } func (x Recipient_Type) String() string { return proto.EnumName(Recipient_Type_name, int32(x)) } func (Recipient_Type) EnumDescriptor() ([]byte, []int) { return fileDescriptor_96e4d7d76a734cd8, []int{1, 0} } type Recipient_Status int32 const ( Recipient_PENDING Recipient_Status = 0 Recipient_SENT Recipient_Status = 1 Recipient_FAILED Recipient_Status = 2 ) var Recipient_Status_name = map[int32]string{ 0: "PENDING", 1: "SENT", 2: "FAILED", } var Recipient_Status_value = map[string]int32{ "PENDING": 0, "SENT": 1, "FAILED": 2, } func (x Recipient_Status) String() string { return proto.EnumName(Recipient_Status_name, int32(x)) } func (Recipient_Status) EnumDescriptor() ([]byte, []int) { return fileDescriptor_96e4d7d76a734cd8, []int{1, 1} } type Message struct { // Message ID. Uniquely identifies this message, it is used for // auditing and troubleshooting. ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` // The envelope for this message. From string `protobuf:"bytes,2,opt,name=from,proto3" json:"from,omitempty"` To []string `protobuf:"bytes,3,rep,name=To,proto3" json:"To,omitempty"` Rcpt []*Recipient `protobuf:"bytes,4,rep,name=rcpt,proto3" json:"rcpt,omitempty"` Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` // Creation timestamp. CreatedAtTs *timestamp.Timestamp `protobuf:"bytes,6,opt,name=created_at_ts,json=createdAtTs,proto3" json:"created_at_ts,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } func (m *Message) Reset() { *m = Message{} } func (m *Message) String() string { return proto.CompactTextString(m) } func (*Message) ProtoMessage() {} func (*Message) Descriptor() ([]byte, []int) { return fileDescriptor_96e4d7d76a734cd8, []int{0} } func (m *Message) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_Message.Unmarshal(m, b) } func (m *Message) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { return xxx_messageInfo_Message.Marshal(b, m, deterministic) } func (m *Message) XXX_Merge(src proto.Message) { xxx_messageInfo_Message.Merge(m, src) } func (m *Message) XXX_Size() int { return xxx_messageInfo_Message.Size(m) } func (m *Message) XXX_DiscardUnknown() { xxx_messageInfo_Message.DiscardUnknown(m) } var xxx_messageInfo_Message proto.InternalMessageInfo func (m *Message) GetID() string { if m != nil { return m.ID } return "" } func (m *Message) GetFrom() string { if m != nil { return m.From } return "" } func (m *Message) GetTo() []string { if m != nil { return m.To } return nil } func (m *Message) GetRcpt() []*Recipient { if m != nil { return m.Rcpt } return nil } func (m *Message) GetData() []byte { if m != nil { return m.Data } return nil } func (m *Message) GetCreatedAtTs() *timestamp.Timestamp { if m != nil { return m.CreatedAtTs } return nil } type Recipient struct { // Address to send the message to. // This is the final one, after expanding aliases. Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` Type Recipient_Type `protobuf:"varint,2,opt,name=type,proto3,enum=queue.Recipient_Type" json:"type,omitempty"` Status Recipient_Status `protobuf:"varint,3,opt,name=status,proto3,enum=queue.Recipient_Status" json:"status,omitempty"` LastFailureMessage string `protobuf:"bytes,4,opt,name=last_failure_message,json=lastFailureMessage,proto3" json:"last_failure_message,omitempty"` // Address that this recipient was originally intended to. // This is before expanding aliases and only used in very particular // cases. OriginalAddress string `protobuf:"bytes,5,opt,name=original_address,json=originalAddress,proto3" json:"original_address,omitempty"` XXX_NoUnkeyedLiteral struct{} `json:"-"` XXX_unrecognized []byte `json:"-"` XXX_sizecache int32 `json:"-"` } func (m *Recipient) Reset() { *m = Recipient{} } func (m *Recipient) String() string { return proto.CompactTextString(m) } func (*Recipient) ProtoMessage() {} func (*Recipient) Descriptor() ([]byte, []int) { return fileDescriptor_96e4d7d76a734cd8, []int{1} } func (m *Recipient) XXX_Unmarshal(b []byte) error { return xxx_messageInfo_Recipient.Unmarshal(m, b) } func (m *Recipient) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) { return xxx_messageInfo_Recipient.Marshal(b, m, deterministic) } func (m *Recipient) XXX_Merge(src proto.Message) { xxx_messageInfo_Recipient.Merge(m, src) } func (m *Recipient) XXX_Size() int { return xxx_messageInfo_Recipient.Size(m) } func (m *Recipient) XXX_DiscardUnknown() { xxx_messageInfo_Recipient.DiscardUnknown(m) } var xxx_messageInfo_Recipient proto.InternalMessageInfo func (m *Recipient) GetAddress() string { if m != nil { return m.Address } return "" } func (m *Recipient) GetType() Recipient_Type { if m != nil { return m.Type } return Recipient_EMAIL } func (m *Recipient) GetStatus() Recipient_Status { if m != nil { return m.Status } return Recipient_PENDING } func (m *Recipient) GetLastFailureMessage() string { if m != nil { return m.LastFailureMessage } return "" } func (m *Recipient) GetOriginalAddress() string { if m != nil { return m.OriginalAddress } return "" } func init() { proto.RegisterEnum("queue.Recipient_Type", Recipient_Type_name, Recipient_Type_value) proto.RegisterEnum("queue.Recipient_Status", Recipient_Status_name, Recipient_Status_value) proto.RegisterType((*Message)(nil), "queue.Message") proto.RegisterType((*Recipient)(nil), "queue.Recipient") } func init() { proto.RegisterFile("queue.proto", fileDescriptor_96e4d7d76a734cd8) } var fileDescriptor_96e4d7d76a734cd8 = []byte{ // 391 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0x64, 0x90, 0xcf, 0x8a, 0x9c, 0x40, 0x10, 0xc6, 0xe3, 0xdf, 0xc9, 0x94, 0xc9, 0x46, 0x9a, 0x84, 0x34, 0x9b, 0x8b, 0x48, 0x0e, 0x2e, 0x01, 0x0d, 0x93, 0x63, 0x20, 0x20, 0xe8, 0x06, 0x61, 0x67, 0x18, 0x1c, 0xef, 0xd2, 0x6a, 0x6b, 0x04, 0x9d, 0x36, 0x76, 0x7b, 0x98, 0x37, 0xca, 0x1b, 0xe4, 0xf5, 0x82, 0xad, 0x26, 0x90, 0xbd, 0x55, 0xd5, 0xf7, 0xeb, 0xea, 0xaf, 0x3e, 0xb0, 0x7e, 0x4e, 0x74, 0xa2, 0xfe, 0x30, 0x32, 0xc1, 0x90, 0x21, 0x9b, 0xfb, 0xaf, 0x4d, 0x2b, 0x7e, 0x4c, 0x85, 0x5f, 0xb2, 0x3e, 0x68, 0x58, 0x47, 0xae, 0x4d, 0x20, 0xf5, 0x62, 0xaa, 0x83, 0x41, 0xdc, 0x06, 0xca, 0x03, 0xd1, 0xf6, 0x94, 0x0b, 0xd2, 0x0f, 0xff, 0xaa, 0x65, 0x87, 0xfb, 0x5b, 0x81, 0xdd, 0x91, 0x72, 0x4e, 0x1a, 0x8a, 0xee, 0x40, 0x4d, 0x22, 0xac, 0x38, 0x8a, 0xb7, 0x4f, 0xd5, 0x24, 0x42, 0x08, 0xf4, 0x7a, 0x64, 0x3d, 0x56, 0xe5, 0x44, 0xd6, 0x33, 0x93, 0x31, 0xac, 0x39, 0xda, 0xcc, 0x64, 0x0c, 0x7d, 0x04, 0x7d, 0x2c, 0x07, 0x81, 0x75, 0x47, 0xf3, 0xac, 0x83, 0xed, 0x2f, 0xfe, 0x52, 0x5a, 0xb6, 0x43, 0x4b, 0xaf, 0x22, 0x95, 0xea, 0xbc, 0xa9, 0x22, 0x82, 0x60, 0xc3, 0x51, 0xbc, 0x57, 0xa9, 0xac, 0xd1, 0x37, 0x78, 0x5d, 0x8e, 0x94, 0x08, 0x5a, 0xe5, 0x44, 0xe4, 0x82, 0x63, 0xd3, 0x51, 0x3c, 0xeb, 0x70, 0xef, 0x37, 0x8c, 0x35, 0xdd, 0x7a, 0x63, 0x31, 0xd5, 0x7e, 0xb6, 0x59, 0x4e, 0xad, 0xf5, 0x41, 0x28, 0x32, 0xee, 0xfe, 0x52, 0x61, 0xff, 0xf7, 0x1f, 0x84, 0x61, 0x47, 0xaa, 0x6a, 0xa4, 0x9c, 0xaf, 0x07, 0x6c, 0x2d, 0x7a, 0x00, 0x7d, 0x0e, 0x41, 0x5e, 0x71, 0x77, 0x78, 0xf7, 0xbf, 0x43, 0x3f, 0xbb, 0x0d, 0x34, 0x95, 0x08, 0x0a, 0xc0, 0xe4, 0x82, 0x88, 0x89, 0x63, 0x4d, 0xc2, 0xef, 0x9f, 0xc1, 0x17, 0x29, 0xa7, 0x2b, 0x86, 0x3e, 0xc3, 0xdb, 0x8e, 0x70, 0x91, 0xd7, 0xa4, 0xed, 0xa6, 0x91, 0xe6, 0xfd, 0x92, 0x24, 0xd6, 0xa5, 0x05, 0x34, 0x6b, 0x8f, 0x8b, 0xb4, 0x65, 0xfc, 0x00, 0x36, 0x1b, 0xdb, 0xa6, 0xbd, 0x92, 0x2e, 0xdf, 0x0c, 0x1b, 0x92, 0x7e, 0xb3, 0xcd, 0xc3, 0x65, 0xec, 0x7e, 0x00, 0x7d, 0xf6, 0x86, 0xf6, 0x60, 0xc4, 0xc7, 0x30, 0x79, 0xb2, 0x5f, 0xa0, 0x97, 0xa0, 0x9f, 0x93, 0x73, 0x6c, 0x2b, 0xee, 0x27, 0x30, 0x17, 0x2f, 0xc8, 0x82, 0xdd, 0x39, 0x3e, 0x45, 0xc9, 0xe9, 0xfb, 0x02, 0x5c, 0xe2, 0x53, 0x66, 0x2b, 0x08, 0xc0, 0x7c, 0x0c, 0x93, 0xa7, 0x38, 0xb2, 0xd5, 0xc2, 0x94, 0x59, 0x7e, 0xf9, 0x13, 0x00, 0x00, 0xff, 0xff, 0x9b, 0xcb, 0xcf, 0x07, 0x3e, 0x02, 0x00, 0x00, } chasquid-1.2/internal/queue/queue.proto000066400000000000000000000016271357247226300203470ustar00rootroot00000000000000 syntax = "proto3"; package queue; import "github.com/golang/protobuf/ptypes/timestamp/timestamp.proto"; message Message { // Message ID. Uniquely identifies this message, it is used for // auditing and troubleshooting. string ID = 1; // The envelope for this message. string from = 2; repeated string To = 3; repeated Recipient rcpt = 4; bytes data = 5; // Creation timestamp. google.protobuf.Timestamp created_at_ts = 6; } message Recipient { // Address to send the message to. // This is the final one, after expanding aliases. string address = 1; enum Type { EMAIL = 0; PIPE = 1; } Type type = 2; enum Status { PENDING = 0; SENT = 1; FAILED = 2; } Status status = 3; string last_failure_message = 4; // Address that this recipient was originally intended to. // This is before expanding aliases and only used in very particular // cases. string original_address = 5; } chasquid-1.2/internal/queue/queue_test.go000066400000000000000000000161461357247226300206520ustar00rootroot00000000000000package queue import ( "bytes" "fmt" "strings" "testing" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/set" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestBasic(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) localC := testlib.NewTestCourier() remoteC := testlib.NewTestCourier() q := New(dir, set.NewString("loco"), aliases.NewResolver(), localC, remoteC) localC.Expect(2) remoteC.Expect(1) id, err := q.Put("from", []string{"am@loco", "x@remote", "nodomain"}, []byte("data")) if err != nil { t.Fatalf("Put: %v", err) } if len(id) < 6 { t.Errorf("short ID: %v", id) } localC.Wait() remoteC.Wait() // Make sure the delivered items leave the queue. testlib.WaitFor(func() bool { return q.Len() == 0 }, 2*time.Second) if q.Len() != 0 { t.Fatalf("%d items not removed from the queue after delivery", q.Len()) } cases := []struct { courier *testlib.TestCourier expectedTo string }{ {localC, "nodomain"}, {localC, "am@loco"}, {remoteC, "x@remote"}, } for _, c := range cases { req := c.courier.ReqFor[c.expectedTo] if req == nil { t.Errorf("missing request for %q", c.expectedTo) continue } if req.From != "from" || req.To != c.expectedTo || !bytes.Equal(req.Data, []byte("data")) { t.Errorf("wrong request for %q: %v", c.expectedTo, req) } } } func TestDSNOnTimeout(t *testing.T) { localC := testlib.NewTestCourier() remoteC := testlib.NewTestCourier() dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q := New(dir, set.NewString("loco"), aliases.NewResolver(), localC, remoteC) // Insert an expired item in the queue. item := &Item{ Message: Message{ ID: <-newID, From: fmt.Sprintf("from@loco"), Rcpt: []*Recipient{ mkR("to@to", Recipient_EMAIL, Recipient_PENDING, "err", "to@to")}, Data: []byte("data"), }, CreatedAt: time.Now().Add(-24 * time.Hour), } q.q[item.ID] = item err := item.WriteTo(q.path) if err != nil { t.Errorf("failed to write item: %v", err) } // Exercise DumpString while at it. q.DumpString() // Launch the sending loop, expect 1 local delivery (the DSN). localC.Expect(1) go item.SendLoop(q) localC.Wait() req := localC.ReqFor["from@loco"] if req == nil { t.Fatal("missing DSN") } if req.From != "<>" || req.To != "from@loco" || !strings.Contains(string(req.Data), "X-Failed-Recipients: to@to,") { t.Errorf("wrong DSN: %q", string(req.Data)) } } func TestAliases(t *testing.T) { localC := testlib.NewTestCourier() remoteC := testlib.NewTestCourier() dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q := New(dir, set.NewString("loco"), aliases.NewResolver(), localC, remoteC) q.aliases.AddDomain("loco") q.aliases.AddAliasForTesting("ab@loco", "pq@loco", aliases.EMAIL) q.aliases.AddAliasForTesting("ab@loco", "rs@loco", aliases.EMAIL) q.aliases.AddAliasForTesting("cd@loco", "ata@hualpa", aliases.EMAIL) // Note the pipe aliases are tested below, as they don't use the couriers // and it can be quite inconvenient to test them in this way. localC.Expect(2) remoteC.Expect(1) _, err := q.Put("from", []string{"ab@loco", "cd@loco"}, []byte("data")) if err != nil { t.Fatalf("Put: %v", err) } localC.Wait() remoteC.Wait() cases := []struct { courier *testlib.TestCourier expectedTo string }{ {localC, "pq@loco"}, {localC, "rs@loco"}, {remoteC, "ata@hualpa"}, } for _, c := range cases { req := c.courier.ReqFor[c.expectedTo] if req == nil { t.Errorf("missing request for %q", c.expectedTo) continue } if req.From != "from" || req.To != c.expectedTo || !bytes.Equal(req.Data, []byte("data")) { t.Errorf("wrong request for %q: %v", c.expectedTo, req) } } } func TestFullQueue(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q := New(dir, set.NewString(), aliases.NewResolver(), testlib.DumbCourier, testlib.DumbCourier) // Force-insert maxQueueSize items in the queue. oneID := "" for i := 0; i < maxQueueSize; i++ { item := &Item{ Message: Message{ ID: <-newID, From: fmt.Sprintf("from-%d", i), Rcpt: []*Recipient{ mkR("to", Recipient_EMAIL, Recipient_PENDING, "", "")}, Data: []byte("data"), }, CreatedAt: time.Now(), } q.q[item.ID] = item oneID = item.ID } // This one should fail due to the queue being too big. id, err := q.Put("from", []string{"to"}, []byte("data-qf")) if err != errQueueFull { t.Errorf("Not failed as expected: %v - %v", id, err) } // Remove one, and try again: it should succeed. // Write it first so we don't get complaints about the file not existing // (as we did not all the items properly). q.q[oneID].WriteTo(q.path) q.Remove(oneID) id, err = q.Put("from", []string{"to"}, []byte("data")) if err != nil { t.Errorf("Put: %v", err) } q.Remove(id) } func TestPipes(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q := New(dir, set.NewString("loco"), aliases.NewResolver(), testlib.DumbCourier, testlib.DumbCourier) item := &Item{ Message: Message{ ID: <-newID, From: "from", Rcpt: []*Recipient{ mkR("true", Recipient_PIPE, Recipient_PENDING, "", "")}, Data: []byte("data"), }, CreatedAt: time.Now(), } if err, _ := item.deliver(q, item.Rcpt[0]); err != nil { t.Errorf("pipe delivery failed: %v", err) } } func TestNextDelay(t *testing.T) { cases := []struct{ since, min time.Duration }{ {10 * time.Second, 1 * time.Minute}, {3 * time.Minute, 5 * time.Minute}, {7 * time.Minute, 10 * time.Minute}, {15 * time.Minute, 20 * time.Minute}, {30 * time.Minute, 20 * time.Minute}, } for _, c := range cases { // Repeat each case a few times to exercise the perturbation a bit. for i := 0; i < 10; i++ { delay := nextDelay(time.Now().Add(-c.since)) max := c.min + 1*time.Minute if delay < c.min || delay > max { t.Errorf("since:%v expected [%v, %v], got %v", c.since, c.min, max, delay) } } } } func TestSerialization(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) // Save an item in the queue directory. item := &Item{ Message: Message{ ID: <-newID, From: fmt.Sprintf("from@loco"), Rcpt: []*Recipient{ mkR("to@to", Recipient_EMAIL, Recipient_PENDING, "err", "to@to")}, Data: []byte("data"), }, CreatedAt: time.Now().Add(-1 * time.Hour), } err := item.WriteTo(dir) if err != nil { t.Errorf("failed to write item: %v", err) } // Create the queue; should load the remoteC := testlib.NewTestCourier() remoteC.Expect(1) q := New(dir, set.NewString("loco"), aliases.NewResolver(), testlib.DumbCourier, remoteC) q.Load() // Launch the sending loop, expect 1 remote delivery for the item we saved. remoteC.Wait() req := remoteC.ReqFor["to@to"] if req == nil { t.Fatal("email not delivered") } if req.From != "from@loco" || req.To != "to@to" { t.Errorf("wrong email: %v", req) } } func mkR(a string, t Recipient_Type, s Recipient_Status, m, o string) *Recipient { return &Recipient{ Address: a, Type: t, Status: s, LastFailureMessage: m, OriginalAddress: o, } } chasquid-1.2/internal/safeio/000077500000000000000000000000001357247226300162525ustar00rootroot00000000000000chasquid-1.2/internal/safeio/safeio.go000066400000000000000000000040621357247226300200510ustar00rootroot00000000000000// Package safeio implements convenient I/O routines that provide additional // levels of safety in the presence of unexpected failures. package safeio import ( "io/ioutil" "os" "path" "syscall" ) // FileOp represents an operation on a file (passed by its name). type FileOp func(fname string) error // WriteFile writes data to a file named by filename, atomically. // // It's a wrapper to ioutil.WriteFile, but provides atomicity (and increased // safety) by writing to a temporary file and renaming it at the end. // // Before the final rename, the given ops (if any) are called. They can be // used to manipulate the file before it is atomically renamed. // If any operation fails, the file is removed and the error is returned. // // Note this relies on same-directory Rename being atomic, which holds in most // reasonably modern filesystems. func WriteFile(filename string, data []byte, perm os.FileMode, ops ...FileOp) error { // Note we create the temporary file in the same directory, otherwise we // would have no expectation of Rename being atomic. // We make the file names start with "." so there's no confusion with the // originals. tmpf, err := ioutil.TempFile(path.Dir(filename), "."+path.Base(filename)) if err != nil { return err } if err = tmpf.Chmod(perm); err != nil { tmpf.Close() os.Remove(tmpf.Name()) return err } if uid, gid := getOwner(filename); uid >= 0 { if err = tmpf.Chown(uid, gid); err != nil { tmpf.Close() os.Remove(tmpf.Name()) return err } } if _, err = tmpf.Write(data); err != nil { tmpf.Close() os.Remove(tmpf.Name()) return err } if err = tmpf.Close(); err != nil { os.Remove(tmpf.Name()) return err } for _, op := range ops { if err = op(tmpf.Name()); err != nil { os.Remove(tmpf.Name()) return err } } return os.Rename(tmpf.Name(), filename) } func getOwner(fname string) (uid, gid int) { uid = -1 gid = -1 stat, err := os.Stat(fname) if err == nil { if sysstat, ok := stat.Sys().(*syscall.Stat_t); ok { uid = int(sysstat.Uid) gid = int(sysstat.Gid) } } return } chasquid-1.2/internal/safeio/safeio_test.go000066400000000000000000000050641357247226300211130ustar00rootroot00000000000000package safeio import ( "bytes" "errors" "fmt" "io/ioutil" "os" "strings" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" ) func testWriteFile(fname string, data []byte, perm os.FileMode, ops ...FileOp) error { err := WriteFile("file1", data, perm, ops...) if err != nil { return fmt.Errorf("error writing new file: %v", err) } // Read and compare the contents. c, err := ioutil.ReadFile(fname) if err != nil { return fmt.Errorf("error reading: %v", err) } if !bytes.Equal(data, c) { return fmt.Errorf("expected %q, got %q", data, c) } // Check permissions. st, err := os.Stat("file1") if err != nil { return fmt.Errorf("error in stat: %v", err) } if st.Mode() != perm { return fmt.Errorf("permissions mismatch, expected %#o, got %#o", st.Mode(), perm) } return nil } func TestWriteFile(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) // Write a new file. content := []byte("content 1") if err := testWriteFile("file1", content, 0660); err != nil { t.Error(err) } // Write an existing file. content = []byte("content 2") if err := testWriteFile("file1", content, 0660); err != nil { t.Error(err) } // Write again, but this time change permissions. content = []byte("content 3") if err := testWriteFile("file1", content, 0600); err != nil { t.Error(err) } } func TestWriteFileWithOp(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) var opFile string op := func(f string) error { opFile = f return nil } content := []byte("content 1") if err := testWriteFile("file1", content, 0660, op); err != nil { t.Error(err) } if opFile == "" { t.Error("operation was not called") } if !strings.Contains(opFile, "file1") { t.Errorf("operation called with suspicious file: %s", opFile) } } func TestWriteFileWithFailingOp(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) var opFile string opOK := func(f string) error { opFile = f return nil } opError := errors.New("operation failed") opFail := func(f string) error { return opError } content := []byte("content 1") err := WriteFile("file1", content, 0660, opOK, opOK, opFail) if err != opError { t.Errorf("different error, got %v, expected %v", err, opError) } if _, err := os.Stat(opFile); err == nil { t.Errorf("temporary file was not removed after failure (%v)", opFile) } } // TODO: We should test the possible failure scenarios for WriteFile, but it // gets tricky without being able to do failure injection (or turning the code // into a mess). chasquid-1.2/internal/set/000077500000000000000000000000001357247226300155775ustar00rootroot00000000000000chasquid-1.2/internal/set/set.go000066400000000000000000000014401357247226300167200ustar00rootroot00000000000000// Package set implement sets for various types. Well, only string for now :) package set // String set. type String struct { m map[string]struct{} } // NewString returns a new string set, with the given values in it. func NewString(values ...string) *String { s := &String{} s.Add(values...) return s } // Add values to the string set. func (s *String) Add(values ...string) { if s.m == nil { s.m = map[string]struct{}{} } for _, v := range values { s.m[v] = struct{}{} } } // Has checks if the set has the given value. func (s *String) Has(value string) bool { // We explicitly allow s to be nil *in this function* to simplify callers' // code. Note that Add will not tolerate it, and will panic. if s == nil || s.m == nil { return false } _, ok := s.m[value] return ok } chasquid-1.2/internal/set/set_test.go000066400000000000000000000014501357247226300177600ustar00rootroot00000000000000package set import "testing" func TestString(t *testing.T) { s1 := &String{} // Test that Has works on a new set. if s1.Has("x") { t.Error("'x' is in the empty set") } s1.Add("a") s1.Add("b", "ccc") expectStrings(s1, []string{"a", "b", "ccc"}, []string{"notin"}, t) s2 := NewString("a", "b", "c") expectStrings(s2, []string{"a", "b", "c"}, []string{"notin"}, t) // Test that Has works (and not panics) on a nil set. var s3 *String if s3.Has("x") { t.Error("'x' is in the nil set") } } func expectStrings(s *String, in []string, notIn []string, t *testing.T) { for _, str := range in { if !s.Has(str) { t.Errorf("String %q not in set, it should be", str) } } for _, str := range notIn { if s.Has(str) { t.Errorf("String %q is in the set, should not be", str) } } } chasquid-1.2/internal/smtp/000077500000000000000000000000001357247226300157675ustar00rootroot00000000000000chasquid-1.2/internal/smtp/smtp.go000066400000000000000000000103601357247226300173010ustar00rootroot00000000000000// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC // 5321. It extends net/smtp as follows: // // - Supports SMTPUTF8, via MailAndRcpt. // - Adds IsPermanent. // package smtp import ( "bufio" "io" "net" "net/smtp" "net/textproto" "unicode" "blitiri.com.ar/go/chasquid/internal/envelope" "golang.org/x/net/idna" ) // A Client represents a client connection to an SMTP server. type Client struct { *smtp.Client } // NewClient uses the given connection to create a new Client. func NewClient(conn net.Conn, host string) (*Client, error) { c, err := smtp.NewClient(conn, host) if err != nil { return nil, err } // Wrap the textproto.Conn reader so we are not exposed to a memory // exhaustion DoS on very long replies from the server. // Limit to 2 MiB total (all replies through the lifetime of the client), // which should be plenty for our uses of SMTP. lr := &io.LimitedReader{R: c.Text.Reader.R, N: 2 * 1024 * 1024} c.Text.Reader.R = bufio.NewReader(lr) return &Client{c}, nil } // cmd sends a command and returns the response over the text connection. // Based on Go's method of the same name. func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { id, err := c.Text.Cmd(format, args...) if err != nil { return 0, "", err } c.Text.StartResponse(id) defer c.Text.EndResponse(id) return c.Text.ReadResponse(expectCode) } // MailAndRcpt issues MAIL FROM and RCPT TO commands, in sequence. // It will check the addresses, decide if SMTPUTF8 is needed, and apply the // necessary transformations. func (c *Client) MailAndRcpt(from string, to string) error { from, fromNeeds, err := c.prepareForSMTPUTF8(from) if err != nil { return err } to, toNeeds, err := c.prepareForSMTPUTF8(to) if err != nil { return err } smtputf8Needed := fromNeeds || toNeeds cmdStr := "MAIL FROM:<%s>" if ok, _ := c.Extension("8BITMIME"); ok { cmdStr += " BODY=8BITMIME" } if smtputf8Needed { cmdStr += " SMTPUTF8" } _, _, err = c.cmd(250, cmdStr, from) if err != nil { return err } _, _, err = c.cmd(25, "RCPT TO:<%s>", to) return err } // prepareForSMTPUTF8 prepares the address for SMTPUTF8. // It returns: // - The address to use. It is based on addr, and possibly modified to make // it not need the extension, if the server does not support it. // - Whether the address needs the extension or not. // - An error if the address needs the extension, but the client does not // support it. func (c *Client) prepareForSMTPUTF8(addr string) (string, bool, error) { // ASCII address pass through. if isASCII(addr) { return addr, false, nil } // Non-ASCII address also pass through if the server supports the // extension. // Note there's a chance the server wants the domain in IDNA anyway, but // it could also require it to be UTF8. We assume that if it supports // SMTPUTF8 then it knows what its doing. if ok, _ := c.Extension("SMTPUTF8"); ok { return addr, true, nil } // Something is not ASCII, and the server does not support SMTPUTF8: // - If it's the local part, there's no way out and is required. // - If it's the domain, use IDNA. user, domain := envelope.Split(addr) if !isASCII(user) { return addr, true, &textproto.Error{Code: 599, Msg: "local part is not ASCII but server does not support SMTPUTF8"} } // If it's only the domain, convert to IDNA and move on. domain, err := idna.ToASCII(domain) if err != nil { // The domain is not IDNA compliant, which is odd. // Fail with a permanent error, not ideal but this should not // happen. return addr, true, &textproto.Error{ Code: 599, Msg: "non-ASCII domain is not IDNA safe"} } return user + "@" + domain, false, nil } // isASCII returns true if all the characters in s are ASCII, false otherwise. func isASCII(s string) bool { for _, c := range s { if c > unicode.MaxASCII { return false } } return true } // IsPermanent returns true if the error is permanent, and false otherwise. // If it can't tell, it returns false. func IsPermanent(err error) bool { terr, ok := err.(*textproto.Error) if !ok { return false } // Error codes 5yz are permanent. // https://tools.ietf.org/html/rfc5321#section-4.2.1 if terr.Code >= 500 && terr.Code < 600 { return true } return false } chasquid-1.2/internal/smtp/smtp_test.go000066400000000000000000000127141357247226300203450ustar00rootroot00000000000000package smtp import ( "bufio" "bytes" "fmt" "io" "net" "net/textproto" "strings" "testing" "time" ) func TestIsPermanent(t *testing.T) { cases := []struct { err error permanent bool }{ {&textproto.Error{Code: 499, Msg: ""}, false}, {&textproto.Error{Code: 500, Msg: ""}, true}, {&textproto.Error{Code: 599, Msg: ""}, true}, {&textproto.Error{Code: 600, Msg: ""}, false}, {fmt.Errorf("something"), false}, } for _, c := range cases { if p := IsPermanent(c.err); p != c.permanent { t.Errorf("%v: expected %v, got %v", c.err, c.permanent, p) } } } func TestIsASCII(t *testing.T) { cases := []struct { str string ascii bool }{ {"", true}, {"<>", true}, {"lalala", true}, {"ñaca", false}, {"año", false}, } for _, c := range cases { if ascii := isASCII(c.str); ascii != c.ascii { t.Errorf("%q: expected %v, got %v", c.str, c.ascii, ascii) } } } func mustNewClient(t *testing.T, nc net.Conn) *Client { t.Helper() c, err := NewClient(nc, "") if err != nil { t.Fatalf("failed to create client: %v", err) } return c } func TestBasic(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO a_test < 250-server replies your hello < 250-SIZE 35651584 < 250-SMTPUTF8 < 250-8BITMIME < 250 HELP > MAIL FROM: BODY=8BITMIME < 250 MAIL FROM is fine > RCPT TO: < 250 RCPT TO is fine `) c := mustNewClient(t, fake) if err := c.Hello("a_test"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("from@from", "to@to"); err != nil { t.Fatalf("MailAndRcpt failed: %v", err) } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestSMTPUTF8(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO araña < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-SMTPUTF8 < 250-8BITMIME < 250 HELP > MAIL FROM: BODY=8BITMIME SMTPUTF8 < 250 MAIL FROM is fine > RCPT TO:<ñaca@ñoño> < 250 RCPT TO is fine `) c := mustNewClient(t, fake) if err := c.Hello("araña"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("año@ñudo", "ñaca@ñoño"); err != nil { t.Fatalf("MailAndRcpt failed: %v\nDialog: %s", err, fake.Client()) } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestSMTPUTF8NotSupported(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO araña < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-8BITMIME < 250 HELP `) c := mustNewClient(t, fake) if err := c.Hello("araña"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("año@ñudo", "ñaca@ñoño"); err != nil { terr, ok := err.(*textproto.Error) if !ok || terr.Code != 599 { t.Fatalf("MailAndRcpt failed with unexpected error: %v\nDialog: %s", err, fake.Client()) } } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestFallbackToIDNA(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO araña < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-8BITMIME < 250 HELP > MAIL FROM: BODY=8BITMIME < 250 MAIL FROM is fine > RCPT TO: < 250 RCPT TO is fine `) c := mustNewClient(t, fake) if err := c.Hello("araña"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("gran@ñudo", "alto@ñoño"); err != nil { terr, ok := err.(*textproto.Error) if !ok || terr.Code != 599 { t.Fatalf("MailAndRcpt failed with unexpected error: %v\nDialog: %s", err, fake.Client()) } } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestLineTooLong(t *testing.T) { // Fake the server sending a >2MiB reply. dialog := `< 220 welcome > EHLO araña < 250 HELP > NOOP < 250 longreply:` + fmt.Sprintf("%2097152s", "x") + `: > NOOP < 250 ok ` fake, client := fakeDialog(dialog) c := mustNewClient(t, fake) if err := c.Hello("araña"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.Noop(); err != nil { t.Errorf("Noop failed: %v", err) } if err := c.Noop(); err != io.EOF { t.Errorf("Expected EOF, got: %v", err) } cmds := fake.Client() if client != cmds { t.Errorf("Got:\n%s\nExpected:\n%s", cmds, client) } } type faker struct { buf *bytes.Buffer *bufio.ReadWriter } func (f faker) Close() error { return nil } func (f faker) LocalAddr() net.Addr { return nil } func (f faker) RemoteAddr() net.Addr { return nil } func (f faker) SetDeadline(time.Time) error { return nil } func (f faker) SetReadDeadline(time.Time) error { return nil } func (f faker) SetWriteDeadline(time.Time) error { return nil } func (f faker) Client() string { f.ReadWriter.Writer.Flush() return f.buf.String() } var _ net.Conn = faker{} // Takes a dialog, returns the corresponding faker and expected client // messages. Ideally we would check this interactively, and it's not that // difficult, but this is good enough for now. func fakeDialog(dialog string) (faker, string) { var client, server string for _, l := range strings.Split(dialog, "\n") { if strings.HasPrefix(l, "< ") { server += l[2:] + "\r\n" } else if strings.HasPrefix(l, "> ") { client += l[2:] + "\r\n" } } fake := faker{} fake.buf = &bytes.Buffer{} fake.ReadWriter = bufio.NewReadWriter( bufio.NewReader(strings.NewReader(server)), bufio.NewWriter(fake.buf)) return fake, client } chasquid-1.2/internal/smtpsrv/000077500000000000000000000000001357247226300165225ustar00rootroot00000000000000chasquid-1.2/internal/smtpsrv/conn.go000066400000000000000000000742751357247226300200250ustar00rootroot00000000000000package smtpsrv import ( "bufio" "bytes" "context" "crypto/tls" "expvar" "flag" "fmt" "io" "io/ioutil" "math/rand" "net" "net/mail" "net/textproto" "os" "os/exec" "strconv" "strings" "syscall" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/auth" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/queue" "blitiri.com.ar/go/chasquid/internal/set" "blitiri.com.ar/go/chasquid/internal/tlsconst" "blitiri.com.ar/go/chasquid/internal/trace" "blitiri.com.ar/go/spf" ) // Exported variables. var ( commandCount = expvar.NewMap("chasquid/smtpIn/commandCount") responseCodeCount = expvar.NewMap("chasquid/smtpIn/responseCodeCount") spfResultCount = expvar.NewMap("chasquid/smtpIn/spfResultCount") loopsDetected = expvar.NewInt("chasquid/smtpIn/loopsDetected") tlsCount = expvar.NewMap("chasquid/smtpIn/tlsCount") slcResults = expvar.NewMap("chasquid/smtpIn/securityLevelChecks") hookResults = expvar.NewMap("chasquid/smtpIn/hookResults") ) var ( maxReceivedHeaders = flag.Int("testing__max_received_headers", 50, "max Received headers, for loop detection; ONLY FOR TESTING") // Some go tests disable SPF, to avoid leaking DNS lookups. disableSPFForTesting = false ) // SocketMode represents the mode for a socket (listening or connection). // We keep them distinct, as policies can differ between them. type SocketMode struct { // Is this mode submission? IsSubmission bool // Is this mode TLS-wrapped? That means that we don't use STARTTLS, the // connection is directly established over TLS (like HTTPS). TLS bool } func (mode SocketMode) String() string { s := "SMTP" if mode.IsSubmission { s = "submission" } if mode.TLS { s += "+TLS" } return s } // Valid socket modes. var ( ModeSMTP = SocketMode{IsSubmission: false, TLS: false} ModeSubmission = SocketMode{IsSubmission: true, TLS: false} ModeSubmissionTLS = SocketMode{IsSubmission: true, TLS: true} ) // Conn represents an incoming SMTP connection. type Conn struct { // Main hostname, used for display only. hostname string // Maximum data size. maxDataSize int64 // Post-DATA hook location. postDataHook string // Connection information. conn net.Conn mode SocketMode tlsConnState *tls.ConnectionState // Reader and text writer, so we can control limits. reader *bufio.Reader writer *bufio.Writer // Tracer to use. tr *trace.Trace // TLS configuration. tlsConfig *tls.Config // Address given at HELO/EHLO, used for tracing purposes. ehloAddress string // Envelope. mailFrom string rcptTo []string data []byte // SPF results. spfResult spf.Result spfError error // Are we using TLS? onTLS bool // Have we used EHLO? isESMTP bool // Authenticator, aliases and local domains, taken from the server at // creation time. authr *auth.Authenticator localDomains *set.String aliasesR *aliases.Resolver dinfo *domaininfo.DB // Have we successfully completed AUTH? completedAuth bool // How many times have we attempted AUTH? authAttempts int // Authenticated user and domain, empty if !completedAuth. authUser string authDomain string // When we should close this connection, no matter what. deadline time.Time // Queue where we put incoming mails. queue *queue.Queue // Time we wait for network operations. commandTimeout time.Duration } // Close the connection. func (c *Conn) Close() { c.conn.Close() } // Handle implements the main protocol loop (reading commands, sending // replies). func (c *Conn) Handle() { defer c.Close() c.tr = trace.New("SMTP.Conn", c.conn.RemoteAddr().String()) defer c.tr.Finish() c.tr.Debugf("Connected, mode: %s", c.mode) // Set the first deadline, which covers possibly the TLS handshake and // then our initial greeting. c.conn.SetDeadline(time.Now().Add(c.commandTimeout)) if tc, ok := c.conn.(*tls.Conn); ok { // For TLS connections, complete the handshake and get the state, so // it can be used when we say hello below. tc.Handshake() cstate := tc.ConnectionState() c.tlsConnState = &cstate if name := c.tlsConnState.ServerName; name != "" { c.hostname = name } } // Set up a buffered reader and writer from the conn. // They will be used to do line-oriented, limited I/O. c.reader = bufio.NewReader(c.conn) c.writer = bufio.NewWriter(c.conn) c.printfLine("220 %s ESMTP chasquid", c.hostname) var cmd, params string var err error var errCount int loop: for { if time.Since(c.deadline) > 0 { err = fmt.Errorf("connection deadline exceeded") c.tr.Error(err) break } c.conn.SetDeadline(time.Now().Add(c.commandTimeout)) cmd, params, err = c.readCommand() if err != nil { c.printfLine("554 error reading command: %v", err) break } if cmd == "AUTH" { c.tr.Debugf("-> AUTH ") } else { c.tr.Debugf("-> %s %s", cmd, params) } var code int var msg string switch cmd { case "HELO": code, msg = c.HELO(params) case "EHLO": code, msg = c.EHLO(params) case "HELP": code, msg = c.HELP(params) case "NOOP": code, msg = c.NOOP(params) case "RSET": code, msg = c.RSET(params) case "VRFY": code, msg = c.VRFY(params) case "EXPN": code, msg = c.EXPN(params) case "MAIL": code, msg = c.MAIL(params) case "RCPT": code, msg = c.RCPT(params) case "DATA": // DATA handles the whole sequence. code, msg = c.DATA(params) case "STARTTLS": code, msg = c.STARTTLS(params) case "AUTH": code, msg = c.AUTH(params) case "QUIT": c.writeResponse(221, "2.0.0 Be seeing you...") break loop default: // Sanitize it a bit to avoid filling the logs and events with // noisy data. Keep the first 6 bytes for debugging. cmd = fmt.Sprintf("unknown<%.6s>", cmd) code = 500 msg = "5.5.1 Unknown command" } commandCount.Add(cmd, 1) if code > 0 { c.tr.Debugf("<- %d %s", code, msg) if code >= 400 { // Be verbose about errors, to help troubleshooting. c.tr.Errorf("%s failed: %d %s", cmd, code, msg) errCount++ if errCount > 10 { // https://tools.ietf.org/html/rfc5321#section-4.3.2 c.tr.Errorf("too many errors, breaking connection") c.writeResponse(421, "4.5.0 Too many errors, bye") break } } err = c.writeResponse(code, msg) if err != nil { break } } } if err != nil { c.tr.Errorf("exiting with error: %v", err) } } // HELO SMTP command handler. func (c *Conn) HELO(params string) (code int, msg string) { if len(strings.TrimSpace(params)) == 0 { return 501, "Invisible customers are not welcome!" } c.ehloAddress = strings.Fields(params)[0] types := []string{ "general store", "used armor dealership", "second-hand bookstore", "liquor emporium", "antique weapons outlet", "delicatessen", "jewelers", "quality apparel and accessories", "hardware", "rare books", "lighting store"} t := types[rand.Int()%len(types)] msg = fmt.Sprintf("Hello my friend, welcome to chasqui's %s!", t) return 250, msg } // EHLO SMTP command handler. func (c *Conn) EHLO(params string) (code int, msg string) { if len(strings.TrimSpace(params)) == 0 { return 501, "Invisible customers are not welcome!" } c.ehloAddress = strings.Fields(params)[0] c.isESMTP = true buf := bytes.NewBuffer(nil) fmt.Fprintf(buf, c.hostname+" - Your hour of destiny has come.\n") fmt.Fprintf(buf, "8BITMIME\n") fmt.Fprintf(buf, "PIPELINING\n") fmt.Fprintf(buf, "SMTPUTF8\n") fmt.Fprintf(buf, "ENHANCEDSTATUSCODES\n") fmt.Fprintf(buf, "SIZE %d\n", c.maxDataSize) if c.onTLS { fmt.Fprintf(buf, "AUTH PLAIN\n") } else { fmt.Fprintf(buf, "STARTTLS\n") } fmt.Fprintf(buf, "HELP\n") return 250, buf.String() } // HELP SMTP command handler. func (c *Conn) HELP(params string) (code int, msg string) { return 214, "2.0.0 Hoy por ti, mañana por mi" } // RSET SMTP command handler. func (c *Conn) RSET(params string) (code int, msg string) { c.resetEnvelope() msgs := []string{ "Who was that Maud person anyway?", "Thinking of Maud you forget everything else.", "Your mind releases itself from mundane concerns.", "As your mind turns inward on itself, you forget everything else.", } return 250, "2.0.0 " + msgs[rand.Int()%len(msgs)] } // VRFY SMTP command handler. func (c *Conn) VRFY(params string) (code int, msg string) { // We intentionally don't implement this command. return 502, "5.5.1 You have a strange feeling for a moment, then it passes." } // EXPN SMTP command handler. func (c *Conn) EXPN(params string) (code int, msg string) { // We intentionally don't implement this command. return 502, "5.5.1 You feel disoriented for a moment." } // NOOP SMTP command handler. func (c *Conn) NOOP(params string) (code int, msg string) { return 250, "2.0.0 You hear a faint typing noise." } // MAIL SMTP command handler. func (c *Conn) MAIL(params string) (code int, msg string) { // params should be: "FROM:", and possibly followed by // options such as "BODY=8BITMIME" (which we ignore). // Check that it begins with "FROM:" first, it's mandatory. if !strings.HasPrefix(strings.ToLower(params), "from:") { return 500, "5.5.2 Unknown command" } if c.mode.IsSubmission && !c.completedAuth { return 550, "5.7.9 Mail to submission port must be authenticated" } rawAddr := "" _, err := fmt.Sscanf(params[5:], "%s ", &rawAddr) if err != nil { return 500, "5.5.4 Malformed command: " + err.Error() } // Note some servers check (and fail) if we had a previous MAIL command, // but that's not according to the RFC. We reset the envelope instead. c.resetEnvelope() // Special case a null reverse-path, which is explicitly allowed and used // for notification messages. // It should be written "<>", we check for that and remove spaces just to // be more flexible. addr := "" if strings.Replace(rawAddr, " ", "", -1) == "<>" { addr = "<>" } else { e, err := mail.ParseAddress(rawAddr) if err != nil || e.Address == "" { return 501, "5.1.7 Sender address malformed" } addr = e.Address if !strings.Contains(addr, "@") { return 501, "5.1.8 Sender address must contain a domain" } // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3 if len(addr) > 256 { return 501, "5.1.7 Sender address too long" } // SPF check - https://tools.ietf.org/html/rfc7208#section-2.4 // We opt not to fail on errors, to avoid accidents from preventing // delivery. c.spfResult, c.spfError = c.checkSPF(addr) if c.spfResult == spf.Fail { // https://tools.ietf.org/html/rfc7208#section-8.4 maillog.Rejected(c.conn.RemoteAddr(), addr, nil, fmt.Sprintf("failed SPF: %v", c.spfError)) return 550, fmt.Sprintf( "5.7.23 SPF check failed: %v", c.spfError) } if !c.secLevelCheck(addr) { maillog.Rejected(c.conn.RemoteAddr(), addr, nil, "security level check failed") return 550, "5.7.3 Security level check failed" } addr, err = normalize.DomainToUnicode(addr) if err != nil { maillog.Rejected(c.conn.RemoteAddr(), addr, nil, fmt.Sprintf("malformed address: %v", err)) return 501, "5.1.8 Malformed sender domain (IDNA conversion failed)" } } c.mailFrom = addr return 250, "2.1.5 You feel like you are being watched" } // checkSPF for the given address, based on the current connection. func (c *Conn) checkSPF(addr string) (spf.Result, error) { // Does not apply to authenticated connections, they're allowed regardless. if c.completedAuth { return "", nil } if disableSPFForTesting { return "", nil } if tcp, ok := c.conn.RemoteAddr().(*net.TCPAddr); ok { res, err := spf.CheckHostWithSender( tcp.IP, envelope.DomainOf(addr), addr) c.tr.Debugf("SPF %v (%v)", res, err) spfResultCount.Add(string(res), 1) return res, err } return "", nil } // secLevelCheck checks if the security level is acceptable for the given // address. func (c *Conn) secLevelCheck(addr string) bool { // Only check if SPF passes. This serves two purposes: // - Skip for authenticated connections (we trust them implicitly). // - Don't apply this if we can't be sure the sender is authorized. // Otherwise anyone could raise the level of any domain. if c.spfResult != spf.Pass { slcResults.Add("skip", 1) c.tr.Debugf("SPF did not pass, skipping security level check") return true } domain := envelope.DomainOf(addr) level := domaininfo.SecLevel_PLAIN if c.onTLS { level = domaininfo.SecLevel_TLS_CLIENT } ok := c.dinfo.IncomingSecLevel(domain, level) if ok { slcResults.Add("pass", 1) c.tr.Debugf("security level check for %s passed (%s)", domain, level) } else { slcResults.Add("fail", 1) c.tr.Errorf("security level check for %s failed (%s)", domain, level) } return ok } // RCPT SMTP command handler. func (c *Conn) RCPT(params string) (code int, msg string) { // params should be: "TO:", and possibly followed by options // such as "NOTIFY=SUCCESS,DELAY" (which we ignore). // Check that it begins with "TO:" first, it's mandatory. if !strings.HasPrefix(strings.ToLower(params), "to:") { return 500, "5.5.2 Unknown command" } if c.mailFrom == "" { return 503, "5.5.1 Sender not yet given" } rawAddr := "" _, err := fmt.Sscanf(params[3:], "%s ", &rawAddr) if err != nil { return 500, "5.5.4 Malformed command: " + err.Error() } // RFC says 100 is the minimum limit for this, but it seems excessive. // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.8 if len(c.rcptTo) > 100 { return 452, "4.5.3 Too many recipients" } e, err := mail.ParseAddress(rawAddr) if err != nil || e.Address == "" { return 501, "5.1.3 Malformed destination address" } addr, err := normalize.DomainToUnicode(e.Address) if err != nil { return 501, "5.1.2 Malformed destination domain (IDNA conversion failed)" } // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3 if len(addr) > 256 { return 501, "5.1.3 Destination address too long" } localDst := envelope.DomainIn(addr, c.localDomains) if !localDst && !c.completedAuth { maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr}, "relay not allowed") return 503, "5.7.1 Relay not allowed" } if localDst { addr, err = normalize.Addr(addr) if err != nil { maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr}, fmt.Sprintf("invalid address: %v", err)) return 550, "5.1.3 Destination address is invalid" } if !c.userExists(addr) { maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr}, "local user does not exist") return 550, "5.1.1 Destination address is unknown (user does not exist)" } } c.rcptTo = append(c.rcptTo, addr) return 250, "2.1.5 You have an eerie feeling..." } // DATA SMTP command handler. func (c *Conn) DATA(params string) (code int, msg string) { if c.ehloAddress == "" { return 503, "5.5.1 Invisible customers are not welcome!" } if c.mailFrom == "" { return 503, "5.5.1 Sender not yet given" } if len(c.rcptTo) == 0 { return 503, "5.5.1 Need an address to send to" } // We're going ahead. err := c.writeResponse(354, "You suddenly realize it is unnaturally quiet") if err != nil { return 554, fmt.Sprintf("5.4.0 Error writing DATA response: %v", err) } c.tr.Debugf("<- 354 You experience a strange sense of peace") if c.onTLS { tlsCount.Add("tls", 1) } else { tlsCount.Add("plain", 1) } // Increase the deadline for the data transfer to the connection-level // one, we don't want the command timeout to interfere. c.conn.SetDeadline(c.deadline) // Create a dot reader, limited to the maximum size. dotr := textproto.NewReader(bufio.NewReader( io.LimitReader(c.reader, c.maxDataSize))).DotReader() c.data, err = ioutil.ReadAll(dotr) if err != nil { if err == io.ErrUnexpectedEOF { // Message is too big already. But we need to keep reading until we see // the "\r\n.\r\n", otherwise we will treat the remanent data that // the user keeps sending as commands, and that's a security // issue. readUntilDot(c.reader) return 552, fmt.Sprintf("5.3.4 Message too big") } return 554, fmt.Sprintf("5.4.0 Error reading DATA: %v", err) } c.tr.Debugf("-> ... %d bytes of data", len(c.data)) if err := checkData(c.data); err != nil { maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, c.rcptTo, err.Error()) return 554, err.Error() } c.addReceivedHeader() hookOut, permanent, err := c.runPostDataHook(c.data) if err != nil { maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, c.rcptTo, err.Error()) if permanent { return 554, err.Error() } return 451, err.Error() } c.data = append(hookOut, c.data...) // There are no partial failures here: we put it in the queue, and then if // individual deliveries fail, we report via email. // If we fail to queue, return a transient error. msgID, err := c.queue.Put(c.mailFrom, c.rcptTo, c.data) if err != nil { return 451, fmt.Sprintf("4.3.0 Failed to queue message: %v", err) } c.tr.Printf("Queued from %s to %s - %s", c.mailFrom, c.rcptTo, msgID) maillog.Queued(c.conn.RemoteAddr(), c.mailFrom, c.rcptTo, msgID) // It is very important that we reset the envelope before returning, // so clients can send other emails right away without needing to RSET. c.resetEnvelope() msgs := []string{ "You offer the Amulet of Yendor to Anhur...", "An invisible choir sings, and you are bathed in radiance...", "The voice of Anhur booms out: Congratulations, mortal!", "In return to thy service, I grant thee the gift of Immortality!", "You ascend to the status of Demigod(dess)...", } return 250, "2.0.0 " + msgs[rand.Int()%len(msgs)] } func (c *Conn) addReceivedHeader() { var v string // Format is semi-structured, defined by // https://tools.ietf.org/html/rfc5321#section-4.4 if c.completedAuth { // For authenticated users, only show the EHLO address they gave; // explicitly hide their network address. v += fmt.Sprintf("from %s\n", c.ehloAddress) } else { // For non-authenticated users we show the real address as canonical, // and then the given EHLO address for convenience and // troubleshooting. v += fmt.Sprintf("from [%s] (%s)\n", addrLiteral(c.conn.RemoteAddr()), c.ehloAddress) } v += fmt.Sprintf("by %s (chasquid) ", c.hostname) // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#mail-parameters-7 with := "SMTP" if c.isESMTP { with = "ESMTP" } if c.onTLS { with += "S" } if c.completedAuth { with += "A" } v += fmt.Sprintf("with %s\n", with) if c.tlsConnState != nil { // https://tools.ietf.org/html/rfc8314#section-4.3 v += fmt.Sprintf("tls %s\n", tlsconst.CipherSuiteName(c.tlsConnState.CipherSuite)) } v += fmt.Sprintf("(over %s, ", c.mode) if c.tlsConnState != nil { v += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version)) } else { v += "plain text!, " } // Note we must NOT include c.rcptTo, that would leak BCCs. v += fmt.Sprintf("envelope from %q)\n", c.mailFrom) // This should be the last part in the Received header, by RFC. // The ";" is a mandatory separator. The date format is not standard but // this one seems to be widely used. // https://tools.ietf.org/html/rfc5322#section-3.6.7 v += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z)) c.data = envelope.AddHeader(c.data, "Received", v) if c.spfResult != "" { // https://tools.ietf.org/html/rfc7208#section-9.1 v = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError) c.data = envelope.AddHeader(c.data, "Received-SPF", v) } } // addrLiteral converts a net.Addr (must be TCP) into a string for use as // address literal, compliant with // https://tools.ietf.org/html/rfc5321#section-4.1.3. func addrLiteral(addr net.Addr) string { tcp, ok := addr.(*net.TCPAddr) if !ok { // Fall back to Go's string representation; non-compliant but // better than anything for our purposes. return addr.String() } // IPv6 addresses take the "IPv6:" prefix. // IPv4 addresses are used literally. s := tcp.IP.String() if strings.Contains(s, ":") { return "IPv6:" + s } return s } // checkData performs very basic checks on the body of the email, to help // detect very broad problems like email loops. It does not fully check the // sanity of the headers or the structure of the payload. func checkData(data []byte) error { msg, err := mail.ReadMessage(bytes.NewBuffer(data)) if err != nil { return fmt.Errorf("5.6.0 Error parsing message: %v", err) } // This serves as a basic form of loop prevention. It's not infallible but // should catch most instances of accidental looping. // https://tools.ietf.org/html/rfc5321#section-6.3 if len(msg.Header["Received"]) > *maxReceivedHeaders { loopsDetected.Add(1) return fmt.Errorf("5.4.6 Loop detected (%d hops)", *maxReceivedHeaders) } return nil } // runPostDataHook and return the new headers to add, and on error a boolean // indicating if it's permanent, and the error itself. func (c *Conn) runPostDataHook(data []byte) ([]byte, bool, error) { // TODO: check if the file is executable. if _, err := os.Stat(c.postDataHook); os.IsNotExist(err) { hookResults.Add("post-data:skip", 1) return nil, false, nil } tr := trace.New("Hook.Post-DATA", c.conn.RemoteAddr().String()) defer tr.Finish() ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, c.postDataHook) cmd.Stdin = bytes.NewReader(data) // Prepare the environment, copying some common variables so the hook has // someting reasonable, and then setting the specific ones for this case. for _, v := range strings.Fields("USER PWD SHELL PATH") { cmd.Env = append(cmd.Env, v+"="+os.Getenv(v)) } cmd.Env = append(cmd.Env, "REMOTE_ADDR="+c.conn.RemoteAddr().String()) cmd.Env = append(cmd.Env, "MAIL_FROM="+c.mailFrom) cmd.Env = append(cmd.Env, "RCPT_TO="+strings.Join(c.rcptTo, " ")) if c.completedAuth { cmd.Env = append(cmd.Env, "AUTH_AS="+c.authUser+"@"+c.authDomain) } else { cmd.Env = append(cmd.Env, "AUTH_AS=") } cmd.Env = append(cmd.Env, "ON_TLS="+boolToStr(c.onTLS)) cmd.Env = append(cmd.Env, "FROM_LOCAL_DOMAIN="+boolToStr( envelope.DomainIn(c.mailFrom, c.localDomains))) cmd.Env = append(cmd.Env, "SPF_PASS="+boolToStr(c.spfResult == spf.Pass)) out, err := cmd.Output() tr.Debugf("stdout: %q", out) if err != nil { hookResults.Add("post-data:fail", 1) tr.Error(err) permanent := false if ee, ok := err.(*exec.ExitError); ok { tr.Printf("stderr: %q", string(ee.Stderr)) if status, ok := ee.Sys().(syscall.WaitStatus); ok { permanent = status.ExitStatus() == 20 } } // The error contains the last line of stdout, so filters can pass // some rejection information back to the sender. err = fmt.Errorf(lastLine(string(out))) return nil, permanent, err } // Check that output looks like headers, to avoid breaking the email // contents. If it does not, just skip it. if !isHeader(out) { hookResults.Add("post-data:badoutput", 1) tr.Errorf("error parsing post-data output: %q", out) return nil, false, nil } tr.Debugf("success") hookResults.Add("post-data:success", 1) return out, false, nil } // isHeader checks if the given buffer is a valid MIME header. func isHeader(b []byte) bool { s := string(b) if len(s) == 0 { return true } // If it is just a \n, or contains two \n, then it's not a header. if s == "\n" || strings.Contains(s, "\n\n") { return false } // If it does not end in \n, not a header. if s[len(s)-1] != '\n' { return false } // Each line must either start with a space or have a ':'. seen := false for _, line := range strings.SplitAfter(s, "\n") { if line == "" { continue } if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { if !seen { // Continuation without a header first (invalid). return false } continue } if !strings.Contains(line, ":") { return false } seen = true } return true } func lastLine(s string) string { l := strings.Split(s, "\n") if len(l) < 2 { return "" } return l[len(l)-2] } func boolToStr(b bool) string { if b { return "1" } return "0" } func readUntilDot(r *bufio.Reader) { prevMore := false for { // The reader will not read more than the size of the buffer, // so this doesn't cause increased memory consumption. // The reader's data deadline will prevent this from continuing // forever. l, more, err := r.ReadLine() if err != nil { break } if !more && !prevMore && string(l) == "." { break } prevMore = more } } // STARTTLS SMTP command handler. func (c *Conn) STARTTLS(params string) (code int, msg string) { if c.onTLS { return 503, "5.5.1 You are already wearing that!" } err := c.writeResponse(220, "2.0.0 You experience a strange sense of peace") if err != nil { return 554, fmt.Sprintf("5.4.0 Error writing STARTTLS response: %v", err) } c.tr.Debugf("<- 220 You experience a strange sense of peace") server := tls.Server(c.conn, c.tlsConfig) err = server.Handshake() if err != nil { return 554, fmt.Sprintf("5.5.0 Error in TLS handshake: %v", err) } c.tr.Debugf("<> ... jump to TLS was successful") // Override the connection. We don't need the older one anymore. c.conn = server c.reader = bufio.NewReader(c.conn) c.writer = bufio.NewWriter(c.conn) // Take the connection state, so we can use it later for logging and // tracing purposes. cstate := server.ConnectionState() c.tlsConnState = &cstate // Reset the envelope; clients must start over after switching to TLS. c.resetEnvelope() c.onTLS = true // If the client requested a specific server and we complied, that's our // identity from now on. if name := c.tlsConnState.ServerName; name != "" { c.hostname = name } // 0 indicates not to send back a reply. return 0, "" } // AUTH SMTP command handler. func (c *Conn) AUTH(params string) (code int, msg string) { if !c.onTLS { return 503, "5.7.10 You feel vulnerable" } if c.completedAuth { // After a successful AUTH command completes, a server MUST reject // any further AUTH commands with a 503 reply. // https://tools.ietf.org/html/rfc4954#section-4 return 503, "5.5.1 You are already wearing that!" } if c.authAttempts > 3 { return 503, "5.7.8 Too many attempts, go away" } c.authAttempts++ // We only support PLAIN for now, so no need to make this too complicated. // Params should be either "PLAIN" or "PLAIN ". // If the response is not there, we reply with 334, and expect the // response back from the client in the next message. sp := strings.SplitN(params, " ", 2) if len(sp) < 1 || sp[0] != "PLAIN" { // As we only offer plain, this should not really happen. return 534, "5.7.9 Asmodeus demands 534 zorkmids for safe passage" } // Note we use more "serious" error messages from now own, as these may // find their way to the users in some circumstances. // Get the response, either from the message or interactively. response := "" if len(sp) == 2 { response = sp[1] } else { // Reply 334 and expect the user to provide it. // In this case, the text IS relevant, as it is taken as the // server-side SASL challenge (empty for PLAIN). // https://tools.ietf.org/html/rfc4954#section-4 err := c.writeResponse(334, "") if err != nil { return 554, fmt.Sprintf("5.4.0 Error writing AUTH 334: %v", err) } response, err = c.readLine() if err != nil { return 554, fmt.Sprintf("5.4.0 Error reading AUTH response: %v", err) } } user, domain, passwd, err := auth.DecodeResponse(response) if err != nil { // https://tools.ietf.org/html/rfc4954#section-4 return 501, fmt.Sprintf("5.5.2 Error decoding AUTH response: %v", err) } authOk, err := c.authr.Authenticate(user, domain, passwd) if err != nil { c.tr.Errorf("error authenticating %q@%q: %v", user, domain, err) } if authOk { c.authUser = user c.authDomain = domain c.completedAuth = true maillog.Auth(c.conn.RemoteAddr(), user+"@"+domain, true) return 235, "2.7.0 Authentication successful" } maillog.Auth(c.conn.RemoteAddr(), user+"@"+domain, false) return 535, "5.7.8 Incorrect user or password" } func (c *Conn) resetEnvelope() { c.mailFrom = "" c.rcptTo = nil c.data = nil c.spfResult = "" c.spfError = nil } func (c *Conn) userExists(addr string) bool { var ok bool addr, ok = c.aliasesR.Exists(addr) if ok { return true } // Note we used the address returned by the aliases resolver, which has // cleaned it up. This means that a check for "us.er@domain" will have us // look up "user" in our databases if the domain is local, which is what // we want. user, domain := envelope.Split(addr) ok, err := c.authr.Exists(user, domain) if err != nil { c.tr.Errorf("error checking if user %q exists: %v", addr, err) } return ok } func (c *Conn) readCommand() (cmd, params string, err error) { msg, err := c.readLine() if err != nil { return "", "", err } sp := strings.SplitN(msg, " ", 2) cmd = strings.ToUpper(sp[0]) if len(sp) > 1 { params = sp[1] } return cmd, params, err } func (c *Conn) readLine() (line string, err error) { // The bufio reader's ReadLine will only read up to the buffer size, which // prevents DoS due to memory exhaustion on extremely long lines. l, more, err := c.reader.ReadLine() if err != nil { return "", err } // As per RFC, the maximum length of a text line is 1000 octets. // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.6 if len(l) > 1000 || more { // Keep reading to maintain the protocol status, but discard the data. for more && err == nil { _, more, err = c.reader.ReadLine() } return "", fmt.Errorf("line too long") } return string(l), nil } func (c *Conn) writeResponse(code int, msg string) error { defer c.writer.Flush() responseCodeCount.Add(strconv.Itoa(code), 1) return writeResponse(c.writer, code, msg) } func (c *Conn) printfLine(format string, args ...interface{}) error { fmt.Fprintf(c.writer, format+"\r\n", args...) return c.writer.Flush() } // writeResponse writes a multi-line response to the given writer. // This is the writing version of textproto.Reader.ReadResponse(). func writeResponse(w io.Writer, code int, msg string) error { var i int lines := strings.Split(msg, "\n") // The first N-1 lines use "-". for i = 0; i < len(lines)-2; i++ { _, err := w.Write([]byte(fmt.Sprintf("%d-%s\r\n", code, lines[i]))) if err != nil { return err } } // The last line uses " ". _, err := w.Write([]byte(fmt.Sprintf("%d %s\r\n", code, lines[i]))) if err != nil { return err } return nil } chasquid-1.2/internal/smtpsrv/conn_test.go000066400000000000000000000073741357247226300210600ustar00rootroot00000000000000package smtpsrv import ( "bufio" "net" "strings" "testing" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/trace" "blitiri.com.ar/go/spf" ) func TestSecLevel(t *testing.T) { // We can't simulate this externally because of the SPF record // requirement, so do a narrow test on Conn.secLevelCheck. dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) dinfo, err := domaininfo.New(dir) if err != nil { t.Fatalf("Failed to create domain info: %v", err) } c := &Conn{ tr: trace.New("testconn", "testconn"), dinfo: dinfo, } // No SPF, skip security checks. c.spfResult = spf.None c.onTLS = true if !c.secLevelCheck("from@slc") { t.Fatalf("TLS seclevel failed") } c.onTLS = false if !c.secLevelCheck("from@slc") { t.Fatalf("plain seclevel failed, even though SPF does not exist") } // Now the real checks, once SPF passes. c.spfResult = spf.Pass if !c.secLevelCheck("from@slc") { t.Fatalf("plain seclevel failed") } c.onTLS = true if !c.secLevelCheck("from@slc") { t.Fatalf("TLS seclevel failed") } c.onTLS = false if c.secLevelCheck("from@slc") { t.Fatalf("plain seclevel worked, downgrade was allowed") } } func TestIsHeader(t *testing.T) { no := []string{ "a", "\n", "\n\n", " \n", " ", "a:b", "a: b\nx: y", "\na:b\n", " a\nb:c\n", } for _, s := range no { if isHeader([]byte(s)) { t.Errorf("%q accepted as header, should be rejected", s) } } yes := []string{ "", "a:b\n", "X-Post-Data: success\n", } for _, s := range yes { if !isHeader([]byte(s)) { t.Errorf("%q rejected as header, should be accepted", s) } } } func TestReadUntilDot(t *testing.T) { // This must be > than the minimum buffer size for bufio.Reader, which // unfortunately is not available to us. The current value is 16, these // tests will break if it gets increased, and the nonfinal cases will need // to be adjusted. size := 20 xs := "12345678901234567890" final := []string{ "", ".", "..", ".\r\n", "\r\n.", "\r\n.\r\n", ".\n", "\n.", "\n.\n", ".\r", "\r.", "\r.\r", xs + "\r\n.\r\n", xs + "1234\r\n.\r\n", xs + xs + "\r\n.\r\n", xs + xs + xs + "\r\n.\r\n", xs + "." + xs + "\n.", xs + ".\n" + xs + "\n.", } for _, s := range final { t.Logf("testing %q", s) buf := bufio.NewReaderSize(strings.NewReader(s), size) readUntilDot(buf) if r := buf.Buffered(); r != 0 { t.Errorf("%q: there are %d remaining bytes", s, r) } } nonfinal := []struct { s string r int }{ {".\na", 1}, {"\n.\na", 1}, {"\n.\nabc", 3}, {"\n.\n12345678", 8}, {"\n.\n" + xs, size - 3}, {"\n.\n" + xs + xs, size - 3}, {"\n.\n.\n", 2}, } for _, c := range nonfinal { t.Logf("testing %q", c.s) buf := bufio.NewReaderSize(strings.NewReader(c.s), size) readUntilDot(buf) if r := buf.Buffered(); r != c.r { t.Errorf("%q: expected %d remaining bytes, got %d", c.s, c.r, r) } } } func TestAddrLiteral(t *testing.T) { // TCP addresses. casesTCP := []struct { addr net.IP expected string }{ {net.IPv4(1, 2, 3, 4), "1.2.3.4"}, {net.IPv4(0, 0, 0, 0), "0.0.0.0"}, {net.ParseIP("1.2.3.4"), "1.2.3.4"}, {net.ParseIP("2001:db8::68"), "IPv6:2001:db8::68"}, {net.ParseIP("::1"), "IPv6:::1"}, } for _, c := range casesTCP { tcp := &net.TCPAddr{ IP: c.addr, Port: 12345, } s := addrLiteral(tcp) if s != c.expected { t.Errorf("%v: expected %q, got %q", tcp, c.expected, s) } } // Non-TCP addresses. We expect these to match addr.String(). casesOther := []net.Addr{ &net.UDPAddr{ IP: net.ParseIP("1.2.3.4"), Port: 12345, }, } for _, addr := range casesOther { s := addrLiteral(addr) if s != addr.String() { t.Errorf("%v: expected %q, got %q", addr, addr.String(), s) } } } chasquid-1.2/internal/smtpsrv/fuzz.go000066400000000000000000000132731357247226300200550ustar00rootroot00000000000000// Fuzz testing for package smtpsrv. Based on server_test. // +build gofuzz package smtpsrv import ( "bufio" "bytes" "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "flag" "fmt" "io" "io/ioutil" "math/big" "net" "net/textproto" "os" "strings" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/courier" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/userdb" "blitiri.com.ar/go/log" ) var ( // Server addresses. Will be filled in at init time. smtpAddr = "" submissionAddr = "" submissionTLSAddr = "" // TLS configuration to use in the clients. // Will contain the generated server certificate as root CA. tlsConfig *tls.Config ) // // === Fuzz test === // func Fuzz(data []byte) int { // Byte 0: mode // The rest is what we will send the server, one line per command. if len(data) < 1 { return 0 } var mode SocketMode addr := "" switch data[0] { case '0': mode = ModeSMTP addr = smtpAddr case '1': mode = ModeSubmission addr = submissionAddr case '2': mode = ModeSubmissionTLS addr = submissionTLSAddr default: return 0 } data = data[1:] var err error var conn net.Conn if mode.TLS { conn, err = tls.Dial("tcp", addr, tlsConfig) } else { conn, err = net.Dial("tcp", addr) } if err != nil { panic(fmt.Errorf("failed to dial: %v", err)) } defer conn.Close() tconn := textproto.NewConn(conn) defer tconn.Close() in_data := false scanner := bufio.NewScanner(bytes.NewBuffer(data)) for scanner.Scan() { line := scanner.Text() // Skip STARTTLS if it happens on a non-TLS connection - the jump is // not going to happen via fuzzer, it will just cause a timeout (which // is considered a crash). if strings.TrimSpace(strings.ToUpper(line)) == "STARTTLS" && !mode.TLS { continue } if err = tconn.PrintfLine(line); err != nil { break } if in_data { if line == "." { in_data = false } else { continue } } if _, _, err = tconn.ReadResponse(-1); err != nil { break } in_data = strings.HasPrefix(strings.ToUpper(line), "DATA") } if (err != nil && err != io.EOF) || scanner.Err() != nil { return 1 } return 0 } // // === Test environment === // // generateCert generates a new, INSECURE self-signed certificate and writes // it to a pair of (cert.pem, key.pem) files to the given path. // Note the certificate is only useful for testing purposes. func generateCert(path string) error { tmpl := x509.Certificate{ SerialNumber: big.NewInt(1234), Subject: pkix.Name{ Organization: []string{"chasquid_test.go"}, }, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, NotBefore: time.Now(), NotAfter: time.Now().Add(24 * time.Hour), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, BasicConstraintsValid: true, IsCA: true, } priv, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { return err } derBytes, err := x509.CreateCertificate( rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { return err } // Create a global config for convenience. srvCert, err := x509.ParseCertificate(derBytes) if err != nil { return err } rootCAs := x509.NewCertPool() rootCAs.AddCert(srvCert) tlsConfig = &tls.Config{ ServerName: "localhost", RootCAs: rootCAs, } certOut, err := os.Create(path + "/cert.pem") if err != nil { return err } defer certOut.Close() pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) keyOut, err := os.OpenFile( path+"/key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer keyOut.Close() block := &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv), } pem.Encode(keyOut, block) return nil } // waitForServer waits 10 seconds for the server to start, and returns an error // if it fails to do so. // It does this by repeatedly connecting to the address until it either // replies or times out. Note we do not do any validation of the reply. func waitForServer(addr string) { start := time.Now() for time.Since(start) < 10*time.Second { conn, err := net.Dial("tcp", addr) if err == nil { conn.Close() return } time.Sleep(100 * time.Millisecond) } panic(fmt.Errorf("%v not reachable", addr)) } func init() { flag.Parse() log.Default.Level = log.Debug // Generate certificates in a temporary directory. tmpDir, err := ioutil.TempDir("", "chasquid_smtpsrv_fuzz:") if err != nil { panic(fmt.Errorf("Failed to create temp dir: %v\n", tmpDir)) } defer os.RemoveAll(tmpDir) err = generateCert(tmpDir) if err != nil { panic(fmt.Errorf("Failed to generate cert for testing: %v\n", err)) } smtpAddr = testlib.GetFreePort() submissionAddr = testlib.GetFreePort() submissionTLSAddr = testlib.GetFreePort() s := NewServer() s.Hostname = "localhost" s.MaxDataSize = 50 * 1024 * 1025 s.AddCerts(tmpDir+"/cert.pem", tmpDir+"/key.pem") s.AddAddr(smtpAddr, ModeSMTP) s.AddAddr(submissionAddr, ModeSubmission) s.AddAddr(submissionTLSAddr, ModeSubmissionTLS) localC := &courier.Procmail{} remoteC := &courier.SMTP{} s.InitQueue(tmpDir+"/queue", localC, remoteC) s.InitDomainInfo(tmpDir + "/domaininfo") udb := userdb.New("/dev/null") udb.AddUser("testuser", "testpasswd") s.aliasesR.AddAliasForTesting( "to@localhost", "testuser@localhost", aliases.EMAIL) s.AddDomain("localhost") s.AddUserDB("localhost", udb) // Disable SPF lookups, to avoid leaking DNS queries. disableSPFForTesting = true go s.ListenAndServe() waitForServer(smtpAddr) waitForServer(submissionAddr) waitForServer(submissionTLSAddr) } chasquid-1.2/internal/smtpsrv/server.go000066400000000000000000000155271357247226300203710ustar00rootroot00000000000000// Package smtpsrv implements chasquid's SMTP server and connection handler. package smtpsrv import ( "crypto/tls" "flag" "net" "net/http" "path" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/auth" "blitiri.com.ar/go/chasquid/internal/courier" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/queue" "blitiri.com.ar/go/chasquid/internal/set" "blitiri.com.ar/go/chasquid/internal/userdb" "blitiri.com.ar/go/log" ) var ( // Reload frequency. // We should consider making this a proper option if there's interest in // changing it, but until then, it's a test-only flag for simplicity. reloadEvery = flag.Duration("testing__reload_every", 30*time.Second, "how often to reload, ONLY FOR TESTING") ) // Server represents an SMTP server instance. type Server struct { // Main hostname, used for display only. Hostname string // Maximum data size. MaxDataSize int64 // Addresses. addrs map[SocketMode][]string // Listeners (that came via systemd). listeners map[SocketMode][]net.Listener // TLS config (including loaded certificates). tlsConfig *tls.Config // Local domains. localDomains *set.String // User databases (per domain). // Authenticator. authr *auth.Authenticator // Aliases resolver. aliasesR *aliases.Resolver // Domain info database. dinfo *domaininfo.DB // Time before we give up on a connection, even if it's sending data. connTimeout time.Duration // Time we wait for command round-trips (excluding DATA). commandTimeout time.Duration // Queue where we put incoming mail. queue *queue.Queue // Path to the hooks. HookPath string } // NewServer returns a new empty Server. func NewServer() *Server { return &Server{ addrs: map[SocketMode][]string{}, listeners: map[SocketMode][]net.Listener{}, tlsConfig: &tls.Config{}, connTimeout: 20 * time.Minute, commandTimeout: 1 * time.Minute, localDomains: &set.String{}, authr: auth.NewAuthenticator(), aliasesR: aliases.NewResolver(), } } // AddCerts (TLS) to the server. func (s *Server) AddCerts(certPath, keyPath string) error { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return err } s.tlsConfig.Certificates = append(s.tlsConfig.Certificates, cert) return nil } // AddAddr adds an address for the server to listen on. func (s *Server) AddAddr(a string, m SocketMode) { s.addrs[m] = append(s.addrs[m], a) } // AddListeners adds listeners for the server to listen on. func (s *Server) AddListeners(ls []net.Listener, m SocketMode) { s.listeners[m] = append(s.listeners[m], ls...) } // AddDomain adds a local domain to the server. func (s *Server) AddDomain(d string) { s.localDomains.Add(d) s.aliasesR.AddDomain(d) } // AddUserDB adds a userdb.DB instance as backend for the domain. func (s *Server) AddUserDB(domain string, db *userdb.DB) { s.authr.Register(domain, auth.WrapNoErrorBackend(db)) } // AddAliasesFile adds an aliases file for the given domain. func (s *Server) AddAliasesFile(domain, f string) error { return s.aliasesR.AddAliasesFile(domain, f) } // SetAuthFallback sets the authentication backend to use as fallback. func (s *Server) SetAuthFallback(be auth.Backend) { s.authr.Fallback = be } // SetAliasesConfig sets the aliases configuration options. func (s *Server) SetAliasesConfig(suffixSep, dropChars string) { s.aliasesR.SuffixSep = suffixSep s.aliasesR.DropChars = dropChars s.aliasesR.ResolveHook = path.Join(s.HookPath, "alias-resolve") s.aliasesR.ExistsHook = path.Join(s.HookPath, "alias-exists") } // InitDomainInfo initializes the domain info database. func (s *Server) InitDomainInfo(dir string) *domaininfo.DB { var err error s.dinfo, err = domaininfo.New(dir) if err != nil { log.Fatalf("Error opening domain info database: %v", err) } return s.dinfo } // InitQueue initializes the queue. func (s *Server) InitQueue(path string, localC, remoteC courier.Courier) { q := queue.New(path, s.localDomains, s.aliasesR, localC, remoteC) err := q.Load() if err != nil { log.Fatalf("Error loading queue: %v", err) } s.queue = q http.HandleFunc("/debug/queue", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(q.DumpString())) }) } // periodicallyReload some of the server's information, such as aliases and // the user databases. func (s *Server) periodicallyReload() { if reloadEvery == nil { return } for range time.Tick(*reloadEvery) { err := s.aliasesR.Reload() if err != nil { log.Errorf("Error reloading aliases: %v", err) } err = s.authr.Reload() if err != nil { log.Errorf("Error reloading authenticators: %v", err) } err = s.dinfo.Reload() if err != nil { log.Errorf("Error reloading domaininfo: %v", err) } } } // ListenAndServe on the addresses and listeners that were previously added. // This function will not return. func (s *Server) ListenAndServe() { if len(s.tlsConfig.Certificates) == 0 { // chasquid assumes there's at least one valid certificate (for things // like STARTTLS, user authentication, etc.), so we fail if none was // found. log.Errorf("No SSL/TLS certificates found") log.Errorf("Ideally there should be a certificate for each MX you act as") log.Fatalf("At least one valid certificate is needed") } // At this point the TLS config should be done, build the // name->certificate map (used by the TLS library for SNI). s.tlsConfig.BuildNameToCertificate() go s.periodicallyReload() for m, addrs := range s.addrs { for _, addr := range addrs { l, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("Error listening: %v", err) } log.Infof("Server listening on %s (%v)", addr, m) maillog.Listening(addr) go s.serve(l, m) } } for m, ls := range s.listeners { for _, l := range ls { log.Infof("Server listening on %s (%v, via systemd)", l.Addr(), m) maillog.Listening(l.Addr().String()) go s.serve(l, m) } } // Never return. If the serve goroutines have problems, they will abort // execution. for { time.Sleep(24 * time.Hour) } } func (s *Server) serve(l net.Listener, mode SocketMode) { // If this mode is expected to be TLS-wrapped, make it so. if mode.TLS { l = tls.NewListener(l, s.tlsConfig) } pdhook := path.Join(s.HookPath, "post-data") for { conn, err := l.Accept() if err != nil { log.Fatalf("Error accepting: %v", err) } sc := &Conn{ hostname: s.Hostname, maxDataSize: s.MaxDataSize, postDataHook: pdhook, conn: conn, mode: mode, tlsConfig: s.tlsConfig, onTLS: mode.TLS, authr: s.authr, aliasesR: s.aliasesR, localDomains: s.localDomains, dinfo: s.dinfo, deadline: time.Now().Add(s.connTimeout), commandTimeout: s.commandTimeout, queue: s.queue, } go sc.Handle() } } chasquid-1.2/internal/smtpsrv/server_test.go000066400000000000000000000343741357247226300214310ustar00rootroot00000000000000package smtpsrv import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "flag" "fmt" "io/ioutil" "math/big" "net" "net/smtp" "os" "testing" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/userdb" ) // Flags. var ( externalSMTPAddr = flag.String("external_smtp_addr", "", "SMTP server address to test (defaults to use internal)") externalSubmissionAddr = flag.String("external_submission_addr", "", "submission server address to test (defaults to use internal)") externalSubmissionTLSAddr = flag.String("external_submission_tls_addr", "", "submission+TLS server address to test (defaults to use internal)") ) var ( // Server addresses. Will be filled in at init time. // We default to internal ones, but may get overridden via flags. smtpAddr = "" submissionAddr = "" submissionTLSAddr = "" // TLS configuration to use in the clients. // Will contain the generated server certificate as root CA. tlsConfig *tls.Config // Test couriers, so we can validate that emails got sent. localC = testlib.NewTestCourier() remoteC = testlib.NewTestCourier() // Max data size, in MiB. maxDataSizeMiB = 5 ) // // === Tests === // func mustDial(tb testing.TB, mode SocketMode, startTLS bool) *smtp.Client { addr := "" switch mode { case ModeSMTP: addr = smtpAddr case ModeSubmission: addr = submissionAddr case ModeSubmissionTLS: addr = submissionTLSAddr } var err error var conn net.Conn if mode.TLS { conn, err = tls.Dial("tcp", addr, tlsConfig) } else { conn, err = net.Dial("tcp", addr) } if err != nil { tb.Fatalf("(net||tls).Dial: %v", err) } c, err := smtp.NewClient(conn, "127.0.0.1") if err != nil { tb.Fatalf("smtp.Dial: %v", err) } if err = c.Hello("test"); err != nil { tb.Fatalf("c.Hello: %v", err) } if startTLS { if ok, _ := c.Extension("STARTTLS"); !ok { tb.Fatalf("STARTTLS not advertised in EHLO") } if err = c.StartTLS(tlsConfig); err != nil { tb.Fatalf("StartTLS: %v", err) } } return c } func sendEmail(tb testing.TB, c *smtp.Client) { sendEmailWithAuth(tb, c, nil) } func sendEmailWithAuth(tb testing.TB, c *smtp.Client, auth smtp.Auth) { var err error from := "from@from" if auth != nil { if err = c.Auth(auth); err != nil { tb.Errorf("Auth: %v", err) } // If we authenticated, we must use the user as from, as the server // checks otherwise. from = "testuser@localhost" } if err = c.Mail(from); err != nil { tb.Errorf("Mail: %v", err) } if err = c.Rcpt("to@localhost"); err != nil { tb.Errorf("Rcpt: %v", err) } w, err := c.Data() if err != nil { tb.Fatalf("Data: %v", err) } msg := []byte("Subject: Hi!\n\n This is an email\n") if _, err = w.Write(msg); err != nil { tb.Errorf("Data write: %v", err) } localC.Expect(1) if err = w.Close(); err != nil { tb.Errorf("Data close: %v", err) } localC.Wait() } func TestSimple(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() sendEmail(t, c) } func TestSimpleTLS(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() sendEmail(t, c) } func TestManyEmails(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() sendEmail(t, c) sendEmail(t, c) sendEmail(t, c) } func TestAuth(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") sendEmailWithAuth(t, c, auth) } func TestSubmissionWithoutAuth(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() if err := c.Mail("from@from"); err == nil { t.Errorf("Mail not failed as expected") } } func TestAuthOnTLS(t *testing.T) { c := mustDial(t, ModeSubmissionTLS, false) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") sendEmailWithAuth(t, c, auth) } func TestAuthOnSMTP(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") // At least for now, we allow AUTH over the SMTP port to avoid unnecessary // complexity, so we expect it to work. sendEmailWithAuth(t, c, auth) } func TestWrongMailParsing(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() addrs := []string{"from", "a b c", "a @ b", "", "", "><"} for _, addr := range addrs { if err := c.Mail(addr); err == nil { t.Errorf("Mail not failed as expected with %q", addr) } } if err := c.Mail("from@plain"); err != nil { t.Errorf("Mail: %v", err) } for _, addr := range addrs { if err := c.Rcpt(addr); err == nil { t.Errorf("Rcpt not failed as expected with %q", addr) } } } func TestNullMailFrom(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() addrs := []string{"<>", " <>", "<> OPTION"} for _, addr := range addrs { simpleCmd(t, c, fmt.Sprintf("MAIL FROM:%s", addr), 250) } } func TestRcptBeforeMail(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() if err := c.Rcpt("to@to"); err == nil { t.Errorf("Rcpt not failed as expected") } } func TestRcptOption(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() if err := c.Mail("from@localhost"); err != nil { t.Fatalf("Mail: %v", err) } params := []string{ "", " ", " OPTION"} for _, p := range params { simpleCmd(t, c, fmt.Sprintf("RCPT TO:%s", p), 250) } } func TestRelayForbidden(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() if err := c.Mail("from@somewhere"); err != nil { t.Errorf("Mail: %v", err) } if err := c.Rcpt("to@somewhere"); err == nil { t.Errorf("Accepted relay email") } } func TestTooManyRecipients(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") if err := c.Auth(auth); err != nil { t.Fatalf("Auth: %v", err) } if err := c.Mail("testuser@localhost"); err != nil { t.Fatalf("Mail: %v", err) } for i := 0; i < 101; i++ { if err := c.Rcpt(fmt.Sprintf("to%d@somewhere", i)); err != nil { t.Fatalf("Rcpt: %v", err) } } err := c.Rcpt("to102@somewhere") if err == nil || err.Error() != "452 4.5.3 Too many recipients" { t.Errorf("Expected too many recipients, got: %v", err) } } var str1MiB string func sendLargeEmail(tb testing.TB, c *smtp.Client, sizeMiB int) error { tb.Helper() if err := c.Mail("from@from"); err != nil { tb.Fatalf("Mail: %v", err) } if err := c.Rcpt("to@localhost"); err != nil { tb.Fatalf("Rcpt: %v", err) } w, err := c.Data() if err != nil { tb.Fatalf("Data: %v", err) } if _, err := w.Write([]byte("Subject: I ate too much\n\n")); err != nil { tb.Fatalf("Data write: %v", err) } // Write the 1 MiB string sizeMiB times. for i := 0; i < sizeMiB; i++ { if _, err := w.Write([]byte(str1MiB)); err != nil { tb.Fatalf("Data write: %v", err) } } return w.Close() } func TestTooMuchData(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() localC.Expect(1) err := sendLargeEmail(t, c, maxDataSizeMiB-1) if err != nil { t.Errorf("Error sending large but ok email: %v", err) } localC.Wait() // Repeat the test - we want to check that the limit applies to each // message, not the entire connection. localC.Expect(1) err = sendLargeEmail(t, c, maxDataSizeMiB-1) if err != nil { t.Errorf("Error sending large but ok email: %v", err) } localC.Wait() err = sendLargeEmail(t, c, maxDataSizeMiB+1) if err == nil || err.Error() != "552 5.3.4 Message too big" { t.Fatalf("Expected message too big, got: %v", err) } // Repeat the test once again, the limit should not prevent connection // from continuing. localC.Expect(1) err = sendLargeEmail(t, c, maxDataSizeMiB-1) if err != nil { t.Errorf("Error sending large but ok email: %v", err) } localC.Wait() } func simpleCmd(t *testing.T, c *smtp.Client, cmd string, expected int) string { t.Helper() if err := c.Text.PrintfLine(cmd); err != nil { t.Fatalf("Failed to write %s: %v", cmd, err) } _, msg, err := c.Text.ReadResponse(expected) if err != nil { t.Errorf("Incorrect %s response: %v", cmd, err) } return msg } func TestSimpleCommands(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() simpleCmd(t, c, "HELP", 214) simpleCmd(t, c, "NOOP", 250) simpleCmd(t, c, "VRFY", 502) simpleCmd(t, c, "EXPN", 502) } func TestLongLines(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() // Send a not-too-long line. simpleCmd(t, c, fmt.Sprintf("%1000s", "x"), 500) // Send a very long line, expect an error. msg := simpleCmd(t, c, fmt.Sprintf("%1001s", "x"), 554) if msg != "error reading command: line too long" { t.Errorf("Expected 'line too long', got %v", msg) } } func TestReset(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() if err := c.Mail("from@plain"); err != nil { t.Fatalf("MAIL FROM: %v", err) } if err := c.Reset(); err != nil { t.Errorf("RSET: %v", err) } if err := c.Mail("from@plain"); err != nil { t.Errorf("MAIL after RSET: %v", err) } } func TestRepeatedStartTLS(t *testing.T) { c, err := smtp.Dial(smtpAddr) if err != nil { t.Fatalf("smtp.Dial: %v", err) } if err = c.StartTLS(tlsConfig); err != nil { t.Fatalf("StartTLS: %v", err) } if err = c.StartTLS(tlsConfig); err == nil { t.Errorf("Second STARTTLS did not fail as expected") } } // Test that STARTTLS fails on a TLS connection. func TestStartTLSOnTLS(t *testing.T) { c := mustDial(t, ModeSubmissionTLS, false) defer c.Close() if err := c.StartTLS(tlsConfig); err == nil { t.Errorf("STARTTLS did not fail as expected") } } // // === Benchmarks === // func BenchmarkManyEmails(b *testing.B) { c := mustDial(b, ModeSMTP, false) defer c.Close() b.ResetTimer() for i := 0; i < b.N; i++ { sendEmail(b, c) } } func BenchmarkManyEmailsParallel(b *testing.B) { b.RunParallel(func(pb *testing.PB) { c := mustDial(b, ModeSMTP, false) defer c.Close() for pb.Next() { sendEmail(b, c) } }) } // // === Test environment === // // generateCert generates a new, INSECURE self-signed certificate and writes // it to a pair of (cert.pem, key.pem) files to the given path. // Note the certificate is only useful for testing purposes. func generateCert(path string) error { tmpl := x509.Certificate{ SerialNumber: big.NewInt(1234), Subject: pkix.Name{ Organization: []string{"chasquid_test.go"}, }, DNSNames: []string{"localhost"}, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, NotBefore: time.Now(), NotAfter: time.Now().Add(30 * time.Minute), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, BasicConstraintsValid: true, IsCA: true, } priv, err := rsa.GenerateKey(rand.Reader, 1024) if err != nil { return err } derBytes, err := x509.CreateCertificate( rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { return err } // Create a global config for convenience. srvCert, err := x509.ParseCertificate(derBytes) if err != nil { return err } rootCAs := x509.NewCertPool() rootCAs.AddCert(srvCert) tlsConfig = &tls.Config{ ServerName: "localhost", RootCAs: rootCAs, } certOut, err := os.Create(path + "/cert.pem") if err != nil { return err } defer certOut.Close() pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) keyOut, err := os.OpenFile( path+"/key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return err } defer keyOut.Close() block := &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv), } pem.Encode(keyOut, block) return nil } // waitForServer waits 5 seconds for the server to start, and returns an error // if it fails to do so. // It does this by repeatedly connecting to the address until it either // replies or times out. Note we do not do any validation of the reply. func waitForServer(addr string) error { start := time.Now() for time.Since(start) < 10*time.Second { conn, err := net.Dial("tcp", addr) if err == nil { conn.Close() return nil } time.Sleep(100 * time.Millisecond) } return fmt.Errorf("not reachable") } // realMain is the real main function, which returns the value to pass to // os.Exit(). We have to do this so we can use defer. func realMain(m *testing.M) int { flag.Parse() // Create a 1MiB string, which the large message tests use. buf := make([]byte, 1024*1024) for i := 0; i < len(buf); i++ { buf[i] = 'a' } str1MiB = string(buf) // Set up the mail log to stdout, which is captured by the test runner, // so we have better debugging information on failures. maillog.Default = maillog.New(os.Stdout) if *externalSMTPAddr != "" { smtpAddr = *externalSMTPAddr submissionAddr = *externalSubmissionAddr submissionTLSAddr = *externalSubmissionTLSAddr tlsConfig = &tls.Config{ InsecureSkipVerify: true, } } else { // Generate certificates in a temporary directory. tmpDir, err := ioutil.TempDir("", "chasquid_test:") if err != nil { fmt.Printf("Failed to create temp dir: %v\n", tmpDir) return 1 } defer os.RemoveAll(tmpDir) err = generateCert(tmpDir) if err != nil { fmt.Printf("Failed to generate cert for testing: %v\n", err) return 1 } smtpAddr = testlib.GetFreePort() submissionAddr = testlib.GetFreePort() submissionTLSAddr = testlib.GetFreePort() s := NewServer() s.Hostname = "localhost" s.MaxDataSize = int64(maxDataSizeMiB) * 1024 * 1024 s.AddCerts(tmpDir+"/cert.pem", tmpDir+"/key.pem") s.AddAddr(smtpAddr, ModeSMTP) s.AddAddr(submissionAddr, ModeSubmission) s.AddAddr(submissionTLSAddr, ModeSubmissionTLS) s.InitQueue(tmpDir+"/queue", localC, remoteC) s.InitDomainInfo(tmpDir + "/domaininfo") udb := userdb.New("/dev/null") udb.AddUser("testuser", "testpasswd") s.aliasesR.AddAliasForTesting( "to@localhost", "testuser@localhost", aliases.EMAIL) s.AddDomain("localhost") s.AddUserDB("localhost", udb) // Disable SPF lookups, to avoid leaking DNS queries. disableSPFForTesting = true // Disable reloading. reloadEvery = nil go s.ListenAndServe() } waitForServer(smtpAddr) waitForServer(submissionAddr) waitForServer(submissionTLSAddr) return m.Run() } func TestMain(m *testing.M) { os.Exit(realMain(m)) } chasquid-1.2/internal/smtpsrv/testdata/000077500000000000000000000000001357247226300203335ustar00rootroot00000000000000chasquid-1.2/internal/smtpsrv/testdata/fuzz/000077500000000000000000000000001357247226300213315ustar00rootroot00000000000000chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/000077500000000000000000000000001357247226300226445ustar00rootroot00000000000000chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-auth_multi_dialog000066400000000000000000000003541357247226300265240ustar00rootroot000000000000002EHLO localhost AUTH SOMETHINGELSE AUTH PLAIN dXNlckB0ZXN0c2VydmVyAHlalala== AUTH PLAIN dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgB3cm9uZ3Bhc3N3b3Jk AUTH PLAIN dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgBzZWNyZXRwYXNzd29yZA== AUTH PLAIN chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-auth_not_tls000066400000000000000000000000331357247226300255270ustar00rootroot000000000000000EHLO localhost AUTH PLAIN chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-auth_too_many_failures000066400000000000000000000001711357247226300275670ustar00rootroot000000000000000EHLO localhost AUTH PLAIN something AUTH PLAIN something AUTH PLAIN something AUTH PLAIN something AUTH PLAIN something chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-bad_data000066400000000000000000000002631357247226300245500ustar00rootroot000000000000000DATA HELO localhost DATA MAIL FROM: RCPT TO: user@testserver DATA From: Mailer daemon Subject: I've come to haunt you Bad header Muahahahaha . QUIT chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-bad_mail_from000066400000000000000000000006101357247226300256000ustar00rootroot000000000000000HELO localhost MAIL LALA: <> MAIL FROM: MAIL FROM: MAIL FROM: MAIL FROM: chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-bad_rcpt_to000066400000000000000000000006711357247226300253140ustar00rootroot000000000000000HELO localhost MAIL FROM: RCPT LALA: <> RCPT TO: RCPT TO: RCPT TO: RCPT TO: RCPT TO: chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-empty_helo000066400000000000000000000000321357247226300251700ustar00rootroot000000000000000HELO EHLO HELO localhost chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-helo000066400000000000000000000000251357247226300237540ustar00rootroot000000000000000HELO localhost QUIT chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-null_address000066400000000000000000000002651357247226300255120ustar00rootroot000000000000000EHLO localhost MAIL FROM: <> RCPT TO: user@testserver DATA From: Mailer daemon Subject: I've come to haunt you Message-ID: Ñañañañaña! . QUIT chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-sendmail000066400000000000000000000002341357247226300246230ustar00rootroot000000000000000EHLO localhost MAIL FROM: <> RCPT TO: user@testserver DATA From: Mailer daemon Subject: I've come to haunt you Muahahahaha . QUIT chasquid-1.2/internal/smtpsrv/testdata/fuzz/corpus/t-unknown_command000066400000000000000000000000331357247226300262210ustar00rootroot000000000000000EHLO localhost WHATISTHIS chasquid-1.2/internal/sts/000077500000000000000000000000001357247226300156155ustar00rootroot00000000000000chasquid-1.2/internal/sts/sts.go000066400000000000000000000325201357247226300167570ustar00rootroot00000000000000// Package sts implements the MTA-STS (Strict Transport Security), RFC 8461. // // Note that "report" mode is not supported. // // Reference: https://tools.ietf.org/html/rfc8461 // 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/rfc8461#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 } // A 0 max age is invalid (could also represent an Atoi error), and so is // one greater than 31557600 (1 year), as per // https://tools.ietf.org/html/rfc8461#section-3.2. if p.MaxAge <= 0 || p.MaxAge > 31557600*time.Second { 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/rfc8461#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/rfc8461#section-3.3 // https://tools.ietf.org/html/rfc8461#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/rfc8461#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/rfc8461#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/rfc8461#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") } chasquid-1.2/internal/sts/sts_test.go000066400000000000000000000367241357247226300200300ustar00rootroot00000000000000package sts import ( "context" "expvar" "fmt" "net/http" "net/http/httptest" "os" "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) } 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"}}, {Version: "STSv1", Mode: "none", MaxAge: 31557600 * time.Second, 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: 31557601 * time.Second}, 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 int64) { if v.Value() != expected { t.Errorf("%s is %d, expected %d", name, v.Value(), expected) } } func TestCacheBasics(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) 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) } // Test how the cache behaves when the files are corrupt. func TestCacheBadData(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) 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) } func (c *PolicyCache) mustFetch(ctx context.Context, t *testing.T, 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) defer testlib.RemoveIfOk(t, dir) 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 := c.mustFetch(ctx, t, "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 = c.mustFetch(ctx, t, "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. cacheRefreshCycles.Set(0) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() go c.PeriodicallyRefresh(ctx) for cacheRefreshCycles.Value() == 0 { time.Sleep(5 * time.Millisecond) } p = c.mustFetch(ctx, t, "refresh-test") if p.MaxAge != 200*time.Second { t.Fatalf("policy.MaxAge is %v, expected 200s", p.MaxAge) } } 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) } } chasquid-1.2/internal/testlib/000077500000000000000000000000001357247226300164525ustar00rootroot00000000000000chasquid-1.2/internal/testlib/testlib.go000066400000000000000000000052261357247226300204540ustar00rootroot00000000000000// Package testlib provides common test utilities. package testlib import ( "io/ioutil" "net" "os" "strings" "sync" "testing" "time" ) // MustTempDir creates a temporary directory, or dies trying. func MustTempDir(t *testing.T) string { dir, err := ioutil.TempDir("", "testlib_") if err != nil { t.Fatal(err) } err = os.Chdir(dir) if err != nil { t.Fatal(err) } t.Logf("test directory: %q", dir) return dir } // RemoveIfOk removes the given directory, but only if we have not failed. We // want to keep the failed directories for debugging. func RemoveIfOk(t *testing.T, dir string) { // Safeguard, to make sure we only remove test directories. // This should help prevent accidental deletions. if !strings.Contains(dir, "testlib_") { panic("invalid/dangerous directory") } if !t.Failed() { os.RemoveAll(dir) } } // Rewrite a file with the given contents. func Rewrite(t *testing.T, path, contents string) error { // Safeguard, to make sure we only mess with test files. if !strings.Contains(path, "testlib_") { panic("invalid/dangerous path") } err := ioutil.WriteFile(path, []byte(contents), 0600) if err != nil { t.Errorf("failed to rewrite file: %v", err) } return err } // GetFreePort returns a free TCP port. This is hacky and not race-free, but // it works well enough for testing purposes. func GetFreePort() string { l, err := net.Listen("tcp", "localhost:0") if err != nil { panic(err) } defer l.Close() return l.Addr().String() } func WaitFor(f func() bool, d time.Duration) bool { start := time.Now() for time.Since(start) < d { if f() { return true } time.Sleep(20 * time.Millisecond) } return false } type DeliverRequest struct { From string To string Data []byte } // Courier for test purposes. Never fails, and always remembers everything. type TestCourier struct { wg sync.WaitGroup Requests []*DeliverRequest ReqFor map[string]*DeliverRequest sync.Mutex } func (tc *TestCourier) Deliver(from string, to string, data []byte) (error, bool) { defer tc.wg.Done() dr := &DeliverRequest{from, to, data} tc.Lock() tc.Requests = append(tc.Requests, dr) tc.ReqFor[to] = dr tc.Unlock() return nil, false } func (tc *TestCourier) Expect(i int) { tc.wg.Add(i) } func (tc *TestCourier) Wait() { tc.wg.Wait() } // NewTestCourier returns a new, empty TestCourier instance. func NewTestCourier() *TestCourier { return &TestCourier{ ReqFor: map[string]*DeliverRequest{}, } } type dumbCourier struct{} func (c dumbCourier) Deliver(from string, to string, data []byte) (error, bool) { return nil, false } // Dumb courier, for when we just don't care about the result. var DumbCourier = dumbCourier{} chasquid-1.2/internal/testlib/testlib_test.go000066400000000000000000000032441357247226300215110ustar00rootroot00000000000000package testlib import ( "io/ioutil" "os" "testing" ) func TestBasic(t *testing.T) { dir := MustTempDir(t) if err := ioutil.WriteFile(dir+"/file", nil, 0660); err != nil { t.Fatalf("could not create file in %s: %v", dir, err) } wd, err := os.Getwd() if err != nil { t.Fatalf("could not get working directory: %v", err) } if wd != dir { t.Errorf("MustTempDir did not change directory") t.Errorf(" expected %q, got %q", dir, wd) } RemoveIfOk(t, dir) if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Fatalf("%s existed, should have been deleted: %v", dir, err) } } func TestRemoveCheck(t *testing.T) { defer func() { if r := recover(); r != nil { t.Logf("recovered: %v", r) } else { t.Fatalf("check did not panic as expected") } }() RemoveIfOk(t, "/tmp/something") } func TestLeaveDirOnError(t *testing.T) { myt := &testing.T{} dir := MustTempDir(myt) myt.Errorf("something bad happened") RemoveIfOk(myt, dir) if _, err := os.Stat(dir); os.IsNotExist(err) { t.Fatalf("%s was removed, should have been kept", dir) } // Remove the directory for real this time. RemoveIfOk(t, dir) } func TestRewriteSafeguard(t *testing.T) { myt := &testing.T{} defer func() { if r := recover(); r != nil { t.Logf("recovered: %v", r) } else { t.Fatalf("check did not panic as expected") } }() Rewrite(myt, "/something", "test") } func TestRewrite(t *testing.T) { dir := MustTempDir(t) defer RemoveIfOk(t, dir) myt := &testing.T{} Rewrite(myt, dir+"/file", "hola") if myt.Failed() { t.Errorf("basic rewrite failed") } } func TestGetFreePort(t *testing.T) { p := GetFreePort() if p == "" { t.Errorf("failed to get free port") } } chasquid-1.2/internal/tlsconst/000077500000000000000000000000001357247226300166555ustar00rootroot00000000000000chasquid-1.2/internal/tlsconst/ciphers.go000066400000000000000000000377111357247226300206520ustar00rootroot00000000000000package tlsconst // AUTOGENERATED - DO NOT EDIT // // This file was autogenerated by generate-ciphers.py. var cipherSuiteName = map[uint16]string{ 0x0000: "TLS_NULL_WITH_NULL_NULL", 0x0001: "TLS_RSA_WITH_NULL_MD5", 0x0002: "TLS_RSA_WITH_NULL_SHA", 0x0003: "TLS_RSA_EXPORT_WITH_RC4_40_MD5", 0x0004: "TLS_RSA_WITH_RC4_128_MD5", 0x0005: "TLS_RSA_WITH_RC4_128_SHA", 0x0006: "TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5", 0x0007: "TLS_RSA_WITH_IDEA_CBC_SHA", 0x0008: "TLS_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x0009: "TLS_RSA_WITH_DES_CBC_SHA", 0x000a: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", 0x000b: "TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA", 0x000c: "TLS_DH_DSS_WITH_DES_CBC_SHA", 0x000d: "TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA", 0x000e: "TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x000f: "TLS_DH_RSA_WITH_DES_CBC_SHA", 0x0010: "TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA", 0x0011: "TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA", 0x0012: "TLS_DHE_DSS_WITH_DES_CBC_SHA", 0x0013: "TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA", 0x0014: "TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x0015: "TLS_DHE_RSA_WITH_DES_CBC_SHA", 0x0016: "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", 0x0017: "TLS_DH_anon_EXPORT_WITH_RC4_40_MD5", 0x0018: "TLS_DH_anon_WITH_RC4_128_MD5", 0x0019: "TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA", 0x001a: "TLS_DH_anon_WITH_DES_CBC_SHA", 0x001b: "TLS_DH_anon_WITH_3DES_EDE_CBC_SHA", 0x001e: "TLS_KRB5_WITH_DES_CBC_SHA", 0x001f: "TLS_KRB5_WITH_3DES_EDE_CBC_SHA", 0x0020: "TLS_KRB5_WITH_RC4_128_SHA", 0x0021: "TLS_KRB5_WITH_IDEA_CBC_SHA", 0x0022: "TLS_KRB5_WITH_DES_CBC_MD5", 0x0023: "TLS_KRB5_WITH_3DES_EDE_CBC_MD5", 0x0024: "TLS_KRB5_WITH_RC4_128_MD5", 0x0025: "TLS_KRB5_WITH_IDEA_CBC_MD5", 0x0026: "TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA", 0x0027: "TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA", 0x0028: "TLS_KRB5_EXPORT_WITH_RC4_40_SHA", 0x0029: "TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5", 0x002a: "TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5", 0x002b: "TLS_KRB5_EXPORT_WITH_RC4_40_MD5", 0x002c: "TLS_PSK_WITH_NULL_SHA", 0x002d: "TLS_DHE_PSK_WITH_NULL_SHA", 0x002e: "TLS_RSA_PSK_WITH_NULL_SHA", 0x002f: "TLS_RSA_WITH_AES_128_CBC_SHA", 0x0030: "TLS_DH_DSS_WITH_AES_128_CBC_SHA", 0x0031: "TLS_DH_RSA_WITH_AES_128_CBC_SHA", 0x0032: "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", 0x0033: "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", 0x0034: "TLS_DH_anon_WITH_AES_128_CBC_SHA", 0x0035: "TLS_RSA_WITH_AES_256_CBC_SHA", 0x0036: "TLS_DH_DSS_WITH_AES_256_CBC_SHA", 0x0037: "TLS_DH_RSA_WITH_AES_256_CBC_SHA", 0x0038: "TLS_DHE_DSS_WITH_AES_256_CBC_SHA", 0x0039: "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", 0x003a: "TLS_DH_anon_WITH_AES_256_CBC_SHA", 0x003b: "TLS_RSA_WITH_NULL_SHA256", 0x003c: "TLS_RSA_WITH_AES_128_CBC_SHA256", 0x003d: "TLS_RSA_WITH_AES_256_CBC_SHA256", 0x003e: "TLS_DH_DSS_WITH_AES_128_CBC_SHA256", 0x003f: "TLS_DH_RSA_WITH_AES_128_CBC_SHA256", 0x0040: "TLS_DHE_DSS_WITH_AES_128_CBC_SHA256", 0x0041: "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0042: "TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA", 0x0043: "TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0044: "TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA", 0x0045: "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0046: "TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA", 0x0067: "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", 0x0068: "TLS_DH_DSS_WITH_AES_256_CBC_SHA256", 0x0069: "TLS_DH_RSA_WITH_AES_256_CBC_SHA256", 0x006a: "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256", 0x006b: "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", 0x006c: "TLS_DH_anon_WITH_AES_128_CBC_SHA256", 0x006d: "TLS_DH_anon_WITH_AES_256_CBC_SHA256", 0x0084: "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0085: "TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA", 0x0086: "TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0087: "TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA", 0x0088: "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0089: "TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA", 0x008a: "TLS_PSK_WITH_RC4_128_SHA", 0x008b: "TLS_PSK_WITH_3DES_EDE_CBC_SHA", 0x008c: "TLS_PSK_WITH_AES_128_CBC_SHA", 0x008d: "TLS_PSK_WITH_AES_256_CBC_SHA", 0x008e: "TLS_DHE_PSK_WITH_RC4_128_SHA", 0x008f: "TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA", 0x0090: "TLS_DHE_PSK_WITH_AES_128_CBC_SHA", 0x0091: "TLS_DHE_PSK_WITH_AES_256_CBC_SHA", 0x0092: "TLS_RSA_PSK_WITH_RC4_128_SHA", 0x0093: "TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA", 0x0094: "TLS_RSA_PSK_WITH_AES_128_CBC_SHA", 0x0095: "TLS_RSA_PSK_WITH_AES_256_CBC_SHA", 0x0096: "TLS_RSA_WITH_SEED_CBC_SHA", 0x0097: "TLS_DH_DSS_WITH_SEED_CBC_SHA", 0x0098: "TLS_DH_RSA_WITH_SEED_CBC_SHA", 0x0099: "TLS_DHE_DSS_WITH_SEED_CBC_SHA", 0x009a: "TLS_DHE_RSA_WITH_SEED_CBC_SHA", 0x009b: "TLS_DH_anon_WITH_SEED_CBC_SHA", 0x009c: "TLS_RSA_WITH_AES_128_GCM_SHA256", 0x009d: "TLS_RSA_WITH_AES_256_GCM_SHA384", 0x009e: "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", 0x009f: "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", 0x00a0: "TLS_DH_RSA_WITH_AES_128_GCM_SHA256", 0x00a1: "TLS_DH_RSA_WITH_AES_256_GCM_SHA384", 0x00a2: "TLS_DHE_DSS_WITH_AES_128_GCM_SHA256", 0x00a3: "TLS_DHE_DSS_WITH_AES_256_GCM_SHA384", 0x00a4: "TLS_DH_DSS_WITH_AES_128_GCM_SHA256", 0x00a5: "TLS_DH_DSS_WITH_AES_256_GCM_SHA384", 0x00a6: "TLS_DH_anon_WITH_AES_128_GCM_SHA256", 0x00a7: "TLS_DH_anon_WITH_AES_256_GCM_SHA384", 0x00a8: "TLS_PSK_WITH_AES_128_GCM_SHA256", 0x00a9: "TLS_PSK_WITH_AES_256_GCM_SHA384", 0x00aa: "TLS_DHE_PSK_WITH_AES_128_GCM_SHA256", 0x00ab: "TLS_DHE_PSK_WITH_AES_256_GCM_SHA384", 0x00ac: "TLS_RSA_PSK_WITH_AES_128_GCM_SHA256", 0x00ad: "TLS_RSA_PSK_WITH_AES_256_GCM_SHA384", 0x00ae: "TLS_PSK_WITH_AES_128_CBC_SHA256", 0x00af: "TLS_PSK_WITH_AES_256_CBC_SHA384", 0x00b0: "TLS_PSK_WITH_NULL_SHA256", 0x00b1: "TLS_PSK_WITH_NULL_SHA384", 0x00b2: "TLS_DHE_PSK_WITH_AES_128_CBC_SHA256", 0x00b3: "TLS_DHE_PSK_WITH_AES_256_CBC_SHA384", 0x00b4: "TLS_DHE_PSK_WITH_NULL_SHA256", 0x00b5: "TLS_DHE_PSK_WITH_NULL_SHA384", 0x00b6: "TLS_RSA_PSK_WITH_AES_128_CBC_SHA256", 0x00b7: "TLS_RSA_PSK_WITH_AES_256_CBC_SHA384", 0x00b8: "TLS_RSA_PSK_WITH_NULL_SHA256", 0x00b9: "TLS_RSA_PSK_WITH_NULL_SHA384", 0x00ba: "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bb: "TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256", 0x00bc: "TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bd: "TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256", 0x00be: "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bf: "TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256", 0x00c0: "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c1: "TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256", 0x00c2: "TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c3: "TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256", 0x00c4: "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c5: "TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256", 0x00c6: "TLS_SM4_GCM_SM3", 0x00c7: "TLS_SM4_CCM_SM3", 0x00ff: "TLS_EMPTY_RENEGOTIATION_INFO_SCSV", 0x1301: "TLS_AES_128_GCM_SHA256", 0x1302: "TLS_AES_256_GCM_SHA384", 0x1303: "TLS_CHACHA20_POLY1305_SHA256", 0x1304: "TLS_AES_128_CCM_SHA256", 0x1305: "TLS_AES_128_CCM_8_SHA256", 0x5600: "TLS_FALLBACK_SCSV", 0xc001: "TLS_ECDH_ECDSA_WITH_NULL_SHA", 0xc002: "TLS_ECDH_ECDSA_WITH_RC4_128_SHA", 0xc003: "TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA", 0xc004: "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA", 0xc005: "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA", 0xc006: "TLS_ECDHE_ECDSA_WITH_NULL_SHA", 0xc007: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", 0xc008: "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", 0xc009: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", 0xc00a: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", 0xc00b: "TLS_ECDH_RSA_WITH_NULL_SHA", 0xc00c: "TLS_ECDH_RSA_WITH_RC4_128_SHA", 0xc00d: "TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA", 0xc00e: "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA", 0xc00f: "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA", 0xc010: "TLS_ECDHE_RSA_WITH_NULL_SHA", 0xc011: "TLS_ECDHE_RSA_WITH_RC4_128_SHA", 0xc012: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", 0xc013: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", 0xc014: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", 0xc015: "TLS_ECDH_anon_WITH_NULL_SHA", 0xc016: "TLS_ECDH_anon_WITH_RC4_128_SHA", 0xc017: "TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA", 0xc018: "TLS_ECDH_anon_WITH_AES_128_CBC_SHA", 0xc019: "TLS_ECDH_anon_WITH_AES_256_CBC_SHA", 0xc01a: "TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA", 0xc01b: "TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA", 0xc01c: "TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA", 0xc01d: "TLS_SRP_SHA_WITH_AES_128_CBC_SHA", 0xc01e: "TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA", 0xc01f: "TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA", 0xc020: "TLS_SRP_SHA_WITH_AES_256_CBC_SHA", 0xc021: "TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA", 0xc022: "TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA", 0xc023: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", 0xc024: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", 0xc025: "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256", 0xc026: "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384", 0xc027: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", 0xc028: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", 0xc029: "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256", 0xc02a: "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384", 0xc02b: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", 0xc02c: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", 0xc02d: "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256", 0xc02e: "TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384", 0xc02f: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", 0xc030: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", 0xc031: "TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256", 0xc032: "TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384", 0xc033: "TLS_ECDHE_PSK_WITH_RC4_128_SHA", 0xc034: "TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA", 0xc035: "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA", 0xc036: "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA", 0xc037: "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256", 0xc038: "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384", 0xc039: "TLS_ECDHE_PSK_WITH_NULL_SHA", 0xc03a: "TLS_ECDHE_PSK_WITH_NULL_SHA256", 0xc03b: "TLS_ECDHE_PSK_WITH_NULL_SHA384", 0xc03c: "TLS_RSA_WITH_ARIA_128_CBC_SHA256", 0xc03d: "TLS_RSA_WITH_ARIA_256_CBC_SHA384", 0xc03e: "TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256", 0xc03f: "TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384", 0xc040: "TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256", 0xc041: "TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384", 0xc042: "TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256", 0xc043: "TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384", 0xc044: "TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256", 0xc045: "TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384", 0xc046: "TLS_DH_anon_WITH_ARIA_128_CBC_SHA256", 0xc047: "TLS_DH_anon_WITH_ARIA_256_CBC_SHA384", 0xc048: "TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256", 0xc049: "TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384", 0xc04a: "TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256", 0xc04b: "TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384", 0xc04c: "TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256", 0xc04d: "TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384", 0xc04e: "TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256", 0xc04f: "TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384", 0xc050: "TLS_RSA_WITH_ARIA_128_GCM_SHA256", 0xc051: "TLS_RSA_WITH_ARIA_256_GCM_SHA384", 0xc052: "TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256", 0xc053: "TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384", 0xc054: "TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256", 0xc055: "TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384", 0xc056: "TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256", 0xc057: "TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384", 0xc058: "TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256", 0xc059: "TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384", 0xc05a: "TLS_DH_anon_WITH_ARIA_128_GCM_SHA256", 0xc05b: "TLS_DH_anon_WITH_ARIA_256_GCM_SHA384", 0xc05c: "TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256", 0xc05d: "TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384", 0xc05e: "TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256", 0xc05f: "TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384", 0xc060: "TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256", 0xc061: "TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384", 0xc062: "TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256", 0xc063: "TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384", 0xc064: "TLS_PSK_WITH_ARIA_128_CBC_SHA256", 0xc065: "TLS_PSK_WITH_ARIA_256_CBC_SHA384", 0xc066: "TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256", 0xc067: "TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384", 0xc068: "TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256", 0xc069: "TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384", 0xc06a: "TLS_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06b: "TLS_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06c: "TLS_DHE_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06d: "TLS_DHE_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06e: "TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06f: "TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384", 0xc070: "TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256", 0xc071: "TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384", 0xc072: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc073: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc074: "TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc075: "TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc076: "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc077: "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc078: "TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc079: "TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc07a: "TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07b: "TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07c: "TLS_DHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07d: "TLS_DHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07e: "TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07f: "TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc080: "TLS_DHE_DSS_WITH_CAMELLIA_128_GCM_SHA256", 0xc081: "TLS_DHE_DSS_WITH_CAMELLIA_256_GCM_SHA384", 0xc082: "TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256", 0xc083: "TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384", 0xc084: "TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256", 0xc085: "TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384", 0xc086: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc087: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc088: "TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc089: "TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08a: "TLS_ECDHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc08b: "TLS_ECDHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08c: "TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc08d: "TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08e: "TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc08f: "TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc090: "TLS_DHE_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc091: "TLS_DHE_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc092: "TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc093: "TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc094: "TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc095: "TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc096: "TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc097: "TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc098: "TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc099: "TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc09a: "TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc09b: "TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc09c: "TLS_RSA_WITH_AES_128_CCM", 0xc09d: "TLS_RSA_WITH_AES_256_CCM", 0xc09e: "TLS_DHE_RSA_WITH_AES_128_CCM", 0xc09f: "TLS_DHE_RSA_WITH_AES_256_CCM", 0xc0a0: "TLS_RSA_WITH_AES_128_CCM_8", 0xc0a1: "TLS_RSA_WITH_AES_256_CCM_8", 0xc0a2: "TLS_DHE_RSA_WITH_AES_128_CCM_8", 0xc0a3: "TLS_DHE_RSA_WITH_AES_256_CCM_8", 0xc0a4: "TLS_PSK_WITH_AES_128_CCM", 0xc0a5: "TLS_PSK_WITH_AES_256_CCM", 0xc0a6: "TLS_DHE_PSK_WITH_AES_128_CCM", 0xc0a7: "TLS_DHE_PSK_WITH_AES_256_CCM", 0xc0a8: "TLS_PSK_WITH_AES_128_CCM_8", 0xc0a9: "TLS_PSK_WITH_AES_256_CCM_8", 0xc0aa: "TLS_PSK_DHE_WITH_AES_128_CCM_8", 0xc0ab: "TLS_PSK_DHE_WITH_AES_256_CCM_8", 0xc0ac: "TLS_ECDHE_ECDSA_WITH_AES_128_CCM", 0xc0ad: "TLS_ECDHE_ECDSA_WITH_AES_256_CCM", 0xc0ae: "TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8", 0xc0af: "TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8", 0xc0b0: "TLS_ECCPWD_WITH_AES_128_GCM_SHA256", 0xc0b1: "TLS_ECCPWD_WITH_AES_256_GCM_SHA384", 0xc0b2: "TLS_ECCPWD_WITH_AES_128_CCM_SHA256", 0xc0b3: "TLS_ECCPWD_WITH_AES_256_CCM_SHA384", 0xc0b4: "TLS_SHA256_SHA256", 0xc0b5: "TLS_SHA384_SHA384", 0xc100: "TLS_GOSTR341112_256_WITH_KUZNYECHIK_CTR_OMAC", 0xc101: "TLS_GOSTR341112_256_WITH_MAGMA_CTR_OMAC", 0xc102: "TLS_GOSTR341112_256_WITH_28147_CNT_IMIT", 0xcca8: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", 0xcca9: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", 0xccaa: "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", 0xccab: "TLS_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xccac: "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xccad: "TLS_DHE_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xccae: "TLS_RSA_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xd001: "TLS_ECDHE_PSK_WITH_AES_128_GCM_SHA256", 0xd002: "TLS_ECDHE_PSK_WITH_AES_256_GCM_SHA384", 0xd003: "TLS_ECDHE_PSK_WITH_AES_128_CCM_8_SHA256", 0xd005: "TLS_ECDHE_PSK_WITH_AES_128_CCM_SHA256", } chasquid-1.2/internal/tlsconst/generate-ciphers.py000077500000000000000000000022421357247226300224570ustar00rootroot00000000000000#!/usr/bin/env python3 # # This hacky script generates a go file with a map of version -> name for the # entries in the TLS Cipher Suite Registry. import csv import urllib.request import sys # Where to get the TLS parameters from. # See http://www.iana.org/assignments/tls-parameters/tls-parameters.xml. URL = "https://www.iana.org/assignments/tls-parameters/tls-parameters-4.csv" def getCiphers(): req = urllib.request.urlopen(URL) data = req.read().decode('utf-8') ciphers = [] reader = csv.DictReader(data.splitlines()) for row in reader: desc = row["Description"] rawval = row["Value"] # Just plain TLS values for now, to keep it simple. if "-" in rawval or not desc.startswith("TLS"): continue rv1, rv2 = rawval.split(",") rv1, rv2 = int(rv1, 16), int(rv2, 16) val = "0x%02x%02x" % (rv1, rv2) ciphers.append((val, desc)) return ciphers ciphers = getCiphers() out = open(sys.argv[1], 'w') out.write("""\ package tlsconst // AUTOGENERATED - DO NOT EDIT // // This file was autogenerated by generate-ciphers.py. var cipherSuiteName = map[uint16]string{ """) for ver, desc in ciphers: out.write('\t%s: "%s",\n' % (ver, desc)) out.write('}\n') chasquid-1.2/internal/tlsconst/tlsconst.go000066400000000000000000000014111357247226300210520ustar00rootroot00000000000000// Package tlsconst contains TLS constants for human consumption. package tlsconst // Most of the constants get automatically generated from IANA's assignments. //go:generate ./generate-ciphers.py ciphers.go import "fmt" var versionName = map[uint16]string{ 0x0300: "SSL-3.0", 0x0301: "TLS-1.0", 0x0302: "TLS-1.1", 0x0303: "TLS-1.2", 0x0304: "TLS-1.3", } // VersionName returns a human-readable TLS version name. func VersionName(v uint16) string { name, ok := versionName[v] if !ok { return fmt.Sprintf("TLS-%#04x", v) } return name } // CipherSuiteName returns a human-readable TLS cipher suite name. func CipherSuiteName(s uint16) string { name, ok := cipherSuiteName[s] if !ok { return fmt.Sprintf("TLS_UNKNOWN_CIPHER_SUITE-%#04x", s) } return name } chasquid-1.2/internal/tlsconst/tlsconst_test.go000066400000000000000000000013471357247226300221210ustar00rootroot00000000000000package tlsconst import "testing" func TestVersionName(t *testing.T) { cases := []struct { ver uint16 expected string }{ {0x0302, "TLS-1.1"}, {0x1234, "TLS-0x1234"}, } for _, c := range cases { got := VersionName(c.ver) if got != c.expected { t.Errorf("VersionName(%x) = %q, expected %q", c.ver, got, c.expected) } } } func TestCipherSuiteName(t *testing.T) { cases := []struct { suite uint16 expected string }{ {0xc073, "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384"}, {0x1234, "TLS_UNKNOWN_CIPHER_SUITE-0x1234"}, } for _, c := range cases { got := CipherSuiteName(c.suite) if got != c.expected { t.Errorf("CipherSuiteName(%x) = %q, expected %q", c.suite, got, c.expected) } } } chasquid-1.2/internal/trace/000077500000000000000000000000001357247226300161025ustar00rootroot00000000000000chasquid-1.2/internal/trace/trace.go000066400000000000000000000056711357247226300175400ustar00rootroot00000000000000// Package trace extends golang.org/x/net/trace. package trace import ( "fmt" "strconv" "blitiri.com.ar/go/log" nettrace "golang.org/x/net/trace" ) // A Trace represents an active request. type Trace struct { family string title string t nettrace.Trace } // New trace. func New(family, title string) *Trace { t := &Trace{family, title, nettrace.New(family, title)} // The default for max events is 10, which is a bit short for a normal // SMTP exchange. Expand it to 30 which should be large enough to keep // most of the traces. t.t.SetMaxEvents(30) return t } // Printf adds this message to the trace's log. func (t *Trace) Printf(format string, a ...interface{}) { t.t.LazyPrintf(format, a...) log.Log(log.Info, 1, "%s %s: %s", t.family, t.title, quote(fmt.Sprintf(format, a...))) } // Debugf adds this message to the trace's log, with a debugging level. func (t *Trace) Debugf(format string, a ...interface{}) { t.t.LazyPrintf(format, a...) log.Log(log.Debug, 1, "%s %s: %s", t.family, t.title, quote(fmt.Sprintf(format, a...))) } // Errorf adds this message to the trace's log, with an error level. func (t *Trace) Errorf(format string, a ...interface{}) error { // Note we can't just call t.Error here, as it breaks caller logging. err := fmt.Errorf(format, a...) t.t.SetError() t.t.LazyPrintf("error: %v", err) log.Log(log.Info, 1, "%s %s: error: %s", t.family, t.title, quote(err.Error())) return err } // Error marks the trace as having seen an error, and also logs it to the // trace's log. func (t *Trace) Error(err error) error { t.t.SetError() t.t.LazyPrintf("error: %v", err) log.Log(log.Info, 1, "%s %s: error: %s", t.family, t.title, quote(err.Error())) return err } // Finish the trace. It should not be changed after this is called. func (t *Trace) Finish() { t.t.Finish() } // EventLog is used for tracing long-lived objects. type EventLog struct { family string title string e nettrace.EventLog } // NewEventLog returns a new EventLog. func NewEventLog(family, title string) *EventLog { return &EventLog{family, title, nettrace.NewEventLog(family, title)} } // Printf adds the message to the EventLog. func (e *EventLog) Printf(format string, a ...interface{}) { e.e.Printf(format, a...) log.Log(log.Info, 1, "%s %s: %s", e.family, e.title, quote(fmt.Sprintf(format, a...))) } // Debugf adds the message to the EventLog, with a debugging level. func (e *EventLog) Debugf(format string, a ...interface{}) { e.e.Printf(format, a...) log.Log(log.Debug, 1, "%s %s: %s", e.family, e.title, quote(fmt.Sprintf(format, a...))) } // Errorf adds the message to the EventLog, with an error level. func (e *EventLog) Errorf(format string, a ...interface{}) error { err := fmt.Errorf(format, a...) e.e.Errorf("error: %v", err) log.Log(log.Info, 1, "%s %s: error: %s", e.family, e.title, quote(err.Error())) return err } func quote(s string) string { qs := strconv.Quote(s) return qs[1 : len(qs)-1] } chasquid-1.2/internal/userdb/000077500000000000000000000000001357247226300162705ustar00rootroot00000000000000chasquid-1.2/internal/userdb/userdb.go000066400000000000000000000132651357247226300201120ustar00rootroot00000000000000// Package userdb implements a simple user database. // // // Format // // The user database is a file containing a list of users and their passwords, // encrypted with some scheme. // We use a text-encoded protobuf, the structure can be found in userdb.proto. // // We write text instead of binary to make it easier for administrators to // troubleshoot, and since performance is not an issue for our expected usage. // // Users must be UTF-8 and NOT contain whitespace; the library will enforce // this. // // // Schemes // // The default scheme is SCRYPT, with hard-coded parameters. The API does not // allow the user to change this, at least for now. // A PLAIN scheme is also supported for debugging purposes. // // // Writing // // The functions that write a database file will not preserve ordering, // invalid lines, empty lines, or any formatting. // // It is also not safe for concurrent use from different processes. // package userdb //go:generate protoc --go_out=. userdb.proto import ( "crypto/rand" "crypto/subtle" "errors" "fmt" "sync" "golang.org/x/crypto/scrypt" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/protoio" ) // DB represents a single user database. type DB struct { fname string db *ProtoDB // Lock protecting db. mu sync.RWMutex } // New returns a new user database, on the given file name. func New(fname string) *DB { return &DB{ fname: fname, db: &ProtoDB{Users: map[string]*Password{}}, } } // Load the database from the given file. // Return the database, and a fatal error if the database could not be // loaded. func Load(fname string) (*DB, error) { db := New(fname) err := protoio.ReadTextMessage(fname, db.db) // Reading may result in an empty protobuf or dictionary; make sure we // return an empty but usable structure. // This simplifies many of our uses, as we can assume the map is not nil. if db.db == nil || db.db.Users == nil { db.db = &ProtoDB{Users: map[string]*Password{}} } return db, err } // Reload the database, refreshing its contents from the current file on disk. // If there are errors reading from the file, they are returned and the // database is not changed. func (db *DB) Reload() error { newdb, err := Load(db.fname) if err != nil { return err } db.mu.Lock() db.db = newdb.db db.mu.Unlock() return nil } // Write the database to disk. It will do a complete rewrite each time, and is // not safe to call it from different processes in parallel. func (db *DB) Write() error { db.mu.RLock() defer db.mu.RUnlock() return protoio.WriteTextMessage(db.fname, db.db, 0660) } // Authenticate returns true if the password is valid for the user, false // otherwise. func (db *DB) Authenticate(name, plainPassword string) bool { db.mu.RLock() passwd, ok := db.db.Users[name] db.mu.RUnlock() if !ok { return false } return passwd.PasswordMatches(plainPassword) } // PasswordMatches returns true if the given password is a match. func (p *Password) PasswordMatches(plain string) bool { switch s := p.Scheme.(type) { case nil: return false case *Password_Scrypt: return s.Scrypt.PasswordMatches(plain) case *Password_Plain: return s.Plain.PasswordMatches(plain) default: return false } } // AddUser to the database. If the user is already present, override it. // Note we enforce that the name has been normalized previously. func (db *DB) AddUser(name, plainPassword string) error { if norm, err := normalize.User(name); err != nil || name != norm { return errors.New("invalid username") } s := &Scrypt{ // Use hard-coded standard parameters for now. // Follow the recommendations from the scrypt paper. LogN: 14, R: 8, P: 1, KeyLen: 32, // 16 bytes of salt (will be filled later). Salt: make([]byte, 16), } n, err := rand.Read(s.Salt) if n != 16 || err != nil { return fmt.Errorf("failed to get salt - %d - %v", n, err) } s.Encrypted, err = scrypt.Key([]byte(plainPassword), s.Salt, 1< users = 1; } message Password { oneof scheme { Scrypt scrypt = 2; Plain plain = 3; } } message Scrypt { uint64 logN = 1; int32 r = 2; int32 p = 3; int32 keyLen = 4; bytes salt = 5; bytes encrypted = 6; } message Plain { bytes password = 1; } chasquid-1.2/internal/userdb/userdb_test.go000066400000000000000000000171371357247226300211530ustar00rootroot00000000000000package userdb import ( "fmt" "io/ioutil" "os" "reflect" "strings" "testing" ) // Remove the file if the test was successful. Used in defer statements, to // leave files around for inspection when the tests failed. func removeIfSuccessful(t *testing.T, fname string) { // Safeguard, to make sure we only remove test files. // This should help prevent accidental deletions. if !strings.Contains(fname, "userdb_test") { panic("invalid/dangerous directory") } if !t.Failed() { os.Remove(fname) } } // Create a database with the given content on a temporary filename. Return // the filename, or an error if there were errors creating it. func mustCreateDB(t *testing.T, content string) string { f, err := ioutil.TempFile("", "userdb_test") if err != nil { t.Fatal(err) } if _, err := f.WriteString(content); err != nil { t.Fatal(err) } t.Logf("file: %q", f.Name()) return f.Name() } func dbEquals(a, b *DB) bool { if a.db == nil || b.db == nil { return a.db == nil && b.db == nil } if len(a.db.Users) != len(b.db.Users) { return false } for k, av := range a.db.Users { bv, ok := b.db.Users[k] if !ok || !reflect.DeepEqual(av, bv) { return false } } return true } var emptyDB = &DB{ db: &ProtoDB{Users: map[string]*Password{}}, } // Test various cases of loading an empty/broken database. func TestEmptyLoad(t *testing.T) { cases := []struct { desc string content string fatal bool fatalErr error }{ {"empty file", "", false, nil}, {"invalid ", "users: < invalid >", true, nil}, } for _, c := range cases { testOneLoad(t, c.desc, c.content, c.fatal, c.fatalErr) } } func testOneLoad(t *testing.T, desc, content string, fatal bool, fatalErr error) { fname := mustCreateDB(t, content) defer removeIfSuccessful(t, fname) db, err := Load(fname) if fatal { if err == nil { t.Errorf("case %q: expected error loading, got nil", desc) } if fatalErr != nil && fatalErr != err { t.Errorf("case %q: expected error %v, got %v", desc, fatalErr, err) } } else if !fatal && err != nil { t.Fatalf("case %q: error loading database: %v", desc, err) } if db != nil && !dbEquals(db, emptyDB) { t.Errorf("case %q: DB not empty: %#v", desc, db.db.Users) } } func mustLoad(t *testing.T, fname string) *DB { db, err := Load(fname) if err != nil { t.Fatalf("error loading database: %v", err) } return db } func TestWrite(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) if err := db.Write(); err != nil { t.Fatalf("error writing database: %v", err) } // Load again, check it works and it's still empty. db = mustLoad(t, fname) if !dbEquals(emptyDB, db) { t.Fatalf("expected %v, got %v", emptyDB, db) } // Add two users, write, and load again. if err := db.AddUser("user1", "passwd1"); err != nil { t.Fatalf("failed to add user1: %v", err) } if err := db.AddUser("ñoño", "añicos"); err != nil { t.Fatalf("failed to add ñoño: %v", err) } if err := db.Write(); err != nil { t.Fatalf("error writing database: %v", err) } db = mustLoad(t, fname) for _, name := range []string{"user1", "ñoño"} { if !db.Exists(name) { t.Errorf("user %q not in database", name) } if db.db.Users[name].GetScheme() == nil { t.Errorf("user %q not using scrypt: %#v", name, db.db.Users[name]) } } // Check various user and password combinations, not all valid. combinations := []struct { user, passwd string expected bool }{ {"user1", "passwd1", true}, {"user1", "passwd", false}, {"user1", "passwd12", false}, {"ñoño", "añicos", true}, {"ñoño", "anicos", false}, {"notindb", "something", false}, {"", "", false}, {" ", " ", false}, } for _, c := range combinations { if db.Authenticate(c.user, c.passwd) != c.expected { t.Errorf("auth(%q, %q) != %v", c.user, c.passwd, c.expected) } } } func TestNew(t *testing.T) { fname := fmt.Sprintf("%s/userdb_test-%d", os.TempDir(), os.Getpid()) defer os.Remove(fname) db1 := New(fname) db1.AddUser("user", "passwd") db1.Write() db2, err := Load(fname) if err != nil { t.Fatalf("error loading: %v", err) } if !dbEquals(db1, db2) { t.Errorf("databases differ. db1:%v != db2:%v", db1, db2) } } func TestInvalidUsername(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) // Names that are invalid. names := []string{ // Contain various types of spaces. " ", " ", "a b", "ñ ñ", "a\xa0b", "a\x85b", "a\nb", "a\tb", "a\xffb", // Contain characters not allowed by PRECIS. "\u00b9", "\u2163", // Names that are not normalized, but would otherwise be valid. "A", "Ñ", } for _, name := range names { err := db.AddUser(name, "passwd") if err == nil { t.Errorf("AddUser(%q) worked, expected it to fail", name) } } } func plainPassword(p string) *Password { return &Password{ Scheme: &Password_Plain{ Plain: &Plain{Password: []byte(p)}, }, } } // Test the plain scheme. Note we don't expect to use it in cases other than // debugging, but it should be functional for that purpose. func TestPlainScheme(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) db.db.Users["user"] = plainPassword("pass word") err := db.Write() if err != nil { t.Errorf("Write failed: %v", err) } db = mustLoad(t, fname) if !db.Authenticate("user", "pass word") { t.Errorf("failed plain authentication") } if db.Authenticate("user", "wrong") { t.Errorf("plain authentication worked but it shouldn't") } } func TestReload(t *testing.T) { content := "users:< key: 'u1' value:< plain:< password: 'pass' >>>" fname := mustCreateDB(t, content) defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) // Add a valid line to the file. content += "users:< key: 'u2' value:< plain:< password: 'pass' >>>" ioutil.WriteFile(fname, []byte(content), 0660) err := db.Reload() if err != nil { t.Errorf("Reload failed: %v", err) } if len(db.db.Users) != 2 { t.Errorf("expected 2 users, got %d", len(db.db.Users)) } // And now a broken one. content += "users:< invalid >" ioutil.WriteFile(fname, []byte(content), 0660) err = db.Reload() if err == nil { t.Errorf("expected error, got nil") } if len(db.db.Users) != 2 { t.Errorf("expected 2 users, got %d", len(db.db.Users)) } // Cause an even bigger error loading, check the database is not changed. db.fname = "/does/not/exist" err = db.Reload() if err == nil { t.Errorf("expected error, got nil") } if len(db.db.Users) != 2 { t.Errorf("expected 2 users, got %d", len(db.db.Users)) } } func TestRemoveUser(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) if ok := db.RemoveUser("unknown"); ok { t.Errorf("removal of unknown user succeeded") } if err := db.AddUser("user", "passwd"); err != nil { t.Fatalf("error adding user: %v", err) } if ok := db.RemoveUser("unknown"); ok { t.Errorf("removal of unknown user succeeded") } if ok := db.RemoveUser("user"); !ok { t.Errorf("removal of existing user failed") } if ok := db.RemoveUser("user"); ok { t.Errorf("removal of unknown user succeeded") } } func TestExists(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) if db.Exists("unknown") { t.Errorf("unknown user exists") } if err := db.AddUser("user", "passwd"); err != nil { t.Fatalf("error adding user: %v", err) } if db.Exists("unknown") { t.Errorf("unknown user exists") } if !db.Exists("user") { t.Errorf("known user does not exist") } if !db.Exists("user") { t.Errorf("known user does not exist") } } chasquid-1.2/test/000077500000000000000000000000001357247226300141475ustar00rootroot00000000000000chasquid-1.2/test/.gitignore000066400000000000000000000002121357247226300161320ustar00rootroot00000000000000 # Ignore the user databases - we create them each time. t-*/config/**/users t-*/?/**/users stress-*/config/**/users stress-*/?/**/users chasquid-1.2/test/Dockerfile000066400000000000000000000042611357247226300161440ustar00rootroot00000000000000# Docker file for creating a docker container that can run the tests. # # Create the image: # docker build -t chasquid-test -f test/Dockerfile . # # Run the tests: # docker run --rm chasquid-test make test # # Get a shell inside the image (for debugging): # docker run -it --entrypoint=/bin/bash chasquid-test FROM golang:latest WORKDIR /go/src/blitiri.com.ar/go/chasquid # Make debconf/frontend non-interactive, to avoid distracting output about the # lack of $TERM. ENV DEBIAN_FRONTEND noninteractive RUN apt-get update -q # Install the required packages for the integration tests. RUN apt-get install -y -q python3 msmtp # Install the optional packages for the integration tests. RUN apt-get install -y -q \ gettext-base dovecot-imapd \ exim4-daemon-light # Install sudo, needed for the docker entrypoint. RUN apt-get install -y -q sudo # Prepare exim. RUN mkdir -p test/t-02-exim/.exim4 \ && ln -s /usr/sbin/exim4 test/t-02-exim/.exim4 # Prepare msmtp: remove setuid, otherwise HOSTALIASES doesn't work. RUN chmod g-s /usr/bin/msmtp # Install binaries for the (optional) DKIM integration test. RUN go get github.com/driusan/dkim/... \ && go install github.com/driusan/dkim/cmd/dkimsign \ && go install github.com/driusan/dkim/cmd/dkimverify \ && go install github.com/driusan/dkim/cmd/dkimkeygen # Copy into the container. Everything below this line will not be cached. COPY . . # Don't run the tests as root: it makes some integration tests more difficult, # as for example Exim has hard-coded protections against running as root. RUN useradd -m chasquid && chown -R chasquid:chasquid . # Update dependencies to the latest versions, and fetch them to the cache. # The fetch is important because once within the entrypoint, we no longer have # network access to the outside, so all modules need to be available. # Do it as chasquid because that is what the tests will run as. USER chasquid ENV GOPATH= RUN go get -v ${GO_GET_ARGS} ./... && go mod download # Build the minidns server, which will be run from within the entrypoint. RUN go build -o /tmp/minidns ./test/util/minidns.go USER root # Custom entry point, which uses our own DNS server. ENTRYPOINT ["./test/util/docker_entrypoint.sh"] chasquid-1.2/test/README.md000066400000000000000000000065371357247226300154410ustar00rootroot00000000000000 # Testing ## Go tests All Go packages have their own test suite, which provides easy and portable tests with decent enough coverage. ## Integration tests In the `test/` directory there is a set of end to end integration tests, written usually in a combination of bash and Python 3. They're not expected to be portable, as that gets impractical very quickly, but should be usable in most Linux environments. They provide critical coverage and integration tests for real life scenarios, as well as interactions with other software (like Exim or Dovecot). ### Dependencies The tests depend on the following things being installed on the system (listed as Debian package, for consistency): - `msmtp` - `util-linux` (for `/usr/bin/setsid`) Some individual tests have additional dependencies, and the tests are skipped if the dependencies are not found: - `t-02-exim` Exim interaction tests: - `gettext-base` (for `/usr/bin/envsubst`) - The `exim` binary available somewhere, but it doesn't have to be installed. There's a script `get-exim4-debian.sh` to get it from the archives. - `t-11-dovecot` Dovecot interaction tests: - `dovecot` - `t-15-driusan_dkim` DKIM integration tests: - The `dkimsign dkimverify dkimkeygen` binaries, from [driusan/dkim](https://github.com/driusan/dkim) (no Debian package yet). For some tests, python >= 3.5 is required; they will be skipped if it's not available. ## Stress tests Also in the `test/` directory there is a set of stress tests, which generate load against chasquid to measure performance and resource consumption. While they are not exhaustive, they are useful to catch regressions and track improvements on the main code paths. ## Fuzz tests Some Go packages also have instrumentation to run fuzz testing against them, with the [go-fuzz](https://github.com/dvyukov/go-fuzz) tool. This is critical for packages that handle sensitive user input, such as authentication encoding, aliases files, or username normalization. They are implemented by a `fuzz.go` file within their respective Go packages. ## Command-line tool tests Each command-line tool has their own set of tests, see the `test.sh` file on their corresponding directories. ## Docker The `test/Dockerfile` can be used to set up a suitable isolated environment to run the integration and stress tests. This is very useful for automated tests, or running the integration tests in constrained or non supported environments. ## Automated tests There are two sets of automated tests which are run on every commit to upstream, and weekly: * [Travis CI](https://travis-ci.org/albertito/chasquid), configured in the `.travis.yml` file, runs the Go tests. * [Gitlab CI](https://gitlab.com/albertito/chasquid/commits/master), configured in the `.gitlab-ci.yml` file, runs integration tests. The tests are run twice: once against the dependencies listed in `go.mod`, and once against the latest version of the dependencies. ## Coverage The `test/cover.sh` script runs the integration tests in coverage mode, and produces a code coverage report in HTML format, for ease of analysis. Unfortunately, exiting with any of the *Fatal* functions does not save coverage output. Those paths are very important to test, but don't expect to see them reflected in the coverage report for now. The target is to keep coverage of the `chasquid` binary above 90%. chasquid-1.2/test/cover.sh000077500000000000000000000031641357247226300156300ustar00rootroot00000000000000#!/bin/bash # Runs tests (both go and integration) in coverage-generation mode. # Generates an HTML report with the results. # # The .coverage directory is used to store the data, it will be erased and # recreated on each run. # # This is not very tidy, and relies on some hacky tricks (see # coverage_test.go), but works for now. set -e . $(dirname ${0})/util/lib.sh init cd "${TBASE}/.." # Recreate the coverage output directory, to avoid including stale results # from previous runs. rm -rf .coverage mkdir -p .coverage export COVER_DIR="$PWD/.coverage" # Normal go tests. go test -tags coverage \ -covermode=count \ -coverprofile="$COVER_DIR/pkg-tests.out"\ -coverpkg=./... ./... # Integration tests. # Will run in coverage mode due to $COVER_DIR being set. setsid -w ./test/run.sh # dovecot tests are also coverage-aware. echo "dovecot cli ..." setsid -w ./cmd/dovecot-auth-cli/test.sh # Merge all coverage output into a single file. # Ignore protocol buffer-generated files, as they are not relevant. go run "${UTILDIR}/gocovcat.go" .coverage/*.out \ | grep -v ".pb.go:" \ > .coverage/all.out # Generate reports based on the merged output. go tool cover -func="$COVER_DIR/all.out" | sort -k 3 -n > "$COVER_DIR/func.txt" go tool cover -html="$COVER_DIR/all.out" -o "$COVER_DIR/classic.html" go run "${UTILDIR}/coverhtml.go" \ -input="$COVER_DIR/all.out" -strip=3 \ -output="$COVER_DIR/coverage.html" \ -title="chasquid coverage report" \ -notes="Generated at commit $(git describe --always --dirty) ($(git log -1 --format=%ci))" echo echo echo "Coverage report can be found in:" echo file://$COVER_DIR/coverage.html chasquid-1.2/test/run.sh000077500000000000000000000002531357247226300153120ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/util/lib.sh init FAILED=0 for i in t-*; do echo $i ... setsid -w $i/run.sh FAILED=$(( $FAILED + $? )) echo done exit $FAILED chasquid-1.2/test/stress-01-load/000077500000000000000000000000001357247226300166255ustar00rootroot00000000000000chasquid-1.2/test/stress-01-load/config/000077500000000000000000000000001357247226300200725ustar00rootroot00000000000000chasquid-1.2/test/stress-01-load/config/chasquid.conf000066400000000000000000000004711357247226300225440ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.2/test/stress-01-load/config/domains/000077500000000000000000000000001357247226300215245ustar00rootroot00000000000000chasquid-1.2/test/stress-01-load/config/domains/testserver/000077500000000000000000000000001357247226300237325ustar00rootroot00000000000000chasquid-1.2/test/stress-01-load/config/domains/testserver/aliases000066400000000000000000000000351357247226300252740ustar00rootroot00000000000000 null: | true fail: | false chasquid-1.2/test/stress-01-load/hosts000066400000000000000000000000251357247226300177050ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/stress-01-load/run.sh000077500000000000000000000011601357247226300177660ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver add_user user@testserver secretpassword # Note we run the server with minimal logging, to avoid generating very large # log files, which are not very useful anyway. mkdir -p .logs chasquid -v=-1 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 echo Peak RAM: `chasquid_ram_peak` if ! loadgen -logtime -addr=localhost:1025 -run_for=3s -noop; then fail fi echo Peak RAM: `chasquid_ram_peak` if ! loadgen -logtime -addr=localhost:1025 -run_for=3s; then fail fi echo Peak RAM: `chasquid_ram_peak` success chasquid-1.2/test/stress-02-connections/000077500000000000000000000000001357247226300202315ustar00rootroot00000000000000chasquid-1.2/test/stress-02-connections/config/000077500000000000000000000000001357247226300214765ustar00rootroot00000000000000chasquid-1.2/test/stress-02-connections/config/chasquid.conf000066400000000000000000000004711357247226300241500ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.2/test/stress-02-connections/hosts000066400000000000000000000000251357247226300213110ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/stress-02-connections/run.sh000077500000000000000000000015501357247226300213750ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver add_user user@testserver secretpassword # Note we run the server with minimal logging, to avoid generating very large # log files, which are not very useful anyway. mkdir -p .logs chasquid -v=-1 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 echo Peak RAM: `chasquid_ram_peak` # Set connection count to (max open files) - (leeway). # We set the leeway to account for file descriptors opened by the runtime and # listeners; 20 should be enough for now. # Cap it to 2000, as otherwise it can be problematic due to port availability. COUNT=$(( `ulimit -n` - 20 )) if [ $COUNT -gt 2000 ]; then COUNT=2000 fi if ! conngen -logtime -addr=localhost:1025 -count=$COUNT; then tail -n 1 .logs/chasquid.log fail fi echo Peak RAM: `chasquid_ram_peak` success chasquid-1.2/test/stress.sh000077500000000000000000000002601357247226300160270ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/util/lib.sh init FAILED=0 for i in stress-*; do echo $i ... setsid -w $i/run.sh FAILED=$(( $FAILED + $? )) echo done exit $FAILED chasquid-1.2/test/t-01-simple_local/000077500000000000000000000000001357247226300172715ustar00rootroot00000000000000chasquid-1.2/test/t-01-simple_local/config/000077500000000000000000000000001357247226300205365ustar00rootroot00000000000000chasquid-1.2/test/t-01-simple_local/config/certs/000077500000000000000000000000001357247226300216565ustar00rootroot00000000000000chasquid-1.2/test/t-01-simple_local/config/certs/noprivkey/000077500000000000000000000000001357247226300237045ustar00rootroot00000000000000chasquid-1.2/test/t-01-simple_local/config/certs/noprivkey/fullchain.pem000066400000000000000000000021131357247226300263510ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIC/TCCAeWgAwIBAgIQeSGdISDwzlRobkplbaT4uTANBgkqhkiG9w0BAQsFADAS MRAwDgYDVQQKEwdBY21lIENvMB4XDTE4MDYwMzIyNDMwMloXDTE4MDYwMzIzNDMw MlowEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC AQoCggEBAKYuJT9DPp7qwDKoNuyhMPgA1456ApSIE+w55N0XyDvIKBTTq0xvRMU/ 1QgL6RvQCOYBh/lf8OF9lSp9IyINFD/H/VRXOOdxLimPOgvu+pZTgOOG9drgivwW 7WdMBIKt+XYhbI0sNgeN2mvkeD1x9Hx0qxRO9n7nurXYYr5ZPCIhlE7NTVbtKxCC qnvJK+nPx/0gMLkhp+38Ishtbr/yUC+KLOtk1Ykt6S8IhiEGbVFSiqZv8KCquTg7 S8e40q9YJkwng6MiHaXoZv4g1QRT1jUZE/8h3VSAfvcRYtWbPQ+R8zIFNfbI8WJA KwrBu34siI5gtzB3GI416DN8YF0l+ncCAwEAAaNPME0wDgYDVR0PAQH/BAQDAgKk MBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wFQYDVR0RBA4w DIIKdGVzdHNlcnZlcjANBgkqhkiG9w0BAQsFAAOCAQEAno2ANy1TPvoqfDebpXJX FqrrF/MmY24PLrnt2VU7dkKatjSaddSL90wTUi/m7gEH8RS3iW6EGRavw30dUrmJ J3lGfDIdm69hemcqcI1jWU0B8HigmOUhKpw/9SnQGV90IBkpv1hNrkdmqhn3a2I0 IPqDshoF1qg3ECmsfnhja5Os5G2Iaxshda5gEk0dZE6epJHwFnJynHw7n3FDTtlQ 1cVvwsamG4mAtey7tPFvG955wZutFgmwoapICvKHKH2ny8dzJCAHkR8RloHLE5ZF HXnhAkgIUX07V314nlUEhrxn28Lhyb92hanc8oExBoJ8OVRtxt2X7y93LY29K0po uQ== -----END CERTIFICATE----- chasquid-1.2/test/t-01-simple_local/config/certs/not_a_dir000066400000000000000000000001371357247226300235400ustar00rootroot00000000000000A simple file, to make sure chasquid does not get confused with them in the "certs" directory. chasquid-1.2/test/t-01-simple_local/config/chasquid.conf000066400000000000000000000003621357247226300232070ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-01-simple_local/content000066400000000000000000000001211357247226300206600ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-01-simple_local/hosts000066400000000000000000000000251357247226300203510ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-01-simple_local/msmtprc000066400000000000000000000006551357247226300207070ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword account smtpport : default port 1025 account subm_tls : default port 1465 tls_starttls off account baduser : default user unknownuser@testserver password secretpassword account badpasswd : default user user@testserver password badsecretpassword chasquid-1.2/test/t-01-simple_local/run.sh000077500000000000000000000027131357247226300204370ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init mkdir -p .logs if ! chasquid --version > /dev/null; then fail "chasquid --version failed" fi # This should fail, as it has no certificates. rm -f config/certs/testserver/*.pem if chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config; then fail "chasquid should not start without certificates" fi generate_certs_for testserver add_user user@testserver secretpassword add_user someone@testserver secretpassword chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 run_msmtp someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver # At least for now, we allow AUTH over the SMTP port to avoid unnecessary # complexity, so we expect it to work. if ! run_msmtp -a smtpport someone@testserver < content 2> /dev/null; then fail "failed auth on the SMTP port" fi # Check deliver over the submission-over-TLS port. if ! run_msmtp -a subm_tls someone@testserver < content 2> /dev/null; then fail "failed submission over TLS" fi if run_msmtp nobody@testserver < content 2> /dev/null; then fail "successfuly sent an email to a non-existent user" fi if run_msmtp -a baduser someone@testserver < content 2> /dev/null; then fail "successfully sent an email with a bad password" fi if run_msmtp -a badpasswd someone@testserver < content 2> /dev/null; then fail "successfully sent an email with a bad password" fi success chasquid-1.2/test/t-02-exim/000077500000000000000000000000001357247226300155715ustar00rootroot00000000000000chasquid-1.2/test/t-02-exim/.gitignore000066400000000000000000000000771357247226300175650ustar00rootroot00000000000000 # Packages, so fetches via get-exim4-* don't add cruft. *.deb chasquid-1.2/test/t-02-exim/config/000077500000000000000000000000001357247226300170365ustar00rootroot00000000000000chasquid-1.2/test/t-02-exim/config/chasquid.conf000066400000000000000000000003621357247226300215070ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-02-exim/config/exim4.in000066400000000000000000000027131357247226300204170ustar00rootroot00000000000000CONFDIR = ${EXIMDIR} spool_directory = CONFDIR/spool exim_path = CONFDIR/exim4 # No need to keep anything on the environment. # This is the default, but exim emits a warning if it's not set # (https://www.exim.org/static/doc/CVE-2016-1531.txt). keep_environment = # Disable TLS for now. tls_advertise_hosts = # Run as the current user. exim_group = ${USER} exim_user = ${USER} # Listen on a non-privileged port. daemon_smtp_port = 2025 # ACLs to let anyone send mail (for testing, obviously). acl_smtp_rcpt = acl_check_rcpt acl_smtp_data = acl_check_data begin acl acl_check_rcpt: accept acl_check_data: accept # Rewrite envelope-from to server@srv-exim. # This is so when we redirect, we don't use user@srv-chasquid in the # envelope-from (we're not authorized to send mail on behalf of # @srv-chasquid). begin rewrite user@srv-chasquid server@srv-exim F # Forward all incoming email to chasquid (running on :1025 in this test). begin routers rewritedst: driver = redirect data = someone@srv-chasquid forwardall: driver = accept transport = tochasquid begin transports tochasquid: driver = smtp # exim4 will by default detect and special-case deliveries to localhost; # this avoids that behaviour and tells it to go ahead anyway. allow_localhost hosts_override # chasquid will be listening on localhost:1025 hosts = localhost port = 1025 # Add headers to help debug failures. delivery_date_add envelope_to_add return_path_add chasquid-1.2/test/t-02-exim/content000066400000000000000000000001211357247226300171600ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-02-exim/get-exim4-debian.sh000077500000000000000000000013741357247226300211600ustar00rootroot00000000000000#!/bin/bash # # This script downloads the exim4 binary from Debian's package. # It assumes "apt" is functional, which means it's not very portable, but # given the nature of these tests that's acceptable for now. set -e . $(dirname ${0})/../util/lib.sh init # Download and extract the package in .exim-bin apt download exim4-daemon-light dpkg -x exim4-daemon-light_*.deb $PWD/.exim-bin/ # Create a symlink to .exim4, which is the directory we will use to store # configuration, spool, etc. # The configuration template will look for it here. mkdir -p .exim4 ln -sf $PWD/.exim-bin/usr/sbin/exim4 .exim4/ # Remove the setuid bit, if there is one - we don't need it and may cause # confusion and/or security troubles. chmod -s .exim-bin/usr/sbin/exim4 success chasquid-1.2/test/t-02-exim/hosts000066400000000000000000000000521357247226300166510ustar00rootroot00000000000000srv-chasquid localhost srv-exim localhost chasquid-1.2/test/t-02-exim/msmtprc000066400000000000000000000002751357247226300172050ustar00rootroot00000000000000account default host srv-chasquid port 1587 tls on tls_trust_file config/certs/srv-chasquid/fullchain.pem from user@srv-chasquid auth on user user@srv-chasquid password secretpassword chasquid-1.2/test/t-02-exim/run.sh000077500000000000000000000034311357247226300167350ustar00rootroot00000000000000#!/bin/bash # # This test checks that we can send and receive mail to/from exim4. # # Setup: # - chasquid listening on :1025. # - exim listening on :2025. # - hosts "srv-chasquid" and "srv-exim" pointing back to localhost. # - exim configured to accept all email and forward it to # someone@srv-chasquid. # # Test: # msmtp --> chasquid --> exim --> chasquid --> local delivery # # msmtp will auth as user@srv-chasquid to chasquid, and send an email with # recipient someone@srv-exim. # # chasquid will deliver the mail to exim. # # exim will deliver the mail back to chasquid (after changing the # destination to someone@chasquid). # # chasquid will receive the email from exim, and deliver it locally. set -e . $(dirname ${0})/../util/lib.sh init if ! .exim4/exim4 --version > /dev/null; then skip "exim4 binary at .exim4/exim4 is not functional" exit 0 fi # Create a temporary directory for exim4 to use, and generate the exim4 # config based on the template. mkdir -p .exim4 EXIMDIR="$PWD/.exim4" envsubst < config/exim4.in > .exim4/config generate_certs_for srv-chasquid add_user user@srv-chasquid secretpassword add_user someone@srv-chasquid secretpassword # Launch chasquid at port 1025 (in config). # Use outgoing port 2025 which is where exim will be at. # Bypass MX lookup, so it can find srv-exim (via our host alias). mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config \ --testing__outgoing_smtp_port=2025 & wait_until_ready 1025 # Launch exim at port 2025 .exim4/exim4 -bd -d -C "$PWD/.exim4/config" > .exim4/log 2>&1 & wait_until_ready 2025 # msmtp will use chasquid to send an email to someone@srv-exim. run_msmtp someone@srv-exim < content wait_for_file .mail/someone@srv-chasquid mail_diff content .mail/someone@srv-chasquid success chasquid-1.2/test/t-03-queue_persistency/000077500000000000000000000000001357247226300204045ustar00rootroot00000000000000chasquid-1.2/test/t-03-queue_persistency/addtoqueue.go000066400000000000000000000022451357247226300230760ustar00rootroot00000000000000// addtoqueue is a test helper which adds a queue item directly to the queue // directory, behind chasquid's back. // // Note that chasquid does NOT support this, we do it before starting up the // daemon for testing purposes only. // // +build ignore package main import ( "flag" "fmt" "io/ioutil" "os" "time" "blitiri.com.ar/go/chasquid/internal/queue" ) var ( queueDir = flag.String("queue_dir", ".queue", "queue directory") id = flag.String("id", "mid1234", "Message ID") from = flag.String("from", "from", "Mail from") rcpt = flag.String("rcpt", "rcpt", "Rcpt to") ) func main() { flag.Parse() data, err := ioutil.ReadAll(os.Stdin) if err != nil { fmt.Printf("error reading data: %v\n", err) os.Exit(1) } item := &queue.Item{ Message: queue.Message{ ID: *id, From: *from, To: []string{*rcpt}, Rcpt: []*queue.Recipient{ { Address: *rcpt, Type: queue.Recipient_EMAIL, Status: queue.Recipient_PENDING, }, }, Data: data, }, CreatedAt: time.Now(), } os.MkdirAll(*queueDir, 0700) err = item.WriteTo(*queueDir) if err != nil { fmt.Printf("error writing item: %v\n", err) os.Exit(1) } } chasquid-1.2/test/t-03-queue_persistency/config/000077500000000000000000000000001357247226300216515ustar00rootroot00000000000000chasquid-1.2/test/t-03-queue_persistency/config/chasquid.conf000066400000000000000000000003621357247226300243220ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-03-queue_persistency/config/domains/000077500000000000000000000000001357247226300233035ustar00rootroot00000000000000chasquid-1.2/test/t-03-queue_persistency/config/domains/testserver/000077500000000000000000000000001357247226300255115ustar00rootroot00000000000000chasquid-1.2/test/t-03-queue_persistency/config/domains/testserver/.gitignore000066400000000000000000000000001357247226300274670ustar00rootroot00000000000000chasquid-1.2/test/t-03-queue_persistency/content000066400000000000000000000001211357247226300217730ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-03-queue_persistency/hosts000066400000000000000000000000251357247226300214640ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-03-queue_persistency/run.sh000077500000000000000000000007751357247226300215600ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init # Add an item to the queue before starting chasquid. go run addtoqueue.go --queue_dir=.data/queue \ --from someone@testserver \ --rcpt someone@testserver \ < content generate_certs_for testserver mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # Check that the item in the queue was delivered. wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver success chasquid-1.2/test/t-04-aliases/000077500000000000000000000000001357247226300162525ustar00rootroot00000000000000chasquid-1.2/test/t-04-aliases/alias-exists-hook000077500000000000000000000002121357247226300215370ustar00rootroot00000000000000#!/bin/bash case "$1" in "vicuña@testserver") exit 0 ;; "ñandú@testserver") exit 0 ;; "roto@testserver") exit 0 ;; esac exit 1 chasquid-1.2/test/t-04-aliases/alias-resolve-hook000077500000000000000000000004041357247226300217020ustar00rootroot00000000000000#!/bin/bash case "$1" in "vicuña@testserver") # Test one naked, one full. These exist in the static aliases file. echo pepe, joan@testserver ;; "ñandú@testserver") echo "| writemailto ../.data/pipe_alias_worked" ;; "roto@testserver") exit 1 ;; esac chasquid-1.2/test/t-04-aliases/config/000077500000000000000000000000001357247226300175175ustar00rootroot00000000000000chasquid-1.2/test/t-04-aliases/config/chasquid.conf000066400000000000000000000004411357247226300221660ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.2/test/t-04-aliases/config/domains/000077500000000000000000000000001357247226300211515ustar00rootroot00000000000000chasquid-1.2/test/t-04-aliases/config/domains/testserver/000077500000000000000000000000001357247226300233575ustar00rootroot00000000000000chasquid-1.2/test/t-04-aliases/config/domains/testserver/aliases000066400000000000000000000002441357247226300247230ustar00rootroot00000000000000 # Easy aliases. pepe: jose joan: juan # UTF-8 aliases. pitanga: ñangapirí añil: azul, índigo # Pipe aliases. tubo: | writemailto ../.data/pipe_alias_worked chasquid-1.2/test/t-04-aliases/content000066400000000000000000000001211357247226300176410ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-04-aliases/hosts000066400000000000000000000000251357247226300173320ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-04-aliases/msmtprc000066400000000000000000000002651357247226300176650ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword chasquid-1.2/test/t-04-aliases/run.sh000077500000000000000000000035251357247226300174220ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver add_user user@testserver secretpassword mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 function send_and_check() { run_msmtp $1@testserver < content shift for i in $@; do wait_for_file .mail/$i@testserver mail_diff content .mail/$i@testserver rm -f .mail/$i@testserver done } # Remove the hooks that could be left over from previous failed tests. rm -f config/hooks/alias-resolve rm -f config/hooks/alias-exists # Test email aliases. send_and_check pepe jose send_and_check joan juan send_and_check pitanga ñangapirí send_and_check añil azul índigo # Test suffix separators and drop characters. send_and_check a.ñi_l azul índigo send_and_check añil-blah azul índigo send_and_check añil+blah azul índigo # Test the pipe alias separately. rm -f .data/pipe_alias_worked run_msmtp tubo@testserver < content wait_for_file .data/pipe_alias_worked mail_diff content .data/pipe_alias_worked # Set up the hooks. mkdir -p config/hooks/ cp alias-exists-hook config/hooks/alias-exists cp alias-resolve-hook config/hooks/alias-resolve # Test email aliases. send_and_check vicuña juan jose # Test the pipe alias separately. rm -f .data/pipe_alias_worked run_msmtp ñandú@testserver < content wait_for_file .data/pipe_alias_worked mail_diff content .data/pipe_alias_worked # Test when alias-resolve exits with an error if run_msmtp roto@testserver < content 2> .logs/msmtp.out; then echo "expected delivery to roto@ to fail, but succeeded" fi # Test a non-existent alias. if run_msmtp nono@testserver < content 2> .logs/msmtp.out; then echo "expected delivery to nono@ to fail, but succeeded" fi # Remove the hooks, leave a clean state. rm -f config/hooks/alias-resolve rm -f config/hooks/alias-exists success chasquid-1.2/test/t-05-null_address/000077500000000000000000000000001357247226300173115ustar00rootroot00000000000000chasquid-1.2/test/t-05-null_address/config/000077500000000000000000000000001357247226300205565ustar00rootroot00000000000000chasquid-1.2/test/t-05-null_address/config/chasquid.conf000066400000000000000000000004711357247226300232300ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.2/test/t-05-null_address/config/domains/000077500000000000000000000000001357247226300222105ustar00rootroot00000000000000chasquid-1.2/test/t-05-null_address/config/domains/testserver/000077500000000000000000000000001357247226300244165ustar00rootroot00000000000000chasquid-1.2/test/t-05-null_address/config/domains/testserver/aliases000066400000000000000000000000201357247226300257520ustar00rootroot00000000000000 fail: | false chasquid-1.2/test/t-05-null_address/content000066400000000000000000000001621357247226300207050ustar00rootroot00000000000000From: Mailer daemon Subject: I've come to haunt you Message-ID: Ñañañañaña! chasquid-1.2/test/t-05-null_address/expected_dsr000066400000000000000000000025431357247226300217110ustar00rootroot00000000000000From user@testserver From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: * Date: * In-Reply-To: References: X-Failed-Recipients: fail@testserver, Auto-Submitted: auto-replied MIME-Version: 1.0 Content-Type: multipart/report; report-type=delivery-status; boundary="???????????" --??????????? Content-Type: text/plain; charset="utf-8" Content-Disposition: inline Content-Description: Notification Content-Transfer-Encoding: 8bit Delivery of your message to the following recipient(s) failed permanently: - fail@testserver Technical details: - "false" (PIPE) failed permanently with error: exit status 1 --??????????? Content-Type: message/global-delivery-status Content-Description: Delivery Report Content-Transfer-Encoding: 8bit Reporting-MTA: dns; testserver Original-Recipient: utf-8; fail@testserver Final-Recipient: utf-8; false Action: failed Status: 5.0.0 Diagnostic-Code: smtp; exit status 1 --??????????? Content-Type: message/rfc822 Content-Description: Undelivered Message Content-Transfer-Encoding: 8bit Received: from localhost by testserver (chasquid) with ESMTPSA tls * (over * ; * Date: * From: Mailer daemon Subject: I've come to haunt you Message-Id: Ñañañañaña! --???????????-- chasquid-1.2/test/t-05-null_address/hosts000066400000000000000000000000251357247226300203710ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-05-null_address/msmtprc000066400000000000000000000002641357247226300207230ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword chasquid-1.2/test/t-05-null_address/run.sh000077500000000000000000000011641357247226300204560ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver add_user user@testserver secretpassword mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # Send mail with an empty address (directly, unauthenticated). chamuyero sendmail.cmy > .logs/chamuyero 2>&1 wait_for_file .mail/user@testserver mail_diff content .mail/user@testserver rm -f .mail/user@testserver # Test that we get mail back for a failed delivery run_msmtp fail@testserver < content wait_for_file .mail/user@testserver mail_diff expected_dsr .mail/user@testserver success chasquid-1.2/test/t-05-null_address/sendmail.cmy000066400000000000000000000005331357247226300216200ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> MAIL FROM: <> c <~ 250 c -> RCPT TO: user@testserver c <~ 250 c -> DATA c <~ 354 c -> From: Mailer daemon c -> Subject: I've come to haunt you c -> Message-ID: c -> c -> Ñañañañaña! c -> c -> c -> . c <~ 250 c -> QUIT c <~ 221 chasquid-1.2/test/t-06-idna/000077500000000000000000000000001357247226300155465ustar00rootroot00000000000000chasquid-1.2/test/t-06-idna/.gitignore000066400000000000000000000000711357247226300175340ustar00rootroot00000000000000# Ignore the configuration domain directories. ?/domains chasquid-1.2/test/t-06-idna/A/000077500000000000000000000000001357247226300157265ustar00rootroot00000000000000chasquid-1.2/test/t-06-idna/A/chasquid.conf000066400000000000000000000003211357247226300203720ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs/mail_log-A" chasquid-1.2/test/t-06-idna/B/000077500000000000000000000000001357247226300157275ustar00rootroot00000000000000chasquid-1.2/test/t-06-idna/B/chasquid.conf000066400000000000000000000003211357247226300203730ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs/mail_log-B" chasquid-1.2/test/t-06-idna/from_A_to_B000066400000000000000000000001421357247226300176340ustar00rootroot00000000000000From: ñangapirí@srv-ñ To: pingüino@srv-ü Subject: Hola amigo pingüino! Que tal va la vida? chasquid-1.2/test/t-06-idna/from_B_to_A000066400000000000000000000001451357247226300176370ustar00rootroot00000000000000From: pingüino@srv-ü To: ñangapirí@srv-ñ Subject: Feliz primavera! Espero que florezcas feliz! chasquid-1.2/test/t-06-idna/hosts000066400000000000000000000000561357247226300166320ustar00rootroot00000000000000xn--srv--3ra localhost xn--srv--jqa localhost chasquid-1.2/test/t-06-idna/run.sh000077500000000000000000000021241357247226300167100ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init rm -rf .data-A .data-B .mail skip_if_python_is_too_old # Two servers: # A - listens on :1025, hosts srv-ñ # B - listens on :2015, hosts srv-ü CONFDIR=A generate_certs_for srv-ñ CONFDIR=A add_user ñangapirí@srv-ñ antaño CONFDIR=A add_user nadaA@nadaA nadaA CONFDIR=B generate_certs_for srv-ü CONFDIR=B add_user pingüino@srv-ü velóz CONFDIR=B add_user nadaB@nadaB nadaB mkdir -p .logs-A .logs-B chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1025 wait_until_ready 2025 # Send from A to B. smtpc.py --server=localhost:1025 --user=nadaA@nadaA --password=nadaA \ < from_A_to_B wait_for_file .mail/pingüino@srv-ü mail_diff from_A_to_B .mail/pingüino@srv-ü # Send from B to A. smtpc.py --server=localhost:2025 --user=nadaB@nadaB --password=nadaB \ < from_B_to_A wait_for_file .mail/ñangapirí@srv-ñ mail_diff from_B_to_A .mail/ñangapirí@srv-ñ success chasquid-1.2/test/t-07-smtputf8/000077500000000000000000000000001357247226300164265ustar00rootroot00000000000000chasquid-1.2/test/t-07-smtputf8/config/000077500000000000000000000000001357247226300176735ustar00rootroot00000000000000chasquid-1.2/test/t-07-smtputf8/config/chasquid.conf000066400000000000000000000003151357247226300223420ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-07-smtputf8/content000066400000000000000000000001741357247226300200250ustar00rootroot00000000000000From: ñandú@ñoÑos To: Ñangapirí@Ñoños Subject: Arañando el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-07-smtputf8/hosts000066400000000000000000000000221357247226300175030ustar00rootroot00000000000000ñoños localhost chasquid-1.2/test/t-07-smtputf8/run.sh000077500000000000000000000017151357247226300175750ustar00rootroot00000000000000#!/bin/bash # Test UTF8 support, including usernames and domains. # Also test normalization: the destinations will have non-matching # capitalizations. set -e . $(dirname ${0})/../util/lib.sh init skip_if_python_is_too_old generate_certs_for ñoños # Intentionally have a config directory for upper case; this should be # normalized to lowercase internally (and match the cert accordingly). add_user ñangapirí@ñoñOS antaño # Python doesn't support UTF8 for auth, use an ascii user and domain. add_user nada@nada nada mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # The envelope from and to are taken from the content, and use a mix of upper # and lower case. smtpc.py --server=localhost:1025 --user=nada@nada --password=nada \ < content # The MDA should see the normalized users and domains, in lower case. wait_for_file .mail/ñangapirí@ñoños mail_diff content .mail/ñangapirí@ñoños success chasquid-1.2/test/t-09-loop/000077500000000000000000000000001357247226300156075ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/A/000077500000000000000000000000001357247226300157675ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/A/chasquid.conf000066400000000000000000000003661357247226300204440ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs/mail_log-A" chasquid-1.2/test/t-09-loop/A/domains/000077500000000000000000000000001357247226300174215ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/A/domains/srv-A/000077500000000000000000000000001357247226300204115ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/A/domains/srv-A/aliases000066400000000000000000000000261357247226300217530ustar00rootroot00000000000000 aliasA: aliasB@srv-B chasquid-1.2/test/t-09-loop/B/000077500000000000000000000000001357247226300157705ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/B/chasquid.conf000066400000000000000000000003661357247226300204450ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs/mail_log-B" chasquid-1.2/test/t-09-loop/B/domains/000077500000000000000000000000001357247226300174225ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/B/domains/srv-B/000077500000000000000000000000001357247226300204135ustar00rootroot00000000000000chasquid-1.2/test/t-09-loop/B/domains/srv-B/aliases000066400000000000000000000000251357247226300217540ustar00rootroot00000000000000aliasB: aliasA@srv-A chasquid-1.2/test/t-09-loop/content000066400000000000000000000003141357247226300172020ustar00rootroot00000000000000From: userA@srv-A To: aliasB@srv-B Subject: Los espejos Yo que sentí el horror de los espejos no sólo ante el cristal impenetrable donde acaba y empieza, inhabitable, un imposible espacio de reflejos chasquid-1.2/test/t-09-loop/hosts000066400000000000000000000000401357247226300166640ustar00rootroot00000000000000srv-A localhost srv-B localhost chasquid-1.2/test/t-09-loop/msmtprc000066400000000000000000000002251357247226300172160ustar00rootroot00000000000000account default host srv-A port 1587 tls on tls_trust_file A/certs/srv-A/fullchain.pem from userA@srv-A auth on user userA@srv-A password userA chasquid-1.2/test/t-09-loop/run.sh000077500000000000000000000037341357247226300167610ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init rm -rf .data-A .data-B .mail # Two servers: # A - listens on :1025, hosts srv-A # B - listens on :2015, hosts srv-B # # We cause the following loop: # userA -> aliasB -> aliasA -> aliasB -> ... CONFDIR=A generate_certs_for srv-A CONFDIR=A add_user userA@srv-A userA CONFDIR=B generate_certs_for srv-B mkdir -p .logs-A .logs-B chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \ --testing__max_received_headers=5 \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1025 wait_until_ready 2025 run_msmtp aliasB@srv-B < content # Get some of the debugging pages, for troubleshooting, and to make sure they # work reasonably well. wget -q -o /dev/null -O .data-A/dbg-root http://localhost:1099/ \ || fail "failed to fetch /" wget -q -o /dev/null -O .data-A/dbg-flags http://localhost:1099/debug/flags \ || fail "failed to fetch /debug/flags" wget -q -o /dev/null -O .data-A/dbg-queue http://localhost:1099/debug/queue \ || fail "failed to fetch /debug/queue" wget -q -o /dev/null -O .data-A/dbg-root http://localhost:1099/404 \ && fail "fetch /404 worked, should have failed" # Wait until one of them has noticed and stopped the loop. while sleep 0.1; do wget -q -o /dev/null -O .data-A/vars http://localhost:1099/debug/vars wget -q -o /dev/null -O .data-B/vars http://localhost:2099/debug/vars # Allow for up to 2 loops to be detected, because if chasquid is fast # enough the DSN will also loop before this check notices it. if grep -q '"chasquid/smtpIn/loopsDetected": [12],' .data-?/vars; then break fi done # Test that A has outgoing domaininfo for srv-b. # This is unrelated to the loop itself, but serves as an end-to-end # verification that outgoing domaininfo works. if ! grep -q "outgoing_sec_level: TLS_INSECURE" ".data-A/domaininfo/s:srv-b"; then fail "A is missing the domaininfo for srv-b" fi success chasquid-1.2/test/t-10-hooks/000077500000000000000000000000001357247226300157515ustar00rootroot00000000000000chasquid-1.2/test/t-10-hooks/.gitignore000066400000000000000000000000271357247226300177400ustar00rootroot00000000000000config/hooks/post-data chasquid-1.2/test/t-10-hooks/config/000077500000000000000000000000001357247226300172165ustar00rootroot00000000000000chasquid-1.2/test/t-10-hooks/config/chasquid.conf000066400000000000000000000003151357247226300216650ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-10-hooks/config/hooks/000077500000000000000000000000001357247226300203415ustar00rootroot00000000000000chasquid-1.2/test/t-10-hooks/config/hooks/post-data.bad1000077500000000000000000000001131357247226300227640ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo "This is not a header" chasquid-1.2/test/t-10-hooks/config/hooks/post-data.bad2000077500000000000000000000001721357247226300227720ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo "X-Post-DATA: This starts like a header" echo echo "But then is not" chasquid-1.2/test/t-10-hooks/config/hooks/post-data.bad3000077500000000000000000000001731357247226300227740ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out # Just a newline is quite problematic, as it would break the headers. echo chasquid-1.2/test/t-10-hooks/config/hooks/post-data.bad4000077500000000000000000000001561357247226300227760ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo -n "X-Post-DATA: valid header with no newline at the end" chasquid-1.2/test/t-10-hooks/config/hooks/post-data.good000077500000000000000000000006141357247226300231130ustar00rootroot00000000000000#!/bin/bash env > ../.data/post-data.out echo >> ../.data/post-data.out cat >> ../.data/post-data.out if [ "$RCPT_TO" == "blockme@testserver" ]; then echo "¡No pasarán!" exit 1 fi if [ "$RCPT_TO" == "permanent@testserver" ]; then echo "Nos hacemos la permanente" exit 20 # permanent fi echo "X-Post-Data: success" echo "X-Post-Data-Multiline: multiline" echo " header for testing." chasquid-1.2/test/t-10-hooks/content000066400000000000000000000001211357247226300173400ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-10-hooks/hosts000066400000000000000000000000251357247226300170310ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-10-hooks/msmtprc000066400000000000000000000003111357247226300173540ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword logfile .logs/msmtp chasquid-1.2/test/t-10-hooks/run.sh000077500000000000000000000036211357247226300171160ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver add_user user@testserver secretpassword add_user someone@testserver secretpassword add_user blockme@testserver secretpassword add_user permanent@testserver secretpassword mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 cp config/hooks/post-data.good config/hooks/post-data run_msmtp someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver if ! grep -q "X-Post-Data: success" .mail/someone@testserver; then fail "missing X-Post-Data header" fi function check() { if ! grep -q "$1" .data/post-data.out; then fail "missing: $1" fi } # Verify that the environment for the hook was reasonable. check "RCPT_TO=someone@testserver" check "MAIL_FROM=user@testserver" check "USER=$USER" check "PWD=$PWD/config" check "FROM_LOCAL_DOMAIN=1" check "ON_TLS=1" check "AUTH_AS=user@testserver" check "PATH=" check "REMOTE_ADDR=" check "SPF_PASS=0" # Check that failures in the script result in failing delivery. # Transient failure. if run_msmtp blockme@testserver < content 2>/dev/null; then fail "ERROR: hook did not block email as expected" fi if ! tail -n 1 .logs/msmtp | grep -q "smtpstatus=451"; then tail -n 1 .logs/msmtp fail "ERROR: transient hook error not returned correctly" fi # Permanent failure. if run_msmtp permanent@testserver < content 2>/dev/null; then fail "ERROR: hook did not block email as expected" fi if ! tail -n 1 .logs/msmtp | grep -q "smtpstatus=554"; then tail -n 1 .logs/msmtp fail "ERROR: permanent hook error not returned correctly" fi # Check that the bad hooks don't prevent delivery. for i in config/hooks/post-data.bad*; do cp $i config/hooks/post-data run_msmtp someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver done success chasquid-1.2/test/t-11-dovecot/000077500000000000000000000000001357247226300162725ustar00rootroot00000000000000chasquid-1.2/test/t-11-dovecot/config/000077500000000000000000000000001357247226300175375ustar00rootroot00000000000000chasquid-1.2/test/t-11-dovecot/config/chasquid.conf000066400000000000000000000006121357247226300222060ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" dovecot_auth: true dovecot_userdb_path: "/tmp/chasquid-dovecot-test/run/auth-userdb" dovecot_client_path: "/tmp/chasquid-dovecot-test/run/auth-client" chasquid-1.2/test/t-11-dovecot/config/domains/000077500000000000000000000000001357247226300211715ustar00rootroot00000000000000chasquid-1.2/test/t-11-dovecot/config/domains/srv/000077500000000000000000000000001357247226300220035ustar00rootroot00000000000000chasquid-1.2/test/t-11-dovecot/config/domains/srv/.keep000066400000000000000000000000001357247226300227160ustar00rootroot00000000000000chasquid-1.2/test/t-11-dovecot/config/dovecot.conf.in000066400000000000000000000027301357247226300224600ustar00rootroot00000000000000base_dir = $ROOT/run/ log_path = $ROOT/dovecot.log ssl = no default_internal_user = $USER default_login_user = $USER # Before auth checks, rename "u@d" to "u-AT-d". This exercises that chasquid # handles well the case where the returned user information does not match the # requested user. auth_username_format = "%n-AT-%d" passdb { driver = passwd-file args = $ROOT/passwd } userdb { driver = passwd-file args = $ROOT/passwd } service auth { unix_listener auth { mode = 0666 } } # Dovecot refuses to start without protocols, so we need to give it one. protocols = imap service imap-login { chroot = inet_listener imap { address = 127.0.0.1 port = 0 } } service anvil { 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 auth_debug_passwords = yes auth_verbose_passwords = yes mail_debug = yes chasquid-1.2/test/t-11-dovecot/config/passwd000066400000000000000000000000621357247226300207610ustar00rootroot00000000000000user-AT-srv:{plain}password:1000:1000::/home/user chasquid-1.2/test/t-11-dovecot/content000066400000000000000000000001211357247226300176610ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-11-dovecot/hosts000066400000000000000000000000161357247226300173520ustar00rootroot00000000000000srv localhost chasquid-1.2/test/t-11-dovecot/msmtprc000066400000000000000000000005751357247226300177110ustar00rootroot00000000000000account default host srv port 1587 tls on tls_trust_file config/certs/srv/fullchain.pem from user@srv auth on user user@srv password password account smtpport : default port 1025 account subm_tls : default port 1465 tls_starttls off account baduser : default user unknownuser@srv password secretpassword account badpasswd : default user user@srv password badsecretpassword chasquid-1.2/test/t-11-dovecot/run.sh000077500000000000000000000037541357247226300174460ustar00rootroot00000000000000#!/bin/bash # # This test checks that we can use dovecot as an authentication mechanism. # # Setup: # - chasquid listening on :1025. # - dovecot listening on unix sockets in .dovecot/ set -e . $(dirname ${0})/../util/lib.sh init if ! dovecot --version > /dev/null; then skip "dovecot not installed" exit 0 fi # Create a temporary directory for dovecot to use, and generate the dovecot # config based on the template. # Note the lenght of the path must be < 100, because unix sockets have a low # limitation, so we use a directory in /tmp, which is not ideal, as a # workaround. export ROOT="/tmp/chasquid-dovecot-test" 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 dovecot -F -c $ROOT/dovecot.conf & # Early tests: run dovecot-auth-cli for testing purposes. These fail early if # there are obvious problems. OUT=$(dovecot-auth-cli $ROOT/run/auth exists user@srv || true) if [ "$OUT" != "yes" ]; then fail "user does not exist: $OUT" fi OUT=$(dovecot-auth-cli $ROOT/run/auth auth user@srv password || true) if [ "$OUT" != "yes" ]; then fail "auth failed: $OUT" fi # Set up chasquid, using dovecot as authentication backend. generate_certs_for srv mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # Send an email as user@srv successfully. run_msmtp user@srv < content wait_for_file .mail/user@srv mail_diff content .mail/user@srv # Fail to send to nobody@srv (user does not exist). if run_msmtp nobody@srv < content 2> /dev/null; then fail "successfuly sent an email to a non-existent user" fi # Fail to send from baduser@srv (user does not exist). if run_msmtp -a baduser user@srv < content 2> /dev/null; then fail "successfully sent an email with a bad user" fi # Fail to send with an incorrect password. if run_msmtp -a badpasswd user@srv < content 2> /dev/null; then fail "successfully sent an email with a bad password" fi success chasquid-1.2/test/t-12-minor_dialogs/000077500000000000000000000000001357247226300174565ustar00rootroot00000000000000chasquid-1.2/test/t-12-minor_dialogs/auth_multi_dialog.cmy000066400000000000000000000010451357247226300236620ustar00rootroot00000000000000 c tls_connect localhost:1465 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH SOMETHINGELSE c <~ 534 c -> AUTH PLAIN c <~ 334 c -> dXNlckB0ZXN0c2VydmVyAHlalala== c <~ 501 5.5.2 Error decoding AUTH response c -> AUTH PLAIN c <~ 334 c -> dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgB3cm9uZ3Bhc3N3b3Jk c <~ 535 5.7.8 Incorrect user or password c -> AUTH PLAIN c <~ 334 c -> dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgBzZWNyZXRwYXNzd29yZA== c <~ 235 2.7.0 Authentication successful c -> AUTH PLAIN c <~ 503 5.5.1 You are already wearing that! chasquid-1.2/test/t-12-minor_dialogs/auth_not_tls.cmy000066400000000000000000000002001357247226300226630ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH PLAIN c <- 503 5.7.10 You feel vulnerable chasquid-1.2/test/t-12-minor_dialogs/auth_too_many_failures.cmy000066400000000000000000000004361357247226300247330ustar00rootroot00000000000000 c tls_connect localhost:1465 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH PLAIN something c <~ 501 c -> AUTH PLAIN something c <~ 501 c -> AUTH PLAIN something c <~ 501 c -> AUTH PLAIN something c <~ 501 c -> AUTH PLAIN something c <~ 503 5.7.8 Too many attempts, go away chasquid-1.2/test/t-12-minor_dialogs/bad_data.cmy000066400000000000000000000007241357247226300217120ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> DATA c <- 503 5.5.1 Invisible customers are not welcome! c -> HELO localhost c <~ 250 c -> DATA c <- 503 5.5.1 Sender not yet given c -> MAIL FROM: c <~ 250 c -> RCPT TO: user@testserver c <~ 250 c -> DATA c <~ 354 c -> From: Mailer daemon c -> Subject: I've come to haunt you c -> Bad header c -> c -> Muahahahaha c -> c -> c -> . c <~ 554 5.6.0 Error parsing message c -> QUIT c <~ 221 chasquid-1.2/test/t-12-minor_dialogs/bad_mail_from.cmy000066400000000000000000000011621357247226300227430ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL LALA: <> c <- 500 5.5.2 Unknown command c -> MAIL FROM: c <~ 500 c -> MAIL FROM: c <~ 501 c -> MAIL FROM: c <- 501 5.1.8 Malformed sender domain (IDNA conversion failed) c -> MAIL FROM: c <- 501 5.1.7 Sender address too long chasquid-1.2/test/t-12-minor_dialogs/bad_rcpt_to.cmy000066400000000000000000000013601357247226300224500ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <~ 250 c -> RCPT LALA: <> c <- 500 5.5.2 Unknown command c -> RCPT TO: c <~ 500 c -> RCPT TO: c <~ 501 c -> RCPT TO: c <- 501 5.1.2 Malformed destination domain (IDNA conversion failed) c -> RCPT TO: c <- 550 5.1.3 Destination address is invalid c -> RCPT TO: c <- 501 5.1.3 Destination address too long chasquid-1.2/test/t-12-minor_dialogs/config/000077500000000000000000000000001357247226300207235ustar00rootroot00000000000000chasquid-1.2/test/t-12-minor_dialogs/config/chasquid.conf000066400000000000000000000004711357247226300233750ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.2/test/t-12-minor_dialogs/config/domains/000077500000000000000000000000001357247226300223555ustar00rootroot00000000000000chasquid-1.2/test/t-12-minor_dialogs/config/domains/testserver/000077500000000000000000000000001357247226300245635ustar00rootroot00000000000000chasquid-1.2/test/t-12-minor_dialogs/config/domains/testserver/aliases000066400000000000000000000000201357247226300261170ustar00rootroot00000000000000 fail: | false chasquid-1.2/test/t-12-minor_dialogs/empty_helo.cmy000066400000000000000000000001541357247226300223350ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO c <~ 501 c -> EHLO c <~ 501 c -> HELO localhost c <~ 250 chasquid-1.2/test/t-12-minor_dialogs/helo.cmy000066400000000000000000000001311357247226300211120ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> QUIT c <~ 221 chasquid-1.2/test/t-12-minor_dialogs/hosts000066400000000000000000000000251357247226300205360ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-12-minor_dialogs/line_too_long.cmy000066400000000000000000000020431357247226300230160ustar00rootroot00000000000000c tcp_connect localhost:1025 c <~ 220 c -> HELO aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1 c <~ 554 chasquid-1.2/test/t-12-minor_dialogs/run.sh000077500000000000000000000006361357247226300206260ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver add_user user@testserver secretpassword mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 FAILED=0 for i in *.cmy; do if ! chamuyero $i > .logs/$i.log 2>&1 ; then echo "test $i failed, see .logs/$i.log" FAILED=1 fi done if [ $FAILED == 1 ]; then fail fi success chasquid-1.2/test/t-12-minor_dialogs/sendmail.cmy000066400000000000000000000004751357247226300217720ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> MAIL FROM: <> c <~ 250 c -> RCPT TO: user@testserver c <~ 250 c -> DATA c <~ 354 c -> From: Mailer daemon c -> Subject: I've come to haunt you c -> c -> Muahahahaha c -> c -> c -> . c <~ 250 c -> QUIT c <~ 221 chasquid-1.2/test/t-12-minor_dialogs/unknown_command.cmy000066400000000000000000000001731357247226300233660ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> WHATISTHIS c <- 500 5.5.1 Unknown command chasquid-1.2/test/t-13-reload/000077500000000000000000000000001357247226300160775ustar00rootroot00000000000000chasquid-1.2/test/t-13-reload/.gitignore000066400000000000000000000000421357247226300200630ustar00rootroot00000000000000config/domains/testserver/aliases chasquid-1.2/test/t-13-reload/config/000077500000000000000000000000001357247226300173445ustar00rootroot00000000000000chasquid-1.2/test/t-13-reload/config/chasquid.conf000066400000000000000000000003151357247226300220130ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-13-reload/content000066400000000000000000000001211357247226300174660ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-13-reload/hosts000066400000000000000000000000251357247226300171570ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-13-reload/msmtprc000066400000000000000000000002701357247226300175060ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from someone@testserver auth on user someone@testserver password password222 chasquid-1.2/test/t-13-reload/run.sh000077500000000000000000000014751357247226300172510ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init generate_certs_for testserver # Start with the user with the wrong password, and no aliases. add_user someone@testserver password111 rm -f config/domains/testserver/aliases mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config \ --testing__reload_every=50ms & wait_until_ready 1025 # First, check that delivery fails with the "wrong" password. if run_msmtp someone@testserver < content 2>/dev/null; then fail "success using the wrong password" fi # Change password, add an alias; then wait a bit more than the reload period # and try again. add_user someone@testserver password222 echo "analias: someone" > config/domains/testserver/aliases sleep 0.2 run_msmtp analias@testserver < content wait_for_file .mail/someone@testserver success chasquid-1.2/test/t-14-tls_tracking/000077500000000000000000000000001357247226300173165ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/A/000077500000000000000000000000001357247226300174765ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/A/chasquid.conf000066400000000000000000000003661357247226300221530ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs-A/mail_log" chasquid-1.2/test/t-14-tls_tracking/A/domains/000077500000000000000000000000001357247226300211305ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/A/domains/srv-A/000077500000000000000000000000001357247226300221205ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/A/domains/srv-A/.keep000066400000000000000000000000001357247226300230330ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/B/000077500000000000000000000000001357247226300174775ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/B/chasquid.conf000066400000000000000000000003661357247226300221540ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs-B/mail_log" chasquid-1.2/test/t-14-tls_tracking/B/domains/000077500000000000000000000000001357247226300211315ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/B/domains/srv-B/000077500000000000000000000000001357247226300221225ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/B/domains/srv-B/.keep000066400000000000000000000000001357247226300230350ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/config/000077500000000000000000000000001357247226300205635ustar00rootroot00000000000000chasquid-1.2/test/t-14-tls_tracking/config/chasquid.conf000066400000000000000000000003621357247226300232340ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-14-tls_tracking/content000066400000000000000000000001211357247226300207050ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-14-tls_tracking/hosts000066400000000000000000000000401357247226300203730ustar00rootroot00000000000000srv-A localhost srv-B localhost chasquid-1.2/test/t-14-tls_tracking/msmtprc000066400000000000000000000002251357247226300207250ustar00rootroot00000000000000account default host srv-A port 1587 tls on tls_trust_file A/certs/srv-A/fullchain.pem from userA@srv-A auth on user userA@srv-A password userA chasquid-1.2/test/t-14-tls_tracking/run.sh000077500000000000000000000032531357247226300204640ustar00rootroot00000000000000#!/bin/bash # Test TLS tracking features, which require faking SPF. set -e . $(dirname ${0})/../util/lib.sh init # Build with the DNS override, so we can fake DNS records. export GOTAGS="dnsoverride" # Launch minidns in the background using our configuration. minidns_bg --addr=":9053" -zones=zones >> .minidns.log 2>&1 # Two chasquid servers: # A - listens on :1025, hosts srv-A # B - listens on :2025, hosts srv-B CONFDIR=A generate_certs_for srv-A CONFDIR=A add_user userA@srv-A userA CONFDIR=B generate_certs_for srv-B CONFDIR=B add_user userB@srv-B userB rm -rf .data-A .data-B .mail .certs mkdir -p .logs-A .logs-B .mail .certs # Put public certs in .certs, and use it as our trusted cert dir. cp A/certs/srv-A/fullchain.pem .certs/srv-a.pem cp B/certs/srv-B/fullchain.pem .certs/srv-b.pem export SSL_CERT_DIR=$PWD/.certs/ chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \ --testing__dns_addr=127.0.0.1:9053 \ --testing__max_received_headers=5 \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1025 wait_until_ready 2025 wait_until_ready 9053 run_msmtp userB@srv-B < content wait_for_file .mail/userb@srv-b mail_diff content .mail/userb@srv-b # A should have a secure outgoing connection to srv-b. if ! grep -q "outgoing_sec_level: TLS_SECURE" ".data-A/domaininfo/s:srv-b"; then fail "A is missing the domaininfo for srv-b" fi # B should have a secure incoming connection from srv-a. if ! grep -q "incoming_sec_level: TLS_CLIENT" ".data-B/domaininfo/s:srv-a"; then fail "B is missing the domaininfo for srv-a" fi success chasquid-1.2/test/t-14-tls_tracking/zones000066400000000000000000000002551357247226300204010ustar00rootroot00000000000000# srv-a zone srv-a A 127.0.0.1 srv-a AAAA ::1 srv-a MX srv-a srv-a TXT v=spf1 a # srv-b zone srv-b A 127.0.0.1 srv-b AAAA ::1 srv-b MX srv-b srv-b TXT v=spf1 a chasquid-1.2/test/t-15-driusan_dkim/000077500000000000000000000000001357247226300173045ustar00rootroot00000000000000chasquid-1.2/test/t-15-driusan_dkim/config/000077500000000000000000000000001357247226300205515ustar00rootroot00000000000000chasquid-1.2/test/t-15-driusan_dkim/config/chasquid.conf000066400000000000000000000003151357247226300232200ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-15-driusan_dkim/config/domains/000077500000000000000000000000001357247226300222035ustar00rootroot00000000000000chasquid-1.2/test/t-15-driusan_dkim/config/domains/testserver/000077500000000000000000000000001357247226300244115ustar00rootroot00000000000000chasquid-1.2/test/t-15-driusan_dkim/config/domains/testserver/dkim_selector000066400000000000000000000000161357247226300271550ustar00rootroot00000000000000testselector1 chasquid-1.2/test/t-15-driusan_dkim/config/hooks/000077500000000000000000000000001357247226300216745ustar00rootroot00000000000000chasquid-1.2/test/t-15-driusan_dkim/config/hooks/post-data000077500000000000000000000012031357247226300235120ustar00rootroot00000000000000#!/bin/bash # If authenticated, sign; otherwise, verify. # # It is not recommended that we fail delivery on dkim verification failures, # but leave it to the MUA to handle verifications. # https://tools.ietf.org/html/rfc6376#section-2.2 # # We do a verification here so we have a stronger integration test (check # encodings/dot-stuffing/etc. works ok), but it's not recommended for general # purposes. if [ "$AUTH_AS" != "" ]; then DOMAIN=$( echo "$MAIL_FROM" | cut -d '@' -f 2 ) exec dkimsign -n -hd -key ../.dkimcerts/private.pem \ -s $(cat "domains/$DOMAIN/dkim_selector") -d "$DOMAIN" fi exec dkimverify -txt ../.dkimcerts/dns.txt chasquid-1.2/test/t-15-driusan_dkim/content000066400000000000000000000002641357247226300207030ustar00rootroot00000000000000Subject: Prueba desde el test To: someone@testserver Crece desde el test el futuro Crece desde el test . El punto de arriba testea el dot-stuffing, que es importante para DKIM. chasquid-1.2/test/t-15-driusan_dkim/hosts000066400000000000000000000000251357247226300203640ustar00rootroot00000000000000testserver localhost chasquid-1.2/test/t-15-driusan_dkim/msmtprc000066400000000000000000000002651357247226300207170ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword chasquid-1.2/test/t-15-driusan_dkim/run.sh000077500000000000000000000026641357247226300204570ustar00rootroot00000000000000#!/bin/bash # # Test integration with driusan's DKIM tools. # https://github.com/driusan/dkim set -e . $(dirname ${0})/../util/lib.sh init for binary in dkimsign dkimverify dkimkeygen; do if ! which $binary > /dev/null; then skip "$binary binary not found" exit 0 fi done generate_certs_for testserver ( mkdir -p .dkimcerts; cd .dkimcerts; dkimkeygen ) add_user user@testserver secretpassword add_user someone@testserver secretpassword mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # Authenticated: user@testserver -> someone@testserver # Should be signed. run_msmtp someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver grep -q "DKIM-Signature:" .mail/someone@testserver # Verify the signature manually, just in case. dkimverify -txt .dkimcerts/dns.txt < .mail/someone@testserver # Save the signed mail so we can verify it later. # Drop the first line ("From blah") so it can be used as email contents. tail -n +2 .mail/someone@testserver > .signed_content # Not authenticated: someone@testserver -> someone@testserver smtpc.py --server=localhost:1025 < .signed_content # Check that the signature fails on modified content. echo "Added content, invalid and not signed" >> .signed_content if smtpc.py --server=localhost:1025 < .signed_content 2> /dev/null; then fail "DKIM verification succeeded on modified content" fi success chasquid-1.2/test/t-16-spf/000077500000000000000000000000001357247226300154245ustar00rootroot00000000000000chasquid-1.2/test/t-16-spf/A/000077500000000000000000000000001357247226300156045ustar00rootroot00000000000000chasquid-1.2/test/t-16-spf/A/chasquid.conf000066400000000000000000000003661357247226300202610ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs-A/mail_log" chasquid-1.2/test/t-16-spf/B/000077500000000000000000000000001357247226300156055ustar00rootroot00000000000000chasquid-1.2/test/t-16-spf/B/chasquid.conf000066400000000000000000000003661357247226300202620ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs-B/mail_log" chasquid-1.2/test/t-16-spf/config/000077500000000000000000000000001357247226300166715ustar00rootroot00000000000000chasquid-1.2/test/t-16-spf/config/chasquid.conf000066400000000000000000000003621357247226300213420ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.2/test/t-16-spf/content000066400000000000000000000001211357247226300170130ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.2/test/t-16-spf/expected_dsn000066400000000000000000000026121357247226300200150ustar00rootroot00000000000000From usera@srv-a From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: > .minidns.log 2>&1 wait_until_ready 9053 } # T0: Successful. launch_minidns zones.t0 run_msmtp userB@srv-B < content wait_for_file .mail/userb@srv-b mail_diff content .mail/userb@srv-b # T1: A is not permitted to send to B. # Check that userA got a DSN about it. rm .mail/* launch_minidns zones.t1 run_msmtp userB@srv-B < content wait_for_file .mail/usera@srv-a mail_diff expected_dsn .mail/usera@srv-a success chasquid-1.2/test/t-16-spf/zones.t0000066400000000000000000000002551357247226300170310ustar00rootroot00000000000000# srv-a zone srv-a A 127.0.0.1 srv-a AAAA ::1 srv-a MX srv-a srv-a TXT v=spf1 a # srv-b zone srv-b A 127.0.0.1 srv-b AAAA ::1 srv-b MX srv-b srv-b TXT v=spf1 a chasquid-1.2/test/t-16-spf/zones.t1000066400000000000000000000003311357247226300170250ustar00rootroot00000000000000# srv-a is forbidden from sending mail. # srv-a zone srv-a A 127.0.0.1 srv-a AAAA ::1 srv-a MX srv-a srv-a TXT v=spf1 -all # srv-b zone srv-b A 127.0.0.1 srv-b AAAA ::1 srv-b MX srv-b srv-b TXT v=spf1 a chasquid-1.2/test/util/000077500000000000000000000000001357247226300151245ustar00rootroot00000000000000chasquid-1.2/test/util/chamuyero000077500000000000000000000175701357247226300170600ustar00rootroot00000000000000#!/usr/bin/env python3 """ chamuyero is a tool to test and validate line-oriented commands and servers. It can launch and communicate with other processes, and follow a script of line-oriented request-response, validating the dialog as it goes along. This can be used to test line-oriented network protocols (such as SMTP) or interactive command-line tools. """ import argparse import os import re import ssl import socket import subprocess import sys import threading import time # Command-line flags. ap = argparse.ArgumentParser() ap.add_argument("script", type=argparse.FileType('r', encoding='utf8')) args = ap.parse_args() # Make sure stdout is open in utf8 mode, as we will print our input, which is # utf8, and want it to work regardless of the environment. sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf8', buffering=1) class Process (object): def __init__(self, cmd, **kwargs): self.cmd = subprocess.Popen(cmd, **kwargs) def write(self, s): self.cmd.stdin.write(s) def readline(self): return self.cmd.stdout.readline() def wait(self): return self.cmd.wait() def close(self): return self.cmd.terminate() class Sock (object): """A (generic) socket. This class implements the common code for socket support. Subclasses will implement the behaviour specific to different socket types. """ def __init__(self, addr): self.addr = addr self.sock = NotImplemented self.connr = None self.connw = None self.has_conn = threading.Event() def listen(self): self.sock.bind(self.addr) self.sock.listen(1) threading.Thread(target=self._accept).start() def _accept(self): conn, _ = self.sock.accept() self.connr = conn.makefile(mode="r", encoding="utf8") self.connw = conn.makefile(mode="w", encoding="utf8") self.has_conn.set() def write(self, s): self.has_conn.wait() self.connw.write(s) self.connw.flush() def readline(self): self.has_conn.wait() return self.connr.readline() def close(self): self.connr.close() self.connw.close() self.sock.close() class UnixSock (Sock): def __init__(self, addr): Sock.__init__(self, addr) self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) def listen(self): if os.path.exists(self.addr): os.remove(self.addr) Sock.listen(self) class TCPSock (Sock): def __init__(self, addr): host, port = addr.rsplit(":", 1) Sock.__init__(self, (host, int(port))) self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) def connect(self): self.sock = socket.create_connection(self.addr) self.connr = self.sock.makefile(mode="r", encoding="utf8") self.connw = self.sock.makefile(mode="w", encoding="utf8") self.has_conn.set() class TLSSock (Sock): def __init__(self, addr): host, port = addr.rsplit(":", 1) Sock.__init__(self, (host, int(port))) plain_sock = socket.create_connection(self.addr) self.sock = ssl.wrap_socket(plain_sock) def connect(self): self.connr = self.sock.makefile(mode="r", encoding="utf8") self.connw = self.sock.makefile(mode="w", encoding="utf8") self.has_conn.set() class Interpreter (object): """Interpreter for chamuyero scripts.""" def __init__(self): # Processes and sockets we have spawn. Indexed by the id provided by # the user. self.procs = {} # Line number we are processing. self.nline = 0 def syntax_error(self, msg): raise SyntaxError("Error in line %d: %s" % (self.nline, msg)) def runtime_error(self, msg): raise RuntimeError("Error in line %d: %s" % (self.nline, msg)) def run(self, fd): """Main processing loop.""" cont_l = "" for l in fd: self.nline += 1 # Remove rightmost \n. l = l[:-1] # Continuations with \. if cont_l: l = cont_l + " " + l.lstrip() if l.endswith("\\"): cont_l = l[:-1] continue else: cont_l = "" # Comments start with a "#". if l.strip().startswith("#") or l.strip() == "": continue print(l) # Everything else is of the form: # [params] sp = l.split(None, 2) if len(sp) == 3: proc, op, params = sp else: proc, op = sp params = "" # = Launch a process. if op == "=": cmd = Process(params, shell=True, universal_newlines=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) self.procs[proc] = cmd # |= Launch a process, do not capture stdout. elif op == "|=": cmd = Process(params, shell=True, stdin=subprocess.PIPE) self.procs[proc] = cmd # unix_listen Listen on an UNIX socket. elif op == "unix_listen": sock = UnixSock(params) sock.listen() self.procs[proc] = sock # tcp_listen Listen on a TCP socket. elif op == "tcp_listen": sock = TCPSock(params) sock.listen() self.procs[proc] = sock elif op == "tcp_connect": sock = TCPSock(params) sock.connect() self.procs[proc] = sock elif op == "tls_connect": sock = TLSSock(params) sock.connect() self.procs[proc] = sock # -> Send to a process stdin, with a \n at the end. # .> Send to a process stdin, no \n at the end. elif op == "->": self.procs[proc].write(params + "\n") elif op == ".>": self.procs[proc].write(params) # <- Read from the process, expect matching input. # <~ Read from the process, match input using regexp. # <... Read many lines until one matches. elif op == "<-": read = self.procs[proc].readline() if read != params + "\n": self.runtime_error("data different that expected:\n" + " expected: %s\n" % repr(params) + " got: %s" % repr(read)) elif op == "<~": read = self.procs[proc].readline() m = re.match(params, read) if m is None: self.runtime_error("data did not match regexp:\n" + " regexp: %s\n" % repr(params) + " got: %s" % repr(read)) elif op == "<...": while True: read = self.procs[proc].readline() m = re.match(params, read) if m: break # sleep Sleep this number of seconds (process-independent). elif op == "sleep": time.sleep(float(params)) # wait Wait for the process to exit (with the given code). elif op == "wait": retcode = self.procs[proc].wait() if params and retcode != int(params): self.runtime_error("return code did not match:\n" + " expected %s, got %d" % (params, retcode)) # close Close the process. elif op == "close": self.procs[proc].close() else: self.syntax_error("unknown syntax") if __name__ == "__main__": i = Interpreter() i.run(args.script) chasquid-1.2/test/util/conngen.go000066400000000000000000000033511357247226300171040ustar00rootroot00000000000000// +build ignore // SMTP connection generator, for testing purposes. package main import ( "flag" "net" "net/http" "net/smtp" "time" "golang.org/x/net/trace" _ "net/http/pprof" "blitiri.com.ar/go/log" ) var ( addr = flag.String("addr", "", "server address") httpAddr = flag.String("http_addr", "localhost:8011", "monitoring HTTP server listening address") wait = flag.Bool("wait", false, "don't exit after --run_for has lapsed") count = flag.Int("count", 1000, "how many connections to open") ) var ( host string exit bool ) func main() { var err error flag.Parse() log.Init() host, _, err = net.SplitHostPort(*addr) if err != nil { log.Fatalf("failed to split --addr=%q: %v", *addr, err) } if *wait { go http.ListenAndServe(*httpAddr, nil) log.Infof("monitoring address: http://%v/debug/requests?fam=one&b=11", *httpAddr) } log.Infof("creating %d simultaneous connections", *count) conns := []*C{} for i := 0; i < *count; i++ { c, err := newC() if err != nil { log.Fatalf("failed to connect #%d: %v", i, err) } conns = append(conns, c) if i%200 == 0 { log.Infof(" ... %d connections", i) } } log.Infof("done, created %d simultaneous connections", *count) if *wait { for { time.Sleep(24 * time.Hour) } } } type C struct { tr trace.Trace n net.Conn s *smtp.Client } func newC() (*C, error) { tr := trace.New("conn", *addr) conn, err := net.Dial("tcp", *addr) if err != nil { return nil, err } client, err := smtp.NewClient(conn, host) if err != nil { conn.Close() return nil, err } err = client.Hello(host) if err != nil { return nil, err } return &C{tr: tr, n: conn, s: client}, nil } func (c *C) close() { c.tr.Finish() c.s.Close() c.n.Close() } chasquid-1.2/test/util/coverhtml.go000066400000000000000000000131011357247226300174520ustar00rootroot00000000000000// +build ignore // Generate an HTML visualization of a Go coverage profile. // Serves a similar purpose to "go tool cover -html", but has a different // visual style. package main import ( "flag" "fmt" "html/template" "io/ioutil" "math" "os" "strings" "golang.org/x/tools/cover" ) var ( input = flag.String("input", "", "input file") output = flag.String("output", "", "output file") strip = flag.Int("strip", 0, "how many path entries to strip") title = flag.String("title", "Coverage report", "page title") notes = flag.String("notes", "", "notes to add at the beginning (HTML)") ) func errorf(f string, a ...interface{}) { fmt.Printf(f, a...) os.Exit(1) } func main() { flag.Parse() profiles, err := cover.ParseProfiles(*input) if err != nil { errorf("Error parsing input %q: %v\n", *input, err) } totals := &Totals{ totalF: map[string]int{}, coveredF: map[string]int{}, } files := []string{} code := map[string]template.HTML{} for _, p := range profiles { files = append(files, p.FileName) totals.Add(p) fname := strings.Join(strings.Split(p.FileName, "/")[*strip:], "/") src, err := ioutil.ReadFile(fname) if err != nil { errorf("Failed to read %q: %v", fname, err) } code[p.FileName] = genHTML(src, p.Boundaries(src)) } out, err := os.Create(*output) if err != nil { errorf("Failed to open output file %q: %v", *output, err) } data := struct { Title string Notes template.HTML Files []string Code map[string]template.HTML Totals *Totals }{ Title: *title, Notes: template.HTML(*notes), Files: files, Code: code, Totals: totals, } tmpl := template.Must(template.New("html").Parse(htmlTmpl)) err = tmpl.Execute(out, data) if err != nil { errorf("Failed to execute template: %v", err) } for _, f := range files { fmt.Printf("%5.1f%% %v\n", totals.Percent(f), f) } fmt.Printf("\n") fmt.Printf("Total: %.1f\n", totals.TotalPercent()) } type Totals struct { // Total statements. total int // Covered statements. covered int // Total statements per file. totalF map[string]int // Covered statements per file. coveredF map[string]int } func (t *Totals) Add(p *cover.Profile) { for _, b := range p.Blocks { t.total += b.NumStmt t.totalF[p.FileName] += b.NumStmt if b.Count > 0 { t.covered += b.NumStmt t.coveredF[p.FileName] += b.NumStmt } } } func (t *Totals) Percent(f string) float32 { return float32(t.coveredF[f]) / float32(t.totalF[f]) * 100 } func (t *Totals) TotalPercent() float32 { return float32(t.covered) / float32(t.total) * 100 } func genHTML(src []byte, boundaries []cover.Boundary) template.HTML { // Position -> []Boundary // The order matters, we expect to receive start-end pairs in order, so // they are properly added. bs := map[int][]cover.Boundary{} for _, b := range boundaries { bs[b.Offset] = append(bs[b.Offset], b) } w := &strings.Builder{} for i := range src { // Emit boundary markers. for _, b := range bs[i] { if b.Start { n := 0 if b.Count > 0 { n = int(math.Floor(b.Norm*4)) + 1 } fmt.Fprintf(w, ``, n, b.Count) } else { w.WriteString("") } } switch b := src[i]; b { case '>': w.WriteString(">") case '<': w.WriteString("<") case '&': w.WriteString("&") case '\t': w.WriteString(" ") default: w.WriteByte(b) } } return template.HTML(w.String()) } const htmlTmpl = ` {{.Title}}

{{.Title}}

{{.Notes}}

Total: {{.Totals.TotalPercent | printf "%.2f"}}%

{{range .Files}} {{.}} ({{$.Totals.Percent . | printf "%.1f%%"}})
{{- end}}

{{range .Files}} {{end}}
` chasquid-1.2/test/util/docker_entrypoint.sh000077500000000000000000000025111357247226300212240ustar00rootroot00000000000000#!/bin/bash # # Script that is used as a Docker entrypoint. # # It starts minidns with a zone resolving "localhost", and overrides # /etc/resolv.conf to use it. Then launches docker CMD. # # This is used for more hermetic Docker test environments. set -e . $(dirname ${0})/../util/lib.sh init # Go to the root of the repository. cd ../.. # Undo the EXIT trap, so minidns continues to run in the background. trap - EXIT set -v # The DNS server resolves only "localhost"; tests will rely on this, as we # $HOSTALIASES to point our test hostnames to localhost, so it needs to # resolve. echo " localhost A 127.0.0.1 localhost AAAA ::1 " > /tmp/zones start-stop-daemon --start --background \ --exec /tmp/minidns \ -- --zones=/tmp/zones echo "nameserver 127.0.0.1" > /etc/resolv.conf echo "nameserver ::1" >> /etc/resolv.conf # Wait until the minidns resolver comes up. wait_until_ready 53 # Disable the Go proxy, since now there is no external network access. # Modules should be already be made available in the environment. export GOPROXY=off # Launch arguments, which come from docker CMD, as "chasquid" user. # Running tests as root makes some integration tests more difficult, as for # example Exim has hard-coded protections against running as root. sudo -u chasquid -g chasquid \ --set-home \ --preserve-env PATH=${PATH} \ -- "$@" chasquid-1.2/test/util/exitcode000077500000000000000000000000241357247226300166520ustar00rootroot00000000000000#!/bin/sh exit $1 chasquid-1.2/test/util/generate_cert.go000066400000000000000000000107221357247226300202640ustar00rootroot00000000000000// Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. // +build ignore // Generate a self-signed X.509 certificate for a TLS server. Outputs to // 'cert.pem' and 'key.pem' and will overwrite existing files. package main import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "flag" "fmt" "log" "math/big" "net" "os" "strings" "time" "golang.org/x/net/idna" ) var ( host = flag.String("host", "", "Comma-separated hostnames and IPs to generate a certificate for") validFrom = flag.String("start-date", "", "Creation date formatted as Jan 1 15:04:05 2011") validFor = flag.Duration("duration", 365*24*time.Hour, "Duration that certificate is valid for") isCA = flag.Bool("ca", false, "whether this cert should be its own Certificate Authority") rsaBits = flag.Int("rsa-bits", 2048, "Size of RSA key to generate. Ignored if --ecdsa-curve is set") ecdsaCurve = flag.String("ecdsa-curve", "", "ECDSA curve to use to generate a key. Valid values are P224, P256, P384, P521") ) func publicKey(priv interface{}) interface{} { switch k := priv.(type) { case *rsa.PrivateKey: return &k.PublicKey case *ecdsa.PrivateKey: return &k.PublicKey default: return nil } } func pemBlockForKey(priv interface{}) *pem.Block { switch k := priv.(type) { case *rsa.PrivateKey: return &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(k)} case *ecdsa.PrivateKey: b, err := x509.MarshalECPrivateKey(k) if err != nil { fmt.Fprintf(os.Stderr, "Unable to marshal ECDSA private key: %v", err) os.Exit(2) } return &pem.Block{Type: "EC PRIVATE KEY", Bytes: b} default: return nil } } func main() { flag.Parse() if len(*host) == 0 { log.Fatalf("Missing required --host parameter") } var priv interface{} var err error switch *ecdsaCurve { case "": priv, err = rsa.GenerateKey(rand.Reader, *rsaBits) case "P224": priv, err = ecdsa.GenerateKey(elliptic.P224(), rand.Reader) case "P256": priv, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader) case "P384": priv, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader) case "P521": priv, err = ecdsa.GenerateKey(elliptic.P521(), rand.Reader) default: fmt.Fprintf(os.Stderr, "Unrecognized elliptic curve: %q", *ecdsaCurve) os.Exit(1) } if err != nil { log.Fatalf("failed to generate private key: %s", err) } var notBefore time.Time if len(*validFrom) == 0 { notBefore = time.Now() } else { notBefore, err = time.Parse("Jan 2 15:04:05 2006", *validFrom) if err != nil { fmt.Fprintf(os.Stderr, "Failed to parse creation date: %s\n", err) os.Exit(1) } } notAfter := notBefore.Add(*validFor) serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128) serialNumber, err := rand.Int(rand.Reader, serialNumberLimit) if err != nil { log.Fatalf("failed to generate serial number: %s", err) } template := x509.Certificate{ SerialNumber: serialNumber, Subject: pkix.Name{ Organization: []string{"Acme Co"}, }, NotBefore: notBefore, NotAfter: notAfter, KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } hosts := strings.Split(*host, ",") for _, h := range hosts { if ip := net.ParseIP(h); ip != nil { template.IPAddresses = append(template.IPAddresses, ip) } else { // We use IDNA-encoded DNS names, otherwise the TLS library won't // load the certificates. ih, err := idna.ToASCII(h) if err != nil { log.Fatalf("host %q cannot be IDNA-encoded: %v", h, err) } template.DNSNames = append(template.DNSNames, ih) } } if *isCA { template.IsCA = true template.KeyUsage |= x509.KeyUsageCertSign } derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, publicKey(priv), priv) if err != nil { log.Fatalf("Failed to create certificate: %s", err) } certOut, err := os.Create("fullchain.pem") if err != nil { log.Fatalf("failed to open fullchain.pem for writing: %s", err) } pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) certOut.Close() keyOut, err := os.OpenFile("privkey.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { log.Fatalf("failed to open privkey.pem for writing: %s", err) return } pem.Encode(keyOut, pemBlockForKey(priv)) keyOut.Close() } chasquid-1.2/test/util/gocovcat.go000077500000000000000000000041521357247226300172650ustar00rootroot00000000000000//usr/bin/env go run "$0" "$@"; exit $? // // From: https://git.lukeshu.com/go/cmd/gocovcat/ // // +build ignore // Copyright 2017 Luke Shumaker // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU General Public License as published by // the Free Software Foundation, either version 3 of the License, or // (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. // // You should have received a copy of the GNU General Public License // along with this program. If not, see . // Command gocovcat combines multiple go cover runs, and prints the // result on stdout. package main import ( "bufio" "fmt" "os" "sort" "strconv" "strings" ) func handleErr(err error) { if err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) os.Exit(1) } } func main() { modeBool := false blocks := map[string]int{} for _, filename := range os.Args[1:] { file, err := os.Open(filename) handleErr(err) buf := bufio.NewScanner(file) for buf.Scan() { line := buf.Text() if strings.HasPrefix(line, "mode: ") { m := strings.TrimPrefix(line, "mode: ") switch m { case "set": modeBool = true case "count", "atomic": // do nothing default: fmt.Fprintf(os.Stderr, "Unrecognized mode: %s\n", m) os.Exit(1) } } else { sp := strings.LastIndexByte(line, ' ') block := line[:sp] cntStr := line[sp+1:] cnt, err := strconv.Atoi(cntStr) handleErr(err) blocks[block] += cnt } } handleErr(buf.Err()) } keys := make([]string, 0, len(blocks)) for key := range blocks { keys = append(keys, key) } sort.Strings(keys) modeStr := "count" if modeBool { modeStr = "set" } fmt.Printf("mode: %s\n", modeStr) for _, block := range keys { cnt := blocks[block] if modeBool && cnt > 1 { cnt = 1 } fmt.Printf("%s %d\n", block, cnt) } } chasquid-1.2/test/util/lib.sh000066400000000000000000000101231357247226300162230ustar00rootroot00000000000000# Library to write the shell scripts in the tests. function init() { if [ "$V" == "1" ]; then set -v fi export UTILDIR="$( realpath `dirname "${BASH_SOURCE[0]}"` )" export TBASE="$(realpath `dirname ${0}`)" cd ${TBASE} if [ "${RACE}" == "1" ]; then GOFLAGS="$GOFLAGS -race" fi # Remove the directory where test-mda will deliver mail, so previous # runs don't interfere with this one. rm -rf .mail # Set traps to kill our subprocesses when we exit (for any reason). trap ":" TERM # Avoid the EXIT handler from killing bash. trap "exit 2" INT # Ctrl-C, make sure we fail in that case. trap "kill 0" EXIT # Kill children on exit. } function chasquid() { if [ "${COVER_DIR}" != "" ]; then chasquid_cover "$@" return fi ( cd ${TBASE}/../../; go build $GOFLAGS -tags="$GOTAGS" . ) # HOSTALIASES: so we "fake" hostnames. # PATH: so chasquid can call test-mda without path issues. # MDA_DIR: so our test-mda knows where to deliver emails. HOSTALIASES=${TBASE}/hosts \ PATH=${UTILDIR}:${PATH} \ MDA_DIR=${TBASE}/.mail \ ${TBASE}/../../chasquid "$@" } function chasquid_cover() { # Build the coverage-enabled binary. # See coverage_test.go for more details. ( cd ${TBASE}/../../; go test -covermode=count -coverpkg=./... -c \ -tags="coveragebin $GOTAGS" $GOFLAGS ) # Run the coverage-enabled binary, named "chasquid.test" for hacky # reasons. See the chasquid function above for details on the # environment variables. HOSTALIASES=${TBASE}/hosts \ PATH=${UTILDIR}:${PATH} \ MDA_DIR=${TBASE}/.mail \ ${TBASE}/../../chasquid.test \ -test.run "^TestRunMain$" \ -test.coverprofile="$COVER_DIR/test-`date +%s.%N`.out" \ "$@" } function add_user() { CONFDIR="${CONFDIR:-config}" DOMAIN=$(echo $1 | cut -d @ -f 2) mkdir -p "${CONFDIR}/domains/$DOMAIN/" go run ${TBASE}/../../cmd/chasquid-util/chasquid-util.go \ -C "${CONFDIR}" \ user-add "$1" \ --password "$2" \ >> .add_user_logs } function dovecot-auth-cli() { go run ${TBASE}/../../cmd/dovecot-auth-cli/dovecot-auth-cli.go "$@" } function run_msmtp() { # msmtp will check that the rc file is only user readable. chmod 600 msmtprc # msmtp binary is often g+s, which causes $HOSTALIASES to not be # honoured, which breaks the tests. Copy the binary to remove the # setgid bit as a workaround. cp -u "`which msmtp`" "${UTILDIR}/.msmtp-bin" HOSTALIASES=${TBASE}/hosts \ ${UTILDIR}/.msmtp-bin -C msmtprc "$@" } function smtpc.py() { ${UTILDIR}/smtpc.py "$@" } function mail_diff() { ${UTILDIR}/mail_diff "$@" } function chamuyero() { ${UTILDIR}/chamuyero "$@" } function generate_cert() { go run ${UTILDIR}/generate_cert.go "$@" } function loadgen() { go run ${UTILDIR}/loadgen.go "$@" } function conngen() { go run ${UTILDIR}/conngen.go "$@" } function minidns_bg() { ( cd ${UTILDIR}; go build minidns.go ) ${UTILDIR}/minidns "$@" & MINIDNS=$! } function success() { echo success } function skip() { echo skipped: $* exit 0 } function fail() { echo FAILED: $* exit 1 } # Wait until there's something listening on the given port. function wait_until_ready() { PORT=$1 while ! bash -c "true < /dev/tcp/localhost/$PORT" 2>/dev/null ; do sleep 0.1 done } # Wait for the given file to exist. function wait_for_file() { while ! [ -e ${1} ]; do sleep 0.1 done } # Generate certs for the given hostname. function generate_certs_for() { CONFDIR="${CONFDIR:-config}" mkdir -p ${CONFDIR}/certs/${1}/ ( cd ${CONFDIR}/certs/${1} generate_cert -ca -duration=1h -host=${1} ) } # Check the Python version, and skip if it's too old. # This will check against the version required for smtpc.py. function skip_if_python_is_too_old() { # We need Python >= 3.5 to be able to use SMTPUTF8. check='import sys; sys.exit(0 if sys.version_info >= (3, 5) else 1)' if ! python3 -c "${check}" > /dev/null 2>&1; then skip "python3 >= 3.5 not available" fi } function chasquid_ram_peak() { # Find the pid of the daemon, which we expect is running on the # background somewhere within our current session. SERVER_PID=`pgrep -s 0 -x chasquid` echo $( cat /proc/$SERVER_PID/status | grep VmHWM | cut -d ':' -f 2- ) } chasquid-1.2/test/util/loadgen.go000066400000000000000000000065741357247226300171000ustar00rootroot00000000000000// +build ignore // SMTP load generator, for testing purposes. package main import ( "flag" "net" "net/http" "runtime" "sync" "time" _ "net/http/pprof" "golang.org/x/net/trace" "blitiri.com.ar/go/chasquid/internal/smtp" "blitiri.com.ar/go/log" ) var ( addr = flag.String("addr", "", "server address") httpAddr = flag.String("http_addr", "localhost:8011", "monitoring HTTP server listening address") parallel = flag.Int("parallel", 0, "how many sending loops to run in parallel") runFor = flag.Duration("run_for", 0, "how long to run for (0 = forever)") wait = flag.Bool("wait", false, "don't exit after --run_for has lapsed") noop = flag.Bool("noop", false, "don't send an email, just connect and run a NOOP") ) var ( host string exit bool globalCount int64 = 0 globalRuntime time.Duration globalMu = &sync.Mutex{} ) func main() { var err error flag.Parse() log.Init() host, _, err = net.SplitHostPort(*addr) if err != nil { log.Fatalf("failed to split --addr=%q: %v", *addr, err) } if *wait { go http.ListenAndServe(*httpAddr, nil) log.Infof("monitoring address: http://%v/debug/requests?fam=one&b=11", *httpAddr) } if *parallel == 0 { *parallel = runtime.GOMAXPROCS(0) } lt := "full" if *noop { lt = "noop" } log.Infof("launching %d %s sending loops in parallel", *parallel, lt) for i := 0; i < *parallel; i++ { go serial(i) } var totalCount int64 var totalRuntime time.Duration start := time.Now() for range time.Tick(1 * time.Second) { globalMu.Lock() totalCount += globalCount totalRuntime += globalRuntime count := globalCount runtime := globalRuntime globalCount = 0 globalRuntime = 0 globalMu.Unlock() if count == 0 { log.Infof("0 ops") } else { log.Infof("%d ops, %v /op", count, time.Duration(runtime.Nanoseconds()/count).Truncate(time.Microsecond)) } if *runFor > 0 && time.Since(start) > *runFor { exit = true break } } end := time.Now() window := end.Sub(start) log.Infof("total: %d ops, %v wall, %v run", totalCount, window.Truncate(time.Millisecond), totalRuntime.Truncate(time.Millisecond)) avgLat := time.Duration(totalRuntime.Nanoseconds() / totalCount) log.Infof("avg: %v /op, %.0f ops/s", avgLat.Truncate(time.Microsecond), float64(totalCount)/window.Seconds(), ) if *wait { for { time.Sleep(24 * time.Hour) } } } func serial(id int) { var count int64 start := time.Now() for { count += 1 err := one() if err != nil { log.Fatalf("%v", err) } if count == 5 { globalMu.Lock() globalCount += count globalRuntime += time.Since(start) globalMu.Unlock() count = 0 start = time.Now() if exit { return } } } } func one() error { tr := trace.New("one", *addr) defer tr.Finish() conn, err := net.Dial("tcp", *addr) if err != nil { return err } defer conn.Close() client, err := smtp.NewClient(conn, host) if err != nil { return err } defer client.Close() if *noop { err = client.Noop() if err != nil { return err } } else { err = client.MailAndRcpt("test@test", "null@testserver") if err != nil { return err } w, err := client.Data() if err != nil { return err } _, err = w.Write(body) if err != nil { return err } err = w.Close() if err != nil { return err } } return nil } var body = []byte(`Subject: Load test This is the body of the load test email. `) chasquid-1.2/test/util/mail_diff000077500000000000000000000042251357247226300167670ustar00rootroot00000000000000#!/usr/bin/env python3 import difflib import email.parser import itertools import mailbox import sys def flexible_eq(expected, got): """Compare two strings, supporting wildcards. This functions compares two strings, but supports wildcards on the expected string. The following characters have special meaning: - ? matches any character. - * matches anything until the end of the line. Returns True if equal (considering wildcards), False otherwise. """ posG = 0 for c in expected: if posG >= len(got): return False if c == '?': posG += 1 continue if c == '*': while posG < len(got) and got[posG] != '\n': posG += 1 continue continue if c != got[posG]: return False posG += 1 if posG != len(got): # We got more than we expected. return False return True def msg_equals(expected, msg): """Compare two messages recursively, using flexible_eq().""" diff = False for h, val in expected.items(): if h not in msg: print("Header missing: %r" % h) diff = True continue if expected[h] == '*': continue if not flexible_eq(val, msg[h]): print("Header %r differs:" % h) print("Exp: %r" % val) print("Got: %r" % msg[h]) diff = True if diff: return False if expected.is_multipart() != msg.is_multipart(): print("Multipart differs, expected %s, got %s" % ( expected.is_multipart(), msg.is_multipart())) return False if expected.is_multipart(): for exp, got in itertools.zip_longest(expected.get_payload(), msg.get_payload()): if not msg_equals(exp, got): return False else: if not flexible_eq(expected.get_payload(), msg.get_payload()): exp = expected.get_payload().splitlines() got = msg.get_payload().splitlines() print("Payload differs:") for l in difflib.ndiff(exp, got): print(l) return False return True if __name__ == "__main__": f1, f2 = sys.argv[1:3] expected = email.parser.Parser().parse(open(f1)) msg = email.parser.Parser().parse(open(f2)) sys.exit(0 if msg_equals(expected, msg) else 1) chasquid-1.2/test/util/minidns.go000066400000000000000000000144661357247226300171270ustar00rootroot00000000000000// +build ignore // minidns is a trivial DNS server used for testing. // // It takes an "answers" file which contains lines with the following format: // // // // For example: // // blah A 1.2.3.4 // blah MX mx1 // // Supported types: A, AAAA, MX, TXT. // // It's only meant to be used for testing, so it's not robust, performant, or // standards compliant. // package main import ( "bufio" "encoding/binary" "flag" "fmt" "net" "os" "regexp" "strings" "sync" "blitiri.com.ar/go/log" "golang.org/x/net/dns/dnsmessage" ) var ( addr = flag.String("addr", ":53", "address to listen to (UDP)") zonesPath = flag.String("zones", "", "file with the zones") ) func main() { flag.Parse() srv := &miniDNS{ answers: map[string][]dnsmessage.Resource{}, } if *zonesPath == "" { log.Fatalf("-zones must be given") } var zonesFile *os.File if *zonesPath == "-" { zonesFile = os.Stdin } else { var err error zonesFile, err = os.Open(*zonesPath) if err != nil { log.Fatalf("error opening %v: %v", *zonesPath, err) } } srv.loadZones(zonesFile) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() srv.listenAndServeUDP(*addr) }() go func() { defer wg.Done() srv.listenAndServeTCP(*addr) }() wg.Wait() } type miniDNS struct { // Domain -> Answers. // We always respond the same regardless of the query. // Not great, but does the trick. answers map[string][]dnsmessage.Resource } func (m *miniDNS) listenAndServeUDP(addr string) { conn, err := net.ListenPacket("udp", addr) if err != nil { log.Fatalf("error listening UDP %q: %v", addr, err) } log.Infof("listening on %v", conn.LocalAddr()) buf := make([]byte, 64*1024) for { n, addr, err := conn.ReadFrom(buf) if err != nil { log.Infof("error reading from udp: %v", err) continue } msg := &dnsmessage.Message{} err = msg.Unpack(buf[:n]) if err != nil { log.Infof("%v error unpacking message: %v", addr, err) } if lq := len(msg.Questions); lq != 1 { log.Infof("%v/%-5d dropping packet with %d questions", addr, msg.ID, lq) continue } q := msg.Questions[0] log.Infof("%v/%-5d Q: %s %s %s", addr, msg.ID, q.Name, q.Type, q.Class) reply := m.handle(msg) rbuf, err := reply.Pack() if err != nil { log.Fatalf("error packing reply: %v", err) } conn.WriteTo(rbuf, addr) } } func (m *miniDNS) listenAndServeTCP(addr string) { ls, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("error listening TCP %q: %v", addr, err) } log.Infof("listening on %v", addr) for { conn, err := ls.Accept() if err != nil { log.Infof("error accepting: %v", err) continue } msg, err := readTCPMessage(conn) if err != nil { log.Infof("%v error reading message: %v", addr, err) conn.Close() continue } if lq := len(msg.Questions); lq != 1 { log.Infof("%v/%-5d dropping packet with %d questions", addr, msg.ID, lq) conn.Close() continue } q := msg.Questions[0] log.Infof("%v/%-5d Q: %s %s %s", addr, msg.ID, q.Name, q.Type, q.Class) reply := m.handle(msg) err = writeTCPMessage(conn, reply) if err != nil { log.Infof("error writing reply: %v", err) } conn.Close() } } func readTCPMessage(conn net.Conn) (*dnsmessage.Message, error) { // Read the 2-byte length first, then the message. lenHdr := struct{ Len uint16 }{} err := binary.Read(conn, binary.BigEndian, &lenHdr) if err != nil { return nil, err } data := make([]byte, lenHdr.Len) err = binary.Read(conn, binary.BigEndian, &data) if err != nil { return nil, err } msg := &dnsmessage.Message{} err = msg.Unpack(data) if err != nil { return nil, fmt.Errorf("%v error unpacking message: %v", addr, err) } return msg, nil } func writeTCPMessage(conn net.Conn, msg *dnsmessage.Message) error { rbuf, err := msg.Pack() if err != nil { return fmt.Errorf("error packing reply: %v", err) } lenHdr := struct{ Len uint16 }{Len: uint16(len(rbuf))} err = binary.Write(conn, binary.BigEndian, lenHdr) if err != nil { return err } _, err = conn.Write(rbuf) return err } func (m *miniDNS) handle(msg *dnsmessage.Message) *dnsmessage.Message { reply := &dnsmessage.Message{ Header: dnsmessage.Header{ ID: msg.ID, Response: true, RCode: dnsmessage.RCodeSuccess, }, Questions: msg.Questions, } q := msg.Questions[0] if answers, ok := m.answers[q.Name.String()]; ok { for _, ans := range answers { if q.Type == ans.Header.Type { log.Infof("-> %s %v", q.Type, ans.Body) reply.Answers = append(reply.Answers, ans) } } } else { log.Infof("-> NXERROR") reply.Header.RCode = dnsmessage.RCodeNameError } return reply } func (m *miniDNS) loadZones(f *os.File) { scanner := bufio.NewScanner(f) lineno := 0 for scanner.Scan() { lineno++ line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") || line == "" { continue } vs := regexp.MustCompile("\\s+").Split(line, 3) if len(vs) != 3 { log.Fatalf("line %d: invalid format", lineno) } domain, t, value := vs[0], vs[1], vs[2] if !strings.HasSuffix(domain, ".") { domain += "." } var body dnsmessage.ResourceBody var qType dnsmessage.Type switch strings.ToLower(t) { case "a": qType = dnsmessage.TypeA ip := net.ParseIP(value).To4() if ip == nil { log.Fatalf("line %d: invalid IP %q", lineno, value) } a := &dnsmessage.AResource{} copy(a.A[:], ip[:4]) body = a case "aaaa": qType = dnsmessage.TypeAAAA ip := net.ParseIP(value).To16() if ip == nil { log.Fatalf("line %d: invalid IP %q", lineno, value) } aaaa := &dnsmessage.AAAAResource{} copy(aaaa.AAAA[:], ip[:16]) body = aaaa case "mx": qType = dnsmessage.TypeMX if !strings.HasPrefix(value, ".") { value += "." } body = &dnsmessage.MXResource{ Pref: 10, MX: dnsmessage.MustNewName(value), } case "txt": qType = dnsmessage.TypeTXT body = &dnsmessage.TXTResource{ TXT: []string{value}, } default: log.Fatalf("line %d: unknown type %q", lineno, t) } answer := dnsmessage.Resource{ Header: dnsmessage.ResourceHeader{ Name: dnsmessage.MustNewName(domain), Type: qType, Class: dnsmessage.ClassINET, }, Body: body, } m.answers[domain] = append(m.answers[domain], answer) } if err := scanner.Err(); err != nil { log.Fatalf("error reading zones: %v", err) } } chasquid-1.2/test/util/smtpc.py000077500000000000000000000025311357247226300166300ustar00rootroot00000000000000#!/usr/bin/env python3 # # Simple SMTP client for testing purposes. import argparse import email.parser import email.policy import re import smtplib import sys ap = argparse.ArgumentParser() ap.add_argument("--server", help="SMTP server to connect to") ap.add_argument("--user", help="Username to use in SMTP AUTH") ap.add_argument("--password", help="Password to use in SMTP AUTH") args = ap.parse_args() # Parse the email using the "default" policy, which is not really the default. # If unspecified, compat32 is used, which does not support UTF8. rawmsg = sys.stdin.buffer.read() msg = email.parser.Parser(policy=email.policy.default).parsestr( rawmsg.decode('utf8')) s = smtplib.SMTP(args.server) s.starttls() if args.user: s.login(args.user, args.password) # Send the raw message, not parsed, because the parser does not handle some # corner cases that well (for example, DKIM-Signature headers get mime-encoded # incorrectly). # Replace \n with \r\n, which is normally done by the library, but will not do # it in this case because we are giving it bytes and not a string (which we # cannot do because it tries to incorrectly escape the headers). crlfmsg = re.sub(br'(?:\r\n|\n|\r(?!\n))', b"\r\n", rawmsg) s.sendmail( from_addr=msg['from'], to_addrs=msg.get_all('to'), msg=crlfmsg, mail_options=['SMTPUTF8']) s.quit() chasquid-1.2/test/util/test-mda000077500000000000000000000004131357247226300165660ustar00rootroot00000000000000#!/bin/bash set -e mkdir -p ${MDA_DIR} # TODO: use flock to lock the file, to prevent atomic writes. echo "From ${1}" >> ${MDA_DIR}/.tmp-${1} cat >> ${MDA_DIR}/.tmp-${1} X=$? if [ -e ${MDA_DIR}/.tmp-${1} ]; then mv ${MDA_DIR}/.tmp-${1} ${MDA_DIR}/${1} fi exit $X chasquid-1.2/test/util/writemailto000077500000000000000000000000751357247226300174140ustar00rootroot00000000000000#!/bin/bash echo "From writemailto" > "$1" exec cat >> "$1"