pax_global_header00006660000000000000000000000064132376767070014534gustar00rootroot0000000000000052 comment=d39d3aaff4df7655b9a1434b3f1d8ce83121903f chasquid-0.04/000077500000000000000000000000001323767670700132605ustar00rootroot00000000000000chasquid-0.04/.gitignore000066400000000000000000000007241323767670700152530ustar00rootroot00000000000000 # 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 cmd/chasquid-util/chasquid-util cmd/smtp-check/smtp-check cmd/spf-check/spf-check cmd/mda-lmtp/mda-lmtp # Exclude any .pem files, to prevent accidentally including test keys and # certificates. *.pem chasquid-0.04/.travis.yml000066400000000000000000000011631323767670700153720ustar00rootroot00000000000000# Configuration for https://travis-ci.org/ language: go go_import_path: blitiri.com.ar/go/chasquid dist: trusty sudo: false go: - 1.7 # Debian stable. - 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 ./... chasquid-0.04/LICENSE000066400000000000000000000263141323767670700142730ustar00rootroot00000000000000 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-0.04/Makefile000066400000000000000000000025311323767670700147210ustar00rootroot00000000000000 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 ./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-0.04/README.md000066400000000000000000000103771323767670700145470ustar00rootroot00000000000000 # chasquid [chasquid](https://blitiri.com.ar/p/chasquid) is an SMTP (email) server. It aims to be easy to configure and maintain for a small mail server, at the expense of flexibility and functionality. It's written in [Go](https://golang.org). ## Features * Easy to configure, hard to mis-configure in ways that are harmful or insecure (e.g. no open relay, clear-text authentication, etc.). * Tracking of per-domain TLS support, prevents connection downgrading. * SMTP UTF8 (international usernames). * IDNA (international domain names). * Hooks for easy integration with greylisting, anti-virus and anti-spam. * Multiple domains, with per-domain user database and aliases. * Multiple TLS certificates. * Suffix dropping (user+something@domain -> user@domain). * Easy integration with letsencrypt. * SPF checking. * Monitoring HTTP server, with exported variables and tracing to help debugging. * Using dovecot for authentication (experimental). The following are intentionally *not* implemented: * Custom email routing and transport. * DKIM/DMARC checking (although the post-data hook can be used for it). ## Status chasquid is in beta. It's functional and has had some production exposure, but some things may still change in backwards-incompatible way, including the configuration format. It should be rare and will be avoided if possible. You should subscribe to the mailing list to get notifications of such changes. ## Contact If you have any questions, comments or patches please send them to the mailing list, chasquid@googlegroups.com. To subscribe, send an email to chasquid+subscribe@googlegroups.com. You can also browse the [archives](https://groups.google.com/forum/#!forum/chasquid). ## Installation If you're using Debian or Ubuntu, chasquid can be installed by running `sudo apt install chasquid`. To get the code and build it, you will need a working [Go](http://golang.org) environment. ```shell # Get the code and build the binaries. go get blitiri.com.ar/go/chasquid cd "$GOPATH/src/blitiri.com.ar/go/chasquid" make # Install the binaries to /usr/local/bin. sudo make install-binaries # Copy the example configuration to /etc/chasquid and /etc/systemd, and create # the /var/lib/chasquid directory. sudo make install-config-skeleton ``` ## Configuration The configuration is in `/etc/chasquid/` by default, and has the following structure: ``` - 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. - mx.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. Make sure the user you use to run chasquid under ("mail" in the example config) can access the certificates and private keys. ### Adding users You can add users with: ``` chasquid-util user-add user@domain ``` This will also create the corresponding domain directory if it doesn't exist. ### Checking your configuration Run `chasquid-util print-config` to parse your configuration and display the resulting values. ### Checking your setup Run `smtp-check yourdomain.com`, it will check: * MX DNS records. * SPF DNS records (will just warn if not present). * TLS certificates. It needs to access port 25, which is often blocked by ISPs, so it's likely that you need to run it from your server. ### Greylisting, anti-spam and anti-virus chasquid supports running a post-DATA hook, which can be used to perform greylisting, and run anti-spam and anti-virus filters. The hook should be at `/etc/chasquid/hooks/post-data`. The one installed by default is a bash script supporting: * greylisting using greylistd. * anti-spam using spamassassin. * anti-virus using clamav. To use them, they just need to be available in your system. For example, in Debian you can run the following to install all three: ``` apt install greylistd spamc clamdscan usermod -a -G greylist mail ``` Note that the default hook may not work in all cases, it is provided as a practical example but you should adjust it to your particular system if needed. chasquid-0.04/UPGRADING.md000066400000000000000000000012001323767670700151130ustar00rootroot00000000000000 This file contains notes for upgrading between different versions. As chasquid is still in beta, it is possible that some things change in backwards-incompatible ways. This should be rare and will be avoided if possible. ## 0.02 → 0.03 * 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. chasquid-0.04/chasquid.go000066400000000000000000000222651323767670700154170ustar00rootroot00000000000000// chasquid is an SMTP (email) server. // // It aims to be easy to configure and maintain for a small mail server, at // the expense of flexibility and functionality. // // See https://blitiri.com.ar/p/chasquid for more details. package main import ( "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/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.PostDataHook = "hooks/post-data" 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") localC := &courier.Procmail{ Binary: conf.MailDeliveryAgentBin, Args: conf.MailDeliveryAgentArgs, Timeout: 30 * time.Second, } remoteC := &courier.SMTP{Dinfo: dinfo} 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-0.04/cmd/000077500000000000000000000000001323767670700140235ustar00rootroot00000000000000chasquid-0.04/cmd/chasquid-util/000077500000000000000000000000001323767670700165775ustar00rootroot00000000000000chasquid-0.04/cmd/chasquid-util/chasquid-util.go000066400000000000000000000124721323767670700217100ustar00rootroot00000000000000// chasquid-util is a command-line utility for chasquid-related operations. package main import ( "fmt" "io/ioutil" "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] print-config 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, } for cmd, f := range commands { if args[cmd].(bool) { f() } } } 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-0.04/cmd/chasquid-util/test.sh000077500000000000000000000024471323767670700201240ustar00rootroot00000000000000#!/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 touch .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 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 success chasquid-0.04/cmd/dovecot-auth-cli/000077500000000000000000000000001323767670700171725ustar00rootroot00000000000000chasquid-0.04/cmd/dovecot-auth-cli/.gitignore000066400000000000000000000000271323767670700211610ustar00rootroot00000000000000*.log dovecot-auth-cli chasquid-0.04/cmd/dovecot-auth-cli/dovecot-auth-cli.go000066400000000000000000000010751323767670700226730ustar00rootroot00000000000000// CLI used for testing the dovecot authentication package. // // NOT for production use. package main import ( "fmt" "os" "blitiri.com.ar/go/chasquid/internal/dovecot" ) func main() { a := dovecot.NewAuth(os.Args[1]+"-userdb", os.Args[1]+"-client") var ok bool var err error switch os.Args[2] { case "exists": ok, err = a.Exists(os.Args[3]) case "auth": ok, err = a.Authenticate(os.Args[3], os.Args[4]) default: fmt.Printf("unknown subcommand\n") os.Exit(1) } if ok { fmt.Printf("yes\n") return } fmt.Printf("no: %v\n", err) os.Exit(1) } chasquid-0.04/cmd/dovecot-auth-cli/test.sh000077500000000000000000000007061323767670700205130ustar00rootroot00000000000000#!/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 dovecot-auth-cli.go 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-0.04/cmd/dovecot-auth-cli/test_auth_error.cmy000066400000000000000000000006711323767670700231210ustar00rootroot00000000000000 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 1 chasquid-0.04/cmd/dovecot-auth-cli/test_auth_no.cmy000066400000000000000000000006571323767670700224100ustar00rootroot00000000000000 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 1 chasquid-0.04/cmd/dovecot-auth-cli/test_auth_yes.cmy000066400000000000000000000006471323767670700225730ustar00rootroot00000000000000 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-0.04/cmd/dovecot-auth-cli/test_exists_notfound.cmy000066400000000000000000000003571323767670700242030ustar00rootroot00000000000000 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 wait 1 c <- no: chasquid-0.04/cmd/dovecot-auth-cli/test_exists_yes.cmy000066400000000000000000000004241323767670700231420ustar00rootroot00000000000000 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-0.04/cmd/dovecot-auth-cli/test_missing_socket.cmy000066400000000000000000000003311323767670700237610ustar00rootroot00000000000000 c = ./dovecot-auth-cli .missingsocket exists username c <~ no: dial unix .missingsocket-userdb c wait 1 c = ./dovecot-auth-cli .missingsocket auth username password c <~ no: dial unix .missingsocket-client c wait 1 chasquid-0.04/cmd/mda-lmtp/000077500000000000000000000000001323767670700155365ustar00rootroot00000000000000chasquid-0.04/cmd/mda-lmtp/.gitignore000066400000000000000000000000171323767670700175240ustar00rootroot00000000000000mda-lmtp *.log chasquid-0.04/cmd/mda-lmtp/mda-lmtp.go000066400000000000000000000057161323767670700176110ustar00rootroot00000000000000// mda-lmtp is a very basic MDA that uses LMTP to do the delivery. // // See the usage below for details. 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-0.04/cmd/mda-lmtp/test-email000066400000000000000000000000371323767670700175250ustar00rootroot00000000000000Subject: test This is a test. chasquid-0.04/cmd/mda-lmtp/test.sh000077500000000000000000000006621323767670700170600ustar00rootroot00000000000000#!/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-0.04/cmd/mda-lmtp/test_tcp_null.cmy000066400000000000000000000006231323767670700211300ustar00rootroot00000000000000 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-0.04/cmd/mda-lmtp/test_tcp_success.cmy000066400000000000000000000006271323767670700216320ustar00rootroot00000000000000 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-0.04/cmd/mda-lmtp/test_unix_failure.cmy000066400000000000000000000006731323767670700220070ustar00rootroot00000000000000 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-0.04/cmd/mda-lmtp/test_unix_success.cmy000066400000000000000000000006541323767670700220270ustar00rootroot00000000000000 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-0.04/cmd/smtp-check/000077500000000000000000000000001323767670700160615ustar00rootroot00000000000000chasquid-0.04/cmd/smtp-check/smtp-check.go000066400000000000000000000033751323767670700204560ustar00rootroot00000000000000// smtp-check is a command-line too for checking SMTP setups. package main import ( "crypto/tls" "flag" "log" "net" "net/smtp" "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) } 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") } for _, mx := range mxs { log.Printf("=== Testing 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.CheckHost(ip, domain) if result != spf.Pass { log.Printf("SPF check != pass for IP %s: %s - %s", ip, result, err) } } 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.Fatalf("TLS error: %v", err) } cstate, _ := c.TLSConnectionState() log.Printf("TLS OK: %s - %s", tlsconst.VersionName(cstate.Version), tlsconst.CipherSuiteName(cstate.CipherSuite)) c.Close() } log.Printf("") } log.Printf("=== Success") } chasquid-0.04/cmd/spf-check/000077500000000000000000000000001323767670700156665ustar00rootroot00000000000000chasquid-0.04/cmd/spf-check/spf-check.go000066400000000000000000000005111323767670700200550ustar00rootroot00000000000000// Command line tool for playing with the SPF library. // // Not for use in production, just development and experimentation. package main import ( "flag" "fmt" "net" "blitiri.com.ar/go/spf" ) func main() { flag.Parse() r, err := spf.CheckHost(net.ParseIP(flag.Arg(0)), flag.Arg(1)) fmt.Println(r) fmt.Println(err) } chasquid-0.04/docs/000077500000000000000000000000001323767670700142105ustar00rootroot00000000000000chasquid-0.04/docs/dovecot.md000066400000000000000000000026061323767670700162010ustar00rootroot00000000000000 # Dovecot integration As of version 0.04 (2018-02), [chasquid] has _experimental_ 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. It is experimental because it was added recently, and the semantics and options are prone to be changed in the future. If you use this feature, please let the authors know, at chasquid@googlegroups.com. ## 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. ## 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-0.04/docs/flow.md000066400000000000000000000033221323767670700155010ustar00rootroot00000000000000 # 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-0.04/docs/hooks.md000066400000000000000000000024001323767670700156510ustar00rootroot00000000000000 # 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. chasquid-0.04/etc/000077500000000000000000000000001323767670700140335ustar00rootroot00000000000000chasquid-0.04/etc/chasquid/000077500000000000000000000000001323767670700156345ustar00rootroot00000000000000chasquid-0.04/etc/chasquid/README000066400000000000000000000016221323767670700165150ustar00rootroot00000000000000 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-0.04/etc/chasquid/certs000077700000000000000000000000001323767670700230412/etc/letsencrypt/live/ustar00rootroot00000000000000chasquid-0.04/etc/chasquid/chasquid.conf000066400000000000000000000050551323767670700203110ustar00rootroot00000000000000 # 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: "" chasquid-0.04/etc/chasquid/domains/000077500000000000000000000000001323767670700172665ustar00rootroot00000000000000chasquid-0.04/etc/chasquid/domains/.gitignore000066400000000000000000000000001323767670700212440ustar00rootroot00000000000000chasquid-0.04/etc/chasquid/hooks/000077500000000000000000000000001323767670700167575ustar00rootroot00000000000000chasquid-0.04/etc/chasquid/hooks/post-data000077500000000000000000000026311323767670700206030ustar00rootroot00000000000000#!/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. # - clamdscan (from ClamAV) to filter virus. # # 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 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 chasquid-0.04/etc/systemd/000077500000000000000000000000001323767670700155235ustar00rootroot00000000000000chasquid-0.04/etc/systemd/system/000077500000000000000000000000001323767670700170475ustar00rootroot00000000000000chasquid-0.04/etc/systemd/system/chasquid-smtp.socket000066400000000000000000000002471323767670700230460ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (SMTP sockets) [Socket] ListenStream=25 FileDescriptorName=smtp Service=chasquid.service [Install] WantedBy=chasquid.target chasquid-0.04/etc/systemd/system/chasquid-submission.socket000066400000000000000000000002641323767670700242550ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (submission sockets) [Socket] ListenStream=587 FileDescriptorName=submission Service=chasquid.service [Install] WantedBy=chasquid.target chasquid-0.04/etc/systemd/system/chasquid-submission_tls.socket000066400000000000000000000003011323767670700251270ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (submission over TLS sockets) [Socket] ListenStream=465 FileDescriptorName=submission_tls Service=chasquid.service [Install] WantedBy=chasquid.target chasquid-0.04/etc/systemd/system/chasquid.service000066400000000000000000000006261323767670700222360ustar00rootroot00000000000000[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-0.04/internal/000077500000000000000000000000001323767670700150745ustar00rootroot00000000000000chasquid-0.04/internal/aliases/000077500000000000000000000000001323767670700165155ustar00rootroot00000000000000chasquid-0.04/internal/aliases/aliases.go000066400000000000000000000175411323767670700204750ustar00rootroot00000000000000// 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, // theat 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" "fmt" "os" "strings" "sync" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/normalize" ) // 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 } type RType string // Valid recipient types. const ( EMAIL RType = "(email)" PIPE RType = "(pipe)" ) var ( 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 // 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 } func NewResolver() *Resolver { return &Resolver{ files: map[string][]string{}, domains: map[string]bool{}, aliases: map[string][]Recipient{}, } } func (v *Resolver) Resolve(addr string) ([]Recipient, error) { v.mu.Lock() defer v.mu.Unlock() 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() defer v.mu.Unlock() addr = v.cleanIfLocal(addr) _, ok := v.aliases[addr] return addr, ok } 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) rcpts := v.aliases[addr] 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 } func (v *Resolver) AddDomain(domain string) { v.mu.Lock() v.domains[domain] = true v.mu.Unlock() } func (v *Resolver) AddAliasesFile(domain, path string) error { // We inconditionally 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 } func (v *Resolver) AddAliasForTesting(addr, rcpt string, rType RType) { v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, rType}) } 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 := map[string][]Recipient{} scanner := bufio.NewScanner(f) 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) if rawalias[0] == '|' { cmd := strings.TrimSpace(rawalias[1:]) aliases[addr] = []Recipient{{cmd, PIPE}} } else { 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}) } aliases[addr] = rs } } if err := scanner.Err(); err != nil { return nil, fmt.Errorf("reading %q: %v", path, err) } return aliases, nil } // 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 } chasquid-0.04/internal/aliases/aliases_test.go000066400000000000000000000175241323767670700215350ustar00rootroot00000000000000package 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}}}, } 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-0.04/internal/auth/000077500000000000000000000000001323767670700160355ustar00rootroot00000000000000chasquid-0.04/internal/auth/auth.go000066400000000000000000000127121323767670700173300ustar00rootroot00000000000000// 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" ) // Interface for authentication backends. type Backend interface { Authenticate(user, password string) (bool, error) Exists(user string) (bool, error) Reload() error } // 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 } 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 } func NewAuthenticator() *Authenticator { return &Authenticator{ backends: map[string]Backend{}, AuthDuration: 100 * time.Millisecond, } } 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 } 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-0.04/internal/auth/auth_test.go000066400000000000000000000153621323767670700203730ustar00rootroot00000000000000package 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) } } chasquid-0.04/internal/config/000077500000000000000000000000001323767670700163415ustar00rootroot00000000000000chasquid-0.04/internal/config/config.go000066400000000000000000000045051323767670700201410ustar00rootroot00000000000000// 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 } 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(" 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-0.04/internal/config/config.pb.go000066400000000000000000000205431323767670700205410ustar00rootroot00000000000000// Code generated by protoc-gen-go. // source: config.proto // DO NOT EDIT! /* Package config is a generated protocol buffer package. It is generated from these files: config.proto It has these top-level messages: Config */ package config import proto "github.com/golang/protobuf/proto" import fmt "fmt" import 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.ProtoPackageIsVersion2 // 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" json:"hostname,omitempty"` // Maximum email size, in megabytes. // Default: 50. MaxDataSizeMb int64 `protobuf:"varint,2,opt,name=max_data_size_mb,json=maxDataSizeMb" 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" 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" 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" 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" 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" 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" 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" 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" 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" 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" json:"mail_log_path,omitempty"` // EXPERIMENTAL - 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" json:"dovecot_auth,omitempty"` // EXPERIMENTAL - 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" json:"dovecot_userdb_path,omitempty"` // EXPERIMENTAL - 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" json:"dovecot_client_path,omitempty"` } func (m *Config) Reset() { *m = Config{} } func (m *Config) String() string { return proto.CompactTextString(m) } func (*Config) ProtoMessage() {} func (*Config) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } func init() { proto.RegisterType((*Config)(nil), "Config") } func init() { proto.RegisterFile("config.proto", fileDescriptor0) } var fileDescriptor0 = []byte{ // 409 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 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-0.04/internal/config/config.proto000066400000000000000000000065401323767670700207000ustar00rootroot00000000000000 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; // EXPERIMENTAL - Enable dovecot authentication. // Domains that don't have an user database will be authenticated via // dovecot. bool dovecot_auth = 13; // EXPERIMENTAL - 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; // EXPERIMENTAL - 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-0.04/internal/config/config_test.go000066400000000000000000000046421323767670700212020ustar00rootroot00000000000000package config import ( "io/ioutil" "os" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" ) 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) } } 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) } } 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) } } chasquid-0.04/internal/courier/000077500000000000000000000000001323767670700165445ustar00rootroot00000000000000chasquid-0.04/internal/courier/courier.go000066400000000000000000000006561323767670700205520ustar00rootroot00000000000000// 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-0.04/internal/courier/procmail.go000066400000000000000000000053531323767670700207070ustar00rootroot00000000000000package 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. } 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.WithDeadline(context.Background(), time.Now().Add(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-0.04/internal/courier/procmail_test.go000066400000000000000000000064431323767670700217470ustar00rootroot00000000000000package 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-0.04/internal/courier/smtp.go000066400000000000000000000145301323767670700200610ustar00rootroot00000000000000package courier import ( "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/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") // Fake MX records, used for testing only. fakeMX = map[string][]string{} ) // Exported variables. var ( tlsCount = expvar.NewMap("chasquid/smtpOut/tlsCount") slcResults = expvar.NewMap("chasquid/smtpOut/securityLevelChecks") ) // SMTP delivers remote mail via outgoing SMTP. type SMTP struct { Dinfo *domaininfo.DB } 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() } for _, mx := range mxs { 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 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 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 lookupMXs(tr *trace.Trace, domain string) ([]string, error) { if v, ok := fakeMX[domain]; ok { return v, nil } domain, err := idna.ToASCII(domain) if err != nil { return nil, err } mxs := []string{} mxRecords, err := net.LookupMX(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-0.04/internal/courier/smtp_test.go000066400000000000000000000105701323767670700211200ustar00rootroot00000000000000package courier import ( "bufio" "net" "net/textproto" "testing" "time" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/testlib" ) 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}, dir } // Fake server, to test SMTP out. func fakeServer(t *testing.T, responses map[string]string) string { l, err := net.Listen("tcp", "localhost:0") if err != nil { t.Fatalf("fake server listen: %v", err) } go func() { 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() } 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 := 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. fakeMX["to"] = []string{":::", host} *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) } } 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 := fakeServer(t, rs) host, port, _ := net.SplitHostPort(addr) fakeMX["to"] = []string{host} *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) } } func TestNoMXServer(t *testing.T) { fakeMX["to"] = []string{} 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) } // TODO: Test STARTTLS negotiation. chasquid-0.04/internal/domaininfo/000077500000000000000000000000001323767670700172175ustar00rootroot00000000000000chasquid-0.04/internal/domaininfo/domaininfo.go000066400000000000000000000055511323767670700216770ustar00rootroot00000000000000// 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 type DB struct { // Persistent store with the list of domains we know. store *protoio.Store info map[string]*Domain sync.Mutex ev *trace.EventLog } 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) return l, nil } // Load the database from disk; should be called once at initialization. func (db *DB) Load() error { 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-0.04/internal/domaininfo/domaininfo.pb.go000066400000000000000000000073151323767670700222770ustar00rootroot00000000000000// Code generated by protoc-gen-go. // source: domaininfo.proto // DO NOT EDIT! /* Package domaininfo is a generated protocol buffer package. It is generated from these files: domaininfo.proto It has these top-level messages: Domain */ package domaininfo import proto "github.com/golang/protobuf/proto" import fmt "fmt" import 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.ProtoPackageIsVersion2 // 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 fileDescriptor0, []int{0} } type Domain struct { Name string `protobuf:"bytes,1,opt,name=name" 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,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,enum=domaininfo.SecLevel" json:"outgoing_sec_level,omitempty"` } func (m *Domain) Reset() { *m = Domain{} } func (m *Domain) String() string { return proto.CompactTextString(m) } func (*Domain) ProtoMessage() {} func (*Domain) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } func init() { proto.RegisterType((*Domain)(nil), "domaininfo.Domain") proto.RegisterEnum("domaininfo.SecLevel", SecLevel_name, SecLevel_value) } func init() { proto.RegisterFile("domaininfo.proto", fileDescriptor0) } var fileDescriptor0 = []byte{ // 189 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 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, 0xa0, 0x0a, 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-0.04/internal/domaininfo/domaininfo.proto000066400000000000000000000010151323767670700224240ustar00rootroot00000000000000 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-0.04/internal/domaininfo/domaininfo_test.go000066400000000000000000000052071323767670700227340ustar00rootroot00000000000000package domaininfo import ( "testing" "time" "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 err := db.Load(); 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") } // Wait until it is written to disk. for dl := time.Now().Add(30 * time.Second); time.Now().Before(dl); { d := &Domain{} ok, _ := db.store.Get("d1", d) if ok { break } time.Sleep(50 * time.Millisecond) } // 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 err := db2.Load(); 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) } } } chasquid-0.04/internal/dovecot/000077500000000000000000000000001323767670700165375ustar00rootroot00000000000000chasquid-0.04/internal/dovecot/dovecot.go000066400000000000000000000153721323767670700205410ustar00rootroot00000000000000// 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" ) // Default timeout 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, } } 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 } // Does user exist? 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"+user+"\t") { return true, nil } else if strings.HasPrefix(resp, "NOTFOUND\t") { return false, nil } return false, fmt.Errorf("invalid response: %q", resp) } // Is the password valud for the user? 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) } 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 := fmt.Fprintf(conn.W, 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-0.04/internal/dovecot/dovecot_test.go000066400000000000000000000065301323767670700215740ustar00rootroot00000000000000package 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) } // TODO: Close the two sockets, and re-do the test from above: Autodetect // should work fine against closed sockets. // To implement this test, we should call SetUnlinkOnClose, but // unfortunately that is only available in Go >= 1.8. // We want to support Go 1.7 for a while as it is in Debian stable; once // Debian stable moves on, we can implement this test easily. // 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) } uL.Close() cL.Close() } 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 } chasquid-0.04/internal/envelope/000077500000000000000000000000001323767670700167115ustar00rootroot00000000000000chasquid-0.04/internal/envelope/envelope.go000066400000000000000000000017401323767670700210570ustar00rootroot00000000000000// 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] } func UserOf(addr string) string { user, _ := Split(addr) return user } func DomainOf(addr string) string { _, domain := Split(addr) return domain } func DomainIn(addr string, locals *set.String) bool { domain := DomainOf(addr) if domain == "" { return true } return locals.Has(domain) } func AddHeader(data []byte, k, v string) []byte { // 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-0.04/internal/envelope/envelope_test.go000066400000000000000000000015241323767670700221160ustar00rootroot00000000000000package 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) } } } chasquid-0.04/internal/maillog/000077500000000000000000000000001323767670700165205ustar00rootroot00000000000000chasquid-0.04/internal/maillog/maillog.go000066400000000000000000000060361323767670700205000ustar00rootroot00000000000000// 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 } 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) } type Logger struct { w io.Writer once sync.Once } func New(w io.Writer) *Logger { return &Logger{w: timedWriter{w}} } 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)") }) } } func (l *Logger) Listening(a string) { l.printf("daemon listening on %s\n", a) } 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) } 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) } 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) } 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) } } 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) } } // The default logger used in the following top-level functions. var Default *Logger = New(ioutil.Discard) func Listening(a string) { Default.Listening(a) } func Auth(netAddr net.Addr, user string, successful bool) { Default.Auth(netAddr, user, successful) } func Rejected(netAddr net.Addr, from string, to []string, err string) { Default.Rejected(netAddr, from, to, err) } func Queued(netAddr net.Addr, from string, to []string, id string) { Default.Queued(netAddr, from, to, id) } func SendAttempt(id, from, to string, err error, permanent bool) { Default.SendAttempt(id, from, to, err, permanent) } func QueueLoop(id, from string, nextDelay time.Duration) { Default.QueueLoop(id, from, nextDelay) } chasquid-0.04/internal/maillog/maillog_test.go000066400000000000000000000057571323767670700215500ustar00rootroot00000000000000package maillog import ( "bytes" "fmt" "net" "strings" "testing" "time" ) 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() } chasquid-0.04/internal/normalize/000077500000000000000000000000001323767670700170745ustar00rootroot00000000000000chasquid-0.04/internal/normalize/normalize.go000066400000000000000000000036251323767670700214310ustar00rootroot00000000000000// 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 } // Name 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 } // Take 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-0.04/internal/normalize/normalize_test.go000066400000000000000000000041561323767670700224700ustar00rootroot00000000000000package 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", } 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) } } } chasquid-0.04/internal/protoio/000077500000000000000000000000001323767670700165675ustar00rootroot00000000000000chasquid-0.04/internal/protoio/protoio.go000066400000000000000000000050121323767670700206070ustar00rootroot00000000000000// 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) } func (s *Store) Put(id string, m proto.Message) error { return WriteTextMessage(s.idToFname(id), m, 0660) } 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 } 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-0.04/internal/protoio/protoio_test.go000066400000000000000000000034211323767670700216500ustar00rootroot00000000000000package protoio import ( "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{"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{"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{"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) } if ids, err := st.ListIDs(); len(ids) != 1 || ids[0] != "f" || err != nil { t.Errorf("expected [f], got %v - %v", ids, err) } } chasquid-0.04/internal/protoio/testpb/000077500000000000000000000000001323767670700200705ustar00rootroot00000000000000chasquid-0.04/internal/protoio/testpb/dummy.go000066400000000000000000000000751323767670700215540ustar00rootroot00000000000000package testpb //go:generate protoc --go_out=. testpb.proto chasquid-0.04/internal/protoio/testpb/testpb.pb.go000066400000000000000000000033471323767670700223270ustar00rootroot00000000000000// Code generated by protoc-gen-go. // source: testpb.proto // DO NOT EDIT! /* Package testpb is a generated protocol buffer package. It is generated from these files: testpb.proto It has these top-level messages: M */ package testpb import proto "github.com/golang/protobuf/proto" import fmt "fmt" import 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.ProtoPackageIsVersion2 // please upgrade the proto package type M struct { Content string `protobuf:"bytes,1,opt,name=content" json:"content,omitempty"` } func (m *M) Reset() { *m = M{} } func (m *M) String() string { return proto.CompactTextString(m) } func (*M) ProtoMessage() {} func (*M) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } func init() { proto.RegisterType((*M)(nil), "testpb.M") } func init() { proto.RegisterFile("testpb.proto", fileDescriptor0) } var fileDescriptor0 = []byte{ // 72 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 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-0.04/internal/protoio/testpb/testpb.proto000066400000000000000000000001111323767670700224470ustar00rootroot00000000000000 syntax = "proto3"; package testpb; message M { string content = 1; } chasquid-0.04/internal/queue/000077500000000000000000000000001323767670700162205ustar00rootroot00000000000000chasquid-0.04/internal/queue/dsn.go000066400000000000000000000050111323767670700173300ustar00rootroot00000000000000package queue import ( "bytes" "text/template" "time" ) // Maximum length of the original message to include in the DSN. const maxOrigMsgLen = 256 * 1024 // deliveryStatusNotification creates a delivery status notification (DSN) for // the given item, and puts it in the queue. // // There is a standard, https://tools.ietf.org/html/rfc3464, although most // MTAs seem to use a plain email and include an X-Failed-Recipients header. // We're going with the latter for now, may extend it to the former later. 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) } buf := &bytes.Buffer{} err := dsnTemplate.Execute(buf, info) return buf.Bytes(), err } 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 } var dsnTemplate = template.Must(template.New("dsn").Parse( `From: Mail Delivery System To: <{{.Destination}}> Subject: Mail delivery failed: returning message to sender Message-ID: <{{.MessageID}}> Date: {{.Date}} X-Failed-Recipients: {{range .FailedTo}}{{.}}, {{end}} Auto-Submitted: auto-replied Delivery to the following recipient(s) failed permanently: {{range .FailedTo -}} - {{.}} {{- end}} ----- Technical details ----- {{range .FailedRecipients}} - "{{.Address}}" ({{.Type}}) failed permanently with error: {{.LastFailureMessage}} {{end}} {{- range .PendingRecipients}} - "{{.Address}}" ({{.Type}}) failed repeatedly and timed out, last error: {{.LastFailureMessage}} {{end}} ----- Original message ----- {{.OriginalMessage}} `)) chasquid-0.04/internal/queue/dsn_test.go000066400000000000000000000054151323767670700203770ustar00rootroot00000000000000package queue import ( "fmt" "sort" "strings" "testing" ) func TestDSN(t *testing.T) { item := &Item{ Message: Message{ ID: <-newID, From: "from@from.org", To: []string{"toto@africa.org", "negra@sosa.org"}, Rcpt: []*Recipient{ {"poe@rcpt", Recipient_EMAIL, Recipient_FAILED, "oh! horror!", "toto@africa.org"}, {"newman@rcpt", Recipient_EMAIL, Recipient_PENDING, "oh! the humanity!", "toto@africa.org"}, {"ant@rcpt", Recipient_EMAIL, Recipient_SENT, "", "negra@sosa.org"}, }, Data: []byte("data ñaca"), }, } 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: * X-Failed-Recipients: toto@africa.org, Auto-Submitted: auto-replied Delivery to the following recipient(s) failed permanently: - toto@africa.org ----- Technical details ----- - "poe@rcpt" (EMAIL) failed permanently with error: oh! horror! - "newman@rcpt" (EMAIL) failed repeatedly and timed out, last error: oh! the humanity! ----- Original message ----- 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-0.04/internal/queue/queue.go000066400000000000000000000302131323767670700176720ustar00rootroot00000000000000// 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" "github.com/golang/protobuf/ptypes/timestamp" "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 = 12 * 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 } 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 } 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 } 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) } 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.WithDeadline(context.Background(), time.Now().Add(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) } else { 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 timestampNow() *timestamp.Timestamp { now := time.Now() ts, _ := ptypes.TimestampProto(now) return ts } func mustIDNAToASCII(s string) string { a, err := idna.ToASCII(s) if err != nil { return a } return s } chasquid-0.04/internal/queue/queue.pb.go000066400000000000000000000155411323767670700203010ustar00rootroot00000000000000// Code generated by protoc-gen-go. // source: queue.proto // DO NOT EDIT! /* Package queue is a generated protocol buffer package. It is generated from these files: queue.proto It has these top-level messages: Message Recipient */ package queue import proto "github.com/golang/protobuf/proto" import fmt "fmt" import math "math" import google_protobuf "github.com/golang/protobuf/ptypes/timestamp" // 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.ProtoPackageIsVersion2 // 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 fileDescriptor0, []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 fileDescriptor0, []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,json=iD" json:"ID,omitempty"` // The envelope for this message. From string `protobuf:"bytes,2,opt,name=from" json:"from,omitempty"` To []string `protobuf:"bytes,3,rep,name=To,json=to" json:"To,omitempty"` Rcpt []*Recipient `protobuf:"bytes,4,rep,name=rcpt" json:"rcpt,omitempty"` Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` // Creation timestamp. CreatedAtTs *google_protobuf.Timestamp `protobuf:"bytes,6,opt,name=created_at_ts,json=createdAtTs" json:"created_at_ts,omitempty"` } func (m *Message) Reset() { *m = Message{} } func (m *Message) String() string { return proto.CompactTextString(m) } func (*Message) ProtoMessage() {} func (*Message) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} } func (m *Message) GetRcpt() []*Recipient { if m != nil { return m.Rcpt } return nil } func (m *Message) GetCreatedAtTs() *google_protobuf.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" json:"address,omitempty"` Type Recipient_Type `protobuf:"varint,2,opt,name=type,enum=queue.Recipient_Type" json:"type,omitempty"` Status Recipient_Status `protobuf:"varint,3,opt,name=status,enum=queue.Recipient_Status" json:"status,omitempty"` LastFailureMessage string `protobuf:"bytes,4,opt,name=last_failure_message,json=lastFailureMessage" 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" json:"original_address,omitempty"` } func (m *Recipient) Reset() { *m = Recipient{} } func (m *Recipient) String() string { return proto.CompactTextString(m) } func (*Recipient) ProtoMessage() {} func (*Recipient) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{1} } func init() { proto.RegisterType((*Message)(nil), "queue.Message") proto.RegisterType((*Recipient)(nil), "queue.Recipient") proto.RegisterEnum("queue.Recipient_Type", Recipient_Type_name, Recipient_Type_value) proto.RegisterEnum("queue.Recipient_Status", Recipient_Status_name, Recipient_Status_value) } func init() { proto.RegisterFile("queue.proto", fileDescriptor0) } var fileDescriptor0 = []byte{ // 386 bytes of a gzipped FileDescriptorProto 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0x64, 0x50, 0xd1, 0x0e, 0x93, 0x30, 0x14, 0x15, 0x06, 0xcc, 0x5d, 0x74, 0x92, 0x46, 0x23, 0x99, 0x2f, 0x0b, 0xf1, 0xc1, 0xc5, 0x04, 0xcc, 0x7c, 0x34, 0x31, 0x59, 0x02, 0x33, 0x24, 0x6e, 0x59, 0x18, 0xef, 0xa4, 0x83, 0x0e, 0x9b, 0xc0, 0x8a, 0xb4, 0x3c, 0xf8, 0x47, 0xfe, 0x81, 0xbf, 0x67, 0x5b, 0x40, 0x13, 0x7d, 0xbb, 0x3d, 0xe7, 0xf4, 0xde, 0x73, 0x0e, 0xb8, 0xdf, 0x07, 0x32, 0x90, 0xb0, 0xeb, 0x99, 0x60, 0xc8, 0xd6, 0x8f, 0xcd, 0xa7, 0x9a, 0x8a, 0x6f, 0xc3, 0x2d, 0x2c, 0x59, 0x1b, 0xd5, 0xac, 0xc1, 0x8f, 0x3a, 0xd2, 0xfc, 0x6d, 0xb8, 0x47, 0x9d, 0xf8, 0xd1, 0x11, 0x1e, 0x09, 0xda, 0x12, 0x2e, 0x70, 0xdb, 0xfd, 0x9d, 0xc6, 0x1d, 0xc1, 0x2f, 0x03, 0x96, 0x27, 0xc2, 0x39, 0xae, 0x09, 0x5a, 0x83, 0x99, 0xc6, 0xbe, 0xb1, 0x35, 0xde, 0xad, 0x32, 0x93, 0xc6, 0x08, 0x81, 0x75, 0xef, 0x59, 0xeb, 0x9b, 0x1a, 0xd1, 0xb3, 0xd2, 0xe4, 0xcc, 0x5f, 0x6c, 0x17, 0x4a, 0x23, 0x3d, 0xbc, 0x05, 0xab, 0x2f, 0x3b, 0xe1, 0x5b, 0x12, 0x71, 0xf7, 0x5e, 0x38, 0xfa, 0xcb, 0x48, 0x49, 0x3b, 0x4a, 0x1e, 0x22, 0xd3, 0xac, 0xda, 0x54, 0x61, 0x81, 0x7d, 0x5b, 0x6e, 0x7a, 0x96, 0xe9, 0x19, 0x7d, 0x86, 0xe7, 0x65, 0x4f, 0xb0, 0x20, 0x55, 0x81, 0x45, 0x21, 0xb8, 0xef, 0x48, 0xd2, 0xdd, 0x6f, 0xc2, 0x9a, 0xb1, 0xba, 0x99, 0x32, 0xca, 0x0c, 0x61, 0x3e, 0x5b, 0xce, 0xdc, 0xe9, 0xc3, 0x41, 0xe4, 0x3c, 0xf8, 0x69, 0xc2, 0xea, 0xcf, 0x1d, 0xe4, 0xc3, 0x12, 0x57, 0x55, 0x2f, 0x93, 0x4c, 0x01, 0xe6, 0x27, 0xda, 0x81, 0xa5, 0x4a, 0xd0, 0x29, 0xd6, 0xfb, 0x57, 0xff, 0x3a, 0x0c, 0x73, 0x49, 0x66, 0x5a, 0x82, 0x22, 0x70, 0xe4, 0x21, 0x31, 0x70, 0x19, 0x50, 0x89, 0x5f, 0xff, 0x27, 0xbe, 0x6a, 0x3a, 0x9b, 0x64, 0xe8, 0x03, 0xbc, 0x6c, 0x30, 0x17, 0xc5, 0x1d, 0xd3, 0x66, 0xe8, 0x49, 0xd1, 0x8e, 0x4d, 0xca, 0x36, 0x94, 0x05, 0xa4, 0xb8, 0xe3, 0x48, 0xcd, 0x1d, 0xef, 0xc0, 0x63, 0x3d, 0xad, 0xe9, 0x03, 0x37, 0xc5, 0x6c, 0xd8, 0xd6, 0xea, 0x17, 0x33, 0x7e, 0x18, 0xe1, 0xe0, 0x0d, 0x58, 0xca, 0x1b, 0x5a, 0x81, 0x9d, 0x9c, 0x0e, 0xe9, 0x57, 0xef, 0x09, 0x7a, 0x0a, 0xd6, 0x25, 0xbd, 0x24, 0x9e, 0x11, 0xbc, 0x07, 0x67, 0xf4, 0x82, 0x5c, 0x58, 0x5e, 0x92, 0x73, 0x9c, 0x9e, 0xbf, 0x8c, 0x82, 0x6b, 0x72, 0xce, 0x3d, 0x03, 0x01, 0x38, 0x47, 0xf9, 0x29, 0x89, 0x3d, 0xf3, 0xe6, 0xe8, 0x2e, 0x3f, 0xfe, 0x0e, 0x00, 0x00, 0xff, 0xff, 0xda, 0xc1, 0x0d, 0xbb, 0x3e, 0x02, 0x00, 0x00, } chasquid-0.04/internal/queue/queue.proto000066400000000000000000000016271323767670700204370ustar00rootroot00000000000000 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-0.04/internal/queue/queue_test.go000066400000000000000000000162601323767670700207370ustar00rootroot00000000000000package queue import ( "bytes" "fmt" "strings" "sync" "testing" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/set" "blitiri.com.ar/go/chasquid/internal/testlib" ) // Test courier. Delivery is done by sending on a channel, so users have fine // grain control over the results. type ChanCourier struct { requests chan deliverRequest results chan error } type deliverRequest struct { from string to string data []byte } func (cc *ChanCourier) Deliver(from string, to string, data []byte) (error, bool) { cc.requests <- deliverRequest{from, to, data} return <-cc.results, false } func newChanCourier() *ChanCourier { return &ChanCourier{ requests: make(chan deliverRequest), results: make(chan error), } } // 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 newTestCourier() *TestCourier { return &TestCourier{ reqFor: map[string]*deliverRequest{}, } } func TestBasic(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) localC := newTestCourier() remoteC := newTestCourier() q := New(dir, set.NewString("loco"), aliases.NewResolver(), localC, remoteC) localC.wg.Add(2) remoteC.wg.Add(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.wg.Wait() remoteC.wg.Wait() // Make sure the delivered items leave the queue. for d := time.Now().Add(2 * time.Second); time.Now().Before(d); { if q.Len() == 0 { break } time.Sleep(20 * time.Millisecond) } if q.Len() != 0 { t.Fatalf("%d items not removed from the queue after delivery", q.Len()) } cases := []struct { courier *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 := newTestCourier() remoteC := 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{ {"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) } // Launch the sending loop, expect 1 local delivery (the DSN). localC.wg.Add(1) go item.SendLoop(q) localC.wg.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 := newTestCourier() remoteC := 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.wg.Add(2) remoteC.wg.Add(1) _, err := q.Put("from", []string{"ab@loco", "cd@loco"}, []byte("data")) if err != nil { t.Fatalf("Put: %v", err) } localC.wg.Wait() remoteC.wg.Wait() cases := []struct { courier *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) } } } // Dumb courier, for when we just want to return directly. type DumbCourier struct{} func (c DumbCourier) Deliver(from string, to string, data []byte) (error, bool) { return nil, false } var dumbCourier = DumbCourier{} func TestFullQueue(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q := New(dir, set.NewString(), aliases.NewResolver(), dumbCourier, 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{ {"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(), dumbCourier, dumbCourier) item := &Item{ Message: Message{ ID: <-newID, From: "from", Rcpt: []*Recipient{ {"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) } } } } chasquid-0.04/internal/safeio/000077500000000000000000000000001323767670700163425ustar00rootroot00000000000000chasquid-0.04/internal/safeio/safeio.go000066400000000000000000000040671323767670700201460ustar00rootroot00000000000000// 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" ) // Type 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-0.04/internal/safeio/safeio_test.go000066400000000000000000000050641323767670700212030ustar00rootroot00000000000000package 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-0.04/internal/set/000077500000000000000000000000001323767670700156675ustar00rootroot00000000000000chasquid-0.04/internal/set/set.go000066400000000000000000000011761323767670700170160ustar00rootroot00000000000000// Package set implement sets for various types. Well, only string for now :) package set type String struct { m map[string]struct{} } func NewString(values ...string) *String { s := &String{} s.Add(values...) return s } func (s *String) Add(values ...string) { if s.m == nil { s.m = map[string]struct{}{} } for _, v := range values { s.m[v] = struct{}{} } } 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-0.04/internal/set/set_test.go000066400000000000000000000014501323767670700200500ustar00rootroot00000000000000package 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-0.04/internal/smtp/000077500000000000000000000000001323767670700160575ustar00rootroot00000000000000chasquid-0.04/internal/smtp/smtp.go000066400000000000000000000075011323767670700173740ustar00rootroot00000000000000// 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 ( "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 } func NewClient(conn net.Conn, host string) (*Client, error) { c, err := smtp.NewClient(conn, host) if err != nil { return nil, err } 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 } // ErrIsPermanent 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-0.04/internal/smtp/smtp_test.go000066400000000000000000000114101323767670700204250ustar00rootroot00000000000000package smtp import ( "bufio" "bytes" "fmt" "net" "net/smtp" "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 TestBasic(t *testing.T) { fake, client := fakeDialog(`> 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 := &Client{ Client: &smtp.Client{Text: textproto.NewConn(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(`> 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 := &Client{ Client: &smtp.Client{Text: textproto.NewConn(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(`> EHLO araña < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-8BITMIME < 250 HELP `) c := &Client{ Client: &smtp.Client{Text: textproto.NewConn(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(`> 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 := &Client{ Client: &smtp.Client{Text: textproto.NewConn(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) } } 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() } // 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-0.04/internal/smtpsrv/000077500000000000000000000000001323767670700166125ustar00rootroot00000000000000chasquid-0.04/internal/smtpsrv/conn.go000066400000000000000000000646001323767670700201040ustar00rootroot00000000000000package smtpsrv import ( "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 ) // 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} ) // 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 tc *textproto.Conn mode SocketMode tlsConnState *tls.ConnectionState // 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 // 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 } func (c *Conn) Close() { c.conn.Close() } 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) 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 } } c.tc.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.tc.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, "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 = "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, "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) } } 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 } 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] 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, "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() } func (c *Conn) HELP(params string) (code int, msg string) { return 214, "hoy por ti, mañana por mi" } 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, msgs[rand.Int()%len(msgs)] } func (c *Conn) VRFY(params string) (code int, msg string) { // 252 can be used for cases like ours, when we don't really want to // confirm or deny anything. // See https://tools.ietf.org/html/rfc2821#section-3.5.3. return 252, "You have a strange feeling for a moment, then it passes." } func (c *Conn) EXPN(params string) (code int, msg string) { // 252 can be used for cases like ours, when we don't really want to // confirm or deny anything. // See https://tools.ietf.org/html/rfc2821#section-3.5.3. return 252, "You feel disoriented for a moment." } func (c *Conn) NOOP(params string) (code int, msg string) { return 250, "You hear a faint typing noise." } 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, "unknown command" } if c.mode.IsSubmission && !c.completedAuth { return 550, "mail to submission port must be authenticated" } rawAddr := "" _, err := fmt.Sscanf(params[5:], "%s ", &rawAddr) if err != nil { return 500, "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, "malformed address" } addr = e.Address if !strings.Contains(addr, "@") { return 501, "sender address must contain a domain" } // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3 if len(addr) > 256 { return 501, "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( "SPF check failed: %v", c.spfError) } if !c.secLevelCheck(addr) { maillog.Rejected(c.conn.RemoteAddr(), addr, nil, "security level check failed") return 550, "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, "malformed address (IDNA conversion failed)" } } c.mailFrom = addr return 250, "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.CheckHost( tcp.IP, envelope.DomainOf(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 } 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, "unknown command" } if c.mailFrom == "" { return 503, "sender not yet given" } rawAddr := "" _, err := fmt.Sscanf(params[3:], "%s ", &rawAddr) if err != nil { return 500, "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, "too many recipients" } e, err := mail.ParseAddress(rawAddr) if err != nil || e.Address == "" { return 501, "malformed address" } addr, err := normalize.DomainToUnicode(e.Address) if err != nil { return 501, "malformed address (IDNA conversion failed)" } // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3 if len(addr) > 256 { return 501, "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, "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, "recipient invalid, please check the address for typos" } if !c.userExists(addr) { maillog.Rejected(c.conn.RemoteAddr(), c.mailFrom, []string{addr}, "local user does not exist") return 550, "recipient unknown, please check the address for typos" } } c.rcptTo = append(c.rcptTo, addr) return 250, "You have an eerie feeling..." } func (c *Conn) DATA(params string) (code int, msg string) { if c.ehloAddress == "" { return 503, "Invisible customers are not welcome!" } if c.mailFrom == "" { return 503, "sender not yet given" } if len(c.rcptTo) == 0 { return 503, "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("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) dotr := io.LimitReader(c.tc.DotReader(), c.maxDataSize) c.data, err = ioutil.ReadAll(dotr) if err != nil { return 554, fmt.Sprintf("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. msgID, err := c.queue.Put(c.mailFrom, c.rcptTo, c.data) if err != nil { return 554, fmt.Sprintf("Failed to enqueue 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, 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 { v += fmt.Sprintf("from %s (authenticated as %s@%s)\n", c.ehloAddress, c.authUser, c.authDomain) } else { v += fmt.Sprintf("from %s (%s)\n", c.ehloAddress, c.conn.RemoteAddr().String()) } v += fmt.Sprintf("by %s (chasquid)\n", c.hostname) v += fmt.Sprintf("(over %s ", c.mode) if c.tlsConnState != nil { v += fmt.Sprintf("%s-%s)\n", tlsconst.VersionName(c.tlsConnState.Version), tlsconst.CipherSuiteName(c.tlsConnState.CipherSuite)) } else { v += "plain text!)\n" } // 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) } } // 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("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("email passed through more than %d MTAs, looping?", *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() tr.Debugf("running") ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(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() if err != nil { hookResults.Add("post-data:fail", 1) tr.Error(err) tr.Debugf("stdout: %s", out) permanent := false if ee, ok := err.(*exec.ExitError); ok { tr.Printf("stderr: %s", 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: '%s'", out) return nil, false, nil } tr.Debugf("success") tr.Debugf("stdout: %s", out) 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 (c *Conn) STARTTLS(params string) (code int, msg string) { if c.onTLS { return 503, "You are already wearing that!" } err := c.writeResponse(220, "You experience a strange sense of peace") if err != nil { return 554, fmt.Sprintf("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("error in TLS handshake: %v", err) } c.tr.Debugf("<> ... jump to TLS was successful") // Override the connections. We don't need the older ones anymore. c.conn = server c.tc = textproto.NewConn(server) // 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, "" } func (c *Conn) AUTH(params string) (code int, msg string) { if !c.onTLS { return 503, "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, "You are already wearing that!" } if c.authAttempts > 3 { return 503, "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, "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("error writing AUTH 334: %v", err) } response, err = c.readLine() if err != nil { return 554, fmt.Sprintf("error reading AUTH response: %v", err) } } user, domain, passwd, err := auth.DecodeResponse(response) if err != nil { return 535, fmt.Sprintf("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, "" } maillog.Auth(c.conn.RemoteAddr(), user+"@"+domain, false) return 535, "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) { var msg string msg, err = c.tc.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) { return c.tc.ReadLine() } func (c *Conn) writeResponse(code int, msg string) error { defer c.tc.W.Flush() responseCodeCount.Add(strconv.Itoa(code), 1) return writeResponse(c.tc.W, code, msg) } // 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-0.04/internal/smtpsrv/conn_test.go000066400000000000000000000032151323767670700211360ustar00rootroot00000000000000package smtpsrv import ( "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) } } } chasquid-0.04/internal/smtpsrv/server.go000066400000000000000000000133011323767670700204450ustar00rootroot00000000000000// Package smtpsrv implements chasquid's SMTP server and connection handler. package smtpsrv import ( "crypto/tls" "net" "net/http" "net/textproto" "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" ) 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 Post-DATA hook. PostDataHook string } 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(), } } 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 } func (s *Server) AddAddr(a string, m SocketMode) { s.addrs[m] = append(s.addrs[m], a) } func (s *Server) AddListeners(ls []net.Listener, m SocketMode) { s.listeners[m] = append(s.listeners[m], ls...) } func (s *Server) AddDomain(d string) { s.localDomains.Add(d) s.aliasesR.AddDomain(d) } func (s *Server) AddUserDB(domain string, db *userdb.DB) { s.authr.Register(domain, auth.WrapNoErrorBackend(db)) } func (s *Server) AddAliasesFile(domain, f string) error { return s.aliasesR.AddAliasesFile(domain, f) } func (s *Server) SetAuthFallback(be auth.Backend) { s.authr.Fallback = be } func (s *Server) SetAliasesConfig(suffixSep, dropChars string) { s.aliasesR.SuffixSep = suffixSep s.aliasesR.DropChars = dropChars } 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) } err = s.dinfo.Load() if err != nil { log.Fatalf("Error loading domain info database: %v", err) } return s.dinfo } 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() { for range time.Tick(30 * time.Second) { 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) } } } 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) } for { conn, err := l.Accept() if err != nil { log.Fatalf("Error accepting: %v", err) } sc := &Conn{ hostname: s.Hostname, maxDataSize: s.MaxDataSize, postDataHook: s.PostDataHook, conn: conn, tc: textproto.NewConn(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-0.04/internal/smtpsrv/server_test.go000066400000000000000000000257721323767670700215230ustar00rootroot00000000000000package 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/courier" "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)") ) var ( // Server addresses. // We default to internal ones, but may get overridden via flags. // TODO: Don't hard-code the default. smtpAddr = "127.0.0.1:13444" submissionAddr = "127.0.0.1:13999" submissionTLSAddr = "127.0.0.1:13777" // TLS configuration to use in the clients. // Will contain the generated server certificate as root CA. tlsConfig *tls.Config ) // // === 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) } if err = w.Close(); err != nil { tb.Errorf("Data close: %v", err) } } 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 simpleCmd(t *testing.T, c *smtp.Client, cmd string, expected int) { if err := c.Text.PrintfLine(cmd); err != nil { t.Fatalf("Failed to write %s: %v", cmd, err) } if _, _, err := c.Text.ReadResponse(expected); err != nil { t.Errorf("Incorrect %s response: %v", cmd, err) } } 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", 252) simpleCmd(t, c, "EXPN", 252) } 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) // TODO: Make sendEmail() wait for delivery, and remove this. time.Sleep(10 * time.Millisecond) } } 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) // TODO: Make sendEmail() wait for delivery, and remove this. time.Sleep(100 * time.Millisecond) } }) } // // === 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() if *externalSMTPAddr != "" { smtpAddr = *externalSMTPAddr submissionAddr = *externalSubmissionAddr 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 } 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) return m.Run() } func TestMain(m *testing.M) { os.Exit(realMain(m)) } chasquid-0.04/internal/testlib/000077500000000000000000000000001323767670700165425ustar00rootroot00000000000000chasquid-0.04/internal/testlib/testlib.go000066400000000000000000000014711323767670700205420ustar00rootroot00000000000000// Package testlib provides common test utilities. package testlib import ( "io/ioutil" "os" "strings" "testing" ) // 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) } } chasquid-0.04/internal/testlib/testlib_test.go000066400000000000000000000021621323767670700215770ustar00rootroot00000000000000package 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) } chasquid-0.04/internal/tlsconst/000077500000000000000000000000001323767670700167455ustar00rootroot00000000000000chasquid-0.04/internal/tlsconst/ciphers.go000066400000000000000000000361671323767670700207460ustar00rootroot00000000000000package 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", 0x00ff: "TLS_EMPTY_RENEGOTIATION_INFO_SCSV", 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", 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", } chasquid-0.04/internal/tlsconst/generate-ciphers.py000077500000000000000000000022421323767670700225470ustar00rootroot00000000000000#!/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-0.04/internal/tlsconst/tlsconst.go000066400000000000000000000013651323767670700211520ustar00rootroot00000000000000// 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", } // 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-0.04/internal/trace/000077500000000000000000000000001323767670700161725ustar00rootroot00000000000000chasquid-0.04/internal/trace/trace.go000066400000000000000000000045131323767670700176220ustar00rootroot00000000000000// Package trace extends golang.org/x/net/trace. package trace import ( "fmt" "strconv" "blitiri.com.ar/go/log" nettrace "golang.org/x/net/trace" ) type Trace struct { family string title string t nettrace.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 } 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...))) } 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...))) } func (t *Trace) SetError() { t.t.SetError() } 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 } 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 } func (t *Trace) Finish() { t.t.Finish() } type EventLog struct { family string title string e nettrace.EventLog } func NewEventLog(family, title string) *EventLog { return &EventLog{family, title, nettrace.NewEventLog(family, title)} } 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...))) } 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...))) } 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-0.04/internal/userdb/000077500000000000000000000000001323767670700163605ustar00rootroot00000000000000chasquid-0.04/internal/userdb/userdb.go000066400000000000000000000126771323767670700202100ustar00rootroot00000000000000// 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" ) type DB struct { fname string db *ProtoDB // Lock protecting db. mu sync.RWMutex } var ( ErrInvalidUsername = errors.New("invalid username") ) 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) } // Is this password valid for the user? 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) } 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 } } // Add a user 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 ErrInvalidUsername } 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-0.04/internal/userdb/userdb_test.go000066400000000000000000000165701323767670700212430ustar00rootroot00000000000000package userdb import ( "fmt" "io/ioutil" "os" "reflect" "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) { 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{[]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-0.04/test/000077500000000000000000000000001323767670700142375ustar00rootroot00000000000000chasquid-0.04/test/.gitignore000066400000000000000000000001351323767670700162260ustar00rootroot00000000000000 # Ignore the user databases - we create them each time. t-*/config/**/users t-*/?/**/users chasquid-0.04/test/README000066400000000000000000000013231323767670700151160ustar00rootroot00000000000000 This directory holds end to end tests, written usually in a combination of shell and some Python. They're not expected to be portable, as that gets impractical very quickly. They also have some dependencies, listed below. ## 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) For 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. For some tests, python >= 3.5 is required; they will be skipped if it's not available. chasquid-0.04/test/run.sh000077500000000000000000000002531323767670700154020ustar00rootroot00000000000000#!/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-0.04/test/t-01-simple_local/000077500000000000000000000000001323767670700173615ustar00rootroot00000000000000chasquid-0.04/test/t-01-simple_local/config/000077500000000000000000000000001323767670700206265ustar00rootroot00000000000000chasquid-0.04/test/t-01-simple_local/config/chasquid.conf000066400000000000000000000003621323767670700232770ustar00rootroot00000000000000smtp_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-0.04/test/t-01-simple_local/content000066400000000000000000000001211323767670700207500ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-01-simple_local/hosts000066400000000000000000000000251323767670700204410ustar00rootroot00000000000000testserver localhost chasquid-0.04/test/t-01-simple_local/msmtprc000066400000000000000000000006551323767670700207770ustar00rootroot00000000000000account 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-0.04/test/t-01-simple_local/run.sh000077500000000000000000000025721323767670700205320ustar00rootroot00000000000000#!/bin/bash set -e . $(dirname ${0})/../util/lib.sh init # 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 mkdir -p .logs 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-0.04/test/t-02-exim/000077500000000000000000000000001323767670700156615ustar00rootroot00000000000000chasquid-0.04/test/t-02-exim/.gitignore000066400000000000000000000000771323767670700176550ustar00rootroot00000000000000 # Packages, so fetches via get-exim4-* don't add cruft. *.deb chasquid-0.04/test/t-02-exim/config/000077500000000000000000000000001323767670700171265ustar00rootroot00000000000000chasquid-0.04/test/t-02-exim/config/chasquid.conf000066400000000000000000000003621323767670700215770ustar00rootroot00000000000000smtp_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-0.04/test/t-02-exim/config/exim4.in000066400000000000000000000027131323767670700205070ustar00rootroot00000000000000CONFDIR = ${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-0.04/test/t-02-exim/content000066400000000000000000000001211323767670700172500ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-02-exim/get-exim4-debian.sh000077500000000000000000000013741323767670700212500ustar00rootroot00000000000000#!/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-0.04/test/t-02-exim/hosts000066400000000000000000000000521323767670700167410ustar00rootroot00000000000000srv-chasquid localhost srv-exim localhost chasquid-0.04/test/t-02-exim/msmtprc000066400000000000000000000002751323767670700172750ustar00rootroot00000000000000account 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-0.04/test/t-02-exim/run.sh000077500000000000000000000034311323767670700170250ustar00rootroot00000000000000#!/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-0.04/test/t-03-queue_persistency/000077500000000000000000000000001323767670700204745ustar00rootroot00000000000000chasquid-0.04/test/t-03-queue_persistency/addtoqueue.go000066400000000000000000000021471323767670700231670ustar00rootroot00000000000000// 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. 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{ {*rcpt, queue.Recipient_EMAIL, 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-0.04/test/t-03-queue_persistency/config/000077500000000000000000000000001323767670700217415ustar00rootroot00000000000000chasquid-0.04/test/t-03-queue_persistency/config/chasquid.conf000066400000000000000000000003621323767670700244120ustar00rootroot00000000000000smtp_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-0.04/test/t-03-queue_persistency/config/domains/000077500000000000000000000000001323767670700233735ustar00rootroot00000000000000chasquid-0.04/test/t-03-queue_persistency/config/domains/testserver/000077500000000000000000000000001323767670700256015ustar00rootroot00000000000000chasquid-0.04/test/t-03-queue_persistency/config/domains/testserver/.gitignore000066400000000000000000000000001323767670700275570ustar00rootroot00000000000000chasquid-0.04/test/t-03-queue_persistency/content000066400000000000000000000001211323767670700220630ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-03-queue_persistency/hosts000066400000000000000000000000251323767670700215540ustar00rootroot00000000000000testserver localhost chasquid-0.04/test/t-03-queue_persistency/run.sh000077500000000000000000000007751323767670700216500ustar00rootroot00000000000000#!/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-0.04/test/t-04-aliases/000077500000000000000000000000001323767670700163425ustar00rootroot00000000000000chasquid-0.04/test/t-04-aliases/config/000077500000000000000000000000001323767670700176075ustar00rootroot00000000000000chasquid-0.04/test/t-04-aliases/config/chasquid.conf000066400000000000000000000004411323767670700222560ustar00rootroot00000000000000smtp_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-0.04/test/t-04-aliases/config/domains/000077500000000000000000000000001323767670700212415ustar00rootroot00000000000000chasquid-0.04/test/t-04-aliases/config/domains/testserver/000077500000000000000000000000001323767670700234475ustar00rootroot00000000000000chasquid-0.04/test/t-04-aliases/config/domains/testserver/aliases000066400000000000000000000002441323767670700250130ustar00rootroot00000000000000 # Easy aliases. pepe: jose joan: juan # UTF-8 aliases. pitanga: ñangapirí añil: azul, índigo # Pipe aliases. tubo: | writemailto ../.data/pipe_alias_worked chasquid-0.04/test/t-04-aliases/content000066400000000000000000000001211323767670700177310ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-04-aliases/hosts000066400000000000000000000000251323767670700174220ustar00rootroot00000000000000testserver localhost chasquid-0.04/test/t-04-aliases/msmtprc000066400000000000000000000002651323767670700177550ustar00rootroot00000000000000account 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-0.04/test/t-04-aliases/run.sh000077500000000000000000000016371323767670700175140ustar00rootroot00000000000000#!/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 } # 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 success chasquid-0.04/test/t-05-null_address/000077500000000000000000000000001323767670700174015ustar00rootroot00000000000000chasquid-0.04/test/t-05-null_address/config/000077500000000000000000000000001323767670700206465ustar00rootroot00000000000000chasquid-0.04/test/t-05-null_address/config/chasquid.conf000066400000000000000000000004711323767670700233200ustar00rootroot00000000000000hostname: "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-0.04/test/t-05-null_address/config/domains/000077500000000000000000000000001323767670700223005ustar00rootroot00000000000000chasquid-0.04/test/t-05-null_address/config/domains/testserver/000077500000000000000000000000001323767670700245065ustar00rootroot00000000000000chasquid-0.04/test/t-05-null_address/config/domains/testserver/aliases000066400000000000000000000000201323767670700260420ustar00rootroot00000000000000 fail: | false chasquid-0.04/test/t-05-null_address/content000066400000000000000000000001301323767670700207700ustar00rootroot00000000000000From: Mailer daemon Subject: I've come to haunt you Muahahahaha chasquid-0.04/test/t-05-null_address/expected_dsr000066400000000000000000000012631323767670700217770ustar00rootroot00000000000000From user@testserver From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: * Date: * X-Failed-Recipients: fail@testserver, Auto-Submitted: auto-replied Delivery to the following recipient(s) failed permanently: - fail@testserver ----- Technical details ----- - "false" (PIPE) failed permanently with error: exit status 1 ----- Original message ----- Received: from localhost (authenticated as user@testserver) by testserver (chasquid) (over * (envelope from "user@testserver") ; * Date: * From: Mailer daemon Subject: I've come to haunt you Muahahahaha chasquid-0.04/test/t-05-null_address/hosts000066400000000000000000000000251323767670700204610ustar00rootroot00000000000000testserver localhost chasquid-0.04/test/t-05-null_address/msmtprc000066400000000000000000000002641323767670700210130ustar00rootroot00000000000000account 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-0.04/test/t-05-null_address/run.sh000077500000000000000000000011641323767670700205460ustar00rootroot00000000000000#!/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-0.04/test/t-05-null_address/sendmail.cmy000066400000000000000000000004751323767670700217150ustar00rootroot00000000000000 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-0.04/test/t-06-idna/000077500000000000000000000000001323767670700156365ustar00rootroot00000000000000chasquid-0.04/test/t-06-idna/.gitignore000066400000000000000000000000711323767670700176240ustar00rootroot00000000000000# Ignore the configuration domain directories. ?/domains chasquid-0.04/test/t-06-idna/A/000077500000000000000000000000001323767670700160165ustar00rootroot00000000000000chasquid-0.04/test/t-06-idna/A/chasquid.conf000066400000000000000000000003211323767670700204620ustar00rootroot00000000000000smtp_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-0.04/test/t-06-idna/B/000077500000000000000000000000001323767670700160175ustar00rootroot00000000000000chasquid-0.04/test/t-06-idna/B/chasquid.conf000066400000000000000000000003211323767670700204630ustar00rootroot00000000000000smtp_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-0.04/test/t-06-idna/from_A_to_B000066400000000000000000000001421323767670700177240ustar00rootroot00000000000000From: ñangapirí@srv-ñ To: pingüino@srv-ü Subject: Hola amigo pingüino! Que tal va la vida? chasquid-0.04/test/t-06-idna/from_B_to_A000066400000000000000000000001451323767670700177270ustar00rootroot00000000000000From: pingüino@srv-ü To: ñangapirí@srv-ñ Subject: Feliz primavera! Espero que florezcas feliz! chasquid-0.04/test/t-06-idna/hosts000066400000000000000000000000561323767670700167220ustar00rootroot00000000000000xn--srv--3ra localhost xn--srv--jqa localhost chasquid-0.04/test/t-06-idna/run.sh000077500000000000000000000021241323767670700170000ustar00rootroot00000000000000#!/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-0.04/test/t-07-smtputf8/000077500000000000000000000000001323767670700165165ustar00rootroot00000000000000chasquid-0.04/test/t-07-smtputf8/config/000077500000000000000000000000001323767670700177635ustar00rootroot00000000000000chasquid-0.04/test/t-07-smtputf8/config/chasquid.conf000066400000000000000000000003151323767670700224320ustar00rootroot00000000000000smtp_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-0.04/test/t-07-smtputf8/content000066400000000000000000000001741323767670700201150ustar00rootroot00000000000000From: ñandú@ñoÑos To: Ñangapirí@Ñoños Subject: Arañando el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-07-smtputf8/hosts000066400000000000000000000000221323767670700175730ustar00rootroot00000000000000ñoños localhost chasquid-0.04/test/t-07-smtputf8/run.sh000077500000000000000000000017151323767670700176650ustar00rootroot00000000000000#!/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-0.04/test/t-09-loop/000077500000000000000000000000001323767670700156775ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/A/000077500000000000000000000000001323767670700160575ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/A/chasquid.conf000066400000000000000000000003661323767670700205340ustar00rootroot00000000000000smtp_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-0.04/test/t-09-loop/A/domains/000077500000000000000000000000001323767670700175115ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/A/domains/srv-A/000077500000000000000000000000001323767670700205015ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/A/domains/srv-A/aliases000066400000000000000000000000261323767670700220430ustar00rootroot00000000000000 aliasA: aliasB@srv-B chasquid-0.04/test/t-09-loop/B/000077500000000000000000000000001323767670700160605ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/B/chasquid.conf000066400000000000000000000003661323767670700205350ustar00rootroot00000000000000smtp_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-0.04/test/t-09-loop/B/domains/000077500000000000000000000000001323767670700175125ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/B/domains/srv-B/000077500000000000000000000000001323767670700205035ustar00rootroot00000000000000chasquid-0.04/test/t-09-loop/B/domains/srv-B/aliases000066400000000000000000000000251323767670700220440ustar00rootroot00000000000000aliasB: aliasA@srv-A chasquid-0.04/test/t-09-loop/content000066400000000000000000000003141323767670700172720ustar00rootroot00000000000000From: 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-0.04/test/t-09-loop/hosts000066400000000000000000000000401323767670700167540ustar00rootroot00000000000000srv-A localhost srv-B localhost chasquid-0.04/test/t-09-loop/msmtprc000066400000000000000000000002251323767670700173060ustar00rootroot00000000000000account 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-0.04/test/t-09-loop/run.sh000077500000000000000000000024531323767670700170460ustar00rootroot00000000000000#!/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 # 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 if grep -q '"chasquid/smtpIn/loopsDetected": 1,' .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-0.04/test/t-10-hooks/000077500000000000000000000000001323767670700160415ustar00rootroot00000000000000chasquid-0.04/test/t-10-hooks/.gitignore000066400000000000000000000000271323767670700200300ustar00rootroot00000000000000config/hooks/post-data chasquid-0.04/test/t-10-hooks/config/000077500000000000000000000000001323767670700173065ustar00rootroot00000000000000chasquid-0.04/test/t-10-hooks/config/chasquid.conf000066400000000000000000000003151323767670700217550ustar00rootroot00000000000000smtp_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-0.04/test/t-10-hooks/config/hooks/000077500000000000000000000000001323767670700204315ustar00rootroot00000000000000chasquid-0.04/test/t-10-hooks/config/hooks/post-data.bad1000077500000000000000000000001131323767670700230540ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo "This is not a header" chasquid-0.04/test/t-10-hooks/config/hooks/post-data.bad2000077500000000000000000000001721323767670700230620ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo "X-Post-DATA: This starts like a header" echo echo "But then is not" chasquid-0.04/test/t-10-hooks/config/hooks/post-data.bad3000077500000000000000000000001731323767670700230640ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out # Just a newline is quite problematic, as it would break the headers. echo chasquid-0.04/test/t-10-hooks/config/hooks/post-data.bad4000077500000000000000000000001561323767670700230660ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo -n "X-Post-DATA: valid header with no newline at the end" chasquid-0.04/test/t-10-hooks/config/hooks/post-data.good000077500000000000000000000003311323767670700231770ustar00rootroot00000000000000#!/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 echo "X-Post-Data: success" chasquid-0.04/test/t-10-hooks/content000066400000000000000000000001211323767670700174300ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-10-hooks/hosts000066400000000000000000000000251323767670700171210ustar00rootroot00000000000000testserver localhost chasquid-0.04/test/t-10-hooks/msmtprc000066400000000000000000000002651323767670700174540ustar00rootroot00000000000000account 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-0.04/test/t-10-hooks/run.sh000077500000000000000000000026441323767670700172120ustar00rootroot00000000000000#!/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 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 a failure in the script results in failing delivery. if run_msmtp blockme@testserver < content 2>/dev/null; then fail "ERROR: hook did not block email as expected" 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-0.04/test/t-11-dovecot/000077500000000000000000000000001323767670700163625ustar00rootroot00000000000000chasquid-0.04/test/t-11-dovecot/config/000077500000000000000000000000001323767670700176275ustar00rootroot00000000000000chasquid-0.04/test/t-11-dovecot/config/chasquid.conf000066400000000000000000000006121323767670700222760ustar00rootroot00000000000000smtp_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-0.04/test/t-11-dovecot/config/domains/000077500000000000000000000000001323767670700212615ustar00rootroot00000000000000chasquid-0.04/test/t-11-dovecot/config/domains/srv/000077500000000000000000000000001323767670700220735ustar00rootroot00000000000000chasquid-0.04/test/t-11-dovecot/config/domains/srv/.keep000066400000000000000000000000001323767670700230060ustar00rootroot00000000000000chasquid-0.04/test/t-11-dovecot/config/dovecot.conf.in000066400000000000000000000012411323767670700225440ustar00rootroot00000000000000base_dir = $ROOT/run/ log_path = $ROOT/dovecot.log ssl = no default_internal_user = $USER default_login_user = $USER 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 = } # 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-0.04/test/t-11-dovecot/config/passwd000066400000000000000000000000571323767670700210550ustar00rootroot00000000000000user@srv:{plain}password:1000:1000::/home/user chasquid-0.04/test/t-11-dovecot/content000066400000000000000000000001211323767670700177510ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-0.04/test/t-11-dovecot/hosts000066400000000000000000000000161323767670700174420ustar00rootroot00000000000000srv localhost chasquid-0.04/test/t-11-dovecot/msmtprc000066400000000000000000000005751323767670700200010ustar00rootroot00000000000000account 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-0.04/test/t-11-dovecot/run.sh000077500000000000000000000037231323767670700175320ustar00rootroot00000000000000#!/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 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-0.04/test/util/000077500000000000000000000000001323767670700152145ustar00rootroot00000000000000chasquid-0.04/test/util/chamuyero000077500000000000000000000153731323767670700171470ustar00rootroot00000000000000#!/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 socket import subprocess import sys import threading import time # Command-line flags. ap = argparse.ArgumentParser() ap.add_argument("script", type=argparse.FileType('r', encoding='UTF-8')) args = ap.parse_args() 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() 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") self.connw = conn.makefile(mode="w") 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() 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") self.connw = self.sock.makefile(mode="w") 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 # -> 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)) else: self.syntax_error("unknown syntax") if __name__ == "__main__": i = Interpreter() i.run(args.script) chasquid-0.04/test/util/exitcode000077500000000000000000000000241323767670700167420ustar00rootroot00000000000000#!/bin/sh exit $1 chasquid-0.04/test/util/generate_cert.go000066400000000000000000000107221323767670700203540ustar00rootroot00000000000000// 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-0.04/test/util/lib.sh000066400000000000000000000051411323767670700163170ustar00rootroot00000000000000# 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 RACE="-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). # https://stackoverflow.com/questions/360201/ trap "exit" INT TERM trap "kill 0" EXIT } function generate_cert() { go run ${UTILDIR}/generate_cert.go "$@" } function chasquid() { # 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 \ go run ${RACE} ${TBASE}/../../chasquid.go "$@" } 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 HOSTALIASES=${TBASE}/hosts \ msmtp -C msmtprc "$@" } function smtpc.py() { ${UTILDIR}/smtpc.py "$@" } function mail_diff() { ${UTILDIR}/mail_diff "$@" } function chamuyero() { ${UTILDIR}/chamuyero "$@" } 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 } chasquid-0.04/test/util/mail_diff000077500000000000000000000032171323767670700170570ustar00rootroot00000000000000#!/usr/bin/env python import difflib import email.parser import mailbox import sys f1, f2 = sys.argv[1:3] expected = email.parser.Parser().parse(open(f1)) mbox = mailbox.mbox(f2, create=False) msg = mbox[0] 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 msg[h] != val: print("Header %r differs: %r != %r" % (h, val, msg[h])) diff = True 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 got[posG] != '\n': posG += 1 continue continue if c != got[posG]: return False posG += 1 return True if not flexible_eq(expected.get_payload(), msg.get_payload()): diff = True if expected.is_multipart() != msg.is_multipart(): print("Multipart differs, expected %s, got %s" % ( expected.is_multipart(), msg.is_multipart())) elif not msg.is_multipart(): exp = expected.get_payload().splitlines() got = msg.get_payload().splitlines() print("Payload differs:") for l in difflib.ndiff(exp, got): print(l) sys.exit(0 if not diff else 1) chasquid-0.04/test/util/smtpc.py000077500000000000000000000014571323767670700167260ustar00rootroot00000000000000#!/usr/bin/env python3 # # Simple SMTP client for testing purposes. import argparse import email.parser import email.policy 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. msg = email.parser.Parser(policy=email.policy.default).parse(sys.stdin) s = smtplib.SMTP(args.server) s.starttls() s.login(args.user, args.password) # Note this does NOT support non-ascii message payloads transparently (headers # are ok). s.send_message(msg) s.quit() chasquid-0.04/test/util/test-mda000077500000000000000000000004131323767670700166560ustar00rootroot00000000000000#!/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-0.04/test/util/writemailto000077500000000000000000000000751323767670700175040ustar00rootroot00000000000000#!/bin/bash echo "From writemailto" > "$1" exec cat >> "$1"