pax_global_header00006660000000000000000000000064147425164530014525gustar00rootroot0000000000000052 comment=deb2a2f22b6f4a49a2193f1b0f5c0544dd0b2253 chasquid-1.15.0/000077500000000000000000000000001474251645300134125ustar00rootroot00000000000000chasquid-1.15.0/.clang-format000066400000000000000000000001141474251645300157610ustar00rootroot00000000000000Language: Proto BasedOnStyle: Google IndentWidth: 8 UseTab: AlignWithSpaces chasquid-1.15.0/.github/000077500000000000000000000000001474251645300147525ustar00rootroot00000000000000chasquid-1.15.0/.github/workflows/000077500000000000000000000000001474251645300170075ustar00rootroot00000000000000chasquid-1.15.0/.github/workflows/codeql-analysis.yml000066400000000000000000000043511474251645300226250ustar00rootroot00000000000000name: "CodeQL" on: push: branches: [ "main", "next" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main", "next" ] schedule: - cron: '29 21 * * 6' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go', 'python' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # â„šī¸ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 chasquid-1.15.0/.github/workflows/docker.yml000066400000000000000000000065551474251645300210140ustar00rootroot00000000000000name: "docker" on: push: branches: [ "main", "next" ] tags: [ "v*", "test-tag-*" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main", "next" ] schedule: - cron: '29 21 * * 6' env: HAS_DOCKER: ${{ secrets.DOCKER_REGISTRY_USER != '' }} HAS_GITLAB: ${{ secrets.GITLAB_REGISTRY_USER != '' }} jobs: integration: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - name: Docker info (for debugging) run: docker info - name: Build test image run: docker build -t chasquid-test -f test/Dockerfile . - name: Run tests run: docker run --name test1 chasquid-test make test coverage: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ">=1.20" - name: Install goveralls run: go install github.com/mattn/goveralls@latest - name: Docker info (for debugging) run: docker info - name: Build test image run: docker build -t chasquid-test -f test/Dockerfile . - name: Run coverage tests run: docker run --name test1 chasquid-test test/cover.sh - name: Extract coverage results run: > docker cp test1:/go/src/blitiri.com.ar/go/chasquid/.coverage/final.out . - name: Upload coverage results run: > goveralls -coverprofile=final.out -repotoken=${{ secrets.COVERALLS_TOKEN }} public-image: runs-on: ubuntu-latest timeout-minutes: 15 needs: [integration, coverage] if: github.event_name == 'push' steps: - uses: actions/checkout@v4 - name: Build run: docker build -t chasquid -f docker/Dockerfile . # Push it to Dockerhub. - name: Dockerhub login if: env.HAS_DOCKER uses: docker/login-action@v3 with: username: ${{ secrets.DOCKER_REGISTRY_USER }} password: ${{ secrets.DOCKER_REGISTRY_TOKEN }} - name: Dockerhub push if: env.HAS_DOCKER run: | docker tag chasquid index.docker.io/${{ secrets.DOCKER_REGISTRY_USER }}/chasquid:$GITHUB_REF_NAME docker push index.docker.io/${{ secrets.DOCKER_REGISTRY_USER }}/chasquid:$GITHUB_REF_NAME - name: Dockerhub tag latest if: env.HAS_DOCKER && github.ref_name == 'main' run: | docker tag chasquid index.docker.io/${{ secrets.DOCKER_REGISTRY_USER }}/chasquid:latest docker push index.docker.io/${{ secrets.DOCKER_REGISTRY_USER }}/chasquid:latest # Push it to Gitlab. - name: Gitlab login if: env.HAS_GITLAB uses: docker/login-action@v3 with: registry: registry.gitlab.com username: ${{ secrets.GITLAB_REGISTRY_USER }} password: ${{ secrets.GITLAB_REGISTRY_TOKEN }} - name: Gitlab push if: env.HAS_GITLAB run: | docker tag chasquid registry.gitlab.com/albertito/chasquid:$GITHUB_REF_NAME docker push registry.gitlab.com/albertito/chasquid:$GITHUB_REF_NAME - name: Gitlab tag latest if: env.HAS_GITLAB && github.ref_name == 'main' run: | docker tag chasquid registry.gitlab.com/albertito/chasquid:latest docker push registry.gitlab.com/albertito/chasquid:latest chasquid-1.15.0/.github/workflows/gotests.yml000066400000000000000000000015271474251645300212270ustar00rootroot00000000000000name: "gotests" on: push: branches: [ "main", "next" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main", "next" ] schedule: - cron: '29 21 * * 6' jobs: oldest_supported: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: 'go.mod' - name: normal tests run: go test ./... - name: race tests run: go test -race ./... latest: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: "1.x" check-latest: true - name: normal tests run: go test ./... - name: race tests run: go test -race ./... chasquid-1.15.0/.github/workflows/govulncheck.yml000066400000000000000000000011341474251645300220410ustar00rootroot00000000000000name: "govulncheck" on: push: branches: [ "main", "next" ] pull_request: # The branches below must be a subset of the branches above branches: [ "main", "next" ] schedule: - cron: '29 21 * * 6' jobs: govulncheck: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version: ">=1.19.2" check-latest: true - name: install govulncheck run: go install golang.org/x/vuln/cmd/govulncheck@latest - name: run govulncheck run: govulncheck ./... chasquid-1.15.0/.gitignore000066400000000000000000000015141474251645300154030ustar00rootroot00000000000000 # 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 !.clang-format !.github/ # The binaries. /chasquid /chasquid-util /smtp-check /mda-lmtp /dovecot-auth-cli cmd/chasquid-util/chasquid-util cmd/smtp-check/smtp-check cmd/mda-lmtp/mda-lmtp cmd/dovecot-auth-cli/dovecot-auth-cli # Test util binaries. test/util/conngen/conngen test/util/coverhtml/coverhtml test/util/fexp/fexp test/util/generate_cert/generate_cert test/util/gocovcat/gocovcat test/util/loadgen/loadgen test/util/minidns/minidns test/util/smtpc/smtpc # Test binary, generated during coverage tests. chasquid.test # chamuyero logs *.cmy.log # Exclude any .pem files, to prevent accidentally including test keys and # certificates. *.pem chasquid-1.15.0/.mkdocs.yml000066400000000000000000000012721474251645300154750ustar00rootroot00000000000000# mkdocs configuration # # To test changes locally, run: # mkdocs serve -f .mkdocs.yml site_name: chasquid documentation # Point the repo to github to make it easier for users to do edits, even if # it's not the canonical location. repo_url: https://github.com/albertito/chasquid markdown_extensions: - codehilite: guess_lang: false - attr_list theme: readthedocs nav: - Home: index.md - How-to: howto.md - Install: install.md - Manpages: man/index.md - All: - aliases.md - hooks.md - clients.md - dovecot.md - dkim.md - haproxy.md - docker.md - flow.md - monitoring.md - sec-levels.md - tests.md - relnotes.md - knownissues.md chasquid-1.15.0/LICENSE000066400000000000000000000263141474251645300144250ustar00rootroot00000000000000 Copyright 2016 Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -------------------------------------------------------------------------- Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. chasquid-1.15.0/Makefile000066400000000000000000000026211474251645300150530ustar00rootroot00000000000000 ifndef VERSION VERSION = `git describe --always --long --dirty --tags` 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 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/ mda-lmtp: go build ${GOFLAGS} ./cmd/mda-lmtp/ dovecot-auth-cli: go build ${GOFLAGS} ./cmd/dovecot-auth-cli/ test: go test ${GOFLAGS} ./... setsid -w ./test/run.sh setsid -w ./test/stress.sh setsid -w ./cmd/chasquid-util/test.sh setsid -w ./cmd/mda-lmtp/test.sh setsid -w ./cmd/dovecot-auth-cli/test.sh install-binaries: chasquid chasquid-util smtp-check mda-lmtp mkdir -p /usr/local/bin/ cp -a chasquid chasquid-util smtp-check mda-lmtp /usr/local/bin/ install-config-skeleton: if ! [ -d /etc/chasquid ] ; then cp -arv etc / ; fi if ! [ -d /var/lib/chasquid ]; then \ mkdir -v /var/lib/chasquid; \ chmod -v 0700 /var/lib/chasquid ; \ chown -v mail:mail /var/lib/chasquid ; \ fi fmt: go vet ./... gofmt -s -w . clang-format -i $(shell find . -iname '*.proto') .PHONY: chasquid test \ chasquid-util smtp-check mda-lmtp dovecot-auth-cli \ fmt chasquid-1.15.0/README.md000066400000000000000000000066141474251645300147000ustar00rootroot00000000000000 # chasquid [chasquid](https://blitiri.com.ar/p/chasquid) is an SMTP (email) server with a focus on simplicity, security, and ease of operation. It is designed mainly for individuals and small groups. It's written in [Go](https://golang.org), and distributed under the [Apache license 2.0](http://en.wikipedia.org/wiki/Apache_License). [![Go tests](https://github.com/albertito/chasquid/actions/workflows/gotests.yml/badge.svg?branch=main)](https://github.com/albertito/chasquid/actions) [![Go Report Card](https://goreportcard.com/badge/github.com/albertito/chasquid)](https://goreportcard.com/report/github.com/albertito/chasquid) [![Coverage](https://coveralls.io/repos/github/albertito/chasquid/badge.svg?branch=next)](https://coveralls.io/github/albertito/chasquid?branch=next) [![Docs](https://img.shields.io/badge/docs-reference-blue.svg)](https://blitiri.com.ar/p/chasquid/) [![OFTC IRC](https://img.shields.io/badge/chat-oftc-blue.svg)](https://webchat.oftc.net/?channels=%23chasquid) ## Features * Easy * Easy to configure. * Hard to mis-configure in ways that are harmful or insecure (e.g. no open relay, or clear-text authentication). * [Monitoring] HTTP server, with exported variables and tracing to help debugging. * Integrated with [Debian], [Ubuntu], and [Arch]. * Supports using [Dovecot] for authentication. * Useful * Multiple/virtual domains, with per-domain users and aliases. * Suffix dropping (`user+something@domain` → `user@domain`). * [Hooks] for integration with greylisting, anti-virus, and anti-spam. * International usernames ([SMTPUTF8]) and domain names ([IDNA]). * Secure * [Tracking] of per-domain TLS support, prevents connection downgrading. * Multiple TLS certificates. * Easy integration with [Let's Encrypt]. * [SPF] and [MTA-STS] checking. * [DKIM] support (signing and verification). [Arch]: https://blitiri.com.ar/p/chasquid/install/#arch [DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail [Debian]: https://blitiri.com.ar/p/chasquid/install/#debianubuntu [Dovecot]: https://blitiri.com.ar/p/chasquid/dovecot/ [Hooks]: https://blitiri.com.ar/p/chasquid/hooks/ [IDNA]: https://en.wikipedia.org/wiki/Internationalized_domain_name [Let's Encrypt]: https://letsencrypt.org [MTA-STS]: https://tools.ietf.org/html/rfc8461 [Monitoring]: https://blitiri.com.ar/p/chasquid/monitoring/ [SMTPUTF8]: https://en.wikipedia.org/wiki/Extended_SMTP#SMTPUTF8 [SPF]: https://en.wikipedia.org/wiki/Sender_Policy_Framework [Tracking]: https://blitiri.com.ar/p/chasquid/sec-levels/ [Ubuntu]: https://blitiri.com.ar/p/chasquid/install/#debianubuntu ## Documentation The [how-to guide](https://blitiri.com.ar/p/chasquid/howto/) and the [installation guide](https://blitiri.com.ar/p/chasquid/install/) are the best starting points on how to install, configure and run chasquid. You will find [all documentation here](https://blitiri.com.ar/p/chasquid/). ## Contact If you have any questions, comments or patches, please send them to the [mailing list](https://groups.google.com/forum/#!forum/chasquid), chasquid@googlegroups.com. To subscribe, send an email to chasquid+subscribe@googlegroups.com. Security issues can be reported privately to albertito@blitiri.com.ar. Bug reports and pull requests on [GitHub](https://github.com/albertito/chasquid) are also welcome. You can also reach out via IRC, `#chasquid` on [OFTC](https://oftc.net/). chasquid-1.15.0/chasquid.go000066400000000000000000000226651474251645300155550ustar00rootroot00000000000000// chasquid is an SMTP (email) server, with a focus on simplicity, security, // and ease of operation. // // See https://blitiri.com.ar/p/chasquid for more details. package main import ( "context" "flag" "fmt" "math/rand" "net" "os" "os/signal" "path" "path/filepath" "strings" "syscall" "time" "blitiri.com.ar/go/chasquid/internal/config" "blitiri.com.ar/go/chasquid/internal/courier" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/dovecot" "blitiri.com.ar/go/chasquid/internal/localrpc" "blitiri.com.ar/go/chasquid/internal/maillog" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/smtpsrv" "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/log" "blitiri.com.ar/go/systemd" ) // Command-line flags. var ( configDir = flag.String("config_dir", "/etc/chasquid", "configuration directory") configOverrides = flag.String("config_overrides", "", "override configuration values (in text protobuf format)") showVer = flag.Bool("version", false, "show version and exit") ) 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", *configOverrides) if err != nil { log.Fatalf("Error loading 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. err = os.Chdir(*configDir) if err != nil { log.Fatalf("Error changing to config dir %q: %v", *configDir, err) } initMailLog(conf.MailLogPath) if conf.MonitoringAddress != "" { go launchMonitoringServer(conf) } s := smtpsrv.NewServer() s.Hostname = conf.Hostname s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024 s.HookPath = "hooks/" s.HAProxyEnabled = conf.HaproxyIncoming 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/") { if info.Type().IsRegular() { // Ignore regular files, we only care about directories. continue } name := info.Name() dir := filepath.Join("certs/", name) loadCert(name, dir, s) } // Load domains from "domains/". log.Infof("Loading domains:") for _, info := range mustReadDir("domains/") { domain, err := normalize.Domain(info.Name()) if err != nil { log.Fatalf("Invalid name %+q: %v", info.Name(), err) } if info.Type().IsRegular() { // Ignore regular files, we only care about directories. continue } 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, err := domaininfo.New(conf.DataDir + "/domaininfo") if err != nil { log.Fatalf("Error opening domain info database: %v", err) } s.SetDomainInfo(dinfo) stsCache, err := sts.NewCache(conf.DataDir + "/sts-cache") if err != nil { log.Fatalf("Failed to initialize STS cache: %v", err) } go stsCache.PeriodicallyRefresh(context.Background()) localC := &courier.MDA{ Binary: conf.MailDeliveryAgentBin, Args: conf.MailDeliveryAgentArgs, Timeout: 30 * time.Second, } remoteC := &courier.SMTP{ HelloDomain: conf.Hostname, Dinfo: dinfo, STSCache: stsCache, } s.InitQueue(conf.DataDir+"/queue", localC, remoteC) // Load the addresses and listeners. systemdLs, err := systemd.Listeners() if err != nil { log.Fatalf("Error getting systemd listeners: %v", err) } naddr := loadAddresses(s, conf.SmtpAddress, systemdLs["smtp"], smtpsrv.ModeSMTP) naddr += loadAddresses(s, conf.SubmissionAddress, systemdLs["submission"], smtpsrv.ModeSubmission) naddr += loadAddresses(s, conf.SubmissionOverTlsAddress, systemdLs["submission_tls"], smtpsrv.ModeSubmissionTLS) if naddr == 0 { log.Fatalf("No address to listen on") } go localrpc.DefaultServer.ListenAndServe(conf.DataDir + "/localrpc-v1") go signalHandler(dinfo, s) s.ListenAndServe() } func loadAddresses(srv *smtpsrv.Server, addrs []string, ls []net.Listener, mode smtpsrv.SocketMode) int { naddr := 0 for _, addr := range addrs { if addr == "" { // An empty address is invalid, to prevent accidental // misconfiguration. log.Errorf("Invalid empty listening address for %v", mode) log.Fatalf("If you want to disable %v, remove it from the config", mode) } else if addr == "systemd" { // The "systemd" address indicates we get listeners via 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 switch path { case "": maillog.Default, err = maillog.NewSyslog() case "": maillog.Default = maillog.New(os.Stdout) case "": maillog.Default = maillog.New(os.Stderr) default: _ = os.MkdirAll(filepath.Dir(path), 0775) maillog.Default, err = maillog.NewFile(path) } if err != nil { log.Fatalf("Error opening mail log: %v", err) } } func signalHandler(dinfo *domaininfo.DB, srv *smtpsrv.Server) { var err error signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT) for { switch sig := <-signals; sig { case syscall.SIGHUP: log.Infof("Received SIGHUP, reloading") // SIGHUP triggers a reopen of the log files. This is used for log // rotation. err = log.Default.Reopen() if err != nil { log.Fatalf("Error reopening log: %v", err) } err = maillog.Default.Reopen() if err != nil { log.Fatalf("Error reopening maillog: %v", err) } // We don't want to reload the domain info database periodically, // as it can be expensive, and it is not expected that the user // changes this behind chasquid's back. err = dinfo.Reload() if err != nil { log.Fatalf("Error reloading domain info: %v", err) } // Also trigger a server reload. srv.Reload() case syscall.SIGTERM, syscall.SIGINT: log.Fatalf("Got signal to exit: %v", sig) default: log.Errorf("Unexpected signal %v", sig) } } } // Helper to load a single certificate configuration into the server. func loadCert(name, dir string, s *smtpsrv.Server) { log.Infof(" %s", name) // Ignore directories that don't have both keys. // We warn about this because it can be hard to debug otherwise. certPath := filepath.Join(dir, "fullchain.pem") if _, err := os.Stat(certPath); err != nil { log.Infof(" skipping: %v", err) return } keyPath := filepath.Join(dir, "privkey.pem") if _, err := os.Stat(keyPath); err != nil { log.Infof(" skipping: %v", err) return } err := s.AddCerts(certPath, keyPath) if err != nil { log.Fatalf(" %v", err) } } // Helper to load a single domain configuration into the server. func loadDomain(name, dir string, s *smtpsrv.Server) { s.AddDomain(name) nu, err := s.AddUserDB(name, dir+"/users") if err != nil { // If there is an error loading users, fail hard to make sure this is // noticed and fixed as soon as it happens. log.Fatalf(" %s: users file error: %v", name, err) } na, err := s.AddAliasesFile(name, dir+"/aliases") if err != nil { // If there's an error loading aliases, fail hard to make sure this is // noticed and fixed as soon as it happens. log.Fatalf(" %s: aliases file error: %v", name, err) } nd, err := loadDKIM(name, dir, s) if err != nil { // DKIM errors are fatal because if the user set DKIM up, then we // don't want it to be failing silently, as that could cause // deliverability issues. log.Fatalf(" %s: DKIM loading error: %v", name, err) } log.Infof(" %s (%d users, %d aliases, %d DKIM keys)", name, nu, na, nd) } func loadDovecot(s *smtpsrv.Server, userdb, client string) { a := dovecot.NewAuth(userdb, client) s.SetAuthFallback(a) log.Infof("Fallback authenticator: %v", a) if err := a.Check(); err != nil { log.Errorf("Warning: Dovecot auth is not responding: %v", err) } } func loadDKIM(domain, dir string, s *smtpsrv.Server) (int, error) { glob := path.Clean(dir + "/dkim:*.pem") pems, err := filepath.Glob(glob) if err != nil { return 0, err } for _, pem := range pems { base := filepath.Base(pem) selector := strings.TrimPrefix(base, "dkim:") selector = strings.TrimSuffix(selector, ".pem") err = s.AddDKIMSigner(domain, selector, pem) if err != nil { return 0, err } } return len(pems), nil } // Read a directory, which must have at least some entries. func mustReadDir(path string) []os.DirEntry { dirs, err := os.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 } chasquid-1.15.0/cmd/000077500000000000000000000000001474251645300141555ustar00rootroot00000000000000chasquid-1.15.0/cmd/chasquid-util/000077500000000000000000000000001474251645300167315ustar00rootroot00000000000000chasquid-1.15.0/cmd/chasquid-util/chasquid-util.go000066400000000000000000000177371474251645300220530ustar00rootroot00000000000000// chasquid-util is a command-line utility for chasquid-related operations. package main import ( "bytes" "fmt" "os" "path/filepath" "sort" "strconv" "strings" "syscall" "blitiri.com.ar/go/chasquid/internal/config" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/localrpc" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/userdb" "golang.org/x/term" "google.golang.org/protobuf/encoding/prototext" ) // Usage to show users on --help or invocation errors. const usage = ` Usage: chasquid-util [options] user-add [--password=] [--receive_only] Add a user to the userdb. chasquid-util [options] user-remove Remove a user from the userdb. chasquid-util [options] authenticate [--password=] Authenticate a user. chasquid-util [options] check-userdb Check if the userdb for the given domain is accessible. chasquid-util [options] aliases-resolve
Resolve an address. Talks to the running chasquid. chasquid-util [options] domaininfo-remove Remove domaininfo for the given domain. Talks to the running chasquid. chasquid-util [options] print-config Print the current chasquid configuration. chasquid-util [options] dkim-keygen [ ] [--algo=rsa3072|rsa4096|ed25519] Generate a new DKIM key pair for the domain. chasquid-util [options] dkim-dns [ ] Print the DNS TXT record to use for the domain, selector and private key. Options: -C=, --configdir= Configuration directory -v Verbose mode ` // Command-line arguments. // Arguments starting with "-" will be parsed as key-value pairs, and // positional arguments will appear as "$POS" -> value. // // For example, "--abc=def x y -p=q -r" will result in: // {"--abc": "def", "$1": "x", "$2": "y", "-p": "q", "-r": ""} var args map[string]string // Globals, loaded from top-level options. var ( configDir = "/etc/chasquid" ) func main() { args = parseArgs(usage) if _, ok := args["--help"]; ok { fmt.Print(usage) return } // Load globals. if d, ok := args["--configdir"]; ok { configDir = d } if d, ok := args["-C"]; ok { configDir = d } commands := map[string]func(){ "user-add": userAdd, "user-remove": userRemove, "authenticate": authenticate, "check-userdb": checkUserDB, "aliases-resolve": aliasesResolve, "print-config": printConfig, "domaininfo-remove": domaininfoRemove, "dkim-keygen": dkimKeygen, "dkim-dns": dkimDNS, // These exist for testing purposes and may be removed in the future. // Do not rely on them. "dkim-verify": dkimVerify, "dkim-sign": dkimSign, } cmd := args["$1"] if f, ok := commands[cmd]; ok { f() } else { fmt.Printf("Unknown argument %q\n", cmd) Fatalf(usage) } } // Fatalf prints the given message to stderr, then exits the program with an // error code. func Fatalf(s string, arg ...interface{}) { fmt.Fprintf(os.Stderr, s+"\n", arg...) os.Exit(1) } func userDBForDomain(domain string) string { if domain == "" { domain = args["$2"] } return configDir + "/domains/" + domain + "/users" } func userDBFromArgs(create bool) (string, string, *userdb.DB) { username := args["$2"] 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") err = os.MkdirAll(filepath.Dir(userDBForDomain(domain)), 0755) if err != nil { Fatalf("Error creating database dir: %v", err) } } 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() { path := userDBForDomain("") // Check if the file exists. This is because userdb.Load does not consider // it an error. if _, err := os.Stat(path); os.IsNotExist(err) { Fatalf("Error: file %q does not exist", path) } udb, err := userdb.Load(path) if err != nil { Fatalf("Error loading database: %v", err) } fmt.Printf("Database loaded (%d users)\n", udb.Len()) } // chasquid-util user-add [--password=] [--receive_only] func userAdd() { user, _, db := userDBFromArgs(true) _, recvOnly := args["--receive_only"] _, hasPassword := args["--password"] if recvOnly && hasPassword { Fatalf("Cannot specify both --receive_only and --password") } var err error if recvOnly { err = db.AddDeniedUser(user) } else { 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"] if ok { return password } fmt.Printf("Password: ") p1, err := term.ReadPassword(syscall.Stdin) fmt.Printf("\n") if err != nil { Fatalf("Error reading password: %v\n", err) } fmt.Printf("Confirm password: ") p2, err := term.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 loading config: %v", err) } c := localrpc.NewClient(conf.DataDir + "/localrpc-v1") vs, err := c.Call("AliasResolve", "Address", args["$2"]) if err != nil { Fatalf("Error resolving: %v", err) } // Result is a map of type -> []addresses. // Sort the types for deterministic output. ts := []string{} for t := range vs { ts = append(ts, t) } sort.Strings(ts) for _, t := range ts { for _, a := range vs[t] { fmt.Printf("%v %s\n", t, a) } } } // chasquid-util print-config func printConfig() { conf, err := config.Load(configDir+"/chasquid.conf", "") if err != nil { Fatalf("Error loading config: %v", err) } fmt.Println(prototext.Format(conf)) } // chasquid-util domaininfo-remove func domaininfoRemove() { conf, err := config.Load(configDir+"/chasquid.conf", "") if err != nil { Fatalf("Error loading config: %v", err) } c := localrpc.NewClient(conf.DataDir + "/localrpc-v1") _, err = c.Call("DomaininfoClear", "Domain", args["$2"]) if err != nil { Fatalf("Error removing domaininfo entry: %v", err) } } // parseArgs parses the command line arguments, and returns a map. // // Arguments starting with "-" will be parsed as key-value pairs, and // positional arguments will appear as "$POS" -> value. // // For example, "--abc=def x y -p=q -r" will result in: // {"--abc": "def", "$1": "x", "$2": "y", "-p": "q", "-r": ""} func parseArgs(usage string) map[string]string { args := map[string]string{} pos := 1 for _, a := range os.Args[1:] { // Note: Consider handling end of args marker "--" explicitly in // the future if needed. if strings.HasPrefix(a, "-") { sp := strings.SplitN(a, "=", 2) if len(sp) < 2 { args[a] = "" } else { args[sp[0]] = sp[1] } } else { args["$"+strconv.Itoa(pos)] = a pos++ } } return args } chasquid-1.15.0/cmd/chasquid-util/dkim.go000066400000000000000000000133201474251645300202030ustar00rootroot00000000000000package main import ( "bytes" "context" "crypto" "crypto/ed25519" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "encoding/pem" "fmt" "io" "net/mail" "os" "path" "path/filepath" "strings" "time" "blitiri.com.ar/go/chasquid/internal/dkim" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/normalize" ) func dkimSign() { domain := args["$2"] selector := args["$3"] keyPath := args["$4"] msg, err := io.ReadAll(os.Stdin) if err != nil { Fatalf("%v", err) } msg = normalize.ToCRLF(msg) if domain == "" { domain = getDomainFromMsg(msg) } if selector == "" { selector = findSelectorForDomain(domain) } if keyPath == "" { keyPath = keyPathFor(domain, selector) } signer := &dkim.Signer{ Domain: domain, Selector: selector, Signer: loadPrivateKey(keyPath), } ctx := context.Background() if _, verbose := args["-v"]; verbose { ctx = dkim.WithTraceFunc(ctx, func(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, format+"\n", args...) }) } header, err := signer.Sign(ctx, string(msg)) if err != nil { Fatalf("Error signing message: %v", err) } fmt.Printf("DKIM-Signature: %s\r\n", strings.ReplaceAll(header, "\r\n", "\r\n\t")) } func dkimVerify() { msg, err := io.ReadAll(os.Stdin) if err != nil { Fatalf("%v", err) } msg = normalize.ToCRLF(msg) ctx := context.Background() if _, verbose := args["-v"]; verbose { ctx = dkim.WithTraceFunc(ctx, func(format string, args ...interface{}) { fmt.Fprintf(os.Stderr, format+"\n", args...) }) } if txt, ok := args["--txt"]; ok { ctx = dkim.WithLookupTXTFunc(ctx, func(ctx context.Context, domain string) ([]string, error) { return []string{txt}, nil }) } results, err := dkim.VerifyMessage(ctx, string(msg)) if err != nil { Fatalf("Error verifying message: %v", err) } hostname, _ := os.Hostname() ar := "Authentication-Results: " + hostname + "\r\n\t" ar += strings.ReplaceAll( results.AuthenticationResults(), "\r\n", "\r\n\t") fmt.Println(ar) } func dkimDNS() { domain := args["$2"] selector := args["$3"] keyPath := args["$4"] if domain == "" { Fatalf("Error: missing domain parameter") } if selector == "" { selector = findSelectorForDomain(domain) } if keyPath == "" { keyPath = keyPathFor(domain, selector) } fmt.Println(dnsRecordFor(domain, selector, loadPrivateKey(keyPath))) } func dnsRecordFor(domain, selector string, private crypto.Signer) string { public := private.Public() var err error algoStr := "" pubBytes := []byte{} switch private.(type) { case *rsa.PrivateKey: algoStr = "rsa" pubBytes, err = x509.MarshalPKIXPublicKey(public) case ed25519.PrivateKey: algoStr = "ed25519" pubBytes = public.(ed25519.PublicKey) } if err != nil { Fatalf("Error marshaling public key: %v", err) } return fmt.Sprintf( "%s._domainkey.%s\tTXT\t\"v=DKIM1; k=%s; p=%s\"", selector, domain, algoStr, base64.StdEncoding.EncodeToString(pubBytes)) } func dkimKeygen() { domain := args["$2"] selector := args["$3"] keyPath := args["$4"] algo := args["--algo"] if domain == "" { Fatalf("Error: missing domain parameter") } if selector == "" { selector = time.Now().UTC().Format("20060102") } if keyPath == "" { keyPath = keyPathFor(domain, selector) } if _, err := os.Stat(keyPath); !os.IsNotExist(err) { Fatalf("Error: key already exists at %q", keyPath) } var private crypto.Signer var err error switch algo { case "", "rsa3072": private, err = rsa.GenerateKey(rand.Reader, 3072) case "rsa4096": private, err = rsa.GenerateKey(rand.Reader, 4096) case "ed25519": _, private, err = ed25519.GenerateKey(rand.Reader) default: Fatalf("Error: unsupported algorithm %q", algo) } if err != nil { Fatalf("Error generating key: %v", err) } privB, err := x509.MarshalPKCS8PrivateKey(private) if err != nil { Fatalf("Error marshaling private key: %v", err) } f, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660) if err != nil { Fatalf("Error creating key file %q: %v", keyPath, err) } block := &pem.Block{ Type: "PRIVATE KEY", Bytes: privB, } if err := pem.Encode(f, block); err != nil { Fatalf("Error PEM-encoding key: %v", err) } f.Close() fmt.Printf("Key written to %q\n\n", keyPath) fmt.Println(dnsRecordFor(domain, selector, private)) } func keyPathFor(domain, selector string) string { return path.Clean(fmt.Sprintf("%s/domains/%s/dkim:%s.pem", configDir, domain, selector)) } func getDomainFromMsg(msg []byte) string { m, err := mail.ReadMessage(bytes.NewReader(msg)) if err != nil { Fatalf("Error parsing message: %v", err) } addr, err := mail.ParseAddress(m.Header.Get("From")) if err != nil { Fatalf("Error parsing From: header: %v", err) } return envelope.DomainOf(addr.Address) } func findSelectorForDomain(domain string) string { glob := path.Clean(configDir + "/domains/" + domain + "/dkim:*.pem") ms, err := filepath.Glob(glob) if err != nil { Fatalf("Error finding DKIM keys: %v", err) } for _, m := range ms { base := filepath.Base(m) selector := strings.TrimPrefix(base, "dkim:") selector = strings.TrimSuffix(selector, ".pem") return selector } Fatalf("No DKIM keys found in %q", glob) return "" } func loadPrivateKey(path string) crypto.Signer { key, err := os.ReadFile(path) if err != nil { Fatalf("Error reading private key from %q: %v", path, err) } block, _ := pem.Decode(key) if block == nil { Fatalf("Error decoding PEM block") } switch strings.ToUpper(block.Type) { case "PRIVATE KEY": k, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { Fatalf("Error parsing private key: %v", err) } return k.(crypto.Signer) default: Fatalf("Unsupported key type: %s", block.Type) return nil } } chasquid-1.15.0/cmd/chasquid-util/test.sh000077500000000000000000000026631474251645300202560ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../../test/util/lib.sh" init if [ "${GOCOVERDIR}" != "" ]; then GOFLAGS="-cover -covermode=count -o chasquid-util $GOFLAGS" fi # shellcheck disable=SC2086 go build $GOFLAGS -tags="$GOTAGS" . function r() { ./chasquid-util -C=.config "$@" } function check_userdb() { if ! r check-userdb domain > /dev/null; then echo check-userdb failed exit 1 fi } rm -rf .config/ mkdir -p .config/domains/domain/ .data/domaininfo echo 'data_dir: ".data"' >> .config/chasquid.conf if ! r print-config > /dev/null; then fail print-config fi if ! r user-add interactive@domain --password=passwd > /dev/null; then fail user-add fi # Interactive authentication. # Need to wrap the execution under "script" since the interaction requires an # actual TTY, and that's a fairly portable way to do that. if hash script 2>/dev/null; then if ! (echo passwd; echo passwd ) \ | script \ -qfec "./chasquid-util -C=.config authenticate interactive@domain" \ ".script-out" \ | grep -q "Authentication succeeded"; then fail interactive authentication fi fi C=$(r print-config | grep hostname) if ! ( echo "$C" | grep -E -q "hostname:.*\"$HOSTNAME\"" ); then echo print-config failed echo output: "$C" exit 1 fi rm -rf .keys/ mkdir .keys/ # Run all the chamuyero tests. for i in *.cmy; do if ! chamuyero "$i" > "$i.log" 2>&1 ; then echo "# Test $i failed, log follows" cat "$i.log" exit 1 fi done success chasquid-1.15.0/cmd/chasquid-util/test_alias_resolve.cmy000066400000000000000000000010201474251645300233230ustar00rootroot00000000000000# Test success. server unix_listen .data/localrpc-v1 c = ./chasquid-util -C=.config aliases-resolve test@test.com server <- AliasResolve Address=test%40test.com server -> 200 %28email%29=r1%40r1.com&%28pipe%29=cmd%20args c <- (email) r1@r1.com c <- (pipe) cmd args c wait 0 # Test error. server unix_listen .data/localrpc-v1 c = ./chasquid-util -C=.config aliases-resolve test@test.com server <- AliasResolve Address=test%40test.com server -> 500 This is a test error c <- Error resolving: This is a test error c wait 1 chasquid-1.15.0/cmd/chasquid-util/test_bad_args.cmy000066400000000000000000000015161474251645300222470ustar00rootroot00000000000000# Unknown argument. c = ./chasquid-util --configdir=.config blahrarghar c <- Unknown argument "blahrarghar" c wait 1 c = ./chasquid-util --configdir=.nonono check-userdb c <- Error: file ".nonono/domains//users" does not exist c wait 1 c = ./chasquid-util --configdir=.nonono print-config c <- Error loading config: failed to read config at ".nonono/chasquid.conf": open .nonono/chasquid.conf: no such file or directory c wait 1 c = ./chasquid-util --configdir=.nonono aliases-resolve email@addr c <- Error loading config: failed to read config at ".nonono/chasquid.conf": open .nonono/chasquid.conf: no such file or directory c wait 1 c = ./chasquid-util --configdir=.nonono domaininfo-remove domain c <- Error loading config: failed to read config at ".nonono/chasquid.conf": open .nonono/chasquid.conf: no such file or directory c wait 1 chasquid-1.15.0/cmd/chasquid-util/test_dkim.cmy000066400000000000000000000142261474251645300214330ustar00rootroot00000000000000# Test dkim-dns subcommand with keys pre-generated by openssl, to validate # interoperability. c = ./chasquid-util dkim-dns example.com sel123 test_openssl_genpkey_ed25519.pem c <- sel123._domainkey.example.com TXT "v=DKIM1; k=ed25519; p=QXNdsDCVOrViGMRh4BIE/IgUCcBEwio3kpJ3e0GAipw=" c wait 0 c = ./chasquid-util dkim-dns example.com sel123 test_openssl_genpkey_rsa.pem c <- sel123._domainkey.example.com TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAieZWhl7dnxHGyucZS2+dyExPQytj/aY46RXJ4yT3zWY8gh5YkVZ2L1x++7XMzzSg/5FR5bkKYV9Xa+jO6YlhriYKo3ttWSmxU0hDKbG7dpD9Tr7tjCcmKqE1IXetl6DXlQl7LRdmkeIND4gtf9A1zOPLR3/+kvsu1u2cUsEFVs36FqbTe4BYLn2RQlT4IQocT5eVEvoHc5apKuTOKBYThhWRaSZG9YXvsdd1UjngR2Xmizu5e/hj2f3W+9rmRRy1ukmUryuMUHMae2V27Wy1vrHiYoMUA1kQJY+HTG5kMkuatxNui9yjmdqrQUvCIU2Fa5jxJYQTLIz4U0/z4tStRwIDAQAB" c wait 0 # Generate our own keys, and then check we can parse them with dkim-dns. # Do this once per algorithm (including the default). # Default algorithm. c = ./chasquid-util dkim-keygen example.com selDef .keys/test_def.pem c <- Key written to ".keys/test_def.pem" c <- c <~ selDef._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*" c wait 0 c = ./chasquid-util dkim-dns example.com selDef .keys/test_def.pem c <~ selDef._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*" c wait 0 # RSA 3072. c = ./chasquid-util dkim-keygen example.com selRSA3 .keys/test_rsa3.pem --algo=rsa3072 c <- Key written to ".keys/test_rsa3.pem" c <- c <~ selRSA3._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*" c wait 0 c = ./chasquid-util dkim-dns example.com selRSA3 .keys/test_rsa3.pem c <~ selRSA3._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{560,570}=*" c wait 0 # RSA 4096. c = ./chasquid-util dkim-keygen example.com selRSA4 .keys/test_rsa4.pem --algo=rsa4096 c <- Key written to ".keys/test_rsa4.pem" c <- c <~ selRSA4._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{730,740}=*" c wait 0 c = ./chasquid-util dkim-dns example.com selRSA4 .keys/test_rsa4.pem c <~ selRSA4._domainkey.example.com\tTXT\t"v=DKIM1; k=rsa; p=[A-Za-z0-9+/]{730,740}=*" c wait 0 # Ed25519. c = ./chasquid-util dkim-keygen example.com selED25519 .keys/test_ed25519.pem --algo=ed25519 c <- Key written to ".keys/test_ed25519.pem" c <- c <~ selED25519._domainkey.example.com\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*" c wait 0 c = ./chasquid-util dkim-dns example.com selED25519 .keys/test_ed25519.pem c <~ selED25519._domainkey.example.com\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*" c wait 0 # Refuse to overwrite a key file. c = ./chasquid-util dkim-keygen example.com selED25519 .keys/test_ed25519.pem --algo=ed25519 c <- Error: key already exists at ".keys/test_ed25519.pem" c wait 1 # Automatically decide on the selector and key path. c = ./chasquid-util -C=.config dkim-keygen domain --algo=ed25519 c <~ Key written to ".config/domains/domain/dkim:[0-9]{8}.pem" c <- c <~ [0-9]{8}._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*" c wait 0 # Custom selector, but automatic key path c = ./chasquid-util -C=.config dkim-keygen domain sel1 --algo=ed25519 c <~ Key written to ".config/domains/domain/dkim:sel1.pem" c <- c <~ sel1._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*" c wait 0 # Missing parameters. c = ./chasquid-util -C=.config dkim-keygen c <- Error: missing domain parameter c wait 1 # Unsupported algorithm c = ./chasquid-util -C=.config dkim-keygen domain s k.pem --algo=xxx666 c <- Error: unsupported algorithm "xxx666" c wait 1 # Automatically find selector and key path. c = ./chasquid-util -C=.config dkim-dns domain c <~ [0-9]{8}._domainkey.domain\tTXT\t"v=DKIM1; k=ed25519; p=[A-Za-z0-9+/]{40,50}=*" c wait 0 # Require at least a domain. c = ./chasquid-util -C=.config dkim-dns c <- Error: missing domain parameter c wait 1 # Error reading key. c = ./chasquid-util -C=.config dkim-dns domain unknownsel badkey.pem c <- Error reading private key from "badkey.pem": open badkey.pem: no such file or directory c wait 1 # No DKIM keys found. c = ./chasquid-util -C=.config dkim-dns unkdomain c <- No DKIM keys found in ".config/domains/unkdomain/dkim:*.pem" c wait 1 # DKIM signing, with various forms. c = ./chasquid-util -C=.config dkim-sign domain c -> From: user-a@srv-a c -> c -> A little tiny message. c close c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; c <~ \td=domain; s=\d+; t=\d+; c <~ \th=from:from:subject:date:to:cc:message-id; c <~ \tbh=.*; c <~ \tb=.* c <~ \t .*; c wait 0 c = ./chasquid-util -C=.config dkim-sign domain sel1 c -> From: user-a@srv-a c -> c -> A little tiny message. c close c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; c wait 0 c = ./chasquid-util -C=.config dkim-sign domain selED25519 .keys/test_ed25519.pem c -> From: user-a@srv-a c -> c -> A little tiny message. c close c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; c wait 0 c = ./chasquid-util -C=.config dkim-sign c -> From: user-a@domain c -> c -> A little tiny message. c close c <- DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; c wait 0 # Bad message for dkim-sign. c = ./chasquid-util -C=.config dkim-sign c -> Invalid message. c close c <- Error parsing message: malformed header line: Invalid message. c wait 1 c = ./chasquid-util -C=.config dkim-sign c -> From: c -> c -> A little tiny message. c close c <- Error parsing From: header: mail: missing @ in addr-spec c wait 1 # DKIM verification. # Just check that the attempt was made. c = ./chasquid-util -C=.config dkim-verify c -> From: user-a@srv-a c -> c -> A little tiny message. c close c <~ Authentication-Results: .* c <~ \t;dkim=none c wait 0 # Tracing. Just check that there's some output, we don't need byte-for-byte # verification as the contents are not expected to be stable. c = ./chasquid-util -C=.config dkim-sign -v c -> From: user-a@domain c -> c -> A little tiny message. c close c <~ Signing for domain / \d+ with ed25519-sha256 c wait 0 c = ./chasquid-util -C=.config dkim-verify -v c -> From: user-a@srv-a c -> c -> A little tiny message. c close c <- Found 0 signatures, 0 valid c <~ Authentication-Results: .* c <~ \t;dkim=none c wait 0 chasquid-1.15.0/cmd/chasquid-util/test_domaininfo_remove.cmy000066400000000000000000000006751474251645300242120ustar00rootroot00000000000000# Test success. server unix_listen .data/localrpc-v1 c = ./chasquid-util -C=.config domaininfo-remove domain.com server <- DomaininfoClear Domain=domain.com server -> 200 c wait 0 # Test error. server unix_listen .data/localrpc-v1 c = ./chasquid-util -C=.config domaininfo-remove domain.com server <- DomaininfoClear Domain=domain.com server -> 500 This is a test error c <- Error removing domaininfo entry: This is a test error c wait 1 chasquid-1.15.0/cmd/chasquid-util/test_general.cmy000066400000000000000000000011621474251645300221170ustar00rootroot00000000000000# --help c = ./chasquid-util --configdir=.config --help c <- c <- Usage: c wait 0 # print-config c = ./chasquid-util -C=.config print-config c <~ hostname: +".*" c <~ max_data_size_mb: +50 c <~ smtp_address: +"systemd" c <~ submission_address: +"systemd" c <~ submission_over_tls_address: +"systemd" c <~ mail_delivery_agent_bin: +"maildrop" c <~ mail_delivery_agent_args: +"-f" c <~ mail_delivery_agent_args: +"%from%" c <~ mail_delivery_agent_args: +"-d" c <~ mail_delivery_agent_args: +"%to_user%" c <~ data_dir: +".data" c <~ suffix_separators: +"\+" c <~ drop_characters: +"\." c <~ mail_log_path: +"" c wait 0 chasquid-1.15.0/cmd/chasquid-util/test_openssl_genpkey_ed25519.pem000066400000000000000000000001671474251645300247620ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEIBul+k51unaApEcZBmt1i65n09asM/howsN4B1AjNY5V -----END PRIVATE KEY----- chasquid-1.15.0/cmd/chasquid-util/test_openssl_genpkey_rsa.pem000066400000000000000000000032501474251645300245450ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCJ5laGXt2fEcbK 5xlLb53ITE9DK2P9pjjpFcnjJPfNZjyCHliRVnYvXH77tczPNKD/kVHluQphX1dr 6M7piWGuJgqje21ZKbFTSEMpsbt2kP1Ovu2MJyYqoTUhd62XoNeVCXstF2aR4g0P iC1/0DXM48tHf/6S+y7W7ZxSwQVWzfoWptN7gFgufZFCVPghChxPl5US+gdzlqkq 5M4oFhOGFZFpJkb1he+x13VSOeBHZeaLO7l7+GPZ/db72uZFHLW6SZSvK4xQcxp7 ZXbtbLW+seJigxQDWRAlj4dMbmQyS5q3E26L3KOZ2qtBS8IhTYVrmPElhBMsjPhT T/Pi1K1HAgMBAAECggEAJRKywk8wv7oUuqnkh/5K6fVx/bnlmOSeOjOsYg+nOyY4 MDceUnxvK45vaRZYKICao/qajOrxWno6U310Wx6fDyWVCJx/KlBmJuCvhb8NifOy 1f/IdzxzK1TJpuS426HXM28oGVhIMAIYxssyiEEepaW8Gc3UUAmNbyTUOP9BgzNZ 8qH5PA5MTTSiC1ql96b5otKPTlizxT13d3MYeSBN4b31Kb/AYRNSZlyOSBFCwcqf qeZEV4cwILX+58PYwfGGRYQWbCT62ZOs5AWiPt/cH9bZg7Gk1GqNx8HKFYaq+QHq hzXkiAjDZrANuK+xeQERuAWViagtX/qtNsQJwAJP6QKBgQDAJxGCYXxv//eM09uU DBz3jrAvROPylrX+eifoleWtdHnBHXcn9G3uNwOSpVS36PcspeH44w2B/WpzDsWn HjVWP2UmeWvPMZsY81Kxd4KINB/l+z03ctYuus80UJmYH70bkJ2uxLWioU1e/Edf ruMGx16ZdBVOCWJ7BtrUc41dswKBgQC3uGZ9QdVoEMDB7dFKl5foYqHE51p4ruMv Rpb5peFQJIdbbCUSaNN9swtDemktf0OnPyGMNLogGBZ/fhf8N2QX5+OwvQeh01Mu vPCFUZ4sNXv7lPPCwj23SmoMd1Z/RdksAlF8kHVBOsHrNurPUqkbhKLChuiAAKDC S0qdoAKwHQKBgQCsqe6X5BW3ZqEBkNX8wK2+3h7/Or5CHJ9JHmeCHkAWj1Vg7KNH 6eJmblTtj1cDM3n4Ss81oIFgz2C6JwoA06pF6A1ydyUjN4YQ84TZJ3TKA1yuggZO Lwi7UO4kKlD6W3rIrDik9OnqS1uFANj55+LlEn21EpSaXOB7gHte8L6U9QKBgEy8 I2qbzbPak3gsiacbLCKu15xzeTFA8rjzRend4/7iUvrXb6CB0hwFZWX4wedz6WD4 mF2ERF1VUkhL9V6uEAuAGnTeb0qjBnJWDivRDDyw1ikdbLbjBH4DAcpVKfacyPl9 umVJvP/St94zoN2ZS/KncofHa2LTYFHmurKde6HtAoGBAIGZHOxJF856GJlq3otA 9wGGkNpmlVhHdYYvRKCMRr1FcduCrWFrr5zZT/fb6eHSoCtYjsiqRB/j6STgnBiX 2jSsPRadUrpyZOkINTl16vC6Bnv4plfP3VIBQAIoD9ViP0v9w8VrQyIGXWAeSHcu eXZyxHh81OEU8M2hWKZf54UI -----END PRIVATE KEY----- chasquid-1.15.0/cmd/chasquid-util/test_users.cmy000066400000000000000000000032341474251645300216450ustar00rootroot00000000000000# Tests for user management commands. # Start with a clean slate by removing the database, which could have been # manipulated by previous tests. c = rm -f .config/domains/domain/users c wait 0 c = ./chasquid-util -C=.config user-add user@domain --password=passwd c <- Added user c wait 0 c = ./chasquid-util -C=.config check-userdb domain c <- Database loaded (1 users) c wait 0 c = ./chasquid-util -C=.config user-add receive@domain --receive_only c <- Added user c wait 0 c = ./chasquid-util -C=.config check-userdb domain c <- Database loaded (2 users) c wait 0 c = ./chasquid-util -C=.config user-add xxx@domain \ --password=passwd --receive_only c <- Cannot specify both --receive_only and --password c wait 1 c = ./chasquid-util -C=.config authenticate user@domain --password=passwd c <- Authentication succeeded c wait 0 c = ./chasquid-util -C=.config authenticate user@domain --password=abcd c <- Authentication failed c wait 1 # Try to authenticate on the receive-only user c = ./chasquid-util -C=.config authenticate receive@domain --password=passwd c <- Authentication failed c wait 1 # Remove a user, then check authentication fails. c = ./chasquid-util -C=.config user-remove user@domain c <- Removed user c wait 0 c = ./chasquid-util -C=.config authenticate user@domain --password=passwd c <- Authentication failed c wait 1 c = ./chasquid-util -C=.config user-remove unknown@domain c <- Unknown user c wait 1 c = ./chasquid-util -C=.config user-add badarg c <- Domain missing, username should be of the form 'user@domain' c wait 1 c = ./chasquid-util -C=.config user-add "bad user@domain" c <- Error normalizing user: precis: disallowed rune encountered c wait 1 chasquid-1.15.0/cmd/dovecot-auth-cli/000077500000000000000000000000001474251645300173245ustar00rootroot00000000000000chasquid-1.15.0/cmd/dovecot-auth-cli/dovecot-auth-cli.go000066400000000000000000000017421474251645300230260ustar00rootroot00000000000000// CLI used for testing the dovecot authentication package. // // NOT for production use. package main import ( "flag" "fmt" "os" "blitiri.com.ar/go/chasquid/internal/dovecot" ) const help = ` Usage: dovecot-auth-cli exists user@domain dovecot-auth-cli auth user@domain password Example: dovecot-auth-cli /var/run/dovecot/auth-chasquid exists user@domain dovecot-auth-cli /var/run/dovecot/auth-chasquid auth user@domain password ` func main() { flag.Parse() if len(flag.Args()) < 3 { fmt.Fprint(os.Stderr, help) fmt.Print("no: invalid arguments\n") return } a := dovecot.NewAuth(flag.Arg(0)+"-userdb", flag.Arg(0)+"-client") var ok bool var err error switch flag.Arg(1) { case "exists": ok, err = a.Exists(flag.Arg(2)) case "auth": ok, err = a.Authenticate(flag.Arg(2), flag.Arg(3)) default: err = fmt.Errorf("unknown subcommand %q", flag.Arg(1)) } if ok { fmt.Print("yes\n") return } fmt.Printf("no: %v\n", err) } chasquid-1.15.0/cmd/dovecot-auth-cli/test.sh000077500000000000000000000013351474251645300206440ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../../test/util/lib.sh" init # Build the binary once, so we can use it and launch it in chamuyero scripts. # Otherwise, we not only spend time rebuilding it over and over, but also "go # run" masks the exit code, which is something we care about. if [ "${GOCOVERDIR}" != "" ]; then GOFLAGS="-cover -covermode=count -o dovecot-auth-cli $GOFLAGS" fi # shellcheck disable=SC2086 go build $GOFLAGS -tags="$GOTAGS" . if ! ./dovecot-auth-cli lalala 2>&1 | grep -q "invalid arguments"; then echo "cli worked with invalid arguments" exit 1 fi for i in *.cmy; do if ! chamuyero "$i" > "$i.log" 2>&1 ; then echo "# Test $i failed, log follows" cat "$i.log" exit 1 fi done success exit 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_auth_bad_proto.cmy000066400000000000000000000014471474251645300240750ustar00rootroot00000000000000 # Break the handshake early. client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client <- VERSION 1 1 client <~ CPID # We are supposed to send the handshake here. client close c <- no: error receiving handshake: EOF c wait 0 # Break before sending the final response. client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= # We're supposed to send the OK/FAIL here. client close c <- no: error receiving response: EOF c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_auth_error.cmy000066400000000000000000000006711474251645300232530ustar00rootroot00000000000000 client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= client -> OTHER c <~ no: invalid response c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_auth_no.cmy000066400000000000000000000006571474251645300225420ustar00rootroot00000000000000 client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= client -> FAIL 1 c <- no: c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_auth_yes.cmy000066400000000000000000000006471474251645300227250ustar00rootroot00000000000000 client unix_listen .dovecot-client c = ./dovecot-auth-cli .dovecot auth username password client -> VERSION 1 1 client -> SPID 12345 client -> CUID 12345 client -> COOKIE lovelycookie client -> MECH PLAIN client -> MECH LOGIN client -> DONE client <- VERSION 1 1 client <~ CPID client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= client -> OK 1 c <- yes c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_exists_bad_proto.cmy000066400000000000000000000012221474251645300244420ustar00rootroot00000000000000 # Invalid version userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 0 c <~ no: error receiving version c wait 0 # No SPID (send "NOSPID" instead userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> NOSPID c <~ no: error receiving SPID: c wait 0 # Break before sending the final response. userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb close c <- no: error receiving response: EOF c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_exists_error.cmy000066400000000000000000000003651474251645300236310ustar00rootroot00000000000000 userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb -> OTHER c <~ no: invalid response: c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_exists_notfound.cmy000066400000000000000000000003561474251645300243340ustar00rootroot00000000000000 userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb -> NOTFOUND 1 c <- no: c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_exists_yes.cmy000066400000000000000000000004241474251645300232740ustar00rootroot00000000000000 userdb unix_listen .dovecot-userdb c = ./dovecot-auth-cli .dovecot exists username userdb -> VERSION 1 1 userdb -> SPID 12345 userdb <- VERSION 1 1 userdb <- USER 1 username service=smtp userdb -> USER 1 username system_groups_user=blah uid=10 gid=10 c <- yes c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_missing_socket.cmy000066400000000000000000000003311474251645300241130ustar00rootroot00000000000000 c = ./dovecot-auth-cli .missingsocket exists username c <~ no: dial unix .missingsocket-userdb c wait 0 c = ./dovecot-auth-cli .missingsocket auth username password c <~ no: dial unix .missingsocket-client c wait 0 chasquid-1.15.0/cmd/dovecot-auth-cli/test_wrong_command.cmy000066400000000000000000000001471474251645300237310ustar00rootroot00000000000000 c = ./dovecot-auth-cli .missingsocket something else c <- no: unknown subcommand "something" c wait 0 chasquid-1.15.0/cmd/mda-lmtp/000077500000000000000000000000001474251645300156705ustar00rootroot00000000000000chasquid-1.15.0/cmd/mda-lmtp/.gitignore000066400000000000000000000000171474251645300176560ustar00rootroot00000000000000mda-lmtp *.log chasquid-1.15.0/cmd/mda-lmtp/mda-lmtp.go000066400000000000000000000066771474251645300177520ustar00rootroot00000000000000// mda-lmtp is a very basic MDA that uses LMTP to do the delivery. // // See the usage below for details. // //go:build !coverage // +build !coverage package main import ( "flag" "fmt" "io" "net" "net/textproto" "os" "strings" "golang.org/x/net/idna" ) // 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") toPuny = flag.Bool("to_puny", false, "Encode addresses using punycode") ) 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 permExit(format string, args ...interface{}) { fmt.Printf(format+"\n", args...) os.Exit(2) } func main() { var err error flag.Usage = usage flag.Parse() if *addr == "" { permExit("No LMTP server address given (use --addr)") } if *toPuny { *fromwhom, err = idna.ToASCII(*fromwhom) if err != nil { permExit("cannot puny-encode from: %v", err) } *recipient, err = idna.ToASCII(*recipient) if err != nil { permExit("cannot puny-encode recipient: %v", err) } } // Try to autodetect the network if it's missing. if *addrNetwork == "" { *addrNetwork = "tcp" if strings.HasPrefix(*addr, "/") { *addrNetwork = "unix" } } conn, err := net.Dial(*addrNetwork, *addr) if err != nil { tempExit("Error connecting to (%s, %s): %v", *addrNetwork, *addr, err) } tc := textproto.NewConn(conn) // Expect the hello from the server. _, _, err = tc.ReadResponse(220) if err != nil { tempExit("Server greeting error: %v", err) } hostname, err := os.Hostname() if err != nil { tempExit("Could not get hostname: %v", err) } if *fromwhom == "<>" { *fromwhom = "" } if *recipient == "<>" { *recipient = "" } cmd(tc, 250, "LHLO %s", hostname) cmd(tc, 250, "MAIL FROM:<%s>", *fromwhom) cmd(tc, 250, "RCPT TO:<%s>", *recipient) cmd(tc, 354, "DATA") w := tc.DotWriter() _, err = io.Copy(w, os.Stdin) w.Close() if err != nil { tempExit("Error writing DATA: %v", err) } // This differs from SMTP: here we get one reply per recipient, with the // result of the delivery. Since we deliver to only one recipient, read // one code. _, _, err = tc.ReadResponse(250) if err != nil { tempExit("Delivery failed remotely: %v", err) } cmd(tc, 221, "QUIT") tc.Close() } // cmd sends a command and checks it matched the expected code. func cmd(conn *textproto.Conn, expectCode int, format string, args ...interface{}) { id, err := conn.Cmd(format, args...) if err != nil { tempExit("Sent %q, got %v", fmt.Sprintf(format, args...), err) } conn.StartResponse(id) defer conn.EndResponse(id) _, _, err = conn.ReadResponse(expectCode) if err != nil { tempExit("Sent %q, got %v", fmt.Sprintf(format, args...), err) } } chasquid-1.15.0/cmd/mda-lmtp/test-email000066400000000000000000000000371474251645300176570ustar00rootroot00000000000000Subject: test This is a test. chasquid-1.15.0/cmd/mda-lmtp/test.sh000077500000000000000000000006721474251645300172130ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../../test/util/lib.sh" init # Build the binary once, so we can use it and launch it in chamuyero scripts. # Otherwise, we not only spend time rebuilding it over and over, but also "go # run" masks the exit code, which is something we care about. go build for i in *.cmy; do if ! chamuyero "$i" > "$i.log" 2>&1 ; then echo "# Test $i failed, log follows" cat "$i.log" exit 1 fi done success chasquid-1.15.0/cmd/mda-lmtp/test_puny_ascii.cmy000066400000000000000000000006651474251645300216130ustar00rootroot00000000000000 nc unix_listen .test-sock mda |= ./mda-lmtp --addr=.test-sock --addr_network=unix \ -to_puny -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.15.0/cmd/mda-lmtp/test_puny_invalid.cmy000066400000000000000000000005331474251645300221430ustar00rootroot00000000000000mda = ./mda-lmtp --addr=.test-sock --addr_network=unix \ -to_puny -f frÃļm -d xn--t < test-email mda <- cannot puny-encode recipient: idna: invalid label "t" mda wait 2 mda = ./mda-lmtp --addr=.test-sock --addr_network=unix \ -to_puny -f xn--f -d to < test-email mda <- cannot puny-encode from: idna: invalid label "f" mda wait 2 chasquid-1.15.0/cmd/mda-lmtp/test_puny_utf8.cmy000066400000000000000000000007051474251645300214040ustar00rootroot00000000000000 nc unix_listen .test-sock mda |= ./mda-lmtp --addr=.test-sock --addr_network=unix \ -to_puny -f frÃļm -d Þo < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.15.0/cmd/mda-lmtp/test_tcp_null.cmy000066400000000000000000000006231474251645300212620ustar00rootroot00000000000000 nc tcp_listen localhost:14932 mda |= ./mda-lmtp --addr=localhost:14932 -f "<>" -d "<>" < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM:<> nc -> 250 Aja nc <- RCPT TO:<> nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.15.0/cmd/mda-lmtp/test_tcp_success.cmy000066400000000000000000000006271474251645300217640ustar00rootroot00000000000000 nc tcp_listen localhost:14932 mda |= ./mda-lmtp --addr=localhost:14932 -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.15.0/cmd/mda-lmtp/test_unix_failure.cmy000066400000000000000000000006731474251645300221410ustar00rootroot00000000000000 nc unix_listen .test-sock mda = ./mda-lmtp --addr=.test-sock --addr_network=unix \ -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 452 Nananana mda <- Delivery failed remotely: 452 Nananana mda wait 75 chasquid-1.15.0/cmd/mda-lmtp/test_unix_success.cmy000066400000000000000000000006541474251645300221610ustar00rootroot00000000000000 nc unix_listen .test-sock mda |= ./mda-lmtp --addr=.test-sock --addr_network=unix \ -f from -d to < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO: nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.15.0/cmd/mda-lmtp/test_utf8.cmy000066400000000000000000000006601474251645300203310ustar00rootroot00000000000000 nc unix_listen .test-sock mda |= ./mda-lmtp --addr=.test-sock --addr_network=unix \ -f frÃļm -d Þo < test-email nc -> 220 Hola desde expect nc <~ LHLO .* nc -> 250-Bienvenido! nc -> 250 Contame... nc <- MAIL FROM: nc -> 250 Aja nc <- RCPT TO:<Þo> nc -> 250 Aja nc <- DATA nc -> 354 Dale nc <- Subject: test nc <- nc <- This is a test. nc <- . nc -> 250 Recibido nc <- QUIT nc -> 221 Chauchas mda wait 0 chasquid-1.15.0/cmd/smtp-check/000077500000000000000000000000001474251645300162135ustar00rootroot00000000000000chasquid-1.15.0/cmd/smtp-check/smtp-check.go000066400000000000000000000056571474251645300206150ustar00rootroot00000000000000// smtp-check is a command-line too for checking SMTP setups. // //go:build !coverage // +build !coverage package main import ( "context" "crypto/tls" "flag" "fmt" "log" "net" "net/smtp" "time" "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/tlsconst" "blitiri.com.ar/go/spf" "golang.org/x/net/idna" ) var ( port = flag.String("port", "smtp", "port to use for connecting to the MX servers") localName = flag.String("localname", "localhost", "specify the local name for the EHLO command") skipTLSCheck = flag.Bool("skip_tls_check", false, "skip TLS check (useful if connections are blocked)") ) func main() { flag.Parse() domain := flag.Arg(0) if domain == "" { log.Fatal("Use: smtp-check ") } domain, err := idna.ToASCII(domain) if err != nil { log.Fatalf("IDNA conversion failed: %v", err) } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() log.Printf("=== STS policy") policy, err := sts.UncheckedFetch(ctx, domain) if err != nil { log.Printf("Not available (%s)", err) } else { log.Printf("Parsed contents: [%+v]\n", *policy) if err := policy.Check(); err != nil { log.Fatalf("Invalid: %v", err) } log.Printf("OK") } log.Printf("") mxs, err := net.LookupMX(domain) if err != nil { log.Fatalf("MX lookup: %v", err) } if len(mxs) == 0 { log.Fatalf("MX lookup returned no results") } errs := []error{} for _, mx := range mxs { log.Printf("=== MX: %2d %s", mx.Pref, mx.Host) ips, err := net.LookupIP(mx.Host) if err != nil { log.Fatal(err) } for _, ip := range ips { result, err := spf.CheckHostWithSender(ip, domain, "test@"+domain) log.Printf("SPF %v for %v: %v", result, ip, err) if result != spf.Pass { errs = append(errs, fmt.Errorf("%s: SPF failed (%v)", mx.Host, ip)) } } if *skipTLSCheck { log.Printf("TLS check skipped") } else { c, err := smtp.Dial(mx.Host + ":" + *port) if err != nil { log.Fatal(err) } err = c.Hello(*localName) if err != nil { log.Fatal(err) } config := &tls.Config{ // Expect the server to have a certificate valid for the MX // we're connecting to. ServerName: mx.Host, } err = c.StartTLS(config) if err != nil { log.Printf("TLS error: %v", err) errs = append(errs, fmt.Errorf("%s: TLS failed", mx.Host)) } else { cstate, _ := c.TLSConnectionState() log.Printf("TLS OK: %s - %s", tlsconst.VersionName(cstate.Version), tlsconst.CipherSuiteName(cstate.CipherSuite)) } c.Close() } if policy != nil { if !policy.MXIsAllowed(mx.Host) { log.Printf("NOT allowed by STS policy") errs = append(errs, fmt.Errorf("%s: STS failed", mx.Host)) } log.Printf("Allowed by policy") } log.Printf("") } if len(errs) == 0 { log.Printf("=== Success") } else { log.Printf("=== FAILED") for _, err := range errs { log.Printf("%v", err) } log.Fatal("") } } chasquid-1.15.0/dnsoverride.go000066400000000000000000000016101474251645300162630ustar00rootroot00000000000000// Support for overriding DNS lookups, for testing purposes. // This is only used in tests, when the "dnsoverride" tag is active. // It requires Go >= 1.8. // //go:build dnsoverride // +build dnsoverride package main import ( "context" "flag" "net" "time" ) var ( dnsAddr = flag.String("testing__dns_addr", "127.0.0.1:9053", "DNS server address to use, for testing purposes only") ) var dialer = &net.Dialer{ // We're going to talk to localhost, so have a short timeout so we fail // fast. Otherwise the callers might hang indefinitely when trying to // dial the DNS server. Timeout: 2 * time.Second, } func dial(ctx context.Context, network, address string) (net.Conn, error) { return dialer.DialContext(ctx, network, *dnsAddr) } func init() { // Override the resolver to talk with our local server for testing. net.DefaultResolver.PreferGo = true net.DefaultResolver.Dial = dial } chasquid-1.15.0/docker/000077500000000000000000000000001474251645300146615ustar00rootroot00000000000000chasquid-1.15.0/docker/Dockerfile000066400000000000000000000053111474251645300166530ustar00rootroot00000000000000# Docker file for creating a container that will run chasquid and Dovecot. # # THIS IS EXPERIMENTAL AND LIKELY TO CHANGE. # # This is not recommended for serious installations, you're probably better # off following the documentation and setting the server up manually. # # See the README.md file for more details. # Build the binaries. FROM golang:latest as build WORKDIR /go/src/blitiri.com.ar/go/chasquid COPY . . RUN go get -d ./... RUN go install ./... # Create the image. FROM debian:stable # Make debconf/frontend non-interactive, to avoid distracting output about the # lack of $TERM. ENV DEBIAN_FRONTEND noninteractive # Install the packages we need. # This includes chasquid, which sets up good defaults. RUN apt-get update -q RUN apt-get install -y -q \ chasquid \ dovecot-lmtpd dovecot-imapd dovecot-pop3d \ dovecot-sieve dovecot-managesieved \ acl libcap2-bin sudo certbot # Copy the binaries. This overrides the debian packages with the ones we just # built above. COPY --from=build /go/bin/chasquid /usr/bin/ COPY --from=build /go/bin/chasquid-util /usr/bin/ COPY --from=build /go/bin/smtp-check /usr/bin/ COPY --from=build /go/bin/mda-lmtp /usr/bin/ # Let chasquid bind privileged ports, so we can run it as its own user. RUN setcap CAP_NET_BIND_SERVICE=+eip /usr/bin/chasquid # Copy docker-specific configurations. COPY docker/dovecot.conf /etc/dovecot/dovecot.conf COPY docker/chasquid.conf /etc/chasquid/chasquid.conf # Copy utility scripts. COPY docker/add-user.sh / COPY docker/entrypoint.sh / # chasquid: SMTP, submission, submission+tls. EXPOSE 25 465 587 # dovecot: POP3s, IMAPs, managesieve. EXPOSE 993 995 4190 # http for letsencrypt/certbot. EXPOSE 80 443 # Store emails and chasquid databases in an external volume, to be mounted at # /data, so they're independent from the image itself. VOLUME /data # Put some directories where we expect persistent user data into /data. RUN rmdir /etc/chasquid/domains/ RUN ln -sf /data/chasquid/domains/ /etc/chasquid/domains RUN rm -rf /etc/letsencrypt/ RUN ln -sf /data/letsencrypt/ /etc/letsencrypt # Give the chasquid user access to the necessary configuration. RUN setfacl -R -m u:chasquid:rX /etc/chasquid/ RUN mv /etc/chasquid/certs/ /etc/chasquid/certs-orig RUN ln -s /etc/letsencrypt/live/ /etc/chasquid/certs # NOTE: Set AUTO_CERTS="example.com example.org" to automatically obtain and # renew certificates upon startup, via Letsencrypt. You're agreeing to their # ToS by setting this variable, so please review them carefully. # CERTS_EMAIL should be set to your email address so letsencrypt can send you # critical notifications. # Custom entry point that does some configuration checks and ensures # letsencrypt is properly set up. ENTRYPOINT ["/entrypoint.sh"] chasquid-1.15.0/docker/README.md000066400000000000000000000044671474251645300161530ustar00rootroot00000000000000 # Docker chasquid comes with a Dockerfile to create a container running [chasquid], [dovecot], and managed certificates with [Let's Encrypt]. Note these are less thoroughly tested than the [traditional setup](howto.md), which is the recommended way to use chasquid. [chasquid]: https://blitiri.com.ar/p/chasquid [dovecot]: https://dovecot.org [Let's Encrypt]: https://letsencrypt.org ## Images There are pre-built images at the [gitlab registry](https://gitlab.com/albertito/chasquid/container_registry) and [dockerhub](https://hub.docker.com/r/albertito/chasquid). They are automatically built, and tagged with the corresponding branch name. Use the *main* tag for a stable version. If, instead, you want to build the image yourself, just run: ```sh $ docker build -t chasquid -f docker/Dockerfile . ``` ## Running First, pull the image into your target machine: ```sh $ docker pull registry.gitlab.com/albertito/chasquid:main ``` You will need a data volume to store persistent data, outside the image. This will contain the mailboxes, user databases, etc. ```sh $ docker volume create chasquid-data ``` To add your first user to the image: ``` $ docker run \ --mount source=chasquid-data,target=/data \ -it --entrypoint=/add-user.sh \ registry.gitlab.com/albertito/chasquid:main Email (full user@domain format): pepe@example.com Password: pepe@example.com added to /data/dovecot/users ``` Upon startup, the image will obtain a TLS certificate for you using [Let's Encrypt](https://letsencrypt.com/). You need to tell it the domain(s) to get a certificate from by setting the `AUTO_CERTS` variable. Because certificates expire, you should restart the container every week or so. Certificates will be renewed automatically upon startup if needed. In order for chasquid to get access to the source IP address, you will need to use host networking, or create a custom docker network that does IP forwarding and not proxying. Finally, start the container: ```sh $ docker run -e AUTO_CERTS=mail.yourdomain.com \ --mount source=chasquid-data,target=/data \ --network host \ registry.gitlab.com/albertito/chasquid:main ``` ## Debugging To get a shell on the running container for debugging, you can use `docker ps` to find the container ID, and then `docker exec -it CONTAINERID /bin/bash` to open a shell on the running container. chasquid-1.15.0/docker/add-user.sh000077500000000000000000000017011474251645300167230ustar00rootroot00000000000000#!/bin/bash # # Creates a user. If it exists, updates the password. # # Note this is not robust, it's only for convenience on extremely simple # setups. set -e if test -z "${EMAIL:-}"; then read -r -p "Email (full user@domain format): " EMAIL fi if ! echo "${EMAIL}" | grep -q @; then echo "Error: email should have '@'." exit 1 fi if test -z "${PASSWORD:-}"; then read -r -p "Password: " -s PASSWORD echo fi DOMAIN=$(echo echo "${EMAIL}" | cut -d '@' -f 2) # If the domain doesn't exist in chasquid's config, create it. mkdir -p "/data/chasquid/domains/${DOMAIN}/" # Encrypt password. ENCPASS=$(doveadm pw -u "${EMAIL}" -p "${PASSWORD}") # Edit dovecot users: remove user if it exits. mkdir -p /data/dovecot touch /data/dovecot/users sed --in-place=.old "/^${EMAIL}:/d" /data/dovecot/users # Edit dovecot users: add user. echo "${EMAIL}:${ENCPASS}::::" >> /data/dovecot/users echo "${EMAIL} added to /data/dovecot/users" chasquid-1.15.0/docker/chasquid.conf000066400000000000000000000012661474251645300173360ustar00rootroot00000000000000 # Listening addresses. smtp_address: ":25" submission_address: ":587" submission_over_tls_address: ":465" # Monitoring HTTP server only bound to localhost, just in case. monitoring_address: "127.0.0.1:1099" # Auth against dovecot. dovecot_auth: true # Use mda-lmtp to talk to dovecot. mail_delivery_agent_bin: "/usr/bin/mda-lmtp" mail_delivery_agent_args: "--addr" mail_delivery_agent_args: "/run/dovecot/lmtp" mail_delivery_agent_args: "-f" mail_delivery_agent_args: "%from%" mail_delivery_agent_args: "-d" mail_delivery_agent_args: "%to%" # Store data in the container volume. data_dir: "/data/chasquid/data" # Mail log to the container volume. mail_log_path: "/data/chasquid/mail.log" chasquid-1.15.0/docker/dovecot.conf000066400000000000000000000040461474251645300171770ustar00rootroot00000000000000 # # Logging # log_path = /data/dovecot/dovecot.log # # Email storage # # Store emails in /data/mail/home/domain/user, which will be inside the # container's volume. mail_home = /data/mail/home/%d/%n # Use Dovecot's native format. mail_location = mdbox:~/mdbox # User and group used to store and access mailboxes. mail_uid = dovecot mail_gid = dovecot # As we're using virtual mailboxes, the system user will be "dovecot", which # has uid in the 100-500 range. By default using uids <500 is blocked, so we # need to explicitly lower the value to allow storage of mail as "dovecot". first_valid_uid = 100 first_valid_gid = 100 # # Authentication # # Static file, in /data/dovecot/users. auth_mechanisms = plain passdb { driver = passwd-file args = scheme=CRYPT username_format=%u /data/dovecot/users } userdb { driver = passwd-file args = /data/dovecot/users } # # TLS # # TLS is mandatory. # The entrypoint generates auto-ssl.conf, with all the certificates. ssl = required !include_try /etc/dovecot/auto-ssl.conf # Only allow TLS 1.2 and up. ssl_min_protocol = TLSv1.2 # # Protocols # protocols = lmtp imap pop3 sieve # # IMAP # service imap-login { inet_listener imap { # Disable plain text IMAP, just in case. port = 0 } inet_listener imaps { port = 993 ssl = yes } } service imap { } # # POP3 # service pop3-login { inet_listener pop3 { # Disable plain text POP3, just in case. port = 0 } inet_listener pop3s { port = 995 ssl = yes } } service pop3 { } # # Sieve/managesieve # service managesieve-login { } service managesieve { } protocol sieve { } plugin { sieve = file:~/sieve;active=~/.dovecot.sieve } # # Internal services # service auth { unix_listener auth-userdb { } # Grant chasquid access to request user authentication. unix_listener auth-chasquid-userdb { mode = 0660 user = chasquid } unix_listener auth-chasquid-client { mode = 0660 user = chasquid } } service auth-worker { } dict { } service lmtp { # This is used by mda-lmtp. unix_listener lmtp { } } chasquid-1.15.0/docker/entrypoint.sh000077500000000000000000000066361474251645300174460ustar00rootroot00000000000000#!/bin/bash # # Script that is used as a Docker entrypoint. # set -e if ! grep -q data /proc/mounts; then echo "/data is not mounted." echo "Check that the /data volume is set up correctly." exit 1 fi # Create the directory structure if it's not there. # Some of these directories are symlink targets, see the Dockerfile. mkdir -p /data/chasquid mkdir -p /data/letsencrypt mkdir -p /data/chasquid mkdir -p /data/chasquid/domains mkdir -p /data/dovecot # Set up the certificates for the requested domains. if [ "$AUTO_CERTS" != "" ]; then # If we were given an email to use for letsencrypt, use it. Otherwise # continue without one. MAIL_OPTS="--register-unsafely-without-email" if [ "$CERTS_MAIL" != "" ]; then MAIL_OPTS="-m $CERTS_MAIL" fi for DOMAIN in $AUTO_CERTS; do # If it has never been set up, then do so. if ! [ -e "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then # shellcheck disable=SC2086 certbot certonly \ --non-interactive \ --standalone \ --agree-tos \ $MAIL_OPTS \ -d "$DOMAIN" else echo "$DOMAIN certificate already set up." fi done # Renew on startup, since the container won't have cron facilities. # Note this requires you to restart every week or so, to make sure # your certificate does not expire. certbot renew # Give chasquid access to the certificates. # Dovecot does not need this as it reads them as root. setfacl -R -m u:chasquid:rX /etc/letsencrypt/{live,archive} fi CERT_DOMAINS="" for i in /etc/letsencrypt/live/*; do D="${i##*/}" # Extract the last component of the path. if [ -e "/etc/letsencrypt/live/$D/fullchain.pem" ]; then CERT_DOMAINS="$CERT_DOMAINS $D" fi done # We need one domain to use as a default - pick the last one. ONE_DOMAIN=$D # Check that there's at least once certificate at this point. if [ "$CERT_DOMAINS" == "" ]; then echo "No certificates found." echo echo "Set AUTO_CERTS='example.com' to automatically get one." exit 1 fi # Give chasquid access to the data directory. mkdir -p /data/chasquid/data chown -R chasquid /data/chasquid/ # Give dovecot access to the mailbox home. mkdir -p /data/mail/ chown dovecot:dovecot /data/mail/ # Generate the dovecot ssl configuration based on all the certificates we have. # The default goes first because dovecot complains otherwise. echo "# Autogenerated by entrypoint.sh" > /etc/dovecot/auto-ssl.conf cat >> /etc/dovecot/auto-ssl.conf <> /etc/dovecot/auto-ssl.conf # Pick the default domain as default hostname for chasquid. This is only used # in plain text sessions and on very rare cases, and it's mostly for aesthetic # purposes. # Since the list of domains could have changed since the last run, always # remove and re-add the setting for consistency. sed -i '/^hostname:/d' /etc/chasquid/chasquid.conf echo "hostname: '$ONE_DOMAIN'" >> /etc/chasquid/chasquid.conf # Start the services: dovecot in background, chasquid in foreground. start-stop-daemon --start --quiet --pidfile /run/dovecot.pid \ --exec /usr/sbin/dovecot -- -c /etc/dovecot/dovecot.conf # shellcheck disable=SC2086 sudo -u chasquid -g chasquid /usr/bin/chasquid $CHASQUID_FLAGS chasquid-1.15.0/docs/000077500000000000000000000000001474251645300143425ustar00rootroot00000000000000chasquid-1.15.0/docs/aliases.md000066400000000000000000000105541474251645300163120ustar00rootroot00000000000000 # Aliases [chasquid] supports [email aliases], which is a mechanism to redirect mail from one account to others. ## File format The aliases are configured per-domain, on a text file named `aliases` within the domain directory. So like `/etc/chasquid/domains/example.com/aliases`. The format is very similar to the one used by classic MTAs (sendmail, exim, postfix), but not identical. ### Comments Lines beginning with `#` are considered comments, and are ignored. ### Email aliases To create email aliases, where mail to a user are redirected to other addresses, write lines of the form `user: address, address, ...`. The user should not have the domain specified, as it is implicit by the location of the file. The domain in target addresses is optional, and defaults to the user domain if not present. For example: ``` # Redirect mail to pepe@ to jose@ on the same domain. pepe: jose # Redirect mail to flowers@ to the individual flowers. flowers: rose@backgarden, lilly@pond ``` Destination addresses can be for a remote domain as well. In that case, the email will be forwarded using [sender rewriting](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme). While the content of the message will not be changed, the envelope sender will be the constructed from the alias user. User names cannot contain spaces, ":" or commas, for parsing reasons. This is a tradeoff between flexibility and keeping the file format easy to edit for people. User names will be normalized internally to lower-case. UTF-8 is allowed and fully supported. ### Pipe aliases A pipe alias is of the form `user: | command`, and causes mail to be sent as standard input to the given command. The command can have space-separated arguments (no quotes or escaping expansion will be performed). For example: ``` # Mail to user@ will be piped to this command for delivery. user: | /usr/bin/email-handler --work # Mail to null@ will be piped to "cat", effectively discarding the email. null: | cat ``` ### Catch-all If the aliased user is `*`, then mail sent to an unknown user will not be rejected, but redirected to the indicated destination instead. ``` pepe: jose *: pepe, rose@backgarden ``` ### Overrides If the same left-side address appears more than once, the last one will take precedence. For example, in this case, the result is that `pepe` is aliased to `jose`, the first line is effectively ignored. ``` pepe: juan pepe: jose ``` ### Drop characters and suffix separators When parsing aliases files, drop characters will be ignored. Suffix separators are kept as-is. When doing lookups, drop characters will also be ignored. If the address has a suffix, the lookup will include it; if there is no match, it will try again without the suffix. In practice, this means that if the aliases file contains: ``` juana.perez: juana juana.perez+fruta: fruta ``` Then (assuming the default drop characters and suffix separators), these are the results: ``` juana.perez -> juana juanaperez -> juana ju.ana.pe.rez -> juana juana.perez+abc -> juana juanaperez+abc -> juana juana.perez+fruta -> fruta juanaperez+fruta -> fruta ``` This allows addresses with suffixes to have specific aliases, without having to worry about drop characters, which is the most common use case. If different semantics are needed, they can be implemented using the [hook](#hooks). ## Processing Aliases files are read upon start-up and refreshed every 30 seconds, so changes to them don't require a daemon restart. The resolver will perform lookups recursively, until it finds all the final recipients. There are recursion limits to avoid alias loops. If the limit (10 levels) is reached, the entire resolution will fail. Commands are given 30s to run, after which it will be killed and the execution will fail. If the command exits with an error (non-0 exit code), the delivery will be considered failed. The `chasquid-util` command-line tool can be used to check and resolve aliases manually. It talks to the running server, so the response is fully authoritative. ## Hooks There is a hook that allows more sophisticated aliases resolution: `alias-resolve`. If it exists, it is invoked as part of the resolution process, and the results are merged with the file-based resolution results. See the [hooks](hooks.md) documentation for more details. [chasquid]: https://blitiri.com.ar/p/chasquid [email aliases]: https://en.wikipedia.org/wiki/Email_alias chasquid-1.15.0/docs/clients.md000066400000000000000000000024151474251645300163270ustar00rootroot00000000000000 # Clients chasquid supports most SMTP clients, but requires them to have some features: - Support TLS (either [STARTTLS](https://datatracker.ietf.org/doc/html/rfc3207) or [implicit TLS](https://datatracker.ietf.org/doc/html/rfc8314#section-3.3)) - Support the [PLAIN authentication method](https://datatracker.ietf.org/doc/html/rfc4954#section-4). All modern clients should support both, and thus have no problems talking to chasquid. ## Configuration examples ### [msmtp](https://marlam.de/msmtp/) This example is useful as either per-user `~/.msmtprc` or system-wide `/etc/msmtprc`: ``` account default tls on auth on # Use the SMTP submission port. Many providers block communications to the # default port 25, but the submission port 587 tends to work just fine. port 587 # Server hostname. host SERVER # Your username (including the domain). user USER@DOMAIN # Your password. password SECRET ``` Replace the `SERVER`, `USER@DOMAIN` and `SECRET` strings with the appropriate values. ## Problematic clients These clients are known to have issues talking to chasquid: - [ssmtp](https://packages.debian.org/source/unstable/ssmtp): does not support the PLAIN authentication method. It is also unmaintained. Please use [msmtp](https://marlam.de/msmtp/) instead. chasquid-1.15.0/docs/dkim.md000066400000000000000000000057371474251645300156240ustar00rootroot00000000000000 # DKIM integration [chasquid] supports verifying and generating [DKIM] signatures since version 1.14. All incoming email is verified, and *authenticated* emails for domains which have a private DKIM key set up will be signed. In versions older than 1.13, support is possible via the [hooks] mechanism. In particular, the [example hook] included support for some command-line implementations. That continues to be an option, especially if customization is needed. ## Easy setup - Run `chasquid-util dkim-keygen DOMAIN` to generate a DKIM private key for your domain. The file will be in `/etc/chasquid/domains/DOMAIN/dkim:*.pem`. - Publish the DKIM DNS record which was shown by the previous command (e.g. by following [this guide](https://support.dnsimple.com/articles/dkim-record/)). - Change the key file's permissions, to ensure it is readable by chasquid (and nobody else). - Restart chasquid. It is highly recommended that you use a DKIM checker (like [Learn DMARC](https://www.learndmarc.com/)) to confirm that your setup is fully functional. ## Advanced setup You need to place the PEM-encoded private key in the domain config directory, with a name like `dkim:SELECTOR.pem`, where `SELECTOR` is the selector string. It needs to be either RSA or Ed25519. ### Key rotation To rotate a key, you can remove the old key file, and generate a new one as per the previous step. It is important to remove the old key from the directory, because chasquid will use *all* the keys in it. You should use a different selector each time. If you don't specify a selector when using `chasquid-util dkim-keygen`, the current date will be used, which is a safe default to prevent accidental reuse. ### Multiple keys Advanced users may want to sign outgoing mail with multiple keys (e.g. to support multiple signing algorithms). This is well supported: chasquid will sign email with all keys it find that match `dkim:*.pem` in a domain directory. ## Verification [chasquid] will verify all DKIM signatures of incoming mail, and record the results in an [`Authentication-Results:`] header, as per [RFC 8601]. Note that emails will *not* be rejected even if they fail verification, as this is not recommended ([source 1](https://tools.ietf.org/html/rfc6376#section-6.3), [source 2](https://tools.ietf.org/html/rfc7601#section-2.7.1)). ## Other implementations [chasquid] also supports [DKIM] via the [hooks] mechanism. This can be useful if more customization is needed. Implementations that have been tried: - [driusan/dkim] - [dkimpy] [chasquid]: https://blitiri.com.ar/p/chasquid [DKIM]: https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail [hooks]: hooks.md [example hook]: https://blitiri.com.ar/git/r/chasquid/b/next/t/etc/chasquid/hooks/f=post-data.html [driusan/dkim]: https://github.com/driusan/dkim [dkimpy]: https://launchpad.net/dkimpy/ [RFC 8601]: https://datatracker.ietf.org/doc/html/rfc8601 [`Authentication-Results:`]: https://en.wikipedia.org/wiki/Email_authentication#Authentication-Results chasquid-1.15.0/docs/docker.md000077700000000000000000000000001474251645300210672../docker/README.mdustar00rootroot00000000000000chasquid-1.15.0/docs/dovecot.md000066400000000000000000000035751474251645300163410ustar00rootroot00000000000000 # Dovecot integration As of version 0.04 (2018-02), [chasquid] has integration with [dovecot] for authenticating users. This means that chasquid can ask dovecot to authenticate users, instead/in addition to having its own per-domain user databases. ## Configuring dovecot The following needs to be added to the Dovecot configuration, usually in `/etc/dovecot/conf.d/10-master.conf`: ``` service auth { unix_listener auth-chasquid-userdb { mode = 0660 user = chasquid } unix_listener auth-chasquid-client { mode = 0660 user = chasquid } } ``` If chasquid is running under a different user, adjust the `user = ` lines accordingly. This lets chasquid issue authentication requests to dovecot. Authentication requests sent by chasquid to dovecot will pass on the username as specified by the client. This will usually be either `user@domain`, or just `user`. ## 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. ## Troubleshooting Dovecot authentication can be tricky to troubleshoot. If you think it is not working as it should, or chasquid isn't correctly talking with it, the easiest way to check is to [increase dovecot auth logging verbosity](https://doc.dovecot.org/admin_manual/logging/?highlight=logging#logging-verbosity): ``` auth_verbose = yes auth_debug = yes ``` One common gotcha is when dovecot is set up to use `user` instead of `user@domain`. In that case you can try setting `auth_username_format = %n` to make it ignore the domain if present. [dovecot]: https://dovecot.org [chasquid]: https://blitiri.com.ar/p/chasquid chasquid-1.15.0/docs/flow.md000066400000000000000000000033221474251645300156330ustar00rootroot00000000000000 # Message flows This document explains at a high level some parts of chasquid's message processing, in particular how messages flow through the system. ## Message reception - Client connects to chasquid on the smtp or submission ports, and issues HELO/EHLO. - Client optionally performs STARTTLS. - Client optionally performs AUTH. - Check that this is done over TLS. - Client sends MAIL FROM. - Check SPF. - Check connection security level. - Client sends one or more RCPT TO. - If the destination is remote, then the user must have authenticated. - If the destination is local, check that the user exists. - Client sends DATA. - Client sends actual data, and ends it with '.' - Run the post-data hook. If the hook fails, return an error. - Parse the data contents to perform loop detection. - Add the required headers (Received, SPF results, post-data hook output). - Put it in the queue and reply success. ## Queue processing Before accepting a message: - Create a (pseudo) random internal ID for it. - For each recipient, use the alias database to expand it, add the results to the list of final recipients (which may not be email). - Save the resulting envelope (with the final recipients) to disk. Queue processing runs asynchronously, there's a goroutine for each message which does, in a loop: - For each recipient which we have not delivered yet: - Attempt delivery. - Write to disk the results. - If there are mails still pending, wait for some time (incrementally). - When all the recipients have completed delivery, or enough time has passed: - If all were successful, remove from the queue. - If some failed, send a delivery status notification back to the sender. chasquid-1.15.0/docs/haproxy.md000066400000000000000000000014371474251645300163630ustar00rootroot00000000000000 # HAProxy integration As of version 1.6, [chasquid] supports being deployed behind a [HAProxy] instance. **This is EXPERIMENTAL for now, and can change in backwards-incompatible ways.** ## Configuring HAProxy In the backend server line, set the [send-proxy] parameter to turn on the use of the PROXY protocol against chasquid. You need to set this for each of the ports that are forwarded. Only PROXY protocol v1 is supported for now. ## Configuring chasquid Add the following line to `/etc/chasquid/chasquid.conf`: ``` haproxy_incoming: true ``` That turns HAProxy support on for all incoming SMTP connections. [chasquid]: https://blitiri.com.ar/p/chasquid [HAProxy]: https://www.haproxy.org/ [send-proxy]: http://cbonte.github.io/haproxy-dconv/2.0/configuration.html#5.2-send-proxy chasquid-1.15.0/docs/hooks.md000066400000000000000000000052761474251645300160210ustar00rootroot00000000000000 # Hooks chasquid supports some functionality via hooks, which are binaries that get executed at specific points in time during delivery. They are optional, and will be skipped if they don't exist. ## Post-DATA hook After completion of DATA, but before accepting the mail for queueing, chasquid will run the command at `$config_dir/hooks/post-data`. The contents of the mail will be written to the command's stdin, and the environment is detailed below. If the exit status is 0, chasquid will move forward processing the command, and its stdout should contain headers which will be added to contents of the email (at the top). Otherwise, chasquid will respond with an error, and the last line of stdout will be passed back to the client as the error message. If the exit status is 20 the error code will be permanent, otherwise it will be temporary. This hook can be used to block based on contents, for example to check for spam or virus. See `etc/hooks/post-data` for an example. ### Environment This hook will run as the chasquid user, so be careful about permissions and privileges. The environment will contain the following variables: - `$USER`: The server's `$USER` env variable. - `$SHELL`: The server's `$SHELL` env variable. - `$PATH`: The server's `$PATH` env variable. - `$PWD`: The working directory, which will be the config directory. - `$REMOTE_ADDR`: IP address of the remote side of the connection. - `$EHLO_DOMAIN`: EHLO/HELO domain, as given by the client; sanitized for safety. - `$EHLO_DOMAIN_RAW`: Same as `$EHLO_DOMAIN`, but not sanitized; be careful as it can contain problematic characters. - `$MAIL_FROM`: MAIL FROM address. - `$RCPT_TO`: RCPT TO addresses, space separated. - `$AUTH_AS`: Authenticated user; empty if the connection has not authenticated successfully. - `$ON_TLS`: 1 if using TLS, 0 if not. - `$FROM_LOCAL_DOMAIN`: 1 if the mail comes from a local domain, 0 if not. - `$SPF_PASS`: 1 if it passed SPF, 0 if not. There is a 1 minute timeout for hook execution. It will be run at the config directory. ## Alias resolve hook When an alias needs to be resolved, chasquid will run the command at `$config_dir/hooks/alias-resolve` (if the file exists). The address to resolve will be passed as the single argument. The output of the command will be parsed as if it was the right-hand side of the aliases configuration file (see [Aliases](aliases.md) for more details). Results are appended to the results of the file-based alias resolution. If there is no alias for the address, the hook should just exit successfully without emitting any output. There is a 5 second timeout for hook execution. If the hook exits with an error, including timeout, delivery will fail. chasquid-1.15.0/docs/howto.md000066400000000000000000000161201474251645300160240ustar00rootroot00000000000000 # chasquid how-to guide This is a practical guide for setting up an email server for personal or small groups use. It does not contain many explanations, but includes links to more detailed references where possible. While a lot of the contents are generic, for simplicity it will use: - [Debian] as base operating system ([Ubuntu] also works) - [Dovecot] for [POP3]+[IMAP] - [chasquid] for [SMTP] - [Let's Encrypt] for [TLS] certificates [Debian]: https://debian.org [Ubuntu]: https://ubuntu.com [Dovecot]: https://dovecot.org [chasquid]: https://blitiri.com.ar/p/chasquid [Let's Encrypt]: https://letsencrypt.org [POP3]: https://en.wikipedia.org/wiki/Post_Office_Protocol [IMAP]: https://en.wikipedia.org/wiki/Internet_Message_Access_Protocol [SMTP]: https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol [TLS]: https://en.wikipedia.org/wiki/Transport_Layer_Security ## Example data This guide will use the following data for illustration purposes, replace them with your own where appropriate. - Domain name: `example.com`. - IPv4 address of your mail server: `198.51.100.7`. - IPv6 address of your mail server: `2001:db8::7`. Note IPv6 is optional but highly encouraged, and supported by most providers. ## Getting a server You first need to have a server to use. This could be an existing one (for example, if you already have one where you host HTTP), doesn't have to be exclusive for email. In this guide we will use a separate server, mostly for clarity. For small groups the size of the server does not matter, any small VPS (virtual private server) will do just fine. Specifically for hosting email servers, there are some things to check when selecting a provider: - Make sure they allow traffic on TCP port 25 (SMTP). While almost all VPS and dedicated server providers are fine, some "cloud" providers (like Google Cloud) block port 25, which is used for sending and receiving mails. - Once you get a server, make sure the IP addresses are not listed in any [blackhole lists]. There are many services to check them, for example [the Anti-Abuse project] or [multirbl.valli.org]. Remember to update your server regularly, setting up [unattended upgrades] is highly recommended. [the Anti-Abuse project]: http://www.anti-abuse.org/multi-rbl-check/ [blackhole lists]: https://en.wikipedia.org/wiki/DNSBL [unattended upgrades]: https://wiki.debian.org/UnattendedUpgrades [multirbl.valli.org]: http://multirbl.valli.org/ ## DNS Set up the following DNS records for `example.com`. This is usually done either in your DNS server, or in the user interface of your DNS provider. ``` ; Assign "mail.example.com" to the server's IP addresses. ; Replace these with the ones for your server. mail A 198.51.100.7 mail AAAA 2001:db8::7 ; The mail server for example.com is mail.example.com. @ MX 10 mail ; Use SPF to say that the servers in "MX" above are allowed to send email ; for this domain, and nobody else. @ TXT "v=spf1 mx -all" ``` Finally, you should go to your server provider and configure the "reverse DNS" (also known as "PTR") for the IP addresses to be to "mail.example.com". This is important, as some spam checkers will consider it a factor. *References: [A record](https://en.wikipedia.org/wiki/A_record), [MX record](https://en.wikipedia.org/wiki/MX_record), [Sender Policy Framework (SPF)](https://en.wikipedia.org/wiki/Sender_Policy_Framework).* ## TLS certificate [TLS] certificates are needed to send and receive email securely. [letsencrypt] will provide us with a free certificate, which needs to be renewed every 90 days, so the following relies on automatic renewal. Note `certbot` is the recommended letsencrypt command line client. ```shell sudo apt install certbot acl # Obtain a TLS certificate for mail.example.com. sudo certbot certonly --standalone -d mail.example.com # Give chasquid access to the certificates. # Dovecot does not need this as it reads them as root. sudo setfacl -R -m u:chasquid:rX /etc/letsencrypt/{live,archive} # Automatically restart the daemons after each certificate renewal. sudo mkdir -p /etc/letsencrypt/renewal-hooks/post cat <. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .nr rF 0 .if \n(.g .if rF .nr rF 1 .if (\n(rF:(\n(.g==0)) \{\ . if \nF \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} . \} .\} .rr rF .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "chasquid-util 1" .TH chasquid-util 1 "2023-12-03" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" chasquid\-util \- chasquid management tool .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBchasquid-util\fR [\fIoptions\fR] user-add \fIuser@domain\fR [\-\-password=\fIpassword\fR] [\-\-receive_only] .PP \&\fBchasquid-util\fR [\fIoptions\fR] user-remove \fIuser@domain\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] authenticate \fIuser@domain\fR [\-\-password=\fIpassword\fR] .PP \&\fBchasquid-util\fR [\fIoptions\fR] check-userdb \fIdomain\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] aliases-resolve \fIaddr\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] domaininfo-remove \fIdomain\fR .PP \&\fBchasquid-util\fR [\fIoptions\fR] print-config .SH "DESCRIPTION" .IX Header "DESCRIPTION" chasquid-util is a command-line utility for \fBchasquid\fR\|(1) operations. .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fBuser-add\fR \fIuser@domain\fR [\-\-password=\fIpassword\fR] [\-\-receive_only]" 8 .IX Item "user-add user@domain [--password=password] [--receive_only]" Add a new user to the domain. .Sp If \fI\-\-receive_only\fR is given, then the user will never successfully authenticate. This is useful when creating receive-only users. .IP "\fBuser-remove\fR \fIuser@domain\fR" 8 .IX Item "user-remove user@domain" Remove the user from the domain. .IP "\fBauthenticate\fR \fIuser@domain\fR [\-\-password=\fIpassword\fR]" 8 .IX Item "authenticate user@domain [--password=password]" Check the user's password. .IP "\fBcheck-userdb\fR \fIdomain\fR" 8 .IX Item "check-userdb domain" Check the integrity of the domain's users database. .IP "\fBaliases-resolve\fR \fIaddr\fR" 8 .IX Item "aliases-resolve addr" Resolve the given address. Talks to the running chasquid instance. .IP "\fBdomaininfo-remove\fR \fIdomain\fR" 8 .IX Item "domaininfo-remove domain" Remove the domain information entry. This can be used to manually allow a security level downgrade. Talks to the running chasquid instance. .IP "\fBprint-config\fR" 8 .IX Item "print-config" Parse and print the configuration in a human-readable way. .IP "\fB\-C\fR or \fB\-\-configdir=" 8 .IX Item "-C or --configdir=" Configuration directory. .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fBchasquid\fR\|(1) chasquid-1.15.0/docs/man/chasquid-util.1.md000066400000000000000000000031221474251645300203500ustar00rootroot00000000000000# NAME chasquid-util - chasquid management tool # SYNOPSIS **chasquid-util** \[_options_\] user-add _user@domain_ \[--password=_password_\] \[--receive\_only\] **chasquid-util** \[_options_\] user-remove _user@domain_ **chasquid-util** \[_options_\] authenticate _user@domain_ \[--password=_password_\] **chasquid-util** \[_options_\] check-userdb _domain_ **chasquid-util** \[_options_\] aliases-resolve _addr_ **chasquid-util** \[_options_\] domaininfo-remove _domain_ **chasquid-util** \[_options_\] print-config # DESCRIPTION chasquid-util is a command-line utility for [chasquid(1)](chasquid.1.md) operations. # OPTIONS - **user-add** _user@domain_ \[--password=_password_\] \[--receive\_only\] Add a new user to the domain. If _--receive\_only_ is given, then the user will never successfully authenticate. This is useful when creating receive-only users. - **user-remove** _user@domain_ Remove the user from the domain. - **authenticate** _user@domain_ \[--password=_password_\] Check the user's password. - **check-userdb** _domain_ Check the integrity of the domain's users database. - **aliases-resolve** _addr_ Resolve the given address. Talks to the running chasquid instance. - **domaininfo-remove** _domain_ Remove the domain information entry. This can be used to manually allow a security level downgrade. Talks to the running chasquid instance. - **print-config** Parse and print the configuration in a human-readable way. - **-C** or **--configdir=<path**> Configuration directory. # SEE ALSO [chasquid(1)](chasquid.1.md) chasquid-1.15.0/docs/man/chasquid-util.1.pod000066400000000000000000000030671474251645300205420ustar00rootroot00000000000000=head1 NAME chasquid-util - chasquid management tool =head1 SYNOPSIS B [I] user-add I [--password=I] [--receive_only] B [I] user-remove I B [I] authenticate I [--password=I] B [I] check-userdb I B [I] aliases-resolve I B [I] domaininfo-remove I B [I] print-config =head1 DESCRIPTION chasquid-util is a command-line utility for chasquid(1) operations. =head1 OPTIONS =over 8 =item B I [--password=I] [--receive_only] Add a new user to the domain. If I<--receive_only> is given, then the user will never successfully authenticate. This is useful when creating receive-only users. =item B I Remove the user from the domain. =item B I [--password=I] Check the user's password. =item B I Check the integrity of the domain's users database. =item B I Resolve the given address. Talks to the running chasquid instance. =item B I Remove the domain information entry. This can be used to manually allow a security level downgrade. Talks to the running chasquid instance. =item B Parse and print the configuration in a human-readable way. =item B<-C> or B<--configdir=> Configuration directory. =back =head1 SEE ALSO chasquid(1) chasquid-1.15.0/docs/man/chasquid.1000066400000000000000000000157161474251645300170120ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.14 (Pod::Simple 3.43) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .nr rF 0 .if \n(.g .if rF .nr rF 1 .if (\n(rF:(\n(.g==0)) \{\ . if \nF \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} . \} .\} .rr rF .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "chasquid 1" .TH chasquid 1 "2023-10-03" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" chasquid \- SMTP (email) server .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBchasquid\fR [\fIoptions\fR...] .SH "DESCRIPTION" .IX Header "DESCRIPTION" chasquid is an \s-1SMTP\s0 (email) server with a focus on simplicity, security, and ease of operation. .PP It's written in Go, and distributed under the Apache license 2.0. .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fB\-config_dir\fR \fIdir\fR" 8 .IX Item "-config_dir dir" configuration directory (default \fI/etc/chasquid\fR) .IP "\fB\-config_overrides\fR \fIconfig\fR" 8 .IX Item "-config_overrides config" configuration values (in text protobuf format) to override the on-disk configuration with. This should only be needed in very specific cases for deployments where editing the configuration file is not feasible. .IP "\fB\-alsologtostderr\fR" 8 .IX Item "-alsologtostderr" also log to stderr, in addition to the file .IP "\fB\-logfile\fR \fIfile\fR" 8 .IX Item "-logfile file" file to log to (enables logtime) .IP "\fB\-logtime\fR" 8 .IX Item "-logtime" include the time when writing the log to stderr .IP "\fB\-logtosyslog\fR \fItag\fR" 8 .IX Item "-logtosyslog tag" log to syslog, with the given tag .IP "\fB\-v\fR \fIlevel\fR" 8 .IX Item "-v level" verbosity level (1 = debug) .IP "\fB\-version\fR" 8 .IX Item "-version" show version and exit .SH "FILES" .IX Header "FILES" The daemon's configuration is by default in \fI/etc/chasquid/\fR, and can be changed with the \fI\-config_dir\fR flag. .PP Inside that directory, the daemon expects the following structure: .IP "\fIchasquid.conf\fR" 8 .IX Item "chasquid.conf" Main config file, see \fBchasquid.conf\fR\|(5). .IP "\fIdomains/\fR" 8 .IX Item "domains/" Per-domain configuration. .IP "\fIdomains/example.com/\fR" 8 .IX Item "domains/example.com/" Domain-specific configuration. Can be empty. .IP "\fIdomains/example.com/users\fR" 8 .IX Item "domains/example.com/users" User and password database for this domain. .IP "\fIdomains/example.com/aliases\fR" 8 .IX Item "domains/example.com/aliases" Aliases for the domain. .IP "\fIcerts/\fR" 8 .IX Item "certs/" Certificates to use, one directory per pair. .IP "\fIcerts/mx.example.com/\fR" 8 .IX Item "certs/mx.example.com/" Certificates for this domain. .IP "\fIcerts/mx.example.com/fullchain.pem\fR" 8 .IX Item "certs/mx.example.com/fullchain.pem" Certificate (full chain). .IP "\fIcerts/mx.example.com/privkey.pem\fR" 8 .IX Item "certs/mx.example.com/privkey.pem" Private key. .PP Note the \fIcerts/\fR directory layout matches the one from certbot (client for Let's Encrypt \s-1CA\s0), so you can just symlink \fIcerts/\fR to \&\fI/etc/letsencrypt/live\fR. .PP Make sure the user you use to run chasquid under (\*(L"mail\*(R" in the example config) can access the certificates and private keys. .SH "CONTACT" .IX Header "CONTACT" Main website . .PP If you have any questions, comments or patches please send them to the mailing list, \f(CW\*(C`chasquid@googlegroups.com\*(C'\fR. To subscribe, send an email to \&\f(CW\*(C`chasquid+subscribe@googlegroups.com\*(C'\fR. .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fBchasquid\-util\fR\|(1), \fBchasquid.conf\fR\|(5) chasquid-1.15.0/docs/man/chasquid.1.md000066400000000000000000000045241474251645300174040ustar00rootroot00000000000000# NAME chasquid - SMTP (email) server # SYNOPSIS **chasquid** \[_options_...\] # DESCRIPTION chasquid is an SMTP (email) server with a focus on simplicity, security, and ease of operation. It's written in Go, and distributed under the Apache license 2.0. # OPTIONS - **-config\_dir** _dir_ configuration directory (default `/etc/chasquid`) - **-config\_overrides** _config_ configuration values (in text protobuf format) to override the on-disk configuration with. This should only be needed in very specific cases for deployments where editing the configuration file is not feasible. - **-alsologtostderr** also log to stderr, in addition to the file - **-logfile** _file_ file to log to (enables logtime) - **-logtime** include the time when writing the log to stderr - **-logtosyslog** _tag_ log to syslog, with the given tag - **-v** _level_ verbosity level (1 = debug) - **-version** show version and exit # FILES The daemon's configuration is by default in `/etc/chasquid/`, and can be changed with the _-config\_dir_ flag. Inside that directory, the daemon expects the following structure: - `chasquid.conf` Main config file, see [chasquid.conf(5)](chasquid.conf.5.md). - `domains/` Per-domain configuration. - `domains/example.com/` Domain-specific configuration. Can be empty. - `domains/example.com/users` User and password database for this domain. - `domains/example.com/aliases` Aliases for the domain. - `certs/` Certificates to use, one directory per pair. - `certs/mx.example.com/` Certificates for this domain. - `certs/mx.example.com/fullchain.pem` Certificate (full chain). - `certs/mx.example.com/privkey.pem` Private key. Note the `certs/` directory layout matches the one from certbot (client for Let's Encrypt CA), so you can just symlink `certs/` 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. # CONTACT [Main website](https://blitiri.com.ar/p/chasquid). 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`. # SEE ALSO [chasquid-util(1)](chasquid-util.1.md), [chasquid.conf(5)](chasquid.conf.5.md) chasquid-1.15.0/docs/man/chasquid.1.pod000066400000000000000000000045321474251645300175650ustar00rootroot00000000000000=head1 NAME chasquid - SMTP (email) server =head1 SYNOPSIS B [I...] =head1 DESCRIPTION chasquid is an SMTP (email) server with a focus on simplicity, security, and ease of operation. It's written in Go, and distributed under the Apache license 2.0. =head1 OPTIONS =over 8 =item B<-config_dir> I configuration directory (default F) =item B<-config_overrides> I configuration values (in text protobuf format) to override the on-disk configuration with. This should only be needed in very specific cases for deployments where editing the configuration file is not feasible. =item B<-alsologtostderr> also log to stderr, in addition to the file =item B<-logfile> I file to log to (enables logtime) =item B<-logtime> include the time when writing the log to stderr =item B<-logtosyslog> I log to syslog, with the given tag =item B<-v> I verbosity level (1 = debug) =item B<-version> show version and exit =back =head1 FILES The daemon's configuration is by default in F, and can be changed with the I<-config_dir> flag. Inside that directory, the daemon expects the following structure: =over 8 =item F Main config file, see chasquid.conf(5). =item F Per-domain configuration. =item F Domain-specific configuration. Can be empty. =item F User and password database for this domain. =item F Aliases for the domain. =item F Certificates to use, one directory per pair. =item F Certificates for this domain. =item F Certificate (full chain). =item F Private key. =back Note the F directory layout matches the one from certbot (client for Let's Encrypt CA), so you can just symlink F to F. Make sure the user you use to run chasquid under ("mail" in the example config) can access the certificates and private keys. =head1 CONTACT L
. If you have any questions, comments or patches please send them to the mailing list, C. To subscribe, send an email to C. =head1 SEE ALSO chasquid-util(1), chasquid.conf(5) chasquid-1.15.0/docs/man/chasquid.conf.5000066400000000000000000000221711474251645300177330ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.14 (Pod::Simple 3.43) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .nr rF 0 .if \n(.g .if rF .nr rF 1 .if (\n(rF:(\n(.g==0)) \{\ . if \nF \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} . \} .\} .rr rF .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "chasquid.conf 5" .TH chasquid.conf 5 "2020-11-12" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" chasquid.conf(5) \-\- chasquid configuration file .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBchasquid.conf\fR\|(5) is \fBchasquid\fR\|(1)'s main configuration file. .SH "DESCRIPTION" .IX Header "DESCRIPTION" The file is in protocol buffers' text format. .PP Comments start with \f(CW\*(C`#\*(C'\fR. Empty lines are allowed. Values are of the form \&\f(CW\*(C`key: value\*(C'\fR. Values can be strings (quoted), integers, or booleans (\f(CW\*(C`true\*(C'\fR or \&\f(CW\*(C`false\*(C'\fR). .PP Some values might be repeated, for example the listening addresses. .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fBhostname\fR (string):" 8 .IX Item "hostname (string):" Default hostname to use when saying hello. This is used to say hello to clients (for aesthetic purposes), and as the \s-1HELO/EHLO\s0 domain on outgoing \s-1SMTP\s0 connections (so ideally it would resolve back to the server, but it isn't a big deal if it doesn't). Default: the system's hostname. .IP "\fBmax_data_size_mb\fR (int):" 8 .IX Item "max_data_size_mb (int):" Maximum email size, in megabytes. Default: 50. .IP "\fBsmtp_address\fR (repeated string):" 8 .IX Item "smtp_address (repeated string):" Addresses to listen on for \s-1SMTP\s0 (usually port 25). Default: \*(L"systemd\*(R", which means systemd passes sockets to us. systemd sockets must be named with \&\fBFileDescriptorName=smtp\fR. .IP "\fBsubmission_address\fR (repeated string):" 8 .IX Item "submission_address (repeated string):" Addresses to listen on for submission (usually port 587). Default: \*(L"systemd\*(R", which means systemd passes sockets to us. systemd sockets must be named with \&\fBFileDescriptorName=submission\fR. .IP "\fBsubmission_over_tls_address\fR (repeated string):" 8 .IX Item "submission_over_tls_address (repeated string):" Addresses to listen on for submission-over-TLS (usually port 465). Default: \&\*(L"systemd\*(R", which means systemd passes sockets to us. systemd sockets must be named with \fBFileDescriptorName=submission_tls\fR. .IP "\fBmonitoring_address\fR (string):" 8 .IX Item "monitoring_address (string):" Address for the monitoring \s-1HTTP\s0 server. Do \s-1NOT\s0 expose this to the public internet. Default: no monitoring server. .IP "\fBmail_delivery_agent_bin\fR (string):" 8 .IX Item "mail_delivery_agent_bin (string):" Mail delivery agent (\s-1MDA,\s0 also known as \s-1LDA\s0) to use. This should point to the binary to use to deliver email to local users. The content of the email will be passed via stdin. If it exits unsuccessfully, we assume the mail was not delivered. Default: \fImaildrop\fR. .IP "\fBmail_delivery_agent_args\fR (repeated string):" 8 .IX Item "mail_delivery_agent_args (repeated string):" Command line arguments for the mail delivery agent. One per argument. Some replacements will be done. .Sp On an email sent from marsnik@mars to venera@venus: .Sp .Vb 6 \& %from% \-> from address (marsnik@mars) \& %from_user% \-> from user (marsnik) \& %from_domain% \-> from domain (mars) \& %to% \-> to address (venera@venus) \& %to_user% \-> to user (venera) \& %to_domain% \-> to domain (venus) .Ve .Sp Default: \f(CW"\-f", "%from%", "\-d", "%to_user%"\fR (adequate for procmail and maildrop). .IP "\fBdata_dir\fR (string):" 8 .IX Item "data_dir (string):" Directory where we store our persistent data. Default: \&\fI/var/lib/chasquid\fR. .IP "\fBsuffix_separators\fR (string):" 8 .IX Item "suffix_separators (string):" Suffix separator, to perform suffix removal of local users. For example, if you set this to \f(CW\*(C`\-+\*(C'\fR, email to local user \f(CW\*(C`user\-blah\*(C'\fR and \&\f(CW\*(C`user+blah\*(C'\fR will be delivered to \f(CW\*(C`user\*(C'\fR. Including \f(CW\*(C`+\*(C'\fR is strongly encouraged, as it is assumed for email forwarding. Default: \f(CW\*(C`+\*(C'\fR. .IP "\fBdrop_characters\fR (string):" 8 .IX Item "drop_characters (string):" Characters to drop from the user part on local emails. For example, if you set this to \f(CW\*(C`._\*(C'\fR, email to local user \f(CW\*(C`u.se_r\*(C'\fR will be delivered to \&\f(CW\*(C`user\*(C'\fR. Default: \f(CW\*(C`.\*(C'\fR. .IP "\fBmail_log_path\fR (string):" 8 .IX Item "mail_log_path (string):" Path where to write the mail log to. If \f(CW\*(C`\*(C'\fR, log using the syslog (at \f(CW\*(C`MAIL|INFO\*(C'\fR priority). If \f(CW\*(C`\*(C'\fR, log to stdout; if \&\f(CW\*(C`\*(C'\fR, log to stderr. Default: \f(CW\*(C`\*(C'\fR. .IP "\fBdovecot_auth\fR (bool):" 8 .IX Item "dovecot_auth (bool):" Enable dovecot authentication. If true, users that are not found in chasquid's databases will be authenticated via dovecot. Default: \f(CW\*(C`false\*(C'\fR. .Sp The path to dovecot's auth sockets is autodetected, but can be manually overridden using the \f(CW\*(C`dovecot_userdb_path\*(C'\fR and \f(CW\*(C`dovecot_client_path\*(C'\fR if needed. .IP "\fBhaproxy_incoming\fR (bool):" 8 .IX Item "haproxy_incoming (bool):" \&\fB\s-1EXPERIMENTAL\s0\fR, might change in backwards-incompatible ways. .Sp If true, expect incoming \s-1SMTP\s0 connections to use the HAProxy protocol. This allows deploying chasquid behind a HAProxy server, as the address information is preserved, and \s-1SPF\s0 checks can be performed properly. Default: \f(CW\*(C`false\*(C'\fR. .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fBchasquid\fR\|(1) chasquid-1.15.0/docs/man/chasquid.conf.5.md000066400000000000000000000103771474251645300203370ustar00rootroot00000000000000# NAME [chasquid.conf(5)](chasquid.conf.5.md) -- chasquid configuration file # SYNOPSIS [chasquid.conf(5)](chasquid.conf.5.md) is [chasquid(1)](chasquid.1.md)'s main configuration file. # DESCRIPTION The file is in protocol buffers' text format. Comments start with `#`. Empty lines are allowed. Values are of the form `key: value`. Values can be strings (quoted), integers, or booleans (`true` or `false`). Some values might be repeated, for example the listening addresses. # OPTIONS - **hostname** (string): Default hostname to use when saying hello. This is used to say hello to clients (for aesthetic purposes), and as the HELO/EHLO domain on outgoing SMTP connections (so ideally it would resolve back to the server, but it isn't a big deal if it doesn't). Default: the system's hostname. - **max\_data\_size\_mb** (int): Maximum email size, in megabytes. Default: 50. - **smtp\_address** (repeated string): Addresses to listen on for SMTP (usually port 25). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with **FileDescriptorName=smtp**. - **submission\_address** (repeated string): Addresses to listen on for submission (usually port 587). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with **FileDescriptorName=submission**. - **submission\_over\_tls\_address** (repeated string): Addresses to listen on for submission-over-TLS (usually port 465). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with **FileDescriptorName=submission\_tls**. - **monitoring\_address** (string): Address for the monitoring HTTP server. Do NOT expose this to the public internet. Default: no monitoring server. - **mail\_delivery\_agent\_bin** (string): Mail delivery agent (MDA, also known as LDA) to use. This should point to the binary to use to deliver email to local users. The content of the email will be passed via stdin. If it exits unsuccessfully, we assume the mail was not delivered. Default: `maildrop`. - **mail\_delivery\_agent\_args** (repeated string): Command line arguments for the mail delivery agent. One per argument. Some replacements will be done. On an email sent from marsnik@mars to venera@venus: %from% -> from address (marsnik@mars) %from_user% -> from user (marsnik) %from_domain% -> from domain (mars) %to% -> to address (venera@venus) %to_user% -> to user (venera) %to_domain% -> to domain (venus) Default: `"-f", "%from%", "-d", "%to_user%"` (adequate for procmail and maildrop). - **data\_dir** (string): Directory where we store our persistent data. Default: `/var/lib/chasquid`. - **suffix\_separators** (string): 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: `+`. - **drop\_characters** (string): 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: `.`. - **mail\_log\_path** (string): Path where to write the mail log to. If ``, log using the syslog (at `MAIL|INFO` priority). If ``, log to stdout; if ``, log to stderr. Default: ``. - **dovecot\_auth** (bool): Enable dovecot authentication. If true, users that are not found in chasquid's databases will be authenticated via dovecot. Default: `false`. The path to dovecot's auth sockets is autodetected, but can be manually overridden using the `dovecot_userdb_path` and `dovecot_client_path` if needed. - **haproxy\_incoming** (bool): **EXPERIMENTAL**, might change in backwards-incompatible ways. If true, expect incoming SMTP connections to use the HAProxy protocol. This allows deploying chasquid behind a HAProxy server, as the address information is preserved, and SPF checks can be performed properly. Default: `false`. # SEE ALSO [chasquid(1)](chasquid.1.md) chasquid-1.15.0/docs/man/chasquid.conf.5.pod000066400000000000000000000101011474251645300205020ustar00rootroot00000000000000=head1 NAME chasquid.conf(5) -- chasquid configuration file =head1 SYNOPSIS chasquid.conf(5) is chasquid(1)'s main configuration file. =head1 DESCRIPTION The file is in protocol buffers' text format. Comments start with C<#>. Empty lines are allowed. Values are of the form C. Values can be strings (quoted), integers, or booleans (C or C). Some values might be repeated, for example the listening addresses. =head1 OPTIONS =over 8 =item B (string): Default hostname to use when saying hello. This is used to say hello to clients (for aesthetic purposes), and as the HELO/EHLO domain on outgoing SMTP connections (so ideally it would resolve back to the server, but it isn't a big deal if it doesn't). Default: the system's hostname. =item B (int): Maximum email size, in megabytes. Default: 50. =item B (repeated string): Addresses to listen on for SMTP (usually port 25). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with B. =item B (repeated string): Addresses to listen on for submission (usually port 587). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with B. =item B (repeated string): Addresses to listen on for submission-over-TLS (usually port 465). Default: "systemd", which means systemd passes sockets to us. systemd sockets must be named with B. =item B (string): Address for the monitoring HTTP server. Do NOT expose this to the public internet. Default: no monitoring server. =item B (string): Mail delivery agent (MDA, also known as LDA) to use. This should point to the binary to use to deliver email to local users. The content of the email will be passed via stdin. If it exits unsuccessfully, we assume the mail was not delivered. Default: F. =item B (repeated string): Command line arguments for the mail delivery agent. One per argument. Some replacements will be done. On an email sent from marsnik@mars to venera@venus: %from% -> from address (marsnik@mars) %from_user% -> from user (marsnik) %from_domain% -> from domain (mars) %to% -> to address (venera@venus) %to_user% -> to user (venera) %to_domain% -> to domain (venus) Default: C<"-f", "%from%", "-d", "%to_user%"> (adequate for procmail and maildrop). =item B (string): Directory where we store our persistent data. Default: F. =item B (string): Suffix separator, to perform suffix removal of local users. For example, if you set this to C<-+>, email to local user C and C will be delivered to C. Including C<+> is strongly encouraged, as it is assumed for email forwarding. Default: C<+>. =item B (string): Characters to drop from the user part on local emails. For example, if you set this to C<._>, email to local user C will be delivered to C. Default: C<.>. =item B (string): Path where to write the mail log to. If C<< >>, log using the syslog (at C priority). If C<< >>, log to stdout; if C<< >>, log to stderr. Default: C<< >>. =item B (bool): Enable dovecot authentication. If true, users that are not found in chasquid's databases will be authenticated via dovecot. Default: C. The path to dovecot's auth sockets is autodetected, but can be manually overridden using the C and C if needed. =item B (bool): B, might change in backwards-incompatible ways. If true, expect incoming SMTP connections to use the HAProxy protocol. This allows deploying chasquid behind a HAProxy server, as the address information is preserved, and SPF checks can be performed properly. Default: C. =back =head1 SEE ALSO chasquid(1) chasquid-1.15.0/docs/man/generate.sh000077500000000000000000000012661474251645300172530ustar00rootroot00000000000000#!/bin/bash # # Convert pod files to manual pages, using pod2man. # # Assumes files are named like: # .
.pod set -e for IN in *.pod; do OUT=$(basename "$IN" .pod) SECTION=${OUT##*.} NAME=${OUT%.*} # If it has not changed in git, set the mtime to the last commit that # touched the file. CHANGED=$( git status --porcelain -- "$IN" | wc -l ) if [ "$CHANGED" -eq 0 ]; then GIT_MTIME=$( git log --pretty=%at -n1 -- "$IN" ) touch -d "@$GIT_MTIME" "$IN" fi podchecker "$IN" pod2man --section="$SECTION" --name="$NAME" \ --release "" --center "" \ "$IN" "$OUT" pod2markdown "$IN" \ | sed 's@\([a-z.-]\+\)(\([1-9]\))@[\1(\2)](\1.\2.md)@g' \ > "$OUT.md" done chasquid-1.15.0/docs/man/index.md000066400000000000000000000020441474251645300165460ustar00rootroot00000000000000# Manual pages From the latest Debian package: - [chasquid(1)](https://manpages.debian.org/unstable/chasquid/chasquid.1): the main binary. - [chasquid.conf(5)](https://manpages.debian.org/unstable/chasquid/chasquid.conf.5): the configuration file and structure. - [chasquid-util(1)](https://manpages.debian.org/unstable/chasquid/chasquid-util.1): the command-line utility helper. - [mda-lmtp(1)](https://manpages.debian.org/unstable/chasquid/mda-lmtp.1): helper to integrate with LMTP delivery. - [smtp-check(1)](https://manpages.debian.org/unstable/chasquid/smtp-check.1): SMTP setup checker. From the current branch (more likely to change, but useful when doing development or running experimental versions): - [chasquid(1)](chasquid.1.md): the main binary. - [chasquid.conf(5)](chasquid.conf.5.md): the configuration file and structure. - [chasquid-util(1)](chasquid-util.1.md): the command-line utility helper. - [mda-lmtp(1)](mda-lmtp.1.md): helper to integrate with LMTP delivery. - [smtp-check(1)](smtp-check.1.md): SMTP setup checker. chasquid-1.15.0/docs/man/mda-lmtp.1000066400000000000000000000126151474251645300167170ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.14 (Pod::Simple 3.43) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .nr rF 0 .if \n(.g .if rF .nr rF 1 .if (\n(rF:(\n(.g==0)) \{\ . if \nF \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} . \} .\} .rr rF .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "mda-lmtp 1" .TH mda-lmtp 1 "2018-04-02" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" mda\-lmtp \- MDA that uses LMTP to do the mail delivery .SH "SYNOPSIS" .IX Header "SYNOPSIS" mda-lmtp [\fB\-addr_network\fR \fInet\fR] \&\fB\-addr\fR \fIaddr\fR \&\fB\-d\fR \fIrecipient\fR \&\fB\-f\fR \fIfrom\fR .SH "DESCRIPTION" .IX Header "DESCRIPTION" mda-lmtp is a very basic \s-1MDA\s0 that uses \s-1LMTP\s0 to do the mail delivery. .PP It takes command line arguments similar to maildrop or procmail, reads an email via standard input, and sends it over the given \s-1LMTP\s0 server. Supports connecting to \s-1LMTP\s0 servers over \s-1UNIX\s0 sockets and \s-1TCP.\s0 .PP It can be used when your mail server does not support \s-1LMTP\s0 directly. .SH "EXAMPLE" .IX Header "EXAMPLE" \&\fBmda-lmtp\fR \fI\-\-addr=localhost:1234\fR \fI\-f juan@casa\fR \fI\-d jose\fR < email .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fB\-addr\fR \fIaddress\fR" 8 .IX Item "-addr address" \&\s-1LMTP\s0 server address to connect to. .IP "\fB\-addr_network\fR \fInetwork\fR" 8 .IX Item "-addr_network network" Network of the \s-1LMTP\s0 address (e.g. \fIunix\fR or \fItcp\fR). If not present, it will be autodetected from the address itself. .IP "\fB\-d\fR \fIrecipient\fR" 8 .IX Item "-d recipient" Recipient. .IP "\fB\-f\fR \fIfrom\fR" 8 .IX Item "-f from" Whom the message is from. .SH "RETURN VALUES" .IX Header "RETURN VALUES" .IP "\fB0\fR" 8 .IX Item "0" success .IP "\fB75\fR" 8 .IX Item "75" temporary failure .IP "\fIother\fR" 8 .IX Item "other" permanent failures (usually indicate misconfiguration) .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fBchasquid\fR\|(1) chasquid-1.15.0/docs/man/mda-lmtp.1.md000066400000000000000000000021251474251645300173110ustar00rootroot00000000000000# NAME mda-lmtp - MDA that uses LMTP to do the mail delivery # SYNOPSIS mda-lmtp \[**-addr\_network** _net_\] **-addr** _addr_ **-d** _recipient_ **-f** _from_ # DESCRIPTION mda-lmtp is a very basic MDA that uses LMTP to do the mail delivery. It takes command line arguments similar to maildrop or procmail, reads an email via standard input, and sends it over the given LMTP server. Supports connecting to LMTP servers over UNIX sockets and TCP. It can be used when your mail server does not support LMTP directly. # EXAMPLE **mda-lmtp** _--addr=localhost:1234_ _-f juan@casa_ _-d jose_ < email # OPTIONS - **-addr** _address_ LMTP server address to connect to. - **-addr\_network** _network_ Network of the LMTP address (e.g. _unix_ or _tcp_). If not present, it will be autodetected from the address itself. - **-d** _recipient_ Recipient. - **-f** _from_ Whom the message is from. # RETURN VALUES - **0** success - **75** temporary failure - _other_ permanent failures (usually indicate misconfiguration) # SEE ALSO [chasquid(1)](chasquid.1.md) chasquid-1.15.0/docs/man/mda-lmtp.1.pod000066400000000000000000000022051474251645300174720ustar00rootroot00000000000000 =head1 NAME mda-lmtp - MDA that uses LMTP to do the mail delivery =head1 SYNOPSIS mda-lmtp [B<-addr_network> I] B<-addr> I B<-d> I B<-f> I =head1 DESCRIPTION mda-lmtp is a very basic MDA that uses LMTP to do the mail delivery. It takes command line arguments similar to maildrop or procmail, reads an email via standard input, and sends it over the given LMTP server. Supports connecting to LMTP servers over UNIX sockets and TCP. It can be used when your mail server does not support LMTP directly. =head1 EXAMPLE B I<--addr=localhost:1234> I<-f juan@casa> I<-d jose> < email =head1 OPTIONS =over 8 =item B<-addr> I
LMTP server address to connect to. =item B<-addr_network> I Network of the LMTP address (e.g. I or I). If not present, it will be autodetected from the address itself. =item B<-d> I Recipient. =item B<-f> I Whom the message is from. =back =head1 RETURN VALUES =over 8 =item B<0> success =item B<75> temporary failure =item I permanent failures (usually indicate misconfiguration) =back =head1 SEE ALSO chasquid(1) chasquid-1.15.0/docs/man/smtp-check.1000066400000000000000000000112141474251645300172340ustar00rootroot00000000000000.\" Automatically generated by Pod::Man 4.14 (Pod::Simple 3.43) .\" .\" Standard preamble: .\" ======================================================================== .de Sp \" Vertical space (when we can't use .PP) .if t .sp .5v .if n .sp .. .de Vb \" Begin verbatim text .ft CW .nf .ne \\$1 .. .de Ve \" End verbatim text .ft R .fi .. .\" Set up some character translations and predefined strings. \*(-- will .\" give an unbreakable dash, \*(PI will give pi, \*(L" will give a left .\" double quote, and \*(R" will give a right double quote. \*(C+ will .\" give a nicer C++. Capital omega is used to do unbreakable dashes and .\" therefore won't be available. \*(C` and \*(C' expand to `' in nroff, .\" nothing in troff, for use with C<>. .tr \(*W- .ds C+ C\v'-.1v'\h'-1p'\s-2+\h'-1p'+\s0\v'.1v'\h'-1p' .ie n \{\ . ds -- \(*W- . ds PI pi . if (\n(.H=4u)&(1m=24u) .ds -- \(*W\h'-12u'\(*W\h'-12u'-\" diablo 10 pitch . if (\n(.H=4u)&(1m=20u) .ds -- \(*W\h'-12u'\(*W\h'-8u'-\" diablo 12 pitch . ds L" "" . ds R" "" . ds C` "" . ds C' "" 'br\} .el\{\ . ds -- \|\(em\| . ds PI \(*p . ds L" `` . ds R" '' . ds C` . ds C' 'br\} .\" .\" Escape single quotes in literal strings from groff's Unicode transform. .ie \n(.g .ds Aq \(aq .el .ds Aq ' .\" .\" If the F register is >0, we'll generate index entries on stderr for .\" titles (.TH), headers (.SH), subsections (.SS), items (.Ip), and index .\" entries marked with X<> in POD. Of course, you'll have to process the .\" output yourself in some meaningful fashion. .\" .\" Avoid warning from groff about undefined register 'F'. .de IX .. .nr rF 0 .if \n(.g .if rF .nr rF 1 .if (\n(rF:(\n(.g==0)) \{\ . if \nF \{\ . de IX . tm Index:\\$1\t\\n%\t"\\$2" .. . if !\nF==2 \{\ . nr % 0 . nr F 2 . \} . \} .\} .rr rF .\" .\" Accent mark definitions (@(#)ms.acc 1.5 88/02/08 SMI; from UCB 4.2). .\" Fear. Run. Save yourself. No user-serviceable parts. . \" fudge factors for nroff and troff .if n \{\ . ds #H 0 . ds #V .8m . ds #F .3m . ds #[ \f1 . ds #] \fP .\} .if t \{\ . ds #H ((1u-(\\\\n(.fu%2u))*.13m) . ds #V .6m . ds #F 0 . ds #[ \& . ds #] \& .\} . \" simple accents for nroff and troff .if n \{\ . ds ' \& . ds ` \& . ds ^ \& . ds , \& . ds ~ ~ . ds / .\} .if t \{\ . ds ' \\k:\h'-(\\n(.wu*8/10-\*(#H)'\'\h"|\\n:u" . ds ` \\k:\h'-(\\n(.wu*8/10-\*(#H)'\`\h'|\\n:u' . ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'^\h'|\\n:u' . ds , \\k:\h'-(\\n(.wu*8/10)',\h'|\\n:u' . ds ~ \\k:\h'-(\\n(.wu-\*(#H-.1m)'~\h'|\\n:u' . ds / \\k:\h'-(\\n(.wu*8/10-\*(#H)'\z\(sl\h'|\\n:u' .\} . \" troff and (daisy-wheel) nroff accents .ds : \\k:\h'-(\\n(.wu*8/10-\*(#H+.1m+\*(#F)'\v'-\*(#V'\z.\h'.2m+\*(#F'.\h'|\\n:u'\v'\*(#V' .ds 8 \h'\*(#H'\(*b\h'-\*(#H' .ds o \\k:\h'-(\\n(.wu+\w'\(de'u-\*(#H)/2u'\v'-.3n'\*(#[\z\(de\v'.3n'\h'|\\n:u'\*(#] .ds d- \h'\*(#H'\(pd\h'-\w'~'u'\v'-.25m'\f2\(hy\fP\v'.25m'\h'-\*(#H' .ds D- D\\k:\h'-\w'D'u'\v'-.11m'\z\(hy\v'.11m'\h'|\\n:u' .ds th \*(#[\v'.3m'\s+1I\s-1\v'-.3m'\h'-(\w'I'u*2/3)'\s-1o\s+1\*(#] .ds Th \*(#[\s+2I\s-2\h'-\w'I'u*3/5'\v'-.3m'o\v'.3m'\*(#] .ds ae a\h'-(\w'a'u*4/10)'e .ds Ae A\h'-(\w'A'u*4/10)'E . \" corrections for vroff .if v .ds ~ \\k:\h'-(\\n(.wu*9/10-\*(#H)'\s-2\u~\d\s+2\h'|\\n:u' .if v .ds ^ \\k:\h'-(\\n(.wu*10/11-\*(#H)'\v'-.4m'^\v'.4m'\h'|\\n:u' . \" for low resolution devices (crt and lpr) .if \n(.H>23 .if \n(.V>19 \ \{\ . ds : e . ds 8 ss . ds o a . ds d- d\h'-1'\(ga . ds D- D\h'-1'\(hy . ds th \o'bp' . ds Th \o'LP' . ds ae ae . ds Ae AE .\} .rm #[ #] #H #V #F C .\" ======================================================================== .\" .IX Title "smtp-check 1" .TH smtp-check 1 "2023-07-14" "" "" .\" For nroff, turn off justification. Always turn off hyphenation; it makes .\" way too many mistakes in technical documents. .if n .ad l .nh .SH "NAME" smtp\-check \- SMTP setup checker .SH "SYNOPSIS" .IX Header "SYNOPSIS" \&\fBsmtp-check\fR [\-port \fIport\fR] [\-localname \fIdomain\fR] [\-skip_tls_check] \fIdomain\fR .SH "DESCRIPTION" .IX Header "DESCRIPTION" smtp-check is a command-line too for checking \s-1SMTP\s0 setups (\s-1DNS\s0 records, \s-1TLS\s0 certificates, \s-1SPF,\s0 etc.). .SH "OPTIONS" .IX Header "OPTIONS" .IP "\fB\-port\fR \fIport\fR:" 8 .IX Item "-port port:" Port to use for connecting to the \s-1MX\s0 servers. .IP "\fB\-localname\fR \fIdomain\fR:" 8 .IX Item "-localname domain:" Local name to use for the \s-1EHLO\s0 command. .IP "\fB\-skip_tls_check\fR:" 8 .IX Item "-skip_tls_check:" Skip \s-1TLS\s0 check (useful if connections are blocked). .SH "SEE ALSO" .IX Header "SEE ALSO" \&\fBchasquid\fR\|(1) chasquid-1.15.0/docs/man/smtp-check.1.md000066400000000000000000000010331474251645300176310ustar00rootroot00000000000000# NAME smtp-check - SMTP setup checker # SYNOPSIS **smtp-check** \[-port _port_\] \[-localname _domain_\] \[-skip\_tls\_check\] _domain_ # DESCRIPTION smtp-check is a command-line too for checking SMTP setups (DNS records, TLS certificates, SPF, etc.). # OPTIONS - **-port** _port_: Port to use for connecting to the MX servers. - **-localname** _domain_: Local name to use for the EHLO command. - **-skip\_tls\_check**: Skip TLS check (useful if connections are blocked). # SEE ALSO [chasquid(1)](chasquid.1.md) chasquid-1.15.0/docs/man/smtp-check.1.pod000066400000000000000000000010531474251645300200150ustar00rootroot00000000000000=head1 NAME smtp-check - SMTP setup checker =head1 SYNOPSIS B [-port I] [-localname I] [-skip_tls_check] I =head1 DESCRIPTION smtp-check is a command-line too for checking SMTP setups (DNS records, TLS certificates, SPF, etc.). =head1 OPTIONS =over 8 =item B<-port> I: Port to use for connecting to the MX servers. =item B<-localname> I: Local name to use for the EHLO command. =item B<-skip_tls_check>: Skip TLS check (useful if connections are blocked). =back =head1 SEE ALSO chasquid(1) chasquid-1.15.0/docs/monitoring.md000066400000000000000000000120411474251645300170470ustar00rootroot00000000000000 # Monitoring chasquid includes an HTTP server for monitoring purposes, which for security it is not enabled by default. You can use the `monitoring_address` configuration option to enable it. Then just browse the address and human-friendly links to various monitoring and debugging tools should appear. These include: - Command-line flags. - [Traces](https://pkg.go.dev/blitiri.com.ar/go/chasquid/internal/trace) of both short and long lived requests. - State of the queue. - State of goroutines. - [Exported variables](#variables) for whitebox monitoring. - Profiling endpoints, for use with `go tool pprof` or similar tools. ## Variables chasquid exports some variables for monitoring, via the standard [expvar](https://golang.org/pkg/expvar/) package and the [OpenMetrics](https://openmetrics.io/) text format, which can be useful for whitebox monitoring. They're accessible on the monitoring HTTP server, at `/debug/vars` (default endpoint for expvars) and `/metrics` (common endpoint for openmetrics). The `/metrics` endpoint is also compatible with [Prometheus](https://prometheus.io/). *Note these are still subject to change, although breaking changes will be avoided whenever possible, and will be noted in the [release notes](relnotes.md).* List of exported variables: - **chasquid/aliases/hookResults** (hook result -> counter) count of aliases hook results, by hook and result. - **chasquid/queue/deliverAttempts** (recipient type -> counter) attempts to deliver mail, by recipient type (pipe/local email/remote email). - **chasquid/queue/dsnQueued** (counter) count of DSNs that we generated (queued). - **chasquid/queue/itemsWritten** (counter) count of items the queue wrote to disk. - **chasquid/queue/putCount** (counter) number of envelopes put in the queue. - **chasquid/smtpIn/commandCount** (map of command -> count) count of SMTP commands received, by command. Note that for unknown commands we use `unknown`. - **chasquid/smtpIn/dkimSignErrors** (counter) count of DKIM sign errors - **chasquid/smtpIn/dkimSigned** (counter) count of successful DKIM signs - **chasquid/smtpIn/dkimVerifyErrors** (counter) count of DKIM verification errors - **chasquid/smtpIn/dkimVerifyFound** (counter) count of messages with at least one DKIM signature - **chasquid/smtpIn/dkimVerifyNotFound** (counter) count of messages with no DKIM signatures - **chasquid/smtpIn/dkimVerifyValid** (counter) count of messages with at least one valid DKIM signature - **chasquid/smtpIn/hookResults** (result -> counter) count of hook invocations, by result. - **chasquid/smtpIn/loopsDetected** (counter) count of email loops detected. - **chasquid/smtpIn/responseCodeCount** (code -> counter) count of response codes returned to incoming SMTP connections, by result code. - **chasquid/smtpIn/securityLevelChecks** (result -> counter) count of security level checks on incoming connections, by result. - **chasquid/smtpIn/spfResultCount** (result -> counter) count of SPF checks, by result. - **chasquid/smtpIn/tlsCount** (tls status -> counter) count of TLS statuses (plain/tls) for incoming SMTP connections. - **chasquid/smtpIn/wrongProtoCount** (command -> counter) count of commands for other protocols (e.g. HTTP commands). - **chasquid/smtpOut/securityLevelChecks** (result -> counter) count of security level checks on outgoing connections, by result. - **chasquid/smtpOut/sts/mode** (mode -> counter) count of STS checks on outgoing connections, by mode (enforce/testing). - **chasquid/smtpOut/sts/security** (result -> counter) count of STS security checks on outgoing connections, by result (pass/fail). - **chasquid/smtpOut/tlsCount** (status -> counter) count of TLS status (insecure TLS/secure TLS/plain) on outgoing connections. - **chasquid/sourceDateStr** (string) timestamp when the binary was built, in human readable format. - **chasquid/sourceDateTimestamp** (int) timestamp when the binary was built, in seconds since epoch. - **chasquid/sts/cache/expired** (counter) count of expired entries in the STS cache. - **chasquid/sts/cache/failedFetch** (counter) count of failed fetches in the STS cache. - **chasquid/sts/cache/fetches** (counter) count of total fetches in the STS cache. - **chasquid/sts/cache/hits** (counter) count of hits in the STS cache. - **chasquid/sts/cache/invalid** (counter) count of invalid policies in the STS cache. - **chasquid/sts/cache/ioErrors** (counter) count of I/O errors when maintaining the STS cache. - **chasquid/sts/cache/marshalErrors** (counter) count of marshaling errors when maintaining the STS cache. - **chasquid/sts/cache/refreshCycles** (counter) count of STS cache refresh cycles. - **chasquid/sts/cache/refreshErrors** (counter) count of STS cache refresh errors. - **chasquid/sts/cache/refreshes** (counter) count of STS cache refreshes. - **chasquid/sts/cache/unmarshalErrors** (counter) count of unmarshaling errors in the STS cache. - **chasquid/version** (string) version string. chasquid-1.15.0/docs/relnotes.md000066400000000000000000000167751474251645300165370ustar00rootroot00000000000000 # Release notes This file contains notes for each release, summarizing changes and explicitly noting backward-incompatible changes or known security issues. ## 1.15.0 (2025-01-17) - Exit if there's an error reading users/aliases files on startup. - Log how many things were loaded for each domain. - Add fail2ban filter configuration example. ## 1.14.0 (2024-04-21) - Add built-in [DKIM](dkim.md) signing and verification. - Rename `master` branch to `main`. Docker users pulling from the `master` docker label should update the label accordingly. No action is needed if using `latest`. - Starting with this release, version numbers will be [SemVer](https://semver.org/)-compatible, to help integration with other software that expects it (e.g. [pkg.go.dev](https://pkg.go.dev/)). ## 1.13 (2023-12-24) Security fixes: - Strict CRLF enforcement in DATA contents, to prevent [SMTP smuggling attacks](https://www.postfix.org/smtp-smuggling.html) ([CVE-2023-52354](https://nvd.nist.gov/vuln/detail/CVE-2023-52354)). \ [RFC5322](https://www.rfc-editor.org/rfc/rfc5322#section-2.3) and [RFC5321](https://www.rfc-editor.org/rfc/rfc5321#section-2.3.8) say that the only valid newline terminator in SMTP is CRLF. \ When an invalid newline terminator is found in an incoming message, the connection is now aborted immediately (previous releases also accepted LF-terminated lines). \ The MTA courier now uses CRLF-terminated lines (previous releases used LF-terminated lines). Other changes: - Add support for receive-only users. - Reject empty listening addresses, to help prevent accidental misconfiguration. To prevent chasquid from listening, just comment out the entry in the config. - `docker/add-user.sh`: Support getting email and password from env variables. ## 1.12 (2023-10-07) - Support [aliases with drop characters and suffix separators](aliases.md#drop-characters-and-suffix-separators). - Improved delivery on some low-level TLS negotiation errors. - `smtp-check`: Add flag to specify local name. - `chasquid-util`: `aliases-resolve` and `domaininfo-remove` subcommands now talk to the running server. That results in more authoritative answers, and performance improvements. - `chasquid-util`: Remove `aliases-add` subcommand. This was an undocumented command that was added a while ago, and there is no need for it anymore. - Handle symlinks under the `certs/` directory. ## 1.11 (2023-02-19) - New tracing library for improved observability. - Update [fuzz tests](tests.md#fuzz-tests) to the new standard infrastructure. ### 1.11.1 (2023-12-26) Backport the security fixes from 1.13 (*Strict CRLF enforcement in DATA contents*, fixes [CVE-2023-52354](https://nvd.nist.gov/vuln/detail/CVE-2023-52354)). ## 1.10 (2022-09-01) - Support [catch-all aliases](aliases.md#catch-all). - Fix bug in Docker image with user-provided certificates. - Miscellaneous test improvements. ## 1.9 (2022-03-05) - Improve certificate validation logic in the SMTP courier. - Remove `alias-exists` hook, and improve aliases resolution logic. - Support `""` values for `drop_characters` and `suffix_separators` in the configuration file. ## 1.8 (2021-07-30) - Stricter error checking to help prevent cross-protocol attacks (like [ALPACA](https://alpaca-attack.com/)). - Allow authenticating users without an `@domain` part. - Add integration for [chasquid-rspamd](https://github.com/Thor77/chasquid-rspamd) and [dkimpy](https://launchpad.net/dkimpy/) in the example hook. - Add a `-to_puny` option to mda-lmtp, to punycode-encode addresses. - Use `application/openmetrics-text` as content type in the openmetrics exporter. ## 1.7 (2021-05-31) - chasquid-util no longer depends on the unmaintained docopt-go. If you relied on undocumented parsing behaviour before, your invocations may need adjustment. In particular, `--a b` is no longer supported, and `--a=b` must be used instead. - Improve handling of errors when talking to Dovecot for authentication. - Fix handling of `hostname` option in the Docker image. - Miscellaneous documentation and test improvements. ## 1.6 (2020-11-22) - Pass the EHLO domain to the post-data hook. - Add /exit endpoint to monitoring server. - Implement HAProxy protocol support (experimental). - Documentation updates. ## 1.5 (2020-09-12) - Add OpenMetrics exporter (compatible with Prometheus). - Support log rotation via SIGHUP, and other misc. logging improvements. - Fix error code on transient authentication issues. - Fix rspamd greylist action handling in the default hook. - Miscellaneous monitoring server improvements. ## 1.4 (2020-05-22) - Use the configured hostname in outgoing SMTP HELO/EHLO. - Allow config overrides from the command line. - Miscellaneous test improvements and code cleanups. ## 1.3 (2020-04-12) - Improved handling of DNS temporary errors. - Documentation updates (use of SRS when forwarding, Dovecot troubleshooting, Arch installation instructions). - Miscellaneous test improvements and cleanups. ## 1.2 (2019-12-06) Security fixes: - DoS through memory exhaustion due to not limiting the line length (on both incoming and outgoing connections). Thanks to Max Mazurov (fox.cpp@disroot.org) for the initial report. Release notes: - Fix handling of excessive long lines on incoming and outgoing connections. - Better error codes when DATA size exceeded the maximum. - New documentation sections (monitoring, release notes). - Many miscellaneous test improvements. ## 1.1 (2019-10-26) - Added hooks for aliases resolution. - Added rspamd integration in the default post-data hook. - Added chasquid-util aliases-add subcommand. - Expanded SPF support. - Documentation and test improvements. - Minor bug fixes. ## 1.0 (2019-07-15) No backwards-incompatible changes. No more are expected within this major version. - Fixed a bug on early connection deadline handling. - Make DSN tidier, especially in handling multi-line errors. - Miscellaneous test improvements. ## 0.07 (2019-01-19) No backwards-incompatible changes. - Send enhanced status codes. - Internationalized Delivery Status Notifications (DSN). - Miscellaneous test improvements. - DKIM integration examples and test. ## 0.06 (2018-07-22) No backwards-incompatible changes. - New MTA-STS (Strict Transport Security) checking. ## 0.05 (2018-06-05) No backwards-incompatible changes. - Lots of new tests. - Added a how-to and manual pages. - Periodic reload of domaininfo, support removing entries manually. - Dovecot auth support no longer considered experimental. ## 0.04 (2018-02-10) No backwards-incompatible changes. - Add Dovecot authentication support (experimental). - Miscellaneous bug fixes to mda-lmtp and tests. ## 0.03 (2017-07-15) **Backwards-incompatible changes:** - The default MTA binary has changed. It's now maildrop by default. If you relied on procmail being the default, add the following to `/etc/chasquid/chasquid.conf`: `mail_delivery_agent_bin: "procmail"`. - chasquid now listens on a third port, submission-on-TLS. If using systemd, copy the `etc/systemd/system/chasquid-submission_tls.socket` file to `/etc/systemd/system/`, and start it. Release notes: - Support submission (directly) over TLS (submissions/smtps/port 465). - Change the default MDA binary to `maildrop`. - Add a very basic MDA that uses LMTP to do the mail delivery. ## 0.02 (2017-03-03) No backwards-incompatible changes. - Improved configuration checks and safeguards. - Fall back through the MX list on errors. - Experimental MTA-STS implementation (disabled by default). ## 0.01 (2016-11-03) Initial release. chasquid-1.15.0/docs/sec-levels.md000066400000000000000000000141751474251645300167360ustar00rootroot00000000000000 # Security level checks chasquid tracks per-domain TLS support, and uses it to prevent connection downgrading. Incoming and outgoing connections are tracked independently, but the principle of operation is the same: once a domain shows it can establish secure connections, chasquid will reject lower-security connections from/to its servers. This is very different from other MTAs, and has some tradeoffs. ## Outgoing connections An outgoing connection has one of 3 security levels, which are (in order): 1. Plain: connection is plain-text (the server does not support TLS). 2. TLS insecure: TLS connection established, but the certificate itself was not valid. 3. TLS secure: TLS connection established, with a valid certificate. When establishing an outgoing connection, chasquid will always attempt to negotiate up to the *TLS secure* level. After the negotiation, it will compare which level it got, with the previously recorded value for this domain: * If the connection level is lower than the recorded value, then the connection will be dropped, and the delivery will fail (with a transient failure). The delivery will be retried as usual (using other MXs if available, and repeat after some delay). * If the connection level is the same as the recorded value, then the connection will proceed. * If the connection level is higher, chasquid will record this new value, and proceed. If there is no previously recorded value for this domain, a *plain* level is assumed. ### Certificate validation A certificate is considered valid if it satisfies all of the following conditions: 1. The certificate is properly signed by one of the system roots. 2. The name used to contact the server (e.g. the name from the MX record) is declared in the certificate. This is the standard method used in other services such as HTTPS; however, there is no standard to do certificate validation on SMTP. chasquid chooses to implement validation this way, which is also consistent with MTA-STS and HTTPS, but it is not universally agreed upon. It's also why the "TLS insecure" state exists, instead of the connection being rejected directly. ### Tradeoffs Almost all other MTAs do TLS negotiation but accept *all* certificates, even self-signed or expired ones. chasquid operates differently, as described above. The main advantage is that, *with domains where secure connections were previously established*, chasquid will detect connection downgrading (caused by malicious interception such as STARTTLS blocking, as well as misconfiguration such as incorrectly configured or expired certificates), and avoid communicating insecurely. The main disadvantage is that if a domain changes the configuration to a lower security level, chasquid will fail the delivery (returning a message to the sender explaining why). Because there is no formal standard for TLS certificate validation, and most MTAs will deliver email in this situation, the domain owners might not see this as a problem and thus require [manual intervention](#manual-override) on the chasquid side to explicitly allow it. ### MTA-STS [MTA-STS](https://tools.ietf.org/html/rfc8461) is a relatively new standard which defines a mechanism enabling mail service providers to declare their ability to receive TLS connections, amongst other things. It is supported by chasquid, out of the box, and in practice it means that for domains that advertise MTA-STS support, the *secure* level will be enforced even if the domain was previously unknown. ## Incoming connections Incoming connections from authenticated users are always done over TLS (chasquid will never accept authentication over plaintext connections). This section applies only to incoming connections from other SMTP servers. An incoming connection from another SMTP server is first checked through [SPF](https://en.wikipedia.org/wiki/Sender_Policy_Framework). If the result of the check is negative (fail, softfail, neutral, or error), then the following is skipped. This prevents a malicious agent from raising the level and interfering with legitimate plaintext delivery. After the SPF check has passed, the connection is assigned one of the 2 security levels, which are (in order): 1. Plain: connection is plain-text (client did not do TLS negotiation). 2. TLS client: connection is over TLS. At this point, chasquid will compare the level with the previously recorded value for this domain: * If the connection level is lower than the recorded value, then the connection is rejected with an SMTP error. * If the connection level is the same as the recorded value, then the connection is allowed. * If the connection level is higher, chasquid will record this new value, and the connection is allowed. If there is no previously recorded value for this domain, a *plain* level is assumed. ### Tradeoffs Almost all other MTAs accept server to server connections regardless of the security level, because there is no way for a domain to advertise that it will always negotiate TLS when sending email. chasquid operates differently, assuming that once a server negotiates TLS, it will always attempt to do so. The main advantage is that, *with domains that had previously used TLS for incoming connections*, chasquid will detect connection downgrading (caused by malicious interception such as STARTTLS blocking), and avoid communicating insecurely. The main disadvantage is that if a domain changes the configuration and is unable to negotiate TLS, chasquid will reject the connection and not receive incoming email from this server. This is unusual nowadays, but because other MTAs will accept the connection anyway, domain owners might not even notice there is a problem, and might require [manual intervention](#manual-override) on the chasquid side to explicitly allow it. ## Accepting lower security levels {#manual-override} If a domain changes its configuration to a lower security level and is causing chasquid to fail delivery, you can use `chasquid-util domaininfo-remove ` to make the server forget about that domain. Then, the next time there is a connection, there is no high security expectation so it will proceed just fine, regardless of the level that was negotiated. chasquid-1.15.0/docs/security.md000066400000000000000000000003621474251645300165340ustar00rootroot00000000000000# Reporting a security issue To privately report a suspected security issue, you can email albertito@blitiri.com.ar, or do it via [GitHub's Security tab]. Thank you! [GitHub's Security tab]: https://github.com/albertito/chasquid/security chasquid-1.15.0/docs/tests.md000077700000000000000000000000001474251645300204722../test/README.mdustar00rootroot00000000000000chasquid-1.15.0/etc/000077500000000000000000000000001474251645300141655ustar00rootroot00000000000000chasquid-1.15.0/etc/chasquid/000077500000000000000000000000001474251645300157665ustar00rootroot00000000000000chasquid-1.15.0/etc/chasquid/README000066400000000000000000000016221474251645300166470ustar00rootroot00000000000000 This directory contains chasquid's configuration. - chasquid.conf Main config file. - domains/ Domains' data. - example.com/ - users User and password database for the domain. - aliases Aliases for the domain. ... - certs/ Certificates to use, one dir per pair. - example.com/ - fullchain.pem Certificate (full chain). - privkey.pem Private key. ... Note the certs/ directory matches certbot's structure, so if you use it you can just symlink to /etc/letsencrypt/live. You need at least one certificate, or the server will refuse to start. Ideally there should be a certificate for each DNS name pointing to you. Make sure the user you use to run chasquid under ("mail" in the example systemd files) can access the certificates and private keys. The user databases can be created and edited with the chasquid-util tool. chasquid-1.15.0/etc/chasquid/certs000077700000000000000000000000001474251645300231732/etc/letsencrypt/live/ustar00rootroot00000000000000chasquid-1.15.0/etc/chasquid/chasquid.conf000066400000000000000000000073741474251645300204510ustar00rootroot00000000000000 # Default hostname to use when saying hello. # This is used to say hello to clients (for aesthetic purposes), and as the # HELO/EHLO domain on outgoing SMTP connections (so ideally it would resolve # back to the server, but it isn't a big deal if it doesn't). # 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" # 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". #submission_over_tls_address: "systemd" submission_over_tls_address: ":465" # 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). # If "", log to stdout; if "", log to stderr. # Default: #mail_log_path: "" # Enable dovecot authentication. # If set to true, users not found in chasquid's user databases will be # authenticated via dovecot. # Default: false #dovecot_auth: false # Dovecot userdb and client socket paths. # Most of the time this is not needed, as chasquid will auto-detect their # location by searching standard paths. # Default: "" (autodetect) #dovecot_userdb_path: "" #dovecot_client_path: "" # Expect incoming SMTP connections to use the HAProxy protocol. # EXPERIMENTAL - Might change in backwards-incompatible ways. # If set to true, this allows deploying chasquid behind a HAProxy server, as # the address information is preserved, and SPF checks can be performed # properly. # Default: false #haproxy_incoming: false chasquid-1.15.0/etc/chasquid/domains/000077500000000000000000000000001474251645300174205ustar00rootroot00000000000000chasquid-1.15.0/etc/chasquid/domains/.gitignore000066400000000000000000000000001474251645300213760ustar00rootroot00000000000000chasquid-1.15.0/etc/chasquid/hooks/000077500000000000000000000000001474251645300171115ustar00rootroot00000000000000chasquid-1.15.0/etc/chasquid/hooks/post-data000077500000000000000000000044311474251645300207350ustar00rootroot00000000000000#!/bin/bash # # This file is an example post-data hook that will run standard filtering # utilities if they are available. # # - greylist (from greylistd) to do greylisting. # - spamc (from Spamassassin) to filter spam. # - rspamc (from rspamd) or chasquid-rspamd 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 # Spam filter through rspamd. # # Use chasquid-rspamd (from https://github.com/Thor77/chasquid-rspamd) if # available, otherwise fall back to rspamc. if command -v chasquid-rspamd >/dev/null; then chasquid-rspamd < "$TF" 2>/dev/null elif command -v rspamc >/dev/null; then # Note the actions emitted by rspamc come from the thresholds # configured in /etc/rspamd/actions.conf. # The ones handled here are common defaults, but they might require # adjusting to match your rspamd configuration. # Note that greylisting is disabled in rspamc by design, so the # "greylist" action is ignored here to prevent false rejections. ACTION=$( rspamc < "$TF" 2>/dev/null | grep Action: | cut -d " " -f 2- ) case "$ACTION" in reject) echo "spam detected" exit 20 # permanent error ;; esac echo "X-Spam-Action:" "$ACTION" fi if command -v clamdscan >/dev/null; then if ! clamdscan --no-summary --infected - < "$TF" 1>&2 ; then echo "virus detected" exit 20 # permanent fi echo "X-Virus-Scanned: pass" fi chasquid-1.15.0/etc/fail2ban/000077500000000000000000000000001474251645300156435ustar00rootroot00000000000000chasquid-1.15.0/etc/fail2ban/filter.d/000077500000000000000000000000001474251645300173525ustar00rootroot00000000000000chasquid-1.15.0/etc/fail2ban/filter.d/chasquid.conf000066400000000000000000000003771474251645300220310ustar00rootroot00000000000000# fail2ban filter config for chasquid SMTP server. [INCLUDES] before = common.conf [Definition] # Identify addresses that triggered an SMTP connection error. failregex = SMTP.Conn :\d+: error: [Init] journalmatch = _SYSTEMD_UNIT=chasquid.service chasquid-1.15.0/etc/systemd/000077500000000000000000000000001474251645300156555ustar00rootroot00000000000000chasquid-1.15.0/etc/systemd/system/000077500000000000000000000000001474251645300172015ustar00rootroot00000000000000chasquid-1.15.0/etc/systemd/system/chasquid.service000066400000000000000000000006111474251645300223620ustar00rootroot00000000000000[Unit] Description=chasquid mail daemon (service) [Service] ExecStart=/usr/local/bin/chasquid \ # -v=3 \ # --log_dir=/var/log/chasquid/ \ # --alsologtostderr \ Type=simple Restart=always User=mail Group=mail # Let chasquid listen on ports < 1024. AmbientCapabilities=CAP_NET_BIND_SERVICE # Simple security measures just in case. ProtectSystem=full [Install] WantedBy=multi-user.target chasquid-1.15.0/go.mod000066400000000000000000000006601474251645300145220ustar00rootroot00000000000000module blitiri.com.ar/go/chasquid go 1.21 require ( blitiri.com.ar/go/log v1.1.0 blitiri.com.ar/go/spf v1.5.1 blitiri.com.ar/go/systemd v1.1.0 github.com/google/go-cmp v0.6.0 golang.org/x/crypto v0.29.0 golang.org/x/net v0.31.0 golang.org/x/term v0.26.0 golang.org/x/text v0.20.0 golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d google.golang.org/protobuf v1.35.2 ) require golang.org/x/sys v0.27.0 // indirect chasquid-1.15.0/go.sum000066400000000000000000000040421474251645300145450ustar00rootroot00000000000000blitiri.com.ar/go/log v1.1.0 h1:prKXp2hnYXRamcrYaCajq1SdQYvHU852lY7QStHyuaw= blitiri.com.ar/go/log v1.1.0/go.mod h1:CobnZ0FcxCAWHnkPCVtNPmj8AGiW9aNLKd/E7tI43Sw= blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE= blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk= blitiri.com.ar/go/systemd v1.1.0 h1:AMr7Ce/5CkvLZvGxsn/ZOagzFf3zU13rcgWdlbWMQ+Y= blitiri.com.ar/go/systemd v1.1.0/go.mod h1:0D9Ttrh+TX+WuKQ/dJpdhFND7NYy505v6jhsWrihmPY= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= chasquid-1.15.0/internal/000077500000000000000000000000001474251645300152265ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/000077500000000000000000000000001474251645300166475ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/aliases.go000066400000000000000000000357061474251645300206320ustar00rootroot00000000000000// 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). // // If the user is the string "*", then it is considered a "catch-all alias": // emails that don't match any known users or other aliases will be sent here. // // # Recipients // // Recipients can be of different types: // - Email: the usual user@domain we all know and love, this is the default. // - Pipe: if the right side starts with "| ", the rest of the line specifies // a command to pipe the email through. // Command and arguments are space separated. No quoting, escaping, or // replacements of any kind. // // # Lookups // // The resolver will perform lookups recursively, until it finds all the final // recipients. // // There are recursion limits to avoid alias loops. If the limit is reached, // the entire resolution will fail. // // # Suffix removal // // The resolver can also remove suffixes from emails, and drop characters // completely. This can be used to turn "user+blah@domain" into "user@domain", // and "us.er@domain" into "user@domain". // // Both are optional, and the characters configurable globally. // // There are more complex semantics around handling of drop characters and // suffixes, see the documentation for more details. package aliases import ( "bufio" "context" "fmt" "io" "os" "os/exec" "strings" "sync" "time" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/expvarom" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/trace" ) // Exported variables. var ( hookResults = expvarom.NewMap("chasquid/aliases/hookResults", "result", "count of aliases hook results, by hook and result") ) // Recipient represents a single recipient, after resolving aliases. // They don't have any special interface, the callers will do a type switch // anyway. type Recipient struct { Addr string Type RType } // RType represents a recipient type, see the constants below for valid values. type RType string // Valid recipient types. const ( EMAIL RType = "(email)" PIPE RType = "(pipe)" ) var ( // ErrRecursionLimitExceeded is returned when the resolving lookup // exceeded the recursion limit. Usually caused by aliases loops. ErrRecursionLimitExceeded = fmt.Errorf("recursion limit exceeded") // How many levels of recursions we allow during lookups. // We don't expect much recursion, so keeping this low to catch errors // quickly. recursionLimit = 10 ) // Type of the "does this user exist" function", for convenience. type existsFn func(tr *trace.Trace, user, domain string) (bool, error) // Resolver represents the aliases resolver. type Resolver struct { // Suffix separator, to perform suffix removal. SuffixSep string // Characters to drop from the user part. DropChars string // Path to the resolve hook. ResolveHook string // Function to check if a user exists in the userdb. userExistsInDB existsFn // Map of domain -> alias files for that domain. // We keep track of them for reloading purposes. files map[string][]string domains map[string]bool // Map of address -> aliases. aliases map[string][]Recipient // Mutex protecting the structure. mu sync.Mutex } // NewResolver returns a new, empty Resolver. func NewResolver(userExists existsFn) *Resolver { return &Resolver{ files: map[string][]string{}, domains: map[string]bool{}, aliases: map[string][]Recipient{}, userExistsInDB: userExists, } } // Resolve the given address, returning the list of corresponding recipients // (if any). func (v *Resolver) Resolve(tr *trace.Trace, addr string) ([]Recipient, error) { tr = tr.NewChild("Alias.Resolve", addr) defer tr.Finish() return v.resolve(0, addr, tr) } // Exists check that the address exists in the database. It must only be // called for local addresses. func (v *Resolver) Exists(tr *trace.Trace, addr string) bool { tr = tr.NewChild("Alias.Exists", addr) defer tr.Finish() // First, see if there's an exact match in the database. // This allows us to have aliases that include suffixes in them, and have // them take precedence. rcpts, _ := v.lookup(addr, tr) if len(rcpts) > 0 { return true } // "Clean" the address, removing drop characters and suffixes, and try // again. addr = v.RemoveDropsAndSuffix(addr) rcpts, _ = v.lookup(addr, tr) if len(rcpts) > 0 { return true } domain := envelope.DomainOf(addr) catchAll, _ := v.lookup("*@"+domain, tr) return len(catchAll) > 0 } func (v *Resolver) lookup(addr string, tr *trace.Trace) ([]Recipient, error) { // Do a lookup in the aliases map. Note we remove drop characters first, // which matches what we did at parsing time. Suffixes, if any, are left // as-is; that is handled by the callers. clean := v.RemoveDropCharacters(addr) v.mu.Lock() rcpts := v.aliases[clean] v.mu.Unlock() // Augment with the hook results. // Note we use the original address, to give maximum flexibility to the // hooks. hr, err := v.runResolveHook(tr, addr) if err != nil { tr.Debugf("lookup(%q) hook error: %v", addr, err) return nil, err } tr.Debugf("lookup(%q) -> %v + %v", addr, rcpts, hr) return append(rcpts, hr...), nil } func (v *Resolver) resolve(rcount int, addr string, tr *trace.Trace) ([]Recipient, error) { tr.Debugf("%d| resolve(%d, %q)", rcount, rcount, addr) if rcount >= recursionLimit { return nil, ErrRecursionLimitExceeded } // If the address is not local, we return it as-is, so delivery is // attempted against it. // Example: an alias that resolves to a non-local address. user, domain := envelope.Split(addr) if _, ok := v.domains[domain]; !ok { tr.Debugf("%d| non-local domain, returning %q", rcount, addr) return []Recipient{{addr, EMAIL}}, nil } // First, see if there's an exact match in the database. // This allows us to have aliases that include suffixes in them, and have // them take precedence. rcpts, err := v.lookup(addr, tr) if err != nil { tr.Debugf("%d| error in lookup: %v", rcount, err) return nil, err } if len(rcpts) == 0 { // Retry after removing drop characters and suffixes. // This also means that we will return the clean version if there's no // match, which our callers can rely upon. addr = v.RemoveDropsAndSuffix(addr) rcpts, err = v.lookup(addr, tr) if err != nil { tr.Debugf("%d| error in lookup: %v", rcount, err) return nil, err } } // No alias for this local address. if len(rcpts) == 0 { tr.Debugf("%d| no alias found", rcount) // If the user exists, then use it as-is, no need to recurse further. ok, err := v.userExistsInDB(tr, user, domain) if err != nil { tr.Debugf("%d| error checking if user exists: %v", rcount, err) return nil, err } if ok { tr.Debugf("%d| user exists, returning %q", rcount, addr) return []Recipient{{addr, EMAIL}}, nil } catchAll, err := v.lookup("*@"+domain, tr) if err != nil { tr.Debugf("%d| error in catchall lookup: %v", rcount, err) return nil, err } if len(catchAll) > 0 { // If there's a catch-all, then use it and keep resolving // recursively (since the catch-all destination could be an // alias). tr.Debugf("%d| using catch-all: %v", rcount, catchAll) rcpts = catchAll } else { // Otherwise, return the original address unchanged. // The caller will handle that situation, and we don't need to // invalidate the whole resolution (there could be other valid // aliases). // The queue will attempt delivery against this local (but // evidently non-existing) address, and the courier will emit a // clearer failure, re-using the existing codepaths and // simplifying the logic. tr.Debugf("%d| no catch-all, returning %q", rcount, addr) 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, tr) if err != nil { tr.Debugf("%d| resolve(%q) returned error: %v", rcount, r.Addr, err) return nil, err } ret = append(ret, ar...) } tr.Debugf("%d| returning %v", rcount, ret) return ret, nil } // Remove drop characters, but only up to the first suffix separator. func (v *Resolver) RemoveDropCharacters(addr string) string { user, domain := envelope.Split(addr) // Remove drop characters up to the first suffix separator. firstSuffixSep := strings.IndexAny(user, v.SuffixSep) if firstSuffixSep == -1 { firstSuffixSep = len(user) } nu := "" for _, c := range user[:firstSuffixSep] { if !strings.ContainsRune(v.DropChars, c) { nu += string(c) } } // Copy any remaining suffix as-is. if firstSuffixSep < len(user) { nu += user[firstSuffixSep:] } nu, _ = normalize.User(nu) return nu + "@" + domain } func (v *Resolver) RemoveDropsAndSuffix(addr string) string { user, domain := envelope.Split(addr) user = removeAllAfter(user, v.SuffixSep) user = removeChars(user, v.DropChars) user, _ = normalize.User(user) return user + "@" + domain } // AddDomain to the resolver, registering its existence. func (v *Resolver) AddDomain(domain string) { v.mu.Lock() v.domains[domain] = true v.mu.Unlock() } // AddAliasesFile to the resolver. The file will be parsed, and an error // returned if it does not parse correctly. Note that the file not existing // does NOT result in an error. func (v *Resolver) AddAliasesFile(domain, path string) (int, error) { // We unconditionally add the domain and file on our list. // Even if the file does not exist now, it may later. This makes it be // consider when doing Reload. // Adding it to the domains mean that we will do drop character and suffix // manipulation even if there are no aliases for it. v.mu.Lock() v.files[domain] = append(v.files[domain], path) v.domains[domain] = true v.mu.Unlock() aliases, err := v.parseFile(domain, path) if os.IsNotExist(err) { return 0, nil } if err != nil { return 0, 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 len(aliases), nil } // AddAliasForTesting adds an alias to the resolver, for testing purposes. // Not for use in production code. func (v *Resolver) AddAliasForTesting(addr, rcpt string, rType RType) { v.aliases[addr] = append(v.aliases[addr], Recipient{rcpt, rType}) } // Reload aliases files for all known domains. func (v *Resolver) Reload() error { newAliases := map[string][]Recipient{} for domain, paths := range v.files { for _, path := range paths { aliases, err := v.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 (v *Resolver) parseFile(domain, path string) (map[string][]Recipient, error) { f, err := os.Open(path) if err != nil { return nil, err } defer f.Close() aliases, err := v.parseReader(domain, f) if err != nil { return nil, fmt.Errorf("reading %q: %v", path, err) } return aliases, nil } func (v *Resolver) parseReader(domain string, r io.Reader) (map[string][]Recipient, error) { aliases := map[string][]Recipient{} scanner := bufio.NewScanner(r) for i := 1; scanner.Scan(); i++ { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") { continue } sp := strings.SplitN(line, ":", 2) if len(sp) != 2 { continue } addr, rawalias := strings.TrimSpace(sp[0]), strings.TrimSpace(sp[1]) if len(addr) == 0 || len(rawalias) == 0 { continue } if strings.Contains(addr, "@") { // It's invalid for lhs addresses to contain @ (for now). continue } // We remove DropChars from the address, but leave the suffixes (if // any). This matches the behaviour expected by Exists and Resolve, // see the documentation for more details. addr = addr + "@" + domain addr = v.RemoveDropCharacters(addr) addr, _ = normalize.Addr(addr) rs := parseRHS(rawalias, domain) aliases[addr] = rs } return aliases, scanner.Err() } func parseRHS(rawalias, domain string) []Recipient { if len(rawalias) == 0 { return nil } if rawalias[0] == '|' { cmd := strings.TrimSpace(rawalias[1:]) if cmd == "" { // A pipe alias without a command is invalid. return nil } return []Recipient{{cmd, PIPE}} } rs := []Recipient{} for _, a := range strings.Split(rawalias, ",") { a = strings.TrimSpace(a) if a == "" { continue } // Addresses with no domain get the current one added, so it's // easier to share alias files. if !strings.Contains(a, "@") { a = a + "@" + domain } a, _ = normalize.Addr(a) rs = append(rs, Recipient{a, EMAIL}) } return rs } // removeAllAfter removes everything from s that comes after the separators, // including them. func removeAllAfter(s, seps string) string { for _, c := range strings.Split(seps, "") { if c == "" { continue } i := strings.Index(s, c) if i == -1 { continue } s = s[:i] } return s } // removeChars removes the runes in "chars" from s. func removeChars(s, chars string) string { for _, c := range strings.Split(chars, "") { s = strings.Replace(s, c, "", -1) } return s } func (v *Resolver) runResolveHook(tr *trace.Trace, addr string) ([]Recipient, error) { if v.ResolveHook == "" { hookResults.Add("resolve:notset", 1) return nil, nil } // TODO: check if the file is executable. if _, err := os.Stat(v.ResolveHook); os.IsNotExist(err) { hookResults.Add("resolve:skip", 1) return nil, nil } // TODO: this should be done via a context propagated all the way through. tr = tr.NewChild("Hook.Alias-Resolve", addr) defer tr.Finish() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() cmd := exec.CommandContext(ctx, v.ResolveHook, addr) outb, err := cmd.Output() out := string(outb) tr.Debugf("stdout: %q", out) if err != nil { hookResults.Add("resolve:fail", 1) tr.Error(err) return nil, err } // Extract recipients from the output. // Same format as the right hand side of aliases file, see parseRHS. domain := envelope.DomainOf(addr) raw := strings.TrimSpace(out) rs := parseRHS(raw, domain) tr.Debugf("recipients: %v", rs) hookResults.Add("resolve:success", 1) return rs, nil } chasquid-1.15.0/internal/aliases/aliases_test.go000066400000000000000000000377741474251645300217000ustar00rootroot00000000000000package aliases import ( "bytes" "errors" "os" "os/exec" "reflect" "strings" "testing" "blitiri.com.ar/go/chasquid/internal/trace" ) type Cases []struct { addr string expect []Recipient err error } func (cases Cases) check(t *testing.T, r *Resolver) { t.Helper() tr := trace.New("test", "check") defer tr.Finish() for _, c := range cases { got, err := r.Resolve(tr, c.addr) if err != c.err { t.Errorf("case %q: expected error %v, got %v", c.addr, c.err, err) } 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) { t.Helper() tr := trace.New("test", "mustExist") defer tr.Finish() for _, addr := range addrs { if ok := r.Exists(tr, addr); !ok { t.Errorf("address %q does not exist, it should", addr) } } } func mustNotExist(t *testing.T, r *Resolver, addrs ...string) { t.Helper() tr := trace.New("test", "mustNotExist") defer tr.Finish() for _, addr := range addrs { if ok := r.Exists(tr, addr); ok { t.Errorf("address %q exists, it should not", addr) } } } func allUsersExist(tr *trace.Trace, user, domain string) (bool, error) { return true, nil } func usersWithXDontExist(tr *trace.Trace, user, domain string) (bool, error) { if strings.HasPrefix(user, "x") { return false, nil } return true, nil } var errUserLookup = errors.New("test error errUserLookup") func usersWithXErrorYDontExist(tr *trace.Trace, user, domain string) (bool, error) { if strings.HasPrefix(user, "x") { return false, errUserLookup } if strings.HasPrefix(user, "y") { return false, nil } return true, nil } func TestBasic(t *testing.T) { resolver := NewResolver(allUsersExist) resolver.AddDomain("localA") resolver.AddDomain("localB") resolver.aliases = map[string][]Recipient{ "a@localA": {{"c@d", EMAIL}, {"e@localB", EMAIL}}, "e@localB": {{"cmd", PIPE}}, "cmd@localA": {{"x@y", EMAIL}}, } cases := Cases{ {"a@localA", []Recipient{{"c@d", EMAIL}, {"cmd", PIPE}}, nil}, {"e@localB", []Recipient{{"cmd", PIPE}}, nil}, {"x@y", []Recipient{{"x@y", EMAIL}}, nil}, } cases.check(t, resolver) mustExist(t, resolver, "a@localA", "e@localB", "cmd@localA") mustNotExist(t, resolver, "x@y") } func TestCatchAll(t *testing.T) { resolver := NewResolver(usersWithXDontExist) resolver.AddDomain("dom") resolver.aliases = map[string][]Recipient{ "a@dom": {{"a@remote", EMAIL}}, "b@dom": {{"c@dom", EMAIL}}, "c@dom": {{"cmd", PIPE}}, "*@dom": {{"c@dom", EMAIL}}, } cases := Cases{ {"a@dom", []Recipient{{"a@remote", EMAIL}}, nil}, {"b@dom", []Recipient{{"cmd", PIPE}}, nil}, {"c@dom", []Recipient{{"cmd", PIPE}}, nil}, {"x@dom", []Recipient{{"cmd", PIPE}}, nil}, // Remote should be returned as-is regardless. {"a@remote", []Recipient{{"a@remote", EMAIL}}, nil}, {"x@remote", []Recipient{{"x@remote", EMAIL}}, nil}, } cases.check(t, resolver) mustExist(t, resolver, // Exist as users. "a@dom", "b@dom", "c@dom", // Do not exist as users, but catch-all saves them. "x@dom", "x1@dom") } func TestUserLookupErrors(t *testing.T) { resolver := NewResolver(usersWithXErrorYDontExist) resolver.AddDomain("dom") resolver.aliases = map[string][]Recipient{ "a@dom": {{"a@remote", EMAIL}}, "b@dom": {{"x@dom", EMAIL}}, "*@dom": {{"x@dom", EMAIL}}, } cases := Cases{ {"a@dom", []Recipient{{"a@remote", EMAIL}}, nil}, {"b@dom", nil, errUserLookup}, {"c@dom", []Recipient{{"c@dom", EMAIL}}, nil}, {"x@dom", nil, errUserLookup}, // This one goes through the catch-all. {"y@dom", nil, errUserLookup}, } cases.check(t, resolver) } func TestAddrRewrite(t *testing.T) { resolver := NewResolver(allUsersExist) 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}}, "remo@def": {{"x-@y-z.com", EMAIL}}, // Aliases with a suffix, to make sure we handle them correctly. // Note we don't allow aliases with drop characters, they get // normalized at parsing time. "recu-zzz@def": {{"z@z", EMAIL}}, } resolver.DropChars = ".~" resolver.SuffixSep = "-+" cases := Cases{ {"abc@def", []Recipient{{"x@y", EMAIL}}, nil}, {"a.b.c@def", []Recipient{{"x@y", EMAIL}}, nil}, {"a~b~c@def", []Recipient{{"x@y", EMAIL}}, nil}, {"a.b~c@def", []Recipient{{"x@y", EMAIL}}, nil}, {"abc-Ãąaca@def", []Recipient{{"x@y", EMAIL}}, nil}, {"abc-Ãąaca@def", []Recipient{{"x@y", EMAIL}}, nil}, {"abc-xyz@def", []Recipient{{"x@y", EMAIL}}, nil}, {"abc+xyz@def", []Recipient{{"x@y", EMAIL}}, nil}, {"abc-x.y+z@def", []Recipient{{"x@y", EMAIL}}, nil}, {"Ãą.o~Ãąo-Ãąaca@def", []Recipient{{"x@y", EMAIL}}, nil}, // Don't mess with the domain, even if it's known. {"a.bc-Ãąaca@p-q.com", []Recipient{{"abc@p-q.com", EMAIL}}, nil}, // Clean the right hand side too (if it's a local domain). {"recu+blah@def", []Recipient{{"ab@p-q.com", EMAIL}}, nil}, // Requests for "recu" and variants, because it has an alias with a // suffix. {"re-cu@def", []Recipient{{"re@def", EMAIL}}, nil}, {"re.cu@def", []Recipient{{"ab@p-q.com", EMAIL}}, nil}, {"re.cu-zzz@def", []Recipient{{"z@z", EMAIL}}, nil}, // Check that because we have an alias with a suffix, we do not // accidentally use it for their "clean" versions. {"re@def", []Recipient{{"re@def", EMAIL}}, nil}, {"r.e.c.u@def", []Recipient{{"ab@p-q.com", EMAIL}}, nil}, {"re.cu-yyy@def", []Recipient{{"ab@p-q.com", EMAIL}}, nil}, // We should not mess with emails for domains we don't know. {"xy@z.com", []Recipient{{"xy@z.com", EMAIL}}, nil}, {"x.y@z.com", []Recipient{{"x.y@z.com", EMAIL}}, nil}, {"x-@y-z.com", []Recipient{{"x-@y-z.com", EMAIL}}, nil}, {"x+blah@y", []Recipient{{"x+blah@y", EMAIL}}, nil}, {"remo@def", []Recipient{{"x-@y-z.com", EMAIL}}, nil}, } cases.check(t, resolver) } func TestExists(t *testing.T) { resolver := NewResolver(allUsersExist) 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}}, // Aliases with a suffix, to make sure we handle them correctly. // Note we don't allow aliases with drop characters, they get // normalized at parsing time. "ex-act@def": {{"x@y", EMAIL}}, } resolver.DropChars = ".~" resolver.SuffixSep = "-+" mustExist(t, resolver, "abc@def", "abc+blah@def", "a.bc+blah@def", "a.b~c@def", "ÃąoÃąo@def", "Ãąo.Ãąo@def", "recu@def", "re.cu@def", "ex-act@def", ) mustNotExist(t, resolver, "abc@d.ef", "nothere@def", "ex@def", "a.bc@unknown", "x.yz@def", "x.yz@d.ef", "abc@d.ef", "exact@def", "exa.ct@def", "ex@def", ) } func TestRemoveDropsAndSuffix(t *testing.T) { resolver := NewResolver(allUsersExist) 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 := []struct { addr string want string }{ {"abc@def", "abc@def"}, {"abc+blah@def", "abc@def"}, {"a.b~c@def", "abc@def"}, {"a.bc+blah@def", "abc@def"}, {"x.yz@def", "xyz@def"}, {"x.yz@d.ef", "xyz@d.ef"}, } for _, c := range cases { addr := resolver.RemoveDropsAndSuffix(c.addr) if addr != c.want { t.Errorf("RemoveDropsAndSuffix(%q): want %q, got %q", c.addr, c.want, addr) } } } func TestRemoveDropCharacters(t *testing.T) { resolver := NewResolver(allUsersExist) resolver.AddDomain("def") resolver.DropChars = "._" resolver.SuffixSep = "-+" cases := []struct { addr string want string }{ {"abc@def", "abc@def"}, {"abc+blah@def", "abc+blah@def"}, {"a.b@def", "ab@def"}, {"a.b+c@def", "ab+c@def"}, {"a.b+c.d@def", "ab+c.d@def"}, {"a@def", "a@def"}, {"a+b@def", "a+b@def"}, // Cases with UTF-8, to make sure we handle indexing correctly. {"ÃąoÃąo@def", "ÃąoÃąo@def"}, {"ÃąoÃąo+blah@def", "ÃąoÃąo+blah@def"}, {"Ãąo.Ãąo@def", "ÃąoÃąo@def"}, {"Ãąo.Ãąo+blah@def", "ÃąoÃąo+blah@def"}, {"Ãąo.Ãąo+Ãąaca@def", "ÃąoÃąo+Ãąaca@def"}, {"Ãąo.Ãąo+Ãąa.ca@def", "ÃąoÃąo+Ãąa.ca@def"}, {"Ãąo.Ãąo+ÃąaÃąa@def", "ÃąoÃąo+ÃąaÃąa@def"}, {"Ãąo.Ãąo+Ãąa.Ãąa@def", "ÃąoÃąo+Ãąa.Ãąa@def"}, // Check "the other" drop char/suffix separator to make sure we // don't skip any of them. {"a_b@def", "ab@def"}, {"a_b-c@def", "ab-c@def"}, {"a_b-c.d@def", "ab-c.d@def"}, {"Ãąo_Ãąo-Ãąa.Ãąa@def", "ÃąoÃąo-Ãąa.Ãąa@def"}, } for _, c := range cases { addr := resolver.RemoveDropCharacters(c.addr) if addr != c.want { t.Errorf("RemoveDropCharacters(%q): want %q, got %q", c.addr, c.want, addr) } } } func TestTooMuchRecursion(t *testing.T) { resolver := NewResolver(allUsersExist) resolver.AddDomain("b") resolver.AddDomain("d") resolver.aliases = map[string][]Recipient{ "a@b": {{"c@d", EMAIL}}, "c@d": {{"a@b", EMAIL}}, } tr := trace.New("test", "TestTooMuchRecursion") defer tr.Finish() rs, err := resolver.Resolve(tr, "a@b") if err != ErrRecursionLimitExceeded { t.Errorf("expected ErrRecursionLimitExceeded, got %v", err) } if rs != nil { t.Errorf("expected nil recipients, got %+v", rs) } } func TestTooMuchRecursionOnCatchAll(t *testing.T) { resolver := NewResolver(usersWithXDontExist) resolver.AddDomain("dom") resolver.aliases = map[string][]Recipient{ "a@dom": {{"x@dom", EMAIL}}, "*@dom": {{"a@dom", EMAIL}}, } cases := Cases{ // b@dom is local and exists. {"b@dom", []Recipient{{"b@dom", EMAIL}}, nil}, // a@remote is remote. {"a@remote", []Recipient{{"a@remote", EMAIL}}, nil}, } cases.check(t, resolver) for _, addr := range []string{"a@dom", "x@dom", "xx@dom"} { tr := trace.New("TestTooMuchRecursionOnCatchAll", addr) defer tr.Finish() rs, err := resolver.Resolve(tr, addr) if err != ErrRecursionLimitExceeded { t.Errorf("%s: expected ErrRecursionLimitExceeded, got %v", addr, err) } if rs != nil { t.Errorf("%s: expected nil recipients, got %+v", addr, rs) } } } func mustWriteFile(t *testing.T, content string) string { f, err := os.CreateTemp("", "aliases_test") if err != nil { t.Fatalf("failed to get temp file: %v", err) } defer f.Close() _, err = f.WriteString(content) if err != nil { t.Fatalf("failed to write temp file: %v", err) } return f.Name() } func TestAddFile(t *testing.T) { cases := []struct { contents string expected []Recipient }{ {"\n", []Recipient{{"a@dom", EMAIL}}}, {" # Comment\n", []Recipient{{"a@dom", EMAIL}}}, {":\n", []Recipient{{"a@dom", EMAIL}}}, {"a: \n", []Recipient{{"a@dom", EMAIL}}}, {"a@dom: b@c \n", []Recipient{{"a@dom", EMAIL}}}, {"a: b\n", []Recipient{{"b@dom", EMAIL}}}, {"a:b\n", []Recipient{{"b@dom", EMAIL}}}, {"a : b \n", []Recipient{{"b@dom", EMAIL}}}, {"a : b, \n", []Recipient{{"b@dom", EMAIL}}}, {"a: |cmd\n", []Recipient{{"cmd", PIPE}}}, {"a:|cmd\n", []Recipient{{"cmd", PIPE}}}, {"a:| cmd \n", []Recipient{{"cmd", PIPE}}}, {"a :| cmd \n", []Recipient{{"cmd", PIPE}}}, {"a: | cmd arg1 arg2\n", []Recipient{{"cmd arg1 arg2", PIPE}}}, {"a: c@d, e@f, g\n", []Recipient{{"c@d", EMAIL}, {"e@f", EMAIL}, {"g@dom", EMAIL}}}, // Invalid pipe aliases, should be ignored. {"a:|\n", []Recipient{{"a@dom", EMAIL}}}, {"a:| \n", []Recipient{{"a@dom", EMAIL}}}, } tr := trace.New("test", "TestAddFile") defer tr.Finish() for _, c := range cases { fname := mustWriteFile(t, c.contents) defer os.Remove(fname) resolver := NewResolver(allUsersExist) _, err := resolver.AddAliasesFile("dom", fname) if err != nil { t.Fatalf("error adding file: %v", err) } got, err := resolver.Resolve(tr, "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 # Test that exact aliases take precedence. pq: pa p.q: pb p.q+r: pc pq+r: pd ppp1: p.q+r ppp2: p.q ppp3: ppp2 # 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(allUsersExist) resolver.DropChars = "." resolver.SuffixSep = "+" n, err := resolver.AddAliasesFile("dom", fname) if err != nil { t.Fatalf("failed to add file: %v", err) } if n != 11 { t.Fatalf("expected 11 aliases, got %d", n) } cases := Cases{ {"a@dom", []Recipient{{"b@dom", EMAIL}}, nil}, {"c@dom", []Recipient{{"d@e", EMAIL}, {"f@dom", EMAIL}}, nil}, {"x@dom", []Recipient{{"command", PIPE}}, nil}, {"o1@dom", []Recipient{{"b@dom", EMAIL}}, nil}, {"aA@dom", []Recipient{{"bb@dom-b", EMAIL}}, nil}, {"aa@dom", []Recipient{{"bb@dom-b", EMAIL}}, nil}, {"pq@dom", []Recipient{{"pb@dom", EMAIL}}, nil}, {"p.q@dom", []Recipient{{"pb@dom", EMAIL}}, nil}, {"p.q+r@dom", []Recipient{{"pd@dom", EMAIL}}, nil}, {"pq+r@dom", []Recipient{{"pd@dom", EMAIL}}, nil}, {"pq+z@dom", []Recipient{{"pb@dom", EMAIL}}, nil}, {"p..q@dom", []Recipient{{"pb@dom", EMAIL}}, nil}, {"p..q+r@dom", []Recipient{{"pd@dom", EMAIL}}, nil}, {"ppp1@dom", []Recipient{{"pd@dom", EMAIL}}, nil}, {"ppp2@dom", []Recipient{{"pb@dom", EMAIL}}, nil}, {"ppp3@dom", []Recipient{{"pb@dom", EMAIL}}, nil}, {"y@dom", []Recipient{{"z@dom", EMAIL}}, nil}, } 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(allUsersExist) 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}}, nil}, {"c@d1", []Recipient{{"d@e", EMAIL}}, nil}, {"x@d1", []Recipient{{"x@d1", EMAIL}}, nil}, {"a@domain2", []Recipient{{"b@domain2", EMAIL}}, nil}, {"c@domain2", []Recipient{{"d@e", EMAIL}}, nil}, {"x@dom3", []Recipient{{"y@dom3", EMAIL}, {"z@dom3", EMAIL}}, nil}, {"a@dom4", []Recipient{{"cmd", PIPE}}, nil}, {"a@xd1", []Recipient{{"cmd", PIPE}}, nil}, } 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() } func TestHookError(t *testing.T) { tr := trace.New("TestHookError", "test") defer tr.Finish() resolver := NewResolver(allUsersExist) resolver.AddDomain("localA") resolver.aliases = map[string][]Recipient{ "a@localA": {{"c@d", EMAIL}}, } // First check that the test is set up reasonably. mustExist(t, resolver, "a@localA") Cases{ {"a@localA", []Recipient{{"c@d", EMAIL}}, nil}, }.check(t, resolver) // Now use a resolver that exits with an error. resolver.ResolveHook = "testdata/erroring-hook.sh" // Check that the hook is run and the error is propagated. mustNotExist(t, resolver, "a@localA") rcpts, err := resolver.Resolve(tr, "a@localA") if len(rcpts) != 0 { t.Errorf("expected no recipients, got %v", rcpts) } execErr := &exec.ExitError{} if !errors.As(err, &execErr) { t.Errorf("expected *exec.ExitError, got %T - %v", err, err) } } // Fuzz testing for the parser. func FuzzReader(f *testing.F) { resolver := NewResolver(allUsersExist) resolver.AddDomain("domain") resolver.DropChars = "." resolver.SuffixSep = "-+" f.Add([]byte(richFileContents)) f.Fuzz(func(t *testing.T, data []byte) { resolver.parseReader("domain", bytes.NewReader(data)) }) } chasquid-1.15.0/internal/aliases/testdata/000077500000000000000000000000001474251645300204605ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/erroring-hook.sh000077500000000000000000000001761474251645300236100ustar00rootroot00000000000000#!/bin/sh # Hook that always returns error. # This could be replaced by /bin/false, but that doesn't work on freebsd. exit 1 chasquid-1.15.0/internal/aliases/testdata/fuzz/000077500000000000000000000000001474251645300214565ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/fuzz/FuzzReader/000077500000000000000000000000001474251645300235375ustar00rootroot000000000000001c24d2215db69748c6fd16797673ad11ebc7e6167fe1bc1f54c6959ec10407b6000066400000000000000000000003531474251645300341420ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/fuzz/FuzzReadergo test fuzz v1 []byte("# First some valid cases.\na: b\nc: d@e, f,\nx: | command\n\n# The following is invalid, should be ignored.\na@dom: x@dom\n\n# Overrides.\no1: a\no1: b\n\n# Finally one to make the file NOT end in \\n:\ny: z\n")4adaceaa32e2b32c00322948769d62c2dd42e1d9f4950d3c5b411c710e6d4a86000066400000000000000000000000551474251645300342340ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/fuzz/FuzzReadergo test fuzz v1 []byte("\nfail: | false\n\n")8234d8c5719f30e50525290db70743bf97d940e60591cf4a638c72158d35504a000066400000000000000000000000621474251645300334750ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/fuzz/FuzzReadergo test fuzz v1 []byte("\naliasA: aliasB@srv-B\n")c9c80ba9f513841cb081fe9bb7439d36f9f7a06bb999d4c39441991ccc878a9e000066400000000000000000000003121474251645300343330ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/fuzz/FuzzReadergo test fuzz v1 []byte("\n# Easy aliases.\npepe: jose\njoan: juan\n\n# UTF-8 aliases.\npitanga: Ãąangapirí\naÃąil: azul, índigo\n\n# Pipe aliases.\ntubo: | writemailto ../.data/pipe_alias_worked\n\n")d40a98862ed393eb712e47a91bcef18e6f24cf368bb4bd248c7a7101ef8e178d000066400000000000000000000000321474251645300344410ustar00rootroot00000000000000chasquid-1.15.0/internal/aliases/testdata/fuzz/FuzzReadergo test fuzz v1 []byte("")chasquid-1.15.0/internal/auth/000077500000000000000000000000001474251645300161675ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/auth.go000066400000000000000000000150471474251645300174660ustar00rootroot00000000000000// 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" "blitiri.com.ar/go/chasquid/internal/trace" ) // Backend is the common interface for all authentication backends. type Backend interface { Authenticate(user, password string) (bool, error) Exists(user string) (bool, error) Reload() error } // NoErrorBackend is the interface for authentication backends that don't need // to emit errors. This allows backends to avoid unnecessary complexity, in // exchange for a bit more here. // They can be converted to normal Backend using WrapNoErrorBackend (defined // below). type NoErrorBackend interface { Authenticate(user, password string) bool Exists(user string) bool Reload() error } // Authenticator tracks the backends for each domain, and allows callers to // query them with a more practical API. type Authenticator struct { // Registered backends, map of domain (string) -> Backend. // Backend operations will _not_ include the domain in the username. backends map[string]Backend // Fallback backend, to use when backends[domain] (which may not exist) // did not yield a positive result. // Note that this backend gets the user with the domain included, of the // form "user@domain" (if available). Fallback Backend // How long Authenticate calls should last, approximately. // This will be applied both for successful and unsuccessful attempts. // We will increase this number by 0-20%. AuthDuration time.Duration } // NewAuthenticator returns a new Authenticator with no backends. func NewAuthenticator() *Authenticator { return &Authenticator{ backends: map[string]Backend{}, AuthDuration: 100 * time.Millisecond, } } // Register a backend to use for the given domain. func (a *Authenticator) Register(domain string, be Backend) { a.backends[domain] = be } // Authenticate the user@domain with the given password. func (a *Authenticator) Authenticate(tr *trace.Trace, user, domain, password string) (bool, error) { tr = tr.NewChild("Auth.Authenticate", user+"@"+domain) defer tr.Finish() // 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) tr.Debugf("Backend: %v %v", ok, err) if ok || err != nil { return ok, err } } if a.Fallback != nil { id := user if domain != "" { id = user + "@" + domain } ok, err := a.Fallback.Authenticate(id, password) tr.Debugf("Fallback: %v %v", ok, err) return ok, err } tr.Debugf("Rejected by default") return false, nil } // Exists checks that user@domain exists. func (a *Authenticator) Exists(tr *trace.Trace, user, domain string) (bool, error) { tr = tr.NewChild("Auth.Exists", user+"@"+domain) defer tr.Finish() if be, ok := a.backends[domain]; ok { ok, err := be.Exists(user) tr.Debugf("Backend: %v %v", ok, err) if ok || err != nil { return ok, err } } if a.Fallback != nil { id := user if domain != "" { id = user + "@" + domain } ok, err := a.Fallback.Exists(id) tr.Debugf("Fallback: %v %v", ok, err) return ok, err } tr.Debugf("Rejected by default") return false, nil } // Reload the registered backends. func (a *Authenticator) Reload() error { msgs := []string{} for domain, be := range a.backends { tr := trace.New("Auth.Reload", domain) err := be.Reload() if err != nil { tr.Error(err) msgs = append(msgs, fmt.Sprintf("%q: %v", domain, err)) } tr.Finish() } if a.Fallback != nil { tr := trace.New("Auth.Reload", "") err := a.Fallback.Reload() if err != nil { tr.Error(err) msgs = append(msgs, fmt.Sprintf(": %v", err)) } tr.Finish() } 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 IDs match, or one of them is empty. // // We split the id into user@domain, since in most cases we expect that to be // the used form, and normalize them. If there is no domain, we just return // "" for it. The rest of the stack will know how to handle it. 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 } // Split identity into "user@domain", if possible. user = identity idsp := strings.SplitN(identity, "@", 2) if len(idsp) >= 2 { user = idsp[0] domain = idsp[1] } // Normalize the user and domain. This is so users can write the username // in their own style and still can log in. For the domain, we use IDNA // and relevant transformations to turn it to utf8 which is what we use // internally. user, err = normalize.User(user) if err != nil { return } domain, err = normalize.Domain(domain) if err != nil { return } return } // WrapNoErrorBackend wraps a NoErrorBackend, converting it into a valid // Backend. This is normally used in Auth.Register calls, to register no-error // backends. func WrapNoErrorBackend(be NoErrorBackend) Backend { return &wrapNoErrorBackend{be} } type wrapNoErrorBackend struct { be NoErrorBackend } func (w *wrapNoErrorBackend) Authenticate(user, password string) (bool, error) { return w.be.Authenticate(user, password), nil } func (w *wrapNoErrorBackend) Exists(user string) (bool, error) { return w.be.Exists(user), nil } func (w *wrapNoErrorBackend) Reload() error { return w.be.Reload() } chasquid-1.15.0/internal/auth/auth_test.go000066400000000000000000000166351474251645300205310ustar00rootroot00000000000000package auth import ( "encoding/base64" "fmt" "testing" "time" "blitiri.com.ar/go/chasquid/internal/dovecot" "blitiri.com.ar/go/chasquid/internal/trace" "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 {"dQB1AHBhc3M=", "u", "", "pass"}, // u\0u\0pass // "Ãą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", "\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") tr := trace.New("test", "TestAuthenticate") defer tr.Finish() 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(tr, "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() tr := trace.New("test", "check") defer tr.Finish() ok, err := a.Authenticate(tr, 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(tr, 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 tr := trace.New("test", "TestErrors") defer tr.Finish() ok, err := a.Authenticate(tr, "user", "domain", "passwd") if err != nil || !ok { t.Fatalf("failed auth") } expectedErr := fmt.Errorf("test error") be.nextError = expectedErr ok, err = a.Authenticate(tr, "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(tr, "user", "domain") if ok { t.Errorf("exists succeeded, expected error") } if err != expectedErr { t.Errorf("expected error, got %v", err) } } func TestReload(t *testing.T) { be1 := NewTestBE() be2 := NewTestBE() fallback := NewTestBE() a := NewAuthenticator() a.Register("domain1", be1) a.Register("domain2", be2) a.Fallback = fallback err := a.Reload() if err != nil { t.Errorf("unexpected error reloading: %v", err) } if be1.reloadCount != 1 || be2.reloadCount != 1 || fallback.reloadCount != 1 { t.Errorf("unexpected reload counts: %d %d %d != 1 1 1", be1.reloadCount, be2.reloadCount, fallback.reloadCount) } be2.nextError = fmt.Errorf("test error") err = a.Reload() if err == nil { t.Errorf("expected error reloading, got nil") } if be1.reloadCount != 2 || be2.reloadCount != 2 || fallback.reloadCount != 2 { t.Errorf("unexpected reload counts: %d %d %d != 2 2 2", be1.reloadCount, be2.reloadCount, fallback.reloadCount) } a2 := NewAuthenticator() a2.Register("domain", WrapNoErrorBackend(userdb.New("/dev/null"))) if err = a2.Reload(); err != nil { t.Errorf("unexpected error reloading wrapped backend: %v", err) } } // Fuzz testing for the response decoder, which handles user-provided data. func FuzzDecodeResponse(f *testing.F) { f.Fuzz(func(t *testing.T, response string) { DecodeResponse(response) }) } chasquid-1.15.0/internal/auth/testdata/000077500000000000000000000000001474251645300200005ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/000077500000000000000000000000001474251645300207765ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponse/000077500000000000000000000000001474251645300245575ustar00rootroot000000000000000274b170c6fe2654ca5418a914b804e9c7cc5d8e5c2a7c5fcf5c29540ec5ae52000066400000000000000000000001231474251645300353600ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("@\xed\x88Ė„ĖĨ̈́ĖĨ̈́̈́ĖĨ̈́̈́ĖĨ̈́̈́ĖĨ̈́ÍĨ̈́ĖĨ̈́ÍĨĖ“\x00\x00")24d35771ef1fe0645d90b061e13a777faea328736483ec2833b63950d26b5399000066400000000000000000000000461474251645300346600ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("AHVAZABwYXNz")2e5d0b26626f2d2dd6fb423e1e1cc432277ae9877c622fe6ca067e247bc11c9d000066400000000000000000000000521474251645300353540ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("dUBkAHVAZABwYXNz")2ef1aee5347414c139270ebb6ea63d2223a8c0c7c8ec30a2ca7152f4c18f1c74000066400000000000000000000001171474251645300353270ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("\xebĖĨĖĨ̈́ÍĨ̈́ĖĨ̈́ĖĨ̈́̈́ĖĨ̈́̈́ĖĨ̈́̈́ĖĨ̈́ÍĨĖ“@\x00\x00")4b9259040da90f06aa2b593ee20fdffefeda813c59430050f15965bd9471235e000066400000000000000000000000641474251645300352200ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("this is not base64 encoded")6c2c0b4f81a675d91d1291bfdcddb7c9d43cf6264dd7763cfed31a3946854e27000066400000000000000000000001151474251645300354610ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("\xebͯ̈́ĖĨ̈́̈́ĖĨ̈́ĖĨ̈́ͯ̈́ĖĨ̈́̈́ĖĨ̈́ĖĨ̈́̈́@\x00\x00")6e05782952b68c7ccd94160ad6ea45e7f766397850b08e78f89407a94350825c000066400000000000000000000001151474251645300346370ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("\xeb̈́ĖĨ̈́̈́ĖĨ̈́ĖĨ̈́ͯ̈́ĖĨ̈́̈́ĖĨ̈́ĖĨ̈́̈́̈́@\x00\x00")c2ae184876dd0fe9acfc8a5e2f2174a968b889b01e0f5c9a61fa27d7361f0091000066400000000000000000000000461474251645300354000ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("dUBkAABwYXNz")d9aa9c617d1f5b3021aca758b9d896d136e3b16ed53233d02abffd02aa73ffa4000066400000000000000000000000721474251645300355610ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("w7FhY2FAw7FlcXVlAABjbGF2YXLDqQ==")de05c7993312bab83e8114e9d9ced331c49822dc55c1a353f1cc9718a28226e7000066400000000000000000000000521474251645300351470ustar00rootroot00000000000000chasquid-1.15.0/internal/auth/testdata/fuzz/FuzzDecodeResponsego test fuzz v1 string("dUBkAABwYXNz/w==")chasquid-1.15.0/internal/config/000077500000000000000000000000001474251645300164735ustar00rootroot00000000000000chasquid-1.15.0/internal/config/config.go000066400000000000000000000101471474251645300202720ustar00rootroot00000000000000// Package config implements the chasquid configuration. package config // Generate the config protobuf. //go:generate protoc --go_out=. --go_opt=paths=source_relative --experimental_allow_proto3_optional config.proto import ( "fmt" "os" "blitiri.com.ar/go/log" "google.golang.org/protobuf/encoding/prototext" "google.golang.org/protobuf/proto" ) var defaultConfig = &Config{ MaxDataSizeMb: 50, SmtpAddress: []string{"systemd"}, SubmissionAddress: []string{"systemd"}, SubmissionOverTlsAddress: []string{"systemd"}, MailDeliveryAgentBin: "maildrop", MailDeliveryAgentArgs: []string{"-f", "%from%", "-d", "%to_user%"}, DataDir: "/var/lib/chasquid", SuffixSeparators: proto.String("+"), DropCharacters: proto.String("."), MailLogPath: "", } // Load the config from the given file, with the given overrides. func Load(path, overrides string) (*Config, error) { // Start with a copy of the default config. c := proto.Clone(defaultConfig).(*Config) // Load from the path. buf, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("failed to read config at %q: %v", path, err) } fromFile := &Config{} err = prototext.Unmarshal(buf, fromFile) if err != nil { return nil, fmt.Errorf("parsing config: %v", err) } override(c, fromFile) // Handle command line overrides. fromOverrides := &Config{} err = prototext.Unmarshal([]byte(overrides), fromOverrides) if err != nil { return nil, fmt.Errorf("parsing override: %v", err) } override(c, fromOverrides) // Handle hostname separate, because if it is set, we don't need to call // os.Hostname which can fail. if c.Hostname == "" { c.Hostname, err = os.Hostname() if err != nil { return nil, fmt.Errorf("could not get hostname: %v", err) } } return c, nil } // Override fields in `c` that are set in `o`. We can't use proto.Merge // because the semantics would not be convenient for overriding. func override(c, o *Config) { if o.Hostname != "" { c.Hostname = o.Hostname } if o.MaxDataSizeMb > 0 { c.MaxDataSizeMb = o.MaxDataSizeMb } if len(o.SmtpAddress) > 0 { c.SmtpAddress = o.SmtpAddress } if len(o.SubmissionAddress) > 0 { c.SubmissionAddress = o.SubmissionAddress } if len(o.SubmissionOverTlsAddress) > 0 { c.SubmissionOverTlsAddress = o.SubmissionOverTlsAddress } if o.MonitoringAddress != "" { c.MonitoringAddress = o.MonitoringAddress } if o.MailDeliveryAgentBin != "" { c.MailDeliveryAgentBin = o.MailDeliveryAgentBin } if len(o.MailDeliveryAgentArgs) > 0 { c.MailDeliveryAgentArgs = o.MailDeliveryAgentArgs } if o.DataDir != "" { c.DataDir = o.DataDir } if o.SuffixSeparators != nil { c.SuffixSeparators = o.SuffixSeparators } if o.DropCharacters != nil { c.DropCharacters = o.DropCharacters } if o.MailLogPath != "" { c.MailLogPath = o.MailLogPath } if o.DovecotAuth { c.DovecotAuth = true } if o.DovecotUserdbPath != "" { c.DovecotUserdbPath = o.DovecotUserdbPath } if o.DovecotClientPath != "" { c.DovecotClientPath = o.DovecotClientPath } if o.HaproxyIncoming { c.HaproxyIncoming = true } } // LogConfig logs the given configuration, in a human-friendly way. func LogConfig(c *Config) { log.Infof("Configuration:") log.Infof(" Hostname: %q", c.Hostname) log.Infof(" Max data size (MB): %d", c.MaxDataSizeMb) log.Infof(" SMTP Addresses: %q", c.SmtpAddress) log.Infof(" Submission Addresses: %q", c.SubmissionAddress) log.Infof(" Submission+TLS Addresses: %q", c.SubmissionOverTlsAddress) log.Infof(" Monitoring address: %q", c.MonitoringAddress) log.Infof(" MDA: %q %q", c.MailDeliveryAgentBin, c.MailDeliveryAgentArgs) log.Infof(" Data directory: %q", c.DataDir) if c.SuffixSeparators == nil { log.Infof(" Suffix separators: nil") } else { log.Infof(" Suffix separators: %q", *c.SuffixSeparators) } if c.DropCharacters == nil { log.Infof(" Drop characters: nil") } else { log.Infof(" Drop characters: %q", *c.DropCharacters) } log.Infof(" Mail log: %q", c.MailLogPath) log.Infof(" Dovecot auth: %v (%q, %q)", c.DovecotAuth, c.DovecotUserdbPath, c.DovecotClientPath) log.Infof(" HAProxy incoming: %v", c.HaproxyIncoming) } chasquid-1.15.0/internal/config/config.pb.go000066400000000000000000000363601474251645300206770ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.30.0 // protoc v3.21.12 // source: config.proto package config import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Config struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields // Default hostname to use when saying hello. // This is used: // 1. To say hello to clients, for aesthetic purposes. // 2. As the HELO/EHLO domain on outgoing SMTP connections, so ideally // it would resolve back to the server. In practice, it's not a big // deal if it isn't, but it makes troubleshooting easier. // // Default: the system's hostname. Hostname string `protobuf:"bytes,1,opt,name=hostname,proto3" json:"hostname,omitempty"` // Maximum email size, in megabytes. // Default: 50. MaxDataSizeMb int64 `protobuf:"varint,2,opt,name=max_data_size_mb,json=maxDataSizeMb,proto3" json:"max_data_size_mb,omitempty"` // Addresses to listen on for SMTP (usually port 25). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=smtp". SmtpAddress []string `protobuf:"bytes,3,rep,name=smtp_address,json=smtpAddress,proto3" json:"smtp_address,omitempty"` // Addresses to listen on for submission (usually port 587). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with "FileDescriptorName=submission". SubmissionAddress []string `protobuf:"bytes,4,rep,name=submission_address,json=submissionAddress,proto3" json:"submission_address,omitempty"` // Addresses to listen on for submission-over-TLS (usually port 465). // Default: "systemd", which means systemd passes sockets to us. // systemd sockets must be named with // "FileDescriptorName=submission_tls". SubmissionOverTlsAddress []string `protobuf:"bytes,5,rep,name=submission_over_tls_address,json=submissionOverTlsAddress,proto3" json:"submission_over_tls_address,omitempty"` // Address for the monitoring http server. // Do NOT expose this to the public internet. // Default: no monitoring http server. MonitoringAddress string `protobuf:"bytes,6,opt,name=monitoring_address,json=monitoringAddress,proto3" json:"monitoring_address,omitempty"` // Mail delivery agent (MDA, also known as LDA) to use. // This should point to the binary to use to deliver email to local // users. The content of the email will be passed via stdin. If it exits // unsuccessfully, we assume the mail was not delivered. // Default: "maildrop". MailDeliveryAgentBin string `protobuf:"bytes,7,opt,name=mail_delivery_agent_bin,json=mailDeliveryAgentBin,proto3" json:"mail_delivery_agent_bin,omitempty"` // Command line arguments for the mail delivery agent. One per argument. // Some replacements will be done. // On an email sent from marsnik@mars to venera@venus: // - %from% -> from address (marsnik@mars) // - %from_user% -> from user (marsnik) // - %from_domain% -> from domain (mars) // - %to% -> to address (venera@venus) // - %to_user% -> to user (venera) // - %to_domain% -> to domain (venus) // // Default: "-f", "%from%", "-d", "%to_user%" (adequate for procmail // and maildrop). MailDeliveryAgentArgs []string `protobuf:"bytes,8,rep,name=mail_delivery_agent_args,json=mailDeliveryAgentArgs,proto3" json:"mail_delivery_agent_args,omitempty"` // Directory where we store our persistent data. // Default: "/var/lib/chasquid" DataDir string `protobuf:"bytes,9,opt,name=data_dir,json=dataDir,proto3" json:"data_dir,omitempty"` // Suffix separator, to perform suffix removal of local users. // For example, if you set this to "-+", email to local user // "user-blah" and "user+blah" will be delivered to "user". // Including "+" is strongly encouraged, as it is assumed for email // forwarding. // Default: "+". SuffixSeparators *string `protobuf:"bytes,10,opt,name=suffix_separators,json=suffixSeparators,proto3,oneof" json:"suffix_separators,omitempty"` // Characters to drop from the user part on local emails. // For example, if you set this to "._", email to local user // "u.se_r" will be delivered to "user". // Default: ".". DropCharacters *string `protobuf:"bytes,11,opt,name=drop_characters,json=dropCharacters,proto3,oneof" json:"drop_characters,omitempty"` // Path where to write the mail log to. // If "", log using the syslog (at MAIL|INFO priority). // If "", log to stdout; if "", log to stderr. // Default: MailLogPath string `protobuf:"bytes,12,opt,name=mail_log_path,json=mailLogPath,proto3" json:"mail_log_path,omitempty"` // Enable dovecot authentication. // Domains that don't have an user database will be authenticated via // dovecot. DovecotAuth bool `protobuf:"varint,13,opt,name=dovecot_auth,json=dovecotAuth,proto3" json:"dovecot_auth,omitempty"` // Dovecot userdb path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-userdb DovecotUserdbPath string `protobuf:"bytes,14,opt,name=dovecot_userdb_path,json=dovecotUserdbPath,proto3" json:"dovecot_userdb_path,omitempty"` // Dovecot client path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-client DovecotClientPath string `protobuf:"bytes,15,opt,name=dovecot_client_path,json=dovecotClientPath,proto3" json:"dovecot_client_path,omitempty"` // Expect incoming SMTP connections to use the HAProxy protocol. // This allows deploying chasquid behind a HAProxy server, as the // address information is preserved. HaproxyIncoming bool `protobuf:"varint,16,opt,name=haproxy_incoming,json=haproxyIncoming,proto3" json:"haproxy_incoming,omitempty"` } func (x *Config) Reset() { *x = Config{} if protoimpl.UnsafeEnabled { mi := &file_config_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Config) String() string { return protoimpl.X.MessageStringOf(x) } func (*Config) ProtoMessage() {} func (x *Config) ProtoReflect() protoreflect.Message { mi := &file_config_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Config.ProtoReflect.Descriptor instead. func (*Config) Descriptor() ([]byte, []int) { return file_config_proto_rawDescGZIP(), []int{0} } func (x *Config) GetHostname() string { if x != nil { return x.Hostname } return "" } func (x *Config) GetMaxDataSizeMb() int64 { if x != nil { return x.MaxDataSizeMb } return 0 } func (x *Config) GetSmtpAddress() []string { if x != nil { return x.SmtpAddress } return nil } func (x *Config) GetSubmissionAddress() []string { if x != nil { return x.SubmissionAddress } return nil } func (x *Config) GetSubmissionOverTlsAddress() []string { if x != nil { return x.SubmissionOverTlsAddress } return nil } func (x *Config) GetMonitoringAddress() string { if x != nil { return x.MonitoringAddress } return "" } func (x *Config) GetMailDeliveryAgentBin() string { if x != nil { return x.MailDeliveryAgentBin } return "" } func (x *Config) GetMailDeliveryAgentArgs() []string { if x != nil { return x.MailDeliveryAgentArgs } return nil } func (x *Config) GetDataDir() string { if x != nil { return x.DataDir } return "" } func (x *Config) GetSuffixSeparators() string { if x != nil && x.SuffixSeparators != nil { return *x.SuffixSeparators } return "" } func (x *Config) GetDropCharacters() string { if x != nil && x.DropCharacters != nil { return *x.DropCharacters } return "" } func (x *Config) GetMailLogPath() string { if x != nil { return x.MailLogPath } return "" } func (x *Config) GetDovecotAuth() bool { if x != nil { return x.DovecotAuth } return false } func (x *Config) GetDovecotUserdbPath() string { if x != nil { return x.DovecotUserdbPath } return "" } func (x *Config) GetDovecotClientPath() string { if x != nil { return x.DovecotClientPath } return "" } func (x *Config) GetHaproxyIncoming() bool { if x != nil { return x.HaproxyIncoming } return false } var File_config_proto protoreflect.FileDescriptor var file_config_proto_rawDesc = []byte{ 0x0a, 0x0c, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf4, 0x05, 0x0a, 0x06, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x1a, 0x0a, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x68, 0x6f, 0x73, 0x74, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x27, 0x0a, 0x10, 0x6d, 0x61, 0x78, 0x5f, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x5f, 0x6d, 0x62, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x6d, 0x61, 0x78, 0x44, 0x61, 0x74, 0x61, 0x53, 0x69, 0x7a, 0x65, 0x4d, 0x62, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x6d, 0x74, 0x70, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x6d, 0x74, 0x70, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x11, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x3d, 0x0a, 0x1b, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6f, 0x76, 0x65, 0x72, 0x5f, 0x74, 0x6c, 0x73, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x18, 0x73, 0x75, 0x62, 0x6d, 0x69, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4f, 0x76, 0x65, 0x72, 0x54, 0x6c, 0x73, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x6d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x62, 0x69, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x14, 0x6d, 0x61, 0x69, 0x6c, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x42, 0x69, 0x6e, 0x12, 0x37, 0x0a, 0x18, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x64, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x61, 0x72, 0x67, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x15, 0x6d, 0x61, 0x69, 0x6c, 0x44, 0x65, 0x6c, 0x69, 0x76, 0x65, 0x72, 0x79, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x41, 0x72, 0x67, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x64, 0x69, 0x72, 0x18, 0x09, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x64, 0x61, 0x74, 0x61, 0x44, 0x69, 0x72, 0x12, 0x30, 0x0a, 0x11, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x5f, 0x73, 0x65, 0x70, 0x61, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x10, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x53, 0x65, 0x70, 0x61, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x88, 0x01, 0x01, 0x12, 0x2c, 0x0a, 0x0f, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x63, 0x68, 0x61, 0x72, 0x61, 0x63, 0x74, 0x65, 0x72, 0x73, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0e, 0x64, 0x72, 0x6f, 0x70, 0x43, 0x68, 0x61, 0x72, 0x61, 0x63, 0x74, 0x65, 0x72, 0x73, 0x88, 0x01, 0x01, 0x12, 0x22, 0x0a, 0x0d, 0x6d, 0x61, 0x69, 0x6c, 0x5f, 0x6c, 0x6f, 0x67, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x69, 0x6c, 0x4c, 0x6f, 0x67, 0x50, 0x61, 0x74, 0x68, 0x12, 0x21, 0x0a, 0x0c, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x41, 0x75, 0x74, 0x68, 0x12, 0x2e, 0x0a, 0x13, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x5f, 0x75, 0x73, 0x65, 0x72, 0x64, 0x62, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x55, 0x73, 0x65, 0x72, 0x64, 0x62, 0x50, 0x61, 0x74, 0x68, 0x12, 0x2e, 0x0a, 0x13, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x5f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x70, 0x61, 0x74, 0x68, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x09, 0x52, 0x11, 0x64, 0x6f, 0x76, 0x65, 0x63, 0x6f, 0x74, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x50, 0x61, 0x74, 0x68, 0x12, 0x29, 0x0a, 0x10, 0x68, 0x61, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x18, 0x10, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0f, 0x68, 0x61, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x73, 0x75, 0x66, 0x66, 0x69, 0x78, 0x5f, 0x73, 0x65, 0x70, 0x61, 0x72, 0x61, 0x74, 0x6f, 0x72, 0x73, 0x42, 0x12, 0x0a, 0x10, 0x5f, 0x64, 0x72, 0x6f, 0x70, 0x5f, 0x63, 0x68, 0x61, 0x72, 0x61, 0x63, 0x74, 0x65, 0x72, 0x73, 0x42, 0x2c, 0x5a, 0x2a, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72, 0x69, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73, 0x71, 0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_config_proto_rawDescOnce sync.Once file_config_proto_rawDescData = file_config_proto_rawDesc ) func file_config_proto_rawDescGZIP() []byte { file_config_proto_rawDescOnce.Do(func() { file_config_proto_rawDescData = protoimpl.X.CompressGZIP(file_config_proto_rawDescData) }) return file_config_proto_rawDescData } var file_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_config_proto_goTypes = []interface{}{ (*Config)(nil), // 0: Config } var file_config_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type 0, // [0:0] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_config_proto_init() } func file_config_proto_init() { if File_config_proto != nil { return } if !protoimpl.UnsafeEnabled { file_config_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Config); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } file_config_proto_msgTypes[0].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_config_proto_rawDesc, NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_config_proto_goTypes, DependencyIndexes: file_config_proto_depIdxs, MessageInfos: file_config_proto_msgTypes, }.Build() File_config_proto = out.File file_config_proto_rawDesc = nil file_config_proto_goTypes = nil file_config_proto_depIdxs = nil } chasquid-1.15.0/internal/config/config.proto000066400000000000000000000075521474251645300210360ustar00rootroot00000000000000 syntax = "proto3"; option go_package = "blitiri.com.ar/go/chasquid/internal/config"; message Config { // Default hostname to use when saying hello. // This is used: // 1) To say hello to clients, for aesthetic purposes. // 2) As the HELO/EHLO domain on outgoing SMTP connections, so ideally // it would resolve back to the server. In practice, it's not a big // deal if it isn't, but it makes troubleshooting easier. // 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: "+". optional 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: ".". optional string drop_characters = 11; // Path where to write the mail log to. // If "", log using the syslog (at MAIL|INFO priority). // If "", log to stdout; if "", log to stderr. // Default: string mail_log_path = 12; // Enable dovecot authentication. // Domains that don't have an user database will be authenticated via // dovecot. bool dovecot_auth = 13; // Dovecot userdb path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-userdb string dovecot_userdb_path = 14; // Dovecot client path. If dovecot_auth is set and this // is not, we will try to autodetect it. // Example: /var/run/dovecot/auth-client string dovecot_client_path = 15; // Expect incoming SMTP connections to use the HAProxy protocol. // This allows deploying chasquid behind a HAProxy server, as the // address information is preserved. bool haproxy_incoming = 16; } chasquid-1.15.0/internal/config/config_test.go000066400000000000000000000064531474251645300213360ustar00rootroot00000000000000package config import ( "io" "os" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/log" "github.com/google/go-cmp/cmp" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/testing/protocmp" ) func mustCreateConfig(t *testing.T, contents string) (string, string) { tmpDir := testlib.MustTempDir(t) confStr := []byte(contents) err := os.WriteFile(tmpDir+"/chasquid.conf", confStr, 0600) if err != nil { t.Fatalf("Failed to write tmp config: %v", err) } return tmpDir, tmpDir + "/chasquid.conf" } func TestEmptyStruct(t *testing.T) { testLogConfig(&Config{}) } 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. defaults := proto.Clone(defaultConfig).(*Config) hostname, _ := os.Hostname() defaults.Hostname = hostname diff := cmp.Diff(defaults, c, protocmp.Transform()) if diff != "" { t.Errorf("Load() mismatch (-want +got):\n%s", diff) } testLogConfig(c) } func TestFullConfig(t *testing.T) { confStr := ` hostname: "joust" smtp_address: ":1234" smtp_address: ":5678" submission_address: ":10001" submission_address: ":10002" monitoring_address: ":1111" max_data_size_mb: 26 suffix_separators: "" ` tmpDir, path := mustCreateConfig(t, confStr) defer testlib.RemoveIfOk(t, tmpDir) overrideStr := ` hostname: "proust" submission_address: ":999" dovecot_auth: true drop_characters: "" ` expected := &Config{ Hostname: "proust", MaxDataSizeMb: 26, SmtpAddress: []string{":1234", ":5678"}, SubmissionAddress: []string{":999"}, SubmissionOverTlsAddress: []string{"systemd"}, MonitoringAddress: ":1111", MailDeliveryAgentBin: "maildrop", MailDeliveryAgentArgs: []string{"-f", "%from%", "-d", "%to_user%"}, DataDir: "/var/lib/chasquid", SuffixSeparators: proto.String(""), DropCharacters: proto.String(""), MailLogPath: "", DovecotAuth: true, } c, err := Load(path, overrideStr) if err != nil { t.Fatalf("error loading non-existent config: %v", err) } diff := cmp.Diff(expected, c, protocmp.Transform()) if diff != "" { t.Errorf("Load() mismatch (-want +got):\n%s", diff) } testLogConfig(c) } func TestErrorLoading(t *testing.T) { c, err := Load("/does/not/exist", "") if err == nil { t.Fatalf("loaded a non-existent config: %v", c) } } func TestBrokenConfig(t *testing.T) { tmpDir, path := mustCreateConfig( t, " this is not a valid protobuf") defer testlib.RemoveIfOk(t, tmpDir) c, err := Load(path, "") if err == nil { t.Fatalf("loaded an invalid config: %v", c) } } func TestBrokenOverride(t *testing.T) { tmpDir, path := mustCreateConfig( t, `hostname: "test"`) defer testlib.RemoveIfOk(t, tmpDir) c, err := Load(path, "broken override") if err == nil { t.Fatalf("loaded an invalid config: %v", c) } } // Run LogConfig, overriding the default logger first. This exercises the // code, we don't yet validate the output, but it is an useful sanity check. func testLogConfig(c *Config) { l := log.New(nopWCloser{io.Discard}) log.Default = l LogConfig(c) } type nopWCloser struct { io.Writer } func (nopWCloser) Close() error { return nil } chasquid-1.15.0/internal/courier/000077500000000000000000000000001474251645300166765ustar00rootroot00000000000000chasquid-1.15.0/internal/courier/courier.go000066400000000000000000000006561474251645300207040ustar00rootroot00000000000000// Package courier implements various couriers for delivering messages. package courier // Courier delivers mail to a single recipient. // It is implemented by different couriers, for both local and remote // recipients. type Courier interface { // Deliver mail to a recipient. Return the error (if any), and whether it // is permanent (true) or transient (false). Deliver(from string, to string, data []byte) (error, bool) } chasquid-1.15.0/internal/courier/fakeserver_test.go000066400000000000000000000060351474251645300224250ustar00rootroot00000000000000package courier import ( "bufio" "crypto/tls" "crypto/x509" "net" "net/textproto" "os" "sync" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" ) // Fake server, to test SMTP out. type FakeServer struct { t *testing.T tmpDir string responses map[string]string wg *sync.WaitGroup addr string conns int tlsConfig *tls.Config } func newFakeServer(t *testing.T, responses map[string]string, conns int) *FakeServer { s := &FakeServer{ t: t, tmpDir: testlib.MustTempDir(t), responses: responses, conns: conns, wg: &sync.WaitGroup{}, } s.start() return s } func (s *FakeServer) Cleanup() { // Remove our temporary data. Be extra paranoid and make sure the // directory isn't too shallow. if len(s.tmpDir) > 8 { os.RemoveAll(s.tmpDir) } } func (s *FakeServer) initTLS() { var err error s.tlsConfig, err = testlib.GenerateCert(s.tmpDir) if err != nil { s.t.Fatalf("error generating cert: %v", err) } cert, err := tls.LoadX509KeyPair(s.tmpDir+"/cert.pem", s.tmpDir+"/key.pem") if err != nil { s.t.Fatalf("error loading temp cert: %v", err) } s.tlsConfig.Certificates = []tls.Certificate{cert} } func (s *FakeServer) rootCA() *x509.CertPool { s.t.Helper() pool := x509.NewCertPool() path := s.tmpDir + "/cert.pem" data, err := os.ReadFile(path) if err != nil { s.t.Fatalf("error reading cert %q: %v", path, err) } ok := pool.AppendCertsFromPEM(data) if !ok { s.t.Fatalf("failed to load cert %q", path) } return pool } func (s *FakeServer) start() string { s.t.Helper() l, err := net.Listen("tcp", "localhost:0") if err != nil { s.t.Fatalf("fake server listen: %v", err) } s.addr = l.Addr().String() s.initTLS() s.wg.Add(s.conns) accept := func() { defer s.wg.Done() c, err := l.Accept() if err != nil { panic(err) } defer c.Close() s.t.Logf("fakeServer got connection") r := textproto.NewReader(bufio.NewReader(c)) c.Write([]byte(s.responses["_welcome"])) for { line, err := r.ReadLine() if err != nil { s.t.Logf("fakeServer exiting: %v\n", err) return } s.t.Logf("fakeServer read: %q\n", line) if line == "STARTTLS" && s.responses["_STARTTLS"] == "ok" { c.Write([]byte(s.responses["STARTTLS"])) tlssrv := tls.Server(c, s.tlsConfig) err = tlssrv.Handshake() if err != nil { s.t.Logf("starttls handshake error: %v", err) return } // Replace the connection with the wrapped one. // Don't send a reply, as per the protocol. c = tlssrv defer c.Close() r = textproto.NewReader(bufio.NewReader(c)) continue } c.Write([]byte(s.responses[line])) if line == "DATA" { _, err = r.ReadDotBytes() if err != nil { s.t.Logf("fakeServer exiting: %v\n", err) return } c.Write([]byte(s.responses["_DATA"])) } } } for i := 0; i < s.conns; i++ { go accept() } return s.addr } func (s *FakeServer) HostPort() (string, string) { host, port, _ := net.SplitHostPort(s.addr) return host, port } func (s *FakeServer) Wait() { s.wg.Wait() } chasquid-1.15.0/internal/courier/mda.go000066400000000000000000000061131474251645300177670ustar00rootroot00000000000000package courier import ( "bytes" "context" "fmt" "os/exec" "strings" "syscall" "time" "unicode" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/trace" ) var ( errTimeout = fmt.Errorf("operation timed out") ) // MDA delivers local mail by executing a local binary, like procmail or // maildrop. It works with any binary that: // - Receives the email to deliver via stdin. // - Exits with code EX_TEMPFAIL (75) for transient issues. type MDA struct { Binary string // Path to the binary. Args []string // Arguments to pass. Timeout time.Duration // Timeout for each invocation. } // Deliver an email. On failures, returns an error, and whether or not it is // permanent. func (p *MDA) Deliver(from string, to string, data []byte) (error, bool) { tr := trace.New("Courier.MDA", to) defer tr.Finish() // Sanitize, just in case. from = sanitizeForMDA(from) to = sanitizeForMDA(to) tr.Debugf("%s -> %s", from, to) // Prepare the command, replacing the necessary arguments. replacer := strings.NewReplacer( "%from%", from, "%from_user%", envelope.UserOf(from), "%from_domain%", envelope.DomainOf(from), "%to%", to, "%to_user%", envelope.UserOf(to), "%to_domain%", envelope.DomainOf(to), ) args := []string{} for _, a := range p.Args { args = append(args, replacer.Replace(a)) } tr.Debugf("%s %q", p.Binary, args) ctx, cancel := context.WithTimeout(context.Background(), p.Timeout) defer cancel() cmd := exec.CommandContext(ctx, p.Binary, args...) // Pass the email data via stdin. Normalize it to CRLF which is what the // RFC-compliant representation require. By doing this at this end, we can // keep a simpler internal representation and ensure there won't be any // inconsistencies in newlines within the message (e.g. added headers). cmd.Stdin = bytes.NewReader(normalize.ToCRLF(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("MDA delivery failed: %v - %q", err, string(output)) return err, permanent } tr.Debugf("delivered") return nil, false } // sanitizeForMDA 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 sanitizeForMDA(s string) string { valid := func(r rune) rune { switch { case unicode.IsSpace(r), unicode.IsControl(r), strings.ContainsRune("/;\"'\\|*&$%()[]{}`!", r): return rune(-1) default: return r } } return strings.Map(valid, s) } chasquid-1.15.0/internal/courier/mda_test.go000066400000000000000000000064511474251645300210330ustar00rootroot00000000000000package courier import ( "bytes" "os" "testing" "time" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestMDA(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) p := MDA{ 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 := os.ReadFile(dir + "/to") if err != nil || !bytes.Equal(data, []byte("data")) { t.Errorf("Invalid data: %q - %v", string(data), err) } } func TestMDATimeout(t *testing.T) { p := MDA{"/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 TestMDABadCommandLine(t *testing.T) { // Non-existent binary. p := MDA{"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 = MDA{"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 := &MDA{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. {"ŲĄŲŠŲŠŲŠâ€“ŲĄŲĸâ€“ŲŖŲĄ", "ŲĄŲŠŲŠŲŠâ€“ŲĄŲĸâ€“ŲŖŲĄ"}, //lint:ignore ST1018 The use of a literal U+200C is intentional. {"Ų…ŲˆØ˛Ų‡â€ŒŲ‡Ø§", "Ų…ŲˆØ˛Ų‡\u200cŲ‡Ø§"}, } for _, c := range cases { out := sanitizeForMDA(c.v) if out != c.expected { t.Errorf("%q: expected %q, got %q", c.v, c.expected, out) } } } chasquid-1.15.0/internal/courier/smtp.go000066400000000000000000000214641474251645300202170ustar00rootroot00000000000000package courier import ( "context" "crypto/tls" "crypto/x509" "flag" "net" "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/expvarom" "blitiri.com.ar/go/chasquid/internal/smtp" "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/trace" ) var ( // Timeouts for SMTP delivery. smtpDialTimeout = 1 * time.Minute smtpTotalTimeout = 10 * time.Minute // Port for outgoing SMTP. // Tests can override this. smtpPort = flag.String("testing__outgoing_smtp_port", "25", "port to use for outgoing SMTP connections, ONLY FOR TESTING") // Allow overriding of net.LookupMX for testing purposes. // TODO: replace this with proper lookup interception once it is supported // by Go. netLookupMX = net.LookupMX ) // Exported variables. var ( tlsCount = expvarom.NewMap("chasquid/smtpOut/tlsCount", "result", "count of TLS status on outgoing connections") slcResults = expvarom.NewMap("chasquid/smtpOut/securityLevelChecks", "result", "count of security level checks on outgoing connections") stsSecurityModes = expvarom.NewMap("chasquid/smtpOut/sts/mode", "mode", "count of STS checks on outgoing connections") stsSecurityResults = expvarom.NewMap("chasquid/smtpOut/sts/security", "result", "count of STS security checks on outgoing connections") ) // SMTP delivers remote mail via outgoing SMTP. type SMTP struct { HelloDomain string Dinfo *domaininfo.DB STSCache *sts.PolicyCache } // Deliver an email. On failures, returns an error, and whether or not it is // permanent. func (s *SMTP) Deliver(from string, to string, data []byte) (error, bool) { a := &attempt{ courier: s, from: from, to: to, toDomain: envelope.DomainOf(to), data: data, tr: trace.New("Courier.SMTP", to), } defer a.tr.Finish() a.tr.Debugf("%s -> %s", from, to) // smtp.Client.Mail will add the <> for us when the address is empty. if a.from == "<>" { a.from = "" } mxs, err, perm := 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), perm } a.stsPolicy = s.fetchSTSPolicy(a.tr, a.toDomain) for _, mx := range mxs { if a.stsPolicy != nil && !a.stsPolicy.MXIsAllowed(mx) { a.tr.Printf("%q skipped as per MTA-STA policy", mx) continue } var permanent bool err, permanent = a.deliver(mx) if err == nil { return nil, false } if permanent { return err, true } a.tr.Errorf("%q returned transient error: %v", mx, err) } // We exhausted all MXs failed to deliver, try again later. return a.tr.Errorf("all MXs returned transient failures (last: %v)", err), false } type attempt struct { courier *SMTP from string to string data []byte toDomain string stsPolicy *sts.Policy tr *trace.Trace } func (a *attempt) deliver(mx string) (error, bool) { skipTLS := false 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.courier.HelloDomain); err != nil { return a.tr.Errorf("Error saying hello: %v", err), false } secLevel := domaininfo.SecLevel_PLAIN if ok, _ := c.Extension("STARTTLS"); ok && !skipTLS { config := &tls.Config{ ServerName: mx, // Unfortunately, many servers use self-signed and invalid // certificates. So we use a custom verification (identical to // Go's) to distinguish between invalid and valid certificates. // That information is used to track the security level, to // prevent downgrade attacks. InsecureSkipVerify: true, VerifyConnection: func(cs tls.ConnectionState) error { secLevel = a.verifyConnection(cs) return nil }, } err = c.StartTLS(config) if err != nil { // If we could not complete a jump to TLS (either because the // STARTTLS command itself failed server-side, or because we got a // TLS negotiation error), retry but without trying to use TLS. // This should be quite rare, but it can happen if the server // certificate is not parseable by the Go library, or if it has a // broken TLS stack. // Note that invalid and self-signed certs do NOT fall in this // category, those are handled by the VerifyConnection function // above, and don't need a retry. This is only needed for lower // level errors. tlsCount.Add("tls:failed", 1) a.tr.Errorf("TLS error, retrying without TLS: %v", err) skipTLS = true conn.Close() goto retry } } else { tlsCount.Add("plain", 1) a.tr.Debugf("Insecure - NOT using TLS") } if !a.courier.Dinfo.OutgoingSecLevel(a.tr, a.toDomain, secLevel) { // We consider the failure transient, so transient misconfigurations // do not affect deliveries. slcResults.Add("fail", 1) return a.tr.Errorf("Security level check failed (level:%s)", secLevel), false } slcResults.Add("pass", 1) if a.stsPolicy != nil && a.stsPolicy.Mode == sts.Enforce { // The connection MUST be validated by TLS. // https://tools.ietf.org/html/rfc8461#section-4.2 if secLevel != domaininfo.SecLevel_TLS_SECURE { stsSecurityResults.Add("fail", 1) return a.tr.Errorf("invalid security level (%v) for STS policy", secLevel), false } stsSecurityResults.Add("pass", 1) a.tr.Debugf("STS policy: connection is using valid TLS") } if err = c.MailAndRcpt(a.from, a.to); err != nil { return a.tr.Errorf("MAIL+RCPT %v", err), smtp.IsPermanent(err) } w, err := c.Data() if err != nil { return a.tr.Errorf("DATA %v", err), smtp.IsPermanent(err) } _, err = w.Write(a.data) if err != nil { return a.tr.Errorf("DATA writing: %v", err), smtp.IsPermanent(err) } err = w.Close() if err != nil { return a.tr.Errorf("DATA closing %v", err), smtp.IsPermanent(err) } _ = c.Quit() a.tr.Debugf("done") return nil, false } // CA roots to validate against, so we can override it for testing. var certRoots *x509.CertPool = nil func (a *attempt) verifyConnection(cs tls.ConnectionState) domaininfo.SecLevel { // Validate certificates, using the same logic Go does, and following the // official example at // https://pkg.go.dev/crypto/tls#example-Config-VerifyConnection. opts := x509.VerifyOptions{ DNSName: cs.ServerName, Intermediates: x509.NewCertPool(), Roots: certRoots, } for _, cert := range cs.PeerCertificates[1:] { opts.Intermediates.AddCert(cert) } _, err := cs.PeerCertificates[0].Verify(opts) if err != nil { // Invalid TLS cert, since it could not be verified. a.tr.Debugf("Insecure - using TLS, but with an invalid cert") tlsCount.Add("tls:insecure", 1) return domaininfo.SecLevel_TLS_INSECURE } else { tlsCount.Add("tls:secure", 1) a.tr.Debugf("Secure - using TLS") return domaininfo.SecLevel_TLS_SECURE } } func (s *SMTP) fetchSTSPolicy(tr *trace.Trace, domain string) *sts.Policy { if s.STSCache == nil { return nil } ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) defer cancel() policy, err := s.STSCache.Fetch(ctx, domain) if err != nil { return nil } tr.Debugf("got STS policy") stsSecurityModes.Add(string(policy.Mode), 1) return policy } func lookupMXs(tr *trace.Trace, domain string) ([]string, error, bool) { domain, err := idna.ToASCII(domain) if err != nil { return nil, err, true } mxs := []string{} mxRecords, err := netLookupMX(domain) if err != nil { // There was an error. It could be that the domain has no MX, in which // case we have to fall back to A, or a bigger problem. dnsErr, ok := err.(*net.DNSError) if !ok { tr.Debugf("Error resolving MX on %q: %v", domain, err) return nil, err, false } else if dnsErr.IsNotFound { // MX not found, fall back to A. tr.Debugf("MX for %s not found, falling back to A", domain) mxs = []string{domain} } else { tr.Debugf("MX lookup error on %q: %v", domain, dnsErr) return nil, err, !dnsErr.Temporary() } } 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, true } chasquid-1.15.0/internal/courier/smtp_test.go000066400000000000000000000252761474251645300212630ustar00rootroot00000000000000package courier import ( "fmt" "net" "strings" "testing" "time" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/sts" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/trace" ) // This domain will cause idna.ToASCII to fail. var invalidDomain = "test " + strings.Repeat("x", 65536) + "\uff00" // Override the netLookupMX function, to return controlled results for // testing. var testMX = map[string][]*net.MX{} var testMXErr = map[string]error{} func init() { netLookupMX = func(name string) ([]*net.MX, error) { return testMX[name], testMXErr[name] } } func newSMTP(t *testing.T) (*SMTP, string) { dir := testlib.MustTempDir(t) dinfo, err := domaininfo.New(dir) if err != nil { t.Fatal(err) } return &SMTP{"hello", dinfo, nil}, dir } 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 hello": "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", } srv := newFakeServer(t, responses, 1) defer srv.Cleanup() host, port := srv.HostPort() // Put a non-existing host first, so we check that if the first host // doesn't work, we try with the rest. // The host we use is invalid, to avoid having to do an actual network // lookup whick makes the test more hermetic. This is a hack, ideally we // would be able to override the default resolver, but Go does not // implement that yet. testMX["to"] = []*net.MX{ {Host: ":::", Pref: 10}, {Host: host, Pref: 20}, } *smtpPort = port s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) err, _ := s.Deliver("me@me", "to@to", []byte("data")) if err != nil { t.Errorf("deliver failed: %v", err) } srv.Wait() } func TestSMTPErrors(t *testing.T) { // Shorten the total timeout, so the test fails quickly if the protocol // gets stuck. smtpTotalTimeout = 1 * time.Second responses := []map[string]string{ // First test: hang response, should fail due to timeout. { "_welcome": "220 no newline", }, // MAIL FROM not allowed. { "_welcome": "220 mail from not allowed\n", "EHLO hello": "250 ehlo ok\n", "MAIL FROM:": "501 mail error\n", }, // RCPT TO not allowed. { "_welcome": "220 rcpt to not allowed\n", "EHLO hello": "250 ehlo ok\n", "MAIL FROM:": "250 mail ok\n", "RCPT TO:": "501 rcpt error\n", }, // DATA error. { "_welcome": "220 data error\n", "EHLO hello": "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 hello": "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 { srv := newFakeServer(t, rs, 1) defer srv.Cleanup() host, port := srv.HostPort() testMX["to"] = []*net.MX{{Host: host, Pref: 10}} *smtpPort = port s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) err, _ := s.Deliver("me@me", "to@to", []byte("data")) if err == nil { t.Errorf("deliver not failed in case %q: %v", rs["_welcome"], err) } t.Logf("failed as expected: %v", err) srv.Wait() } } func TestNoMXServer(t *testing.T) { testMX["to"] = []*net.MX{} s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) err, permanent := s.Deliver("me@me", "to@to", []byte("data")) if err == nil { t.Errorf("delivery worked, expected failure") } if !permanent { t.Errorf("expected permanent failure, got transient (%v)", err) } t.Logf("got permanent failure, as expected: %v", err) } func TestTooManyMX(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = []*net.MX{ {Host: "h1", Pref: 10}, {Host: "h2", Pref: 20}, {Host: "h3", Pref: 30}, {Host: "h4", Pref: 40}, {Host: "h5", Pref: 50}, {Host: "h5", Pref: 60}, } mxs, err, perm := lookupMXs(tr, "domain") if err != nil { t.Fatalf("unexpected error: %v", err) } if perm != true { t.Fatalf("expected perm == true") } if len(mxs) != 5 { t.Errorf("expected len(mxs) == 5, got: %v", mxs) } } func TestFallbackToA(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = nil testMXErr["domain"] = &net.DNSError{ Err: "no such host (test)", IsTemporary: false, IsNotFound: true, } mxs, err, perm := lookupMXs(tr, "domain") if err != nil { t.Errorf("unexpected error: %v", err) } if perm != true { t.Errorf("expected perm == true") } if !(len(mxs) == 1 && mxs[0] == "domain") { t.Errorf("expected mxs == [domain], got: %v", mxs) } } func TestTemporaryDNSerror(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = nil testMXErr["domain"] = &net.DNSError{ Err: "temp error (test)", IsTemporary: true, } mxs, err, perm := lookupMXs(tr, "domain") if !(mxs == nil && err == testMXErr["domain"]) { t.Errorf("expected mxs == nil, err == test error, got: %v, %v", mxs, err) } if perm != false { t.Errorf("expected perm == false") } } func TestMXLookupError(t *testing.T) { tr := trace.New("test", "test") testMX["domain"] = nil testMXErr["domain"] = fmt.Errorf("test error") mxs, err, perm := lookupMXs(tr, "domain") if !(mxs == nil && err == testMXErr["domain"]) { t.Errorf("expected mxs == nil, err == test error, got: %v, %v", mxs, err) } if perm != false { t.Errorf("expected perm == false") } } func TestLookupInvalidDomain(t *testing.T) { tr := trace.New("test", "test") mxs, err, perm := lookupMXs(tr, invalidDomain) if !(mxs == nil && err != nil) { t.Errorf("expected err != nil, got: %v, %v", mxs, err) } if perm != true { t.Fatalf("expected perm == true") } } // Server fake responses for a complete TLS delivery. // We use this in a few tests, so make it common. var tlsResponses = map[string]string{ "_welcome": "220 welcome\n", "EHLO hello": "250-ehlo ok\n250 STARTTLS\n", "STARTTLS": "220 starttls go\n", "_STARTTLS": "ok", "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", } func TestTLS(t *testing.T) { smtpTotalTimeout = 5 * time.Second srv := newFakeServer(t, tlsResponses, 1) defer srv.Cleanup() _, *smtpPort = srv.HostPort() testMX["to"] = []*net.MX{ {Host: "localhost", Pref: 20}, } 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) } srv.Wait() // Now do another delivery, but without TLS, to check that the detection // of connection downgrade is working. responses := map[string]string{ "_welcome": "220 welcome\n", "EHLO hello": "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", } srv = newFakeServer(t, responses, 1) defer srv.Cleanup() _, *smtpPort = srv.HostPort() err, permanent := s.Deliver("me@me", "to@to", []byte("data")) if !strings.Contains(err.Error(), "Security level check failed (level:PLAIN)") { t.Errorf("expected sec level check failed, got: %v", err) } if permanent != false { t.Errorf("expected transient failure, got permanent") } srv.Wait() } func TestTLSError(t *testing.T) { smtpTotalTimeout = 5 * time.Second responses := map[string]string{ "_welcome": "220 welcome\n", // STARTTLS should be advertised so we try to initiate it. "EHLO hello": "250-ehlo ok\n250 STARTTLS\n", // Error in STARTTLS request. Note that a TLS-layer error also falls // under this code path, so both situations are covered by this test. "STARTTLS": "500 starttls err\n", "_STARTTLS": "no", // Rest of the transaction is normal and straightforward. "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", } // Note we expect 2 connections to the fake server (because of the retry // after the failed STARTTLS). Note this also checks that we correctly // close the errored connection, instead of leaving it lingering. srv := newFakeServer(t, responses, 2) defer srv.Cleanup() _, *smtpPort = srv.HostPort() testMX["to"] = []*net.MX{ {Host: "localhost", Pref: 20}, } 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) } // Double check that we delivered over a plaintext connection. tr := trace.New("test", "test") defer tr.Finish() if !s.Dinfo.OutgoingSecLevel(tr, "to", domaininfo.SecLevel_PLAIN) { t.Errorf("delivery did not took place over plaintext as expected") } srv.Wait() } func TestSTSPolicyEnforcement(t *testing.T) { smtpTotalTimeout = 5 * time.Second srv := newFakeServer(t, tlsResponses, 1) defer srv.Cleanup() _, *smtpPort = srv.HostPort() s, tmpDir := newSMTP(t) defer testlib.RemoveIfOk(t, tmpDir) a := &attempt{ courier: s, from: "me@me", to: "to@to", toDomain: "to", data: []byte("data"), tr: trace.New("test", "test"), } a.stsPolicy = &sts.Policy{ Version: "STSv1", Mode: sts.Enforce, MXs: []string{"mx"}, MaxAge: 1 * time.Minute, } // At this point the cert is not valid, which is incompatible with STS // policy, so we expect it to fail. err, permanent := a.deliver("localhost") if !strings.Contains(err.Error(), "invalid security level (TLS_INSECURE) for STS policy") { t.Errorf("expected invalid sec level error, got %v", err) } if permanent != false { t.Errorf("expected transient error, got permanent") } srv.Wait() // Do another delivery attempt, but this time we trust the server cert. // This time it should be successful, because the connection level should // be TLS_SECURE which is required by the STS policy. srv = newFakeServer(t, tlsResponses, 1) _, *smtpPort = srv.HostPort() defer srv.Cleanup() certRoots = srv.rootCA() defer func() { certRoots = nil }() err, permanent = a.deliver("localhost") if err != nil { t.Errorf("expected success, got %v (permanent=%v)", err, permanent) } srv.Wait() } chasquid-1.15.0/internal/dkim/000077500000000000000000000000001474251645300161525ustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/canonicalize.go000066400000000000000000000075051474251645300211470ustar00rootroot00000000000000package dkim import ( "errors" "fmt" "regexp" "strings" ) var ( errUnknownCanonicalization = errors.New("unknown canonicalization") ) type canonicalization string var ( simpleCanonicalization canonicalization = "simple" relaxedCanonicalization canonicalization = "relaxed" ) func (c canonicalization) body(b string) string { switch c { case simpleCanonicalization: return simpleBody(b) case relaxedCanonicalization: return relaxBody(b) default: panic("unknown canonicalization") } } func (c canonicalization) headers(hs headers) headers { switch c { case simpleCanonicalization: return hs case relaxedCanonicalization: return relaxHeaders(hs) default: panic("unknown canonicalization") } } func (c canonicalization) header(h header) header { switch c { case simpleCanonicalization: return h case relaxedCanonicalization: return relaxHeader(h) default: panic("unknown canonicalization") } } func stringToCanonicalization(s string) (canonicalization, error) { switch s { case "simple": return simpleCanonicalization, nil case "relaxed": return relaxedCanonicalization, nil default: return "", fmt.Errorf("%w: %s", errUnknownCanonicalization, s) } } // Notes on whitespace reduction: // https://datatracker.ietf.org/doc/html/rfc6376#section-2.8 // There are only 3 forms of whitespace: // - WSP = SP / HTAB // Simple whitespace: space or tab. // - LWSP = *(WSP / CRLF WSP) // Linear whitespace: any number of { simple whitespace OR CRLF followed by // simple whitespace }. // - FWS = [*WSP CRLF] 1*WSP // Folding whitespace: optional { simple whitespace OR CRLF } followed by // one or more simple whitespace. func simpleBody(body string) string { // https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.3 // Replace repeated CRLF at the end of the body with a single CRLF. body = repeatedCRLFAtTheEnd.ReplaceAllString(body, "\r\n") // Ensure a non-empty body ends with a single CRLF. // All bodies (including an empty one) must end with a CRLF. if !strings.HasSuffix(body, "\r\n") { body += "\r\n" } return body } var ( // Continued header: WSP after CRLF. continuedHeader = regexp.MustCompile(`\r\n[ \t]+`) // WSP before CRLF. wspBeforeCRLF = regexp.MustCompile(`[ \t]+\r\n`) // Repeated WSP. repeatedWSP = regexp.MustCompile(`[ \t]+`) // Empty lines at the end of the body. repeatedCRLFAtTheEnd = regexp.MustCompile(`(\r\n)+$`) ) func relaxBody(body string) string { // https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.4 body = wspBeforeCRLF.ReplaceAllLiteralString(body, "\r\n") body = repeatedWSP.ReplaceAllLiteralString(body, " ") body = repeatedCRLFAtTheEnd.ReplaceAllLiteralString(body, "\r\n") // Ensure a non-empty body ends with a single CRLF. if len(body) >= 1 && !strings.HasSuffix(body, "\r\n") { body += "\r\n" } return body } func relaxHeader(h header) header { // https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.2 // Convert all header field names to lowercase. name := strings.ToLower(h.Name) // Remove WSP before the ":" separating the name and value. name = strings.TrimRight(name, " \t") // Unfold continuation lines in values. value := continuedHeader.ReplaceAllString(h.Value, " ") // Reduce all sequences of WSP to a single SP. value = repeatedWSP.ReplaceAllLiteralString(value, " ") // Delete all WSP at the end of each unfolded header field value. value = strings.TrimRight(value, " \t") // Remove WSP after the ":" separating the name and value. value = strings.TrimLeft(value, " \t") return header{ Name: name, Value: value, // The "source" is the relaxed field: name, colon, and value (with // no space around the colon). Source: name + ":" + value, } } func relaxHeaders(hs headers) headers { rh := make(headers, 0, len(hs)) for _, h := range hs { rh = append(rh, relaxHeader(h)) } return rh } chasquid-1.15.0/internal/dkim/canonicalize_test.go000066400000000000000000000130231474251645300221760ustar00rootroot00000000000000package dkim import ( "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestStringToCanonicalization(t *testing.T) { cases := []struct { in string want canonicalization err error }{ {"simple", simpleCanonicalization, nil}, {"relaxed", relaxedCanonicalization, nil}, {"", "", errUnknownCanonicalization}, {" ", "", errUnknownCanonicalization}, {" simple", "", errUnknownCanonicalization}, {"simple ", "", errUnknownCanonicalization}, {"si mple ", "", errUnknownCanonicalization}, } for _, c := range cases { got, err := stringToCanonicalization(c.in) if diff := cmp.Diff(c.want, got); diff != "" { t.Errorf("stringToCanonicalization(%q) diff (-want +got): %s", c.in, diff) } diff := cmp.Diff(c.err, err, cmpopts.EquateErrors()) if diff != "" { t.Errorf("stringToCanonicalization(%q) err diff (-want +got): %s", c.in, diff) } } } func TestSimpleBody(t *testing.T) { cases := []struct { in, want string }{ // Bodies end with \r\n, including the empty one. {"", "\r\n"}, {"a", "a\r\n"}, {"a\r\n", "a\r\n"}, // Repeated CRLF at the end of the body is replaced with a single CRLF. {"Body \r\n\r\n\r\n", "Body \r\n"}, // Example from RFC. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5 { " C \r\nD \t E\r\n\r\n\r\n", " C \r\nD \t E\r\n", }, } for _, c := range cases { got := simpleCanonicalization.body(c.in) if diff := cmp.Diff(c.want, got); diff != "" { t.Errorf("simpleCanonicalization.body(%q) diff (-want +got): %s", c.in, diff) } } } func TestRelaxBody(t *testing.T) { cases := []struct { in, want string }{ {"a\r\n", "a\r\n"}, // Repeated WSP before CRLF. {"a \r\n", "a\r\n"}, {"a \r\n", "a\r\n"}, {"a \t \r\n", "a\r\n"}, {"a\t\t\t\r\n", "a\r\n"}, // Repeated WSP within a line. {"a b\r\n", "a b\r\n"}, {"a\t\t\tb\r\n", "a b\r\n"}, {"a \t \t b\r\n", "a b\r\n"}, // Ignore empty lines at the end. {"a\r\n\r\n", "a\r\n"}, {"a\r\n\r\n\r\n", "a\r\n"}, // Body must end with \r\n, unless it's empty. {"", ""}, {"\r\n", "\r\n"}, {"a", "a\r\n"}, // Example from RFC. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5 {" C \r\nD \t E\r\n\r\n\r\n", " C\r\nD E\r\n"}, } for _, c := range cases { got := relaxedCanonicalization.body(c.in) if diff := cmp.Diff(c.want, got); diff != "" { t.Errorf("relaxedCanonicalization.body(%q) diff (-want +got): %s", c.in, diff) } } } func mkHs(hs ...string) headers { var headers headers for i := 0; i < len(hs); i += 2 { h := header{ Name: hs[i], Value: hs[i+1], Source: hs[i] + ":" + hs[i+1], } headers = append(headers, h) } return headers } func TestHeaders(t *testing.T) { cases := []struct { in string wantS headers wantR headers }{ // Unfold headers. {"A: B\r\n C\r\n", mkHs("A", " B\r\n C"), mkHs("a", "B C")}, {"A: B\r\n\tC\r\n", mkHs("A", " B\r\n\tC"), mkHs("a", "B C")}, {"A: B\r\n \t C\r\n", mkHs("A", " B\r\n \t C"), mkHs("a", "B C")}, // Reduce all sequences of WSP within a line to a single SP. {"A: B C\r\n", mkHs("A", " B C"), mkHs("a", "B C")}, {"A: B\t\tC\r\n", mkHs("A", " B\t\tC"), mkHs("a", "B C")}, {"A: B \t \t C\r\n", mkHs("A", " B \t \t C"), mkHs("a", "B C")}, // Delete all WSP at the end of each unfolded header field. {"A: B \r\n", mkHs("A", " B "), mkHs("a", "B")}, {"A: B \r\n", mkHs("A", " B "), mkHs("a", "B")}, {"A: B\t \r\n", mkHs("A", " B\t "), mkHs("a", "B")}, {"A: B\t\t\t\r\n", mkHs("A", " B\t\t\t"), mkHs("a", "B")}, {"A: B\r\n \t C \t\r\n", mkHs("A", " B\r\n \t C \t"), mkHs("a", "B C")}, // Whitespace before and after the colon. {"A : B\r\n", mkHs("A ", " B"), mkHs("a", "B")}, {"A : B\r\n", mkHs("A ", " B"), mkHs("a", "B")}, {"A\t:\tB\r\n", mkHs("A\t", "\tB"), mkHs("a", "B")}, {"A\t \t : \t \tB\r\n", mkHs("A\t \t ", " \t \tB"), mkHs("a", "B")}, // Example from RFC. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.4.5 {"A: X\r\nB : Y\t\r\n\tZ \r\n", mkHs("A", " X", "B ", " Y\t\r\n\tZ "), mkHs("a", "X", "b", "Y Z")}, } for i, c := range cases { hs, _, err := parseMessage(c.in) if err != nil { t.Fatalf("parseMessage(%q) = %v, want nil", c.in, err) } gotS := simpleCanonicalization.headers(hs) if diff := cmp.Diff(c.wantS, gotS); diff != "" { t.Errorf("%d: simpleCanonicalization.headers(%q) diff (-want +got): %s", i, c.in, diff) } gotR := relaxedCanonicalization.headers(hs) if diff := cmp.Diff(c.wantR, gotR); diff != "" { t.Errorf("%d: relaxedCanonicalization.headers(%q) diff (-want +got): %s", i, c.in, diff) } // Test the single-header variant if possible. if len(hs) == 1 { gotS := simpleCanonicalization.header(hs[0]) if diff := cmp.Diff(c.wantS[0], gotS); diff != "" { t.Errorf("%d: simpleCanonicalization.header(%q) diff (-want +got): %s", i, c.in, diff) } gotR := relaxedCanonicalization.header(hs[0]) if diff := cmp.Diff(c.wantR[0], gotR); diff != "" { t.Errorf("%d: relaxedCanonicalization.header(%q) diff (-want +got): %s", i, c.in, diff) } } } } func TestBadCanonicalization(t *testing.T) { bad := canonicalization("bad") if !panics(func() { bad.body("") }) { t.Errorf("bad.body() did not panic") } if !panics(func() { bad.header(header{}) }) { t.Errorf("bad.header() did not panic") } if !panics(func() { bad.headers(nil) }) { t.Errorf("bad.headers() did not panic") } } func panics(f func()) (panicked bool) { defer func() { r := recover() panicked = r != nil }() f() return } chasquid-1.15.0/internal/dkim/context.go000066400000000000000000000025131474251645300201660ustar00rootroot00000000000000package dkim import ( "context" "net" ) type contextKey string const traceKey contextKey = "trace" func trace(ctx context.Context, f string, args ...interface{}) { traceFunc, ok := ctx.Value(traceKey).(TraceFunc) if !ok { return } traceFunc(f, args...) } type TraceFunc func(f string, a ...interface{}) func WithTraceFunc(ctx context.Context, trace TraceFunc) context.Context { return context.WithValue(ctx, traceKey, trace) } const lookupTXTKey contextKey = "lookupTXT" func lookupTXT(ctx context.Context, domain string) ([]string, error) { lookupTXTFunc, ok := ctx.Value(lookupTXTKey).(lookupTXTFunc) if !ok { return net.LookupTXT(domain) } return lookupTXTFunc(ctx, domain) } type lookupTXTFunc func(ctx context.Context, domain string) ([]string, error) func WithLookupTXTFunc(ctx context.Context, lookupTXT lookupTXTFunc) context.Context { return context.WithValue(ctx, lookupTXTKey, lookupTXT) } const maxHeadersKey contextKey = "maxHeaders" func WithMaxHeaders(ctx context.Context, maxHeaders int) context.Context { return context.WithValue(ctx, maxHeadersKey, maxHeaders) } func maxHeaders(ctx context.Context) int { maxHeaders, ok := ctx.Value(maxHeadersKey).(int) if !ok { // By default, cap the number of headers to 5 (arbitrarily chosen, may // be adjusted in the future). return 5 } return maxHeaders } chasquid-1.15.0/internal/dkim/context_test.go000066400000000000000000000031471474251645300212310ustar00rootroot00000000000000package dkim import ( "context" "fmt" "net" "testing" ) func TestTraceNoCtx(t *testing.T) { // Call trace() on a context without a trace function, to check it doesn't // panic. ctx := context.Background() trace(ctx, "test") } func TestTrace(t *testing.T) { s := "" traceF := func(f string, a ...interface{}) { s = fmt.Sprintf(f, a...) } ctx := WithTraceFunc(context.Background(), traceF) trace(ctx, "test %d", 1) if s != "test 1" { t.Errorf("trace function not called") } } func TestLookupTXTNoCtx(t *testing.T) { // Call lookupTXT() on a context without an override, to check it calls // the real function. // We just check there is a reasonable error. // We don't specifically check that it's NXDOMAIN because if we don't have // internet access, the error may be different. ctx := context.Background() _, err := lookupTXT(ctx, "does.not.exist.example.com") if _, ok := err.(*net.DNSError); !ok { t.Fatalf("expected *net.DNSError, got %T", err) } } func TestLookupTXT(t *testing.T) { called := false lookupTXTF := func(ctx context.Context, name string) ([]string, error) { called = true return nil, nil } ctx := WithLookupTXTFunc(context.Background(), lookupTXTF) lookupTXT(ctx, "example.com") if !called { t.Errorf("lookupTXT function not called") } } func TestMaxHeaders(t *testing.T) { // First without an override, check we return the default. ctx := context.Background() if m := maxHeaders(ctx); m != 5 { t.Errorf("expected 5, got %d", m) } // Now with an override. ctx = WithMaxHeaders(ctx, 10) if m := maxHeaders(ctx); m != 10 { t.Errorf("expected 10, got %d", m) } } chasquid-1.15.0/internal/dkim/dns.go000066400000000000000000000116271474251645300172740ustar00rootroot00000000000000package dkim import ( "context" "crypto" "crypto/ed25519" "crypto/rsa" "crypto/x509" "encoding/base64" "errors" "fmt" "slices" "strings" ) func findPublicKeys(ctx context.Context, domain, selector string) ([]*publicKey, error) { // Subdomain where the key lives. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.2 d := selector + "._domainkey." + domain values, err := lookupTXT(ctx, d) if err != nil { trace(ctx, "TXT lookup of %q failed: %v", d, err) return nil, err } // There should be only a single record; RFC 6376 says the results are // undefined if there are multiple TXT records. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.2.2 // // What other implementations do: // - dkimpy: Use the first TXT record (whatever it is). // - OpenDKIM: Use the first TXT record (whatever it is). // - driusan/dkim: Use the first TXT record that can be parsed as a key. // - go-msgauth: Reject if there are multiple records. // // What we do: use _all_ TXT records that can be parsed as keys. This is // possibly too much, and we could reconsider this in the future. pks := []*publicKey{} for _, v := range values { trace(ctx, "TXT record for %q: %q", d, v) pk, err := parsePublicKey(v) if err != nil { trace(ctx, "Skipping: %v", err) continue } trace(ctx, "Parsed public key: %s", pk) pks = append(pks, pk) } return pks, nil } // Function to verify a signature with this public key. type verifyFunc func(h crypto.Hash, hashed, signature []byte) error type publicKey struct { H []crypto.Hash K keyType P []byte T []string // t= tag, representing flags. verify verifyFunc } func (pk *publicKey) String() string { return fmt.Sprintf("[%s:%.8x]", pk.K, pk.P) } func (pk *publicKey) Matches(kt keyType, h crypto.Hash) bool { if pk.K != kt { return false } if len(pk.H) > 0 { return slices.Contains(pk.H, h) } return true } func (pk *publicKey) StrictDomainCheck() bool { // t=s is set. return slices.Contains(pk.T, "s") } func parsePublicKey(v string) (*publicKey, error) { // Public key is a tag-value list. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1 tags, err := parseTags(v) if err != nil { return nil, err } // "v" is optional, but if present it must be "DKIM1". ver, ok := tags["v"] if ok && ver != "DKIM1" { return nil, fmt.Errorf("%w: %q", errInvalidVersion, ver) } pk := &publicKey{ // The default key type is rsa. K: keyTypeRSA, } // h is a colon-separated list of hashing algorithm names. if tags["h"] != "" { hs := strings.Split(eatWhitespace.Replace(tags["h"]), ":") for _, h := range hs { x, err := hashFromString(h) if err != nil { // Unrecognized algorithms must be ignored. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.6.1 continue } pk.H = append(pk.H, x) } } // k is key type (may not be present, rsa is used in that case). if tags["k"] != "" { pk.K, err = keyTypeFromString(tags["k"]) if err != nil { return nil, err } } // p is public-key data, base64-encoded, and whitespace in it must be // ignored. Required. p, err := base64.StdEncoding.DecodeString( eatWhitespace.Replace(tags["p"])) if err != nil { return nil, fmt.Errorf("error decoding p=: %w", err) } pk.P = p switch pk.K { case keyTypeRSA: pk.verify, err = parseRSAPublicKey(p) case keyTypeEd25519: pk.verify, err = parseEd25519PublicKey(p) } // t is a colon-separated list of flags. if t := eatWhitespace.Replace(tags["t"]); t != "" { pk.T = strings.Split(t, ":") } if err != nil { return nil, err } return pk, nil } var ( errInvalidRSAPublicKey = errors.New("invalid RSA public key") errNotRSAPublicKey = errors.New("not an RSA public key") errRSAKeyTooSmall = errors.New("RSA public key too small") errInvalidEd25519Key = errors.New("invalid Ed25519 public key") ) func parseRSAPublicKey(p []byte) (verifyFunc, error) { // Either PKCS#1 or SubjectPublicKeyInfo. // See https://www.rfc-editor.org/errata/eid3017. pub, err := x509.ParsePKIXPublicKey(p) if err != nil { pub, err = x509.ParsePKCS1PublicKey(p) } if err != nil { return nil, fmt.Errorf("%w: %w", errInvalidRSAPublicKey, err) } rsaPub, ok := pub.(*rsa.PublicKey) if !ok { return nil, errNotRSAPublicKey } // Enforce 1024-bit minimum. // https://datatracker.ietf.org/doc/html/rfc8301#section-3.2 if rsaPub.Size()*8 < 1024 { return nil, errRSAKeyTooSmall } return func(h crypto.Hash, hashed, signature []byte) error { return rsa.VerifyPKCS1v15(rsaPub, h, hashed, signature) }, nil } func parseEd25519PublicKey(p []byte) (verifyFunc, error) { // https: //datatracker.ietf.org/doc/html/rfc8463 if len(p) != ed25519.PublicKeySize { return nil, errInvalidEd25519Key } pub := ed25519.PublicKey(p) return func(h crypto.Hash, hashed, signature []byte) error { if ed25519.Verify(pub, hashed, signature) { return nil } return errors.New("signature verification failed") }, nil } chasquid-1.15.0/internal/dkim/dns_test.go000066400000000000000000000141721474251645300203310ustar00rootroot00000000000000package dkim import ( "context" "crypto" "crypto/ed25519" "crypto/x509" "encoding/base64" "errors" "testing" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestLookupError(t *testing.T) { testErr := errors.New("lookup error") errLookupF := func(ctx context.Context, name string) ([]string, error) { return nil, testErr } ctx := WithLookupTXTFunc(context.Background(), errLookupF) pks, err := findPublicKeys(ctx, "example.com", "selector") if pks != nil || err != testErr { t.Errorf("findPublicKeys expected nil / lookup error, got %v / %v", pks, err) } } // RSA key from the RFC example. // https://datatracker.ietf.org/doc/html/rfc6376#appendix-C const exampleRSAKeyB64 = "MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" + "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" + "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" + "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" + "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB" var exampleRSAKeyBuf, _ = base64.StdEncoding.DecodeString(exampleRSAKeyB64) var exampleRSAKey, _ = x509.ParsePKCS1PublicKey(exampleRSAKeyBuf) // Ed25519 key from the RFC example. // https://datatracker.ietf.org/doc/html/rfc8463#appendix-A.2 const exampleEd25519KeyB64 = "11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=" var exampleEd25519KeyBuf, _ = base64.StdEncoding.DecodeString( exampleEd25519KeyB64) var exampleEd25519Key = ed25519.PublicKey(exampleEd25519KeyBuf) var results = map[string][]string{} var resultErr = map[string]error{} func testLookupTXT(ctx context.Context, name string) ([]string, error) { return results[name], resultErr[name] } func TestSkipBadRecords(t *testing.T) { ctx := WithLookupTXTFunc(context.Background(), testLookupTXT) results["selector._domainkey.example.com"] = []string{ "not a tag", "v=DKIM1; p=" + exampleRSAKeyB64, } defer clear(results) pks, err := findPublicKeys(ctx, "example.com", "selector") if err != nil { t.Errorf("findPublicKeys expected nil, got %v", err) } if len(pks) != 1 { t.Errorf("findPublicKeys expected 1 key, got %v", len(pks)) } } func TestParsePublicKey(t *testing.T) { cases := []struct { in string pk *publicKey err error }{ // Invalid records. {"not a tag", nil, errInvalidTag}, {"v=DKIM666;", nil, errInvalidVersion}, {"p=abc~*#def", nil, base64.CorruptInputError(3)}, {"k=blah; p=" + exampleRSAKeyB64, nil, errUnsupportedKeyType}, // Error parsing the keys. {"p=", nil, errInvalidRSAPublicKey}, // RSA key but the contents are a (valid) ECDSA key. {"p=" + "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEIT0qsh+0jdY" + "DhK5+rSedhT7W/5rTRiulhphqtuplGFAyNiSh9I5t6MsrIu" + "xFQV7A/cWAt8qcbVscT3Q2l6iu3w==", nil, errNotRSAPublicKey}, // Valid RSA key, that is too short. {"p=" + "MEgCQQCo9+BpMRYQ/dL3DS2CyJxRF+j6ctbT3/Qp84+KeFh" + "nii7NT7fELilKUSnxS30WAvQCCo2yU1orfgqr41mM70MBAg" + "MBAAE=", nil, errRSAKeyTooSmall}, // Invalid ed25519 key. {"k=ed25519; p=MFkwEwYH", nil, errInvalidEd25519Key}, // Valid. {"p=" + exampleRSAKeyB64, &publicKey{K: keyTypeRSA, P: exampleRSAKeyBuf}, nil}, {"k=rsa ; p=" + exampleRSAKeyB64, &publicKey{K: keyTypeRSA, P: exampleRSAKeyBuf}, nil}, { "k=rsa; h=sha256; p=" + exampleRSAKeyB64, &publicKey{ K: keyTypeRSA, H: []crypto.Hash{crypto.SHA256}, P: exampleRSAKeyBuf}, nil, }, {"t=s; p=" + exampleRSAKeyB64, &publicKey{ K: keyTypeRSA, P: exampleRSAKeyBuf, T: []string{"s"}, }, nil, }, {"t = s : y; p=" + exampleRSAKeyB64, &publicKey{ K: keyTypeRSA, P: exampleRSAKeyBuf, T: []string{"s", "y"}, }, nil, }, { // We should ignore unrecognized hash algorithms. "k=rsa; h=sha1:xxx123:sha256; p=" + exampleRSAKeyB64, &publicKey{ K: keyTypeRSA, H: []crypto.Hash{crypto.SHA256}, P: exampleRSAKeyBuf}, nil, }, {"k=ed25519; p=" + exampleEd25519KeyB64, &publicKey{K: keyTypeEd25519, P: exampleEd25519KeyBuf}, nil}, } for i, c := range cases { pk, err := parsePublicKey(c.in) diff := cmp.Diff(c.pk, pk, cmpopts.IgnoreUnexported(publicKey{}), cmpopts.EquateEmpty(), ) if diff != "" { t.Errorf("%d: parsePublicKey(%q) key: (-want +got)\n%s", i, c.in, diff) } if !errors.Is(err, c.err) { t.Errorf("%d: parsePublicKey(%q) error: want %v, got %v", i, c.in, c.err, err) } } } func TestPublicKeyMatches(t *testing.T) { cases := []struct { pk *publicKey kt keyType h crypto.Hash ok bool }{ { &publicKey{K: keyTypeRSA}, keyTypeRSA, crypto.SHA256, true, }, { &publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}}, keyTypeRSA, crypto.SHA1, true, }, { &publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}}, keyTypeRSA, crypto.SHA256, false, }, { &publicKey{K: keyTypeRSA, H: []crypto.Hash{crypto.SHA1}}, keyTypeEd25519, crypto.SHA1, false, }, } for i, c := range cases { if ok := c.pk.Matches(c.kt, c.h); ok != c.ok { t.Errorf("%d: matches(%v, %v) = %v, want %v", i, c.kt, c.h, ok, c.ok) } } } func TestStrictDomainCheck(t *testing.T) { cases := []struct { t string ok bool }{ {"", false}, {"y", false}, {"x:y", false}, {":x::y", false}, {"s", true}, {"y:s", true}, {" y: s", true}, {"y:s:x", true}, } for i, c := range cases { pkS := "k=ed25519; p=" + exampleEd25519KeyB64 + "; t=" + c.t pk, err := parsePublicKey(pkS) if err != nil { t.Fatalf("%d: parsePublicKey(%q) = %v", i, pkS, err) } if ok := pk.StrictDomainCheck(); ok != c.ok { t.Errorf("%d: strictDomainCheck(t=%q) = %v, want %v", i, c.t, ok, c.ok) } } } func FuzzParsePublicKey(f *testing.F) { // Add some initial corpus from the tests above. f.Add("not a tag") f.Add("v=DKIM666;") f.Add("p=abc~*#def") f.Add("k=blah; p=" + exampleRSAKeyB64) f.Add("p=") f.Add("k=ed25519; p=") f.Add("k=ed25519; p=MFkwEwYH") f.Add("p=" + exampleEd25519KeyB64) f.Add("k=rsa ; p=" + exampleRSAKeyB64) f.Add("v=DKIM1; p=" + exampleRSAKeyB64) f.Add("t=s; p=" + exampleRSAKeyB64) f.Add("t = s : y; p=" + exampleRSAKeyB64) f.Add("k=rsa; h=sha256; p=" + exampleRSAKeyB64) f.Add("k=rsa; h=sha1:xxx123:sha256; p=" + exampleRSAKeyB64) f.Fuzz(func(t *testing.T, in string) { parsePublicKey(in) }) } chasquid-1.15.0/internal/dkim/file_test.go000066400000000000000000000124271474251645300204650ustar00rootroot00000000000000package dkim import ( "context" "encoding/json" "errors" "fmt" "io/fs" "net" "os" "path/filepath" "strings" "testing" "github.com/google/go-cmp/cmp" ) func TestFromFiles(t *testing.T) { msgfs, err := filepath.Glob("testdata/*.msg") if err != nil { t.Fatalf("error finding test files: %v", err) } for _, msgf := range msgfs { base := strings.TrimSuffix(msgf, filepath.Ext(msgf)) t.Run(base, func(t *testing.T) { testOne(t, base) }) } } // This is the same as TestFromFiles, but it runs the private test files, // which are not included in the git repository. // This is useful for running tests on your own machine, with emails that you // don't necessarily want to share publicly. func TestFromPrivateFiles(t *testing.T) { msgfs, err := filepath.Glob("testdata/private/*/*.msg") if err != nil { t.Fatalf("error finding private test files: %v", err) } for _, msgf := range msgfs { base := strings.TrimSuffix(msgf, filepath.Ext(msgf)) t.Run(base, func(t *testing.T) { testOne(t, base) }) } } func testOne(t *testing.T, base string) { ctx := context.Background() ctx = WithTraceFunc(ctx, t.Logf) ctx = loadDNS(t, ctx, base+".dns") msg := toCRLF(mustReadFile(t, base+".msg")) wantResult := loadResult(t, base+".result") wantError := loadError(t, base+".error") t.Logf("Message: %.60q", msg) t.Logf("Want result: %+v", wantResult) t.Logf("Want error: %v", wantError) res, err := VerifyMessage(ctx, msg) // Write the results out for easy updating. writeResults(t, base, res, err) diff := cmp.Diff(wantResult, res, cmp.Comparer(equalErrors)) if diff != "" { t.Errorf("VerifyMessage result diff (-want +got):\n%s", diff) } // We need to compare them by hand because cmp.Diff won't use our comparer // for top-level errors. if !equalErrors(wantError, err) { diff := cmp.Diff(wantError, err) t.Errorf("VerifyMessage error diff (-want +got):\n%s", diff) } } // Used to make cmp.Diff compare errors by their messages. This is obviously // not great, but it's good enough for this test. func equalErrors(a, b error) bool { if a == nil { return b == nil } if b == nil { return false } return a.Error() == b.Error() } func mustReadFile(t *testing.T, path string) string { t.Helper() contents, err := os.ReadFile(path) if errors.Is(err, fs.ErrNotExist) { return "" } if err != nil { t.Fatalf("error reading %q: %v", path, err) } return string(contents) } func loadDNS(t *testing.T, ctx context.Context, path string) context.Context { t.Helper() results := map[string][]string{} errors := map[string]error{} txtFunc := func(ctx context.Context, domain string) ([]string, error) { return results[domain], errors[domain] } ctx = WithLookupTXTFunc(ctx, txtFunc) c := mustReadFile(t, path) // Unfold \-terminated lines. c = strings.ReplaceAll(c, "\\\n", "") for _, line := range strings.Split(c, "\n") { if line == "" || strings.HasPrefix(line, "#") { continue } domain, txt, ok := strings.Cut(line, ":") if !ok { continue } domain = strings.TrimSpace(domain) switch strings.TrimSpace(txt) { case "TEMPERROR": errors[domain] = &net.DNSError{ Err: "temporary error (for testing)", IsTemporary: true, } case "PERMERROR": errors[domain] = &net.DNSError{ Err: "permanent error (for testing)", IsTemporary: false, } case "NOTFOUND": errors[domain] = &net.DNSError{ Err: "domain not found (for testing)", IsNotFound: true, } default: results[domain] = append(results[domain], txt) } } t.Logf("Loaded DNS results: %#v", results) t.Logf("Loaded DNS errors: %v", errors) return ctx } func loadResult(t *testing.T, path string) *VerifyResult { t.Helper() res := &VerifyResult{} c := mustReadFile(t, path) if c == "" { return nil } err := json.Unmarshal([]byte(c), res) if err != nil { t.Fatalf("error unmarshalling %q: %v", path, err) } return res } func loadError(t *testing.T, path string) error { t.Helper() c := strings.TrimSpace(mustReadFile(t, path)) if c == "" || c == "nil" || c == "" { return nil } return errors.New(c) } func mustWriteFile(t *testing.T, path string, c []byte) { t.Helper() err := os.WriteFile(path, c, 0644) if err != nil { t.Fatalf("error writing %q: %v", path, err) } } func writeResults(t *testing.T, base string, res *VerifyResult, err error) { t.Helper() mustWriteFile(t, base+".error.got", []byte(fmt.Sprintf("%v", err))) c, err := json.MarshalIndent(res, "", "\t") if err != nil { t.Fatalf("error marshalling result: %v", err) } mustWriteFile(t, base+".result.got", c) } // Custom json marshaller so we can write errors as strings. func (or *OneResult) MarshalJSON() ([]byte, error) { // We use an alias to avoid infinite recursion. type Alias OneResult aux := &struct { Error string `json:""` *Alias }{ Alias: (*Alias)(or), } if or.Error != nil { aux.Error = or.Error.Error() } return json.Marshal(aux) } // Custom json unmarshaller so we can read errors as strings. func (or *OneResult) UnmarshalJSON(b []byte) error { // We use an alias to avoid infinite recursion. type Alias OneResult aux := &struct { Error string `json:""` *Alias }{ Alias: (*Alias)(or), } if err := json.Unmarshal(b, aux); err != nil { return err } if aux.Error != "" { or.Error = errors.New(aux.Error) } return nil } chasquid-1.15.0/internal/dkim/header.go000066400000000000000000000175761474251645300177510ustar00rootroot00000000000000package dkim import ( "crypto" "encoding/base64" "errors" "fmt" "slices" "strconv" "strings" "time" ) // https://datatracker.ietf.org/doc/html/rfc6376#section-6 type dkimSignature struct { // Version. Must be "1". v string // Algorithm. Like "rsa-sha256". a string // Key type, extracted from a=. KeyType keyType // Hash, extracted from a=. Hash crypto.Hash // Signature data. // Decoded from base64, ignoring whitespace. b []byte // Hash of canonicalized body. // Decoded from base64, ignoring whitespace. bh []byte // Canonicalization modes. cH canonicalization cB canonicalization // Domain ("SDID"), in plain text. // IDNs MUST be encoded as A-labels. d string // Signed header fields. // Colon-separated list of header fields. h []string // AUID, in plain text. i string // Body octet count of the canonicalized body. l uint64 // Query methods used for DNS lookup. // Colon-separated list of methods. Only "dns/txt" is valid. q []string // Selector. s string // Timestamp. In Seconds since the UNIX epoch. t time.Time // Signature expiration. In Seconds since the UNIX epoch. x time.Time // Copied header fields. // Has a specific encoding but whitespace is ignored. z string } func (sig *dkimSignature) canonicalizationFromString(s string) error { if s == "" { sig.cH = simpleCanonicalization sig.cB = simpleCanonicalization return nil } // Either "header/body" or "header". In the latter case, "simple" is used // for the body canonicalization. // No whitespace around the '/' is allowed. hs, bs, _ := strings.Cut(s, "/") if bs == "" { bs = "simple" } var err error sig.cH, err = stringToCanonicalization(hs) if err != nil { return fmt.Errorf("header: %w", err) } sig.cB, err = stringToCanonicalization(bs) if err != nil { return fmt.Errorf("body: %w", err) } return nil } func (sig *dkimSignature) checkRequiredTags() error { // https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1 if sig.a == "" { return fmt.Errorf("%w: a=", errMissingRequiredTag) } if len(sig.b) == 0 { return fmt.Errorf("%w: b=", errMissingRequiredTag) } if len(sig.bh) == 0 { return fmt.Errorf("%w: bh=", errMissingRequiredTag) } if sig.d == "" { return fmt.Errorf("%w: d=", errMissingRequiredTag) } if len(sig.h) == 0 { return fmt.Errorf("%w: h=", errMissingRequiredTag) } if sig.s == "" { return fmt.Errorf("%w: s=", errMissingRequiredTag) } // h= must contain From. var isFrom = func(s string) bool { return strings.EqualFold(s, "from") } if !slices.ContainsFunc(sig.h, isFrom) { return fmt.Errorf("%w: h= does not contain 'from'", errInvalidTag) } // If i= is present, its domain must be equal to, or a subdomain of, d=. if sig.i != "" { _, domain, _ := strings.Cut(sig.i, "@") if domain != sig.d && !strings.HasSuffix(domain, "."+sig.d) { return fmt.Errorf("%w: i= is not a subdomain of d=", errInvalidTag) } } return nil } var ( errInvalidSignature = errors.New("invalid signature") errInvalidVersion = errors.New("invalid version") errBadATag = errors.New("invalid a= tag") errUnsupportedHash = errors.New("unsupported hash") errUnsupportedKeyType = errors.New("unsupported key type") errMissingRequiredTag = errors.New("missing required tag") errNegativeTimestamp = errors.New("negative timestamp") ) // String replacer that removes whitespace. var eatWhitespace = strings.NewReplacer(" ", "", "\t", "", "\r", "", "\n", "") func dkimSignatureFromHeader(header string) (*dkimSignature, error) { tags, err := parseTags(header) if err != nil { return nil, err } sig := &dkimSignature{ v: tags["v"], a: tags["a"], } // v= tag is mandatory and must be 1. if sig.v != "1" { return nil, errInvalidVersion } // a= tag is mandatory; check that we can parse it and that we support the // algorithms. ktS, hS, found := strings.Cut(sig.a, "-") if !found { return nil, errBadATag } sig.KeyType, err = keyTypeFromString(ktS) if err != nil { return nil, fmt.Errorf("%w: %s", err, sig.a) } sig.Hash, err = hashFromString(hS) if err != nil { return nil, fmt.Errorf("%w: %s", err, sig.a) } // b is base64-encoded, and whitespace in it must be ignored. sig.b, err = base64.StdEncoding.DecodeString( eatWhitespace.Replace(tags["b"])) if err != nil { return nil, fmt.Errorf("%w: failed to decode b: %w", errInvalidSignature, err) } // bh - same as b. sig.bh, err = base64.StdEncoding.DecodeString( eatWhitespace.Replace(tags["bh"])) if err != nil { return nil, fmt.Errorf("%w: failed to decode bh: %w", errInvalidSignature, err) } err = sig.canonicalizationFromString(tags["c"]) if err != nil { return nil, fmt.Errorf("%w: failed to parse c: %w", errInvalidSignature, err) } sig.d = tags["d"] // h is a colon-separated list of header fields. if tags["h"] != "" { sig.h = strings.Split(eatWhitespace.Replace(tags["h"]), ":") } sig.i = tags["i"] if tags["l"] != "" { sig.l, err = strconv.ParseUint(tags["l"], 10, 64) if err != nil { return nil, fmt.Errorf("%w: failed to parse l: %w", errInvalidSignature, err) } } // q is a colon-separated list of query methods. if tags["q"] != "" { sig.q = strings.Split(eatWhitespace.Replace(tags["q"]), ":") } if len(sig.q) > 0 && !slices.Contains(sig.q, "dns/txt") { return nil, fmt.Errorf("%w: no dns/txt query method in q", errInvalidSignature) } sig.s = tags["s"] if tags["t"] != "" { sig.t, err = unixStrToTime(tags["t"]) if err != nil { return nil, fmt.Errorf("%w: failed to parse t: %w", errInvalidSignature, err) } } if tags["x"] != "" { sig.x, err = unixStrToTime(tags["x"]) if err != nil { return nil, fmt.Errorf("%w: failed to parse x: %w", errInvalidSignature, err) } } sig.z = eatWhitespace.Replace(tags["z"]) // Check required tags are present. if err := sig.checkRequiredTags(); err != nil { return nil, err } return sig, nil } func unixStrToTime(s string) (time.Time, error) { // Technically the timestamp is an "unsigned decimal integer", but since // time.Unix takes an int64, we use that and check it's positive. ti, err := strconv.ParseInt(s, 10, 64) if err != nil { return time.Time{}, err } if ti < 0 { return time.Time{}, errNegativeTimestamp } return time.Unix(ti, 0), nil } type keyType string const ( keyTypeRSA keyType = "rsa" keyTypeEd25519 keyType = "ed25519" ) func keyTypeFromString(s string) (keyType, error) { switch s { case "rsa": return keyTypeRSA, nil case "ed25519": return keyTypeEd25519, nil default: return "", errUnsupportedKeyType } } func hashFromString(s string) (crypto.Hash, error) { switch s { // Note SHA1 is not supported: as per RFC 8301, it must not be used // for signing or verifying. // https://datatracker.ietf.org/doc/html/rfc8301#section-3.1 case "sha256": return crypto.SHA256, nil default: return 0, errUnsupportedHash } } // DKIM Tag=Value lists, as defined in RFC 6376, Section 3.2. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.2 type tags map[string]string var errInvalidTag = errors.New("invalid tag") func parseTags(s string) (tags, error) { // First trim space, and trailing semicolon, to simplify parsing below. s = strings.TrimSpace(s) s = strings.TrimSuffix(s, ";") tags := make(tags) for _, tv := range strings.Split(s, ";") { t, v, found := strings.Cut(tv, "=") if !found { return nil, fmt.Errorf("%w: missing '='", errInvalidTag) } // Trim leading and trailing whitespace from tag and value, as per // RFC. t = strings.TrimSpace(t) v = strings.TrimSpace(v) if t == "" { return nil, fmt.Errorf("%w: missing tag name", errInvalidTag) } // RFC 6376, Section 3.2: Tags with duplicate names MUST NOT occur // within a single tag-list; if a tag name does occur more than once, // the entire tag-list is invalid. if _, exists := tags[t]; exists { return nil, fmt.Errorf("%w: duplicate tag", errInvalidTag) } tags[t] = v } return tags, nil } chasquid-1.15.0/internal/dkim/header_test.go000066400000000000000000000225031474251645300207720ustar00rootroot00000000000000package dkim import ( "crypto" "encoding/base64" "errors" "fmt" "strconv" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestSignatureFromHeader(t *testing.T) { cases := []struct { in string want *dkimSignature err error }{ { in: "v=1; a=rsa-sha256", want: nil, err: errMissingRequiredTag, }, { in: "v=1; a=rsa-sha256 ; c = simple/relaxed ;" + " d=example.com; h= from : to: subject ; " + "i=agent@example.com; l=77; q=dns/txt; " + "s=selector; t=1600700888; x=1600700999; " + "z=From:lala@lele | to:lili@lolo;" + "b=aG9sY\r\n SBxdWUgdGFs;" + "bh = Y29\ttby Bhbm Rhcw==", want: &dkimSignature{ v: "1", a: "rsa-sha256", cH: simpleCanonicalization, cB: relaxedCanonicalization, d: "example.com", h: []string{"from", "to", "subject"}, i: "agent@example.com", l: 77, q: []string{"dns/txt"}, s: "selector", t: time.Unix(1600700888, 0), x: time.Unix(1600700999, 0), z: "From:lala@lele|to:lili@lolo", b: []byte("hola que tal"), bh: []byte("como andas"), KeyType: keyTypeRSA, Hash: crypto.SHA256, }, }, { // Example from RFC. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.5 in: "v=1; a=rsa-sha256; d=example.net; s=brisbane;\r\n" + " c=simple; q=dns/txt; i=@eng.example.net;\r\n" + " t=1117574938; x=1118006938;\r\n" + " h=from:to:subject:date;\r\n" + " z=From:foo@eng.example.net|To:joe@example.com|\r\n" + " Subject:demo=20run|Date:July=205,=202005=203:44:08=20PM=20-0700;\r\n" + "bh=MTIzNDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI=;\r\n" + "b=dzdVyOfAKCdLXdJOc9G2q8LoXSlEniS" + "bav+yuU4zGeeruD00lszZVoG4ZHRNiYzR", want: &dkimSignature{ v: "1", a: "rsa-sha256", d: "example.net", s: "brisbane", cH: simpleCanonicalization, cB: simpleCanonicalization, q: []string{"dns/txt"}, i: "@eng.example.net", t: time.Unix(1117574938, 0), x: time.Unix(1118006938, 0), h: []string{"from", "to", "subject", "date"}, z: "From:foo@eng.example.net|To:joe@example.com|" + "Subject:demo=20run|" + "Date:July=205,=202005=203:44:08=20PM=20-0700", bh: []byte("12345678901234567890123456789012"), b: []byte("w7U\xc8\xe7\xc0('K]\xd2Ns\xd1\xb6" + "\xab\xc2\xe8])D\x9e$\x9bj\xff\xb2\xb9N3" + "\x19\xe7\xab\xb8=4\x96\xcc\xd9V\x81\xb8" + "dtM\x89\x8c\xd1"), KeyType: keyTypeRSA, Hash: crypto.SHA256, }, }, { in: "", want: nil, err: errInvalidTag, }, { in: "v=666", want: nil, err: errInvalidVersion, }, { in: "v=1; a=something;", want: nil, err: errBadATag, }, { // Invalid b= tag. in: "v=1; a=rsa-sha256; b=invalid", want: nil, err: base64.CorruptInputError(4), }, { // Invalid bh= tag. in: "v=1; a=rsa-sha256; bh=invalid", want: nil, err: base64.CorruptInputError(4), }, { // Invalid c= tag. in: "v=1; a=rsa-sha256; c=caca", want: nil, err: errUnknownCanonicalization, }, { // Invalid l= tag. in: "v=1; a=rsa-sha256; l=a1234b", want: nil, err: strconv.ErrSyntax, }, { // q= tag without dns/txt. in: "v=1; a=rsa-sha256; q=other/method", want: nil, err: errInvalidSignature, }, { // Invalid t= tag. in: "v=1; a=rsa-sha256; t=a1234b", want: nil, err: strconv.ErrSyntax, }, { // Invalid x= tag. in: "v=1; a=rsa-sha256; x=a1234b", want: nil, err: strconv.ErrSyntax, }, { // Invalid t= tag. in: "v=1; a=rsa-sha256; t=-12345", want: nil, err: errNegativeTimestamp, }, { // Invalid x= tag. in: "v=1; a=rsa-sha256; x=-1234", want: nil, err: errNegativeTimestamp, }, { // Unknown hash algorithm. in: "v=1; a=rsa-sxa666", want: nil, err: errUnsupportedHash, }, { // Unknown key type. in: "v=1; a=rxa-sha256", want: nil, err: errUnsupportedKeyType, }, } for _, c := range cases { sig, err := dkimSignatureFromHeader(c.in) diff := cmp.Diff(c.want, sig, cmp.AllowUnexported(dkimSignature{}), cmpopts.EquateEmpty(), ) if diff != "" { t.Errorf("dkimSignatureFromHeader(%q) mismatch (-want +got):\n%s", c.in, diff) } if !errors.Is(err, c.err) { t.Errorf("dkimSignatureFromHeader(%q) error: got %v, want %v", c.in, err, c.err) } } } func TestCanonicalizationFromString(t *testing.T) { cases := []struct { in string cH, cB canonicalization err error }{ { in: "", cH: simpleCanonicalization, cB: simpleCanonicalization, }, { in: "simple", cH: simpleCanonicalization, cB: simpleCanonicalization, }, { in: "relaxed", cH: relaxedCanonicalization, cB: simpleCanonicalization, }, { in: "simple/simple", cH: simpleCanonicalization, cB: simpleCanonicalization, }, { in: "relaxed/relaxed", cH: relaxedCanonicalization, cB: relaxedCanonicalization, }, { in: "simple/relaxed", cH: simpleCanonicalization, cB: relaxedCanonicalization, }, { in: "relaxed/bad", cH: relaxedCanonicalization, err: errUnknownCanonicalization, }, { in: "bad/relaxed", err: errUnknownCanonicalization, }, { in: "bad", err: errUnknownCanonicalization, }, } for _, c := range cases { sig := &dkimSignature{} err := sig.canonicalizationFromString(c.in) if sig.cH != c.cH || sig.cB != c.cB || !errors.Is(err, c.err) { t.Errorf("canonicalizationFromString(%q) "+ "got (%v, %v, %v), want (%v, %v, %v)", c.in, sig.cH, sig.cB, err, c.cH, c.cB, c.err) } } } func TestCheckRequiredTags(t *testing.T) { cases := []struct { sig *dkimSignature err string }{ { sig: &dkimSignature{}, err: "missing required tag: a=", }, { sig: &dkimSignature{a: "rsa-sha256"}, err: "missing required tag: b=", }, { sig: &dkimSignature{a: "rsa-sha256", b: []byte("hola que tal")}, err: "missing required tag: bh=", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), }, err: "missing required tag: d=", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", }, err: "missing required tag: h=", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"from"}, }, err: "missing required tag: s=", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"subject"}, s: "selector", }, err: "invalid tag: h= does not contain 'from'", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"from"}, s: "selector", i: "@example.net", }, err: "invalid tag: i= is not a subdomain of d=", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"from"}, s: "selector", i: "@anexample.com", // i= is a substring but not subdomain. }, err: "invalid tag: i= is not a subdomain of d=", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"From"}, // Capitalize to check case fold. s: "selector", i: "@example.com", // i= is the same as d= }, err: "", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"From"}, s: "selector", i: "@sub.example.com", // i= is a subdomain of d= }, err: "", }, { sig: &dkimSignature{ a: "rsa-sha256", b: []byte("hola que tal"), bh: []byte("como andas"), d: "example.com", h: []string{"from"}, s: "selector", }, err: "", }, } for i, c := range cases { err := c.sig.checkRequiredTags() got := fmt.Sprintf("%v", err) if c.err != got { t.Errorf("%d: checkRequiredTags() got %v, want %v", i, err, c.err) } } } func TestParseTags(t *testing.T) { cases := []struct { in string want tags err error }{ { in: "v=1; a=lalala; b = 123 ; c= 456;\t d \t= \t789\t ", want: tags{ "v": "1", "a": "lalala", "b": "123", "c": "456", "d": "789", }, err: nil, }, { // Trailing semicolon. in: "v=1; a=lalala ; ", want: tags{ "v": "1", "a": "lalala", }, err: nil, }, { // Missing tag value; this is okay. in: "v=1; b = ; c = d;", want: tags{ "v": "1", "b": "", "c": "d", }, err: nil, }, { // Missing '='. in: "v=1; ; c = d;", want: nil, err: errInvalidTag, }, { // Missing tag name. in: "v=1; = b ; c = d;", want: nil, err: errInvalidTag, }, { // Duplicate tag. in: "v=1; a=b; a=c;", want: nil, err: errInvalidTag, }, } for _, c := range cases { got, err := parseTags(c.in) if diff := cmp.Diff(c.want, got); diff != "" { t.Errorf("parseTags(%q) mismatch (-want +got):\n%s", c.in, diff) } if !errors.Is(err, c.err) { t.Errorf("parseTags(%q) error: got %v, want %v", c.in, err, c.err) } } } chasquid-1.15.0/internal/dkim/message.go000066400000000000000000000032601474251645300201260ustar00rootroot00000000000000package dkim import ( "errors" "fmt" "strings" ) type header struct { Name string Value string Source string } type headers []header // FindAll the headers with the given name, in order of appearance. func (h headers) FindAll(name string) headers { hs := make(headers, 0) for _, header := range h { if strings.EqualFold(header.Name, name) { hs = append(hs, header) } } return hs } var errInvalidHeader = errors.New("invalid header") // Parse a RFC822 message, return the headers, body, and error if any. // We expect it to only contain CRLF line endings. // Does NOT touch whitespace, this is important to preserve the original // message and headers, which is required for the signature. func parseMessage(message string) (headers, string, error) { headers := make(headers, 0) lines := strings.Split(message, "\r\n") eoh := 0 for i, line := range lines { if line == "" { eoh = i break } if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { // Continuation of the previous header. if len(headers) == 0 { return nil, "", fmt.Errorf( "%w: bad continuation", errInvalidHeader) } headers[len(headers)-1].Value += "\r\n" + line headers[len(headers)-1].Source += "\r\n" + line } else { // New header. h, err := parseHeader(line) if err != nil { return nil, "", err } headers = append(headers, h) } } return headers, strings.Join(lines[eoh+1:], "\r\n"), nil } func parseHeader(line string) (header, error) { name, value, found := strings.Cut(line, ":") if !found { return header{}, fmt.Errorf("%w: no colon", errInvalidHeader) } return header{ Name: name, Value: value, Source: line, }, nil } chasquid-1.15.0/internal/dkim/message_test.go000066400000000000000000000043521474251645300211700ustar00rootroot00000000000000package dkim import ( "testing" "blitiri.com.ar/go/chasquid/internal/normalize" "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" ) func TestParseMessage(t *testing.T) { cases := []struct { message string headers headers body string }{ { message: normalize.StringToCRLF(`From: a@b To: c@d Subject: test Continues: This continues. body`), headers: headers{ header{Name: "From", Value: " a@b", Source: "From: a@b"}, header{Name: "To", Value: " c@d", Source: "To: c@d"}, header{Name: "Subject", Value: " test", Source: "Subject: test"}, header{Name: "Continues", Value: " This\r\n continues.", Source: "Continues: This\r\n continues."}, }, body: "body", }, } for i, c := range cases { headers, body, err := parseMessage(c.message) if diff := cmp.Diff(c.headers, headers); diff != "" { t.Errorf("parseMessage([%d]) headers mismatch (-want +got):\n%s", i, diff) } if diff := cmp.Diff(c.body, body); diff != "" { t.Errorf("parseMessage([%d]) body mismatch (-want +got):\n%s", i, diff) } if err != nil { t.Errorf("parseMessage([%d]) error: %v", i, err) } } } func TestParseMessageWithErrors(t *testing.T) { cases := []struct { message string err error }{ { // Continuation without previous header. message: " continuation.", err: errInvalidHeader, }, { // Header without ':'. message: "No colon", err: errInvalidHeader, }, } for i, c := range cases { _, _, err := parseMessage(c.message) if diff := cmp.Diff(c.err, err, cmpopts.EquateErrors()); diff != "" { t.Errorf("parseMessage([%d]) err mismatch (-want +got):\n%s", i, diff) } } } func TestHeadersFindAll(t *testing.T) { hs := headers{ {Name: "From", Value: "a@b", Source: "From: a@b"}, {Name: "To", Value: "c@d", Source: "To: c@d"}, {Name: "Subject", Value: "test", Source: "Subject: test"}, {Name: "fROm", Value: "z@y", Source: "fROm: z@y"}, } fromHs := hs.FindAll("froM") expected := headers{ {Name: "From", Value: "a@b", Source: "From: a@b"}, {Name: "fROm", Value: "z@y", Source: "fROm: z@y"}, } if diff := cmp.Diff(expected, fromHs); diff != "" { t.Errorf("headers.Find() mismatch (-want +got):\n%s", diff) } } chasquid-1.15.0/internal/dkim/sign.go000066400000000000000000000117021474251645300174420ustar00rootroot00000000000000package dkim import ( "context" "crypto" "crypto/ed25519" "crypto/rand" "crypto/rsa" "crypto/sha256" "encoding/base64" "fmt" "strings" "time" ) type Signer struct { // Domain to sign for. Domain string // Selector to use. Selector string // Signer containing the private key. // This can be an *rsa.PrivateKey or a ed25519.PrivateKey. Signer crypto.Signer } var headersToSign = []string{ // https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.1 "From", // Required. "Reply-To", "Subject", "Date", "To", "Cc", "Resent-Date", "Resent-From", "Resent-To", "Resent-Cc", "In-Reply-To", "References", "List-Id", "List-Help", "List-Unsubscribe", "List-Subscribe", "List-Post", "List-Owner", "List-Archive", // Our additions. "Message-ID", } var extraHeadersToSign = []string{ // Headers to add an extra of, to prevent additions after signing. // If they're included here, they must be in headersToSign too. "From", "Subject", "Date", "To", "Cc", "Message-ID", } // Sign the given message. Returns the *value* of the DKIM-Signature header to // be added to the message. It will usually be multi-line, but without // indenting. func (s *Signer) Sign(ctx context.Context, message string) (string, error) { headers, body, err := parseMessage(message) if err != nil { return "", err } algoStr, err := s.algoStr() if err != nil { return "", err } trace(ctx, "Signing for %s / %s with %s", s.Domain, s.Selector, algoStr) dkimSignature := fmt.Sprintf( "v=1; a=%s; c=relaxed/relaxed;\r\n", algoStr) dkimSignature += fmt.Sprintf( "d=%s; s=%s; t=%d;\r\n", s.Domain, s.Selector, time.Now().Unix()) // Add the headers to sign. hsForHeader := []string{} for _, h := range headersToSign { // Append the header as many times as it appears in the message. for i := 0; i < len(headers.FindAll(h)); i++ { hsForHeader = append(hsForHeader, h) } } hsForHeader = append(hsForHeader, extraHeadersToSign...) dkimSignature += fmt.Sprintf( "h=%s;\r\n", formatHeaders(hsForHeader)) // Compute and add bh= (body hash). bodyH := sha256.Sum256([]byte( relaxedCanonicalization.body(body))) dkimSignature += fmt.Sprintf( "bh=%s;\r\n", base64.StdEncoding.EncodeToString(bodyH[:])) // Compute b= (signature). // First, the canonicalized headers. b := sha256.New() for _, h := range headersToSign { for _, header := range headers.FindAll(h) { hsrc := relaxedCanonicalization.header(header).Source + "\r\n" trace(ctx, "Hashing header: %q", hsrc) b.Write([]byte(hsrc)) } } // Now, the (canonicalized) DKIM-Signature header itself, but with an // empty b= tag, without a trailing \r\n, and ending with ";". // We include the ";" because we will add it at the end (see below). It is // legal not to include that final ";", we just choose to do so. // We replace \r\n with \r\n\t so the canonicalization considers them // proper continuations, and works correctly. dkimSignature += "b=" dkimSignatureForSigning := strings.ReplaceAll( dkimSignature, "\r\n", "\r\n\t") + ";" relaxedDH := relaxedCanonicalization.header(header{ Name: "DKIM-Signature", Value: dkimSignatureForSigning, Source: dkimSignatureForSigning, }) b.Write([]byte(relaxedDH.Source)) trace(ctx, "Hashing header: %q", relaxedDH.Source) bSum := b.Sum(nil) trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum)) // Finally, sign the hash. sig, err := s.sign(bSum) if err != nil { return "", err } sigb64 := base64.StdEncoding.EncodeToString(sig) dkimSignature += breakLongLines(sigb64) + ";" return dkimSignature, nil } func (s *Signer) algoStr() (string, error) { switch k := s.Signer.(type) { case *rsa.PrivateKey: return "rsa-sha256", nil case ed25519.PrivateKey: return "ed25519-sha256", nil default: return "", fmt.Errorf("%w: %T", errUnsupportedKeyType, k) } } func (s *Signer) sign(bSum []byte) ([]byte, error) { var h crypto.Hash switch s.Signer.(type) { case *rsa.PrivateKey: h = crypto.SHA256 case ed25519.PrivateKey: h = crypto.Hash(0) } return s.Signer.Sign(rand.Reader, bSum, h) } func breakLongLines(s string) string { // Break long lines, indenting with 2 spaces for continuation (to make // it clear it's under the same tag). const limit = 70 var sb strings.Builder for len(s) > 0 { if len(s) > limit { sb.WriteString(s[:limit]) sb.WriteString("\r\n ") s = s[limit:] } else { sb.WriteString(s) s = "" } } return sb.String() } func formatHeaders(hs []string) string { // Format the list of headers for inclusion in the DKIM-Signature header. // This includes converting them to lowercase, and line-wrapping. // Extra lines will be indented with 2 spaces, to make it clear they're // under the same tag. const limit = 70 var sb strings.Builder line := "" for i, h := range hs { if len(line)+1+len(h) > limit { sb.WriteString(line + "\r\n ") line = "" } if i > 0 { line += ":" } line += h } sb.WriteString(line) return strings.TrimSpace(strings.ToLower(sb.String())) } chasquid-1.15.0/internal/dkim/sign_test.go000066400000000000000000000146121474251645300205040ustar00rootroot00000000000000package dkim import ( "context" "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" "crypto/rand" "crypto/rsa" "crypto/x509" "encoding/base64" "errors" "regexp" "strings" "testing" ) var basicMessage = toCRLF( `Received: from client1.football.example.com [192.0.2.1] by submitserver.example.com with SUBMISSION; Fri, 11 Jul 2003 21:01:54 -0700 (PDT) From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. `) func TestSignRSA(t *testing.T) { ctx := context.Background() ctx = WithTraceFunc(ctx, t.Logf) // Generate a new key pair. priv, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { t.Fatalf("rsa.GenerateKey: %v", err) } pub, err := x509.MarshalPKIXPublicKey(priv.Public()) if err != nil { t.Fatalf("MarshalPKIXPublicKey: %v", err) } ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{ "test._domainkey.example.com": []string{ "v=DKIM1; p=" + base64.StdEncoding.EncodeToString(pub), }, })) s := &Signer{ Domain: "example.com", Selector: "test", Signer: priv, } sig, err := s.Sign(ctx, basicMessage) if err != nil { t.Fatalf("Sign: %v", err) } // Verify the signature. res, err := VerifyMessage(ctx, addSig(sig, basicMessage)) if err != nil || res.Valid != 1 { t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err) } // Compare the reproducible parts against a known-good header. want := regexp.MustCompile( "v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n" + "d=example.com; s=test; t=\\d+;\r\n" + "h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n" + "bh=[A-Za-z0-9+/]+=*;\r\n" + "b=[A-Za-z0-9+/ \r\n]+=*;") if !want.MatchString(sig) { t.Errorf("Unexpected signature:") t.Errorf(" Want: %q (regexp)", want) t.Errorf(" Got: %q", sig) } } func TestSignEd25519(t *testing.T) { ctx := context.Background() ctx = WithTraceFunc(ctx, t.Logf) // Generate a new key pair. pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("ed25519.GenerateKey: %v", err) } ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{ "test._domainkey.example.com": []string{ "v=DKIM1; k=ed25519; p=" + base64.StdEncoding.EncodeToString(pub), }, })) s := &Signer{ Domain: "example.com", Selector: "test", Signer: priv, } sig, err := s.Sign(ctx, basicMessage) if err != nil { t.Fatalf("Sign: %v", err) } // Verify the signature. res, err := VerifyMessage(ctx, addSig(sig, basicMessage)) if err != nil || res.Valid != 1 { t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err) } // Compare the reproducible parts against a known-good header. want := regexp.MustCompile( "v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n" + "d=example.com; s=test; t=\\d+;\r\n" + "h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n" + "bh=[A-Za-z0-9+/]+=*;\r\n" + "b=[A-Za-z0-9+/ \r\n]+=*;") if !want.MatchString(sig) { t.Errorf("Unexpected signature:") t.Errorf(" Want: %q (regexp)", want) t.Errorf(" Got: %q", sig) } } func addSig(sig, message string) string { return "DKIM-Signature: " + strings.ReplaceAll(sig, "\r\n", "\r\n\t") + "\r\n" + message } func TestSignBadMessage(t *testing.T) { s := &Signer{ Domain: "example.com", Selector: "test", } _, err := s.Sign(context.Background(), "Bad message") if err == nil { t.Errorf("Sign: wanted error; got nil") } } func TestSignBadAlgorithm(t *testing.T) { s := &Signer{ Domain: "example.com", Selector: "test", } priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatalf("ecdsa.GenerateKey: %v", err) } s.Signer = priv _, err = s.Sign(context.Background(), basicMessage) if !errors.Is(err, errUnsupportedKeyType) { t.Errorf("Sign: wanted unsupported key type; got %v", err) } } func TestBreakLongLines(t *testing.T) { cases := []struct { in string want string }{ {"1234567890", "1234567890"}, { "xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" + "xxxxxxxx50xxxxxxxx60xxxxxxxx70", "xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" + "xxxxxxxx50xxxxxxxx60xxxxxxxx70", }, { "xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" + "xxxxxxxx50xxxxxxxx60xxxxxxxx70123", "xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" + "xxxxxxxx50xxxxxxxx60xxxxxxxx70\r\n 123", }, { "xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" + "xxxxxxxx50xxxxxxxx60xxxxxxxx70xxxxxxxx80" + "xxxxxxxx90xxxxxxx100xxxxxxx110xxxxxxx120" + "xxxxxxx130xxxxxxx140xxxxxxx150xxxxxxx160", "xxxxxxxx10xxxxxxxx20xxxxxxxx30xxxxxxxx40" + "xxxxxxxx50xxxxxxxx60xxxxxxxx70\r\n " + "xxxxxxxx80xxxxxxxx90xxxxxxx100xxxxxxx110" + "xxxxxxx120xxxxxxx130xxxxxxx140\r\n " + "xxxxxxx150xxxxxxx160", }, } for i, c := range cases { got := breakLongLines(c.in) if got != c.want { t.Errorf("%d: breakLongLines(%q):", i, c.in) t.Errorf(" want %q", c.want) t.Errorf(" got %q", got) } } } func TestFormatHeaders(t *testing.T) { cases := []struct { in []string want string }{ {[]string{"From"}, "from"}, { []string{"From", "Subject", "Date"}, "from:subject:date", }, { []string{"from", "subject", "date", "to", "message-id", "from", "subject", "date", "to", "cc", "in-reply-to", "message-id"}, "from:subject:date:to:message-id:" + "from:subject:date:to:cc:in-reply-to\r\n" + " :message-id", }, { []string{"from", "subject", "date", "to", "message-id", "from", "subject", "date", "to", "cc", "xxxxxxxxxxxx70"}, "from:subject:date:to:message-id:" + "from:subject:date:to:cc:xxxxxxxxxxxx70", }, { []string{"from", "subject", "date", "to", "message-id", "from", "subject", "date", "to", "cc", "xxxxxxxxxxxx701"}, "from:subject:date:to:message-id:from:subject:date:to:cc\r\n" + " :xxxxxxxxxxxx701", }, { []string{"from", "subject", "date", "to", "message-id", "from", "subject", "date", "to", "cc", "xxxxxxxxxxxx70", "1"}, "from:subject:date:to:message-id:" + "from:subject:date:to:cc:xxxxxxxxxxxx70\r\n" + " :1", }, } for i, c := range cases { got := formatHeaders(c.in) if got != c.want { t.Errorf("%d: formatHeaders(%q):", i, c.in) t.Errorf(" want %q", c.want) t.Errorf(" got %q", got) } } } chasquid-1.15.0/internal/dkim/testdata/000077500000000000000000000000001474251645300177635ustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/.gitignore000066400000000000000000000001271474251645300217530ustar00rootroot00000000000000*.got # Ignore private test cases, to reduce the chances of accidental leaks. private chasquid-1.15.0/internal/dkim/testdata/01-rfc8463.dns000066400000000000000000000006201474251645300220040ustar00rootroot00000000000000brisbane._domainkey.football.example.com: \ v=DKIM1; k=ed25519; \ p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo= test._domainkey.football.example.com: \ v=DKIM1; k=rsa; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB chasquid-1.15.0/internal/dkim/testdata/01-rfc8463.error000066400000000000000000000000051474251645300223460ustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/01-rfc8463.msg000066400000000000000000000020471474251645300220130ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/01-rfc8463.result000066400000000000000000000025701474251645300225440ustar00rootroot00000000000000{ "Found": 2, "Valid": 2, "Results": [ { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "Domain": "football.example.com", "Selector": "test", "B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/02-too_many_headers.dns00007770000000000000000000000000147425164530026246201-rfc8463.dnsustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/02-too_many_headers.error000066400000000000000000000000051474251645300245700ustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/02-too_many_headers.msg000066400000000000000000000054451474251645300242420ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/02-too_many_headers.readme000066400000000000000000000002231474251645300246760ustar00rootroot00000000000000Check that we don't process more than 5 headers. The message contains 7 headers, but only the first 5 should be validated (and appear as valid). chasquid-1.15.0/internal/dkim/testdata/02-too_many_headers.result000066400000000000000000000057261474251645300247740ustar00rootroot00000000000000{ "Found": 5, "Valid": 5, "Results": [ { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/03-bad_message.error000066400000000000000000000000401474251645300235020ustar00rootroot00000000000000invalid header: bad continuationchasquid-1.15.0/internal/dkim/testdata/03-bad_message.msg000066400000000000000000000000371474251645300231450ustar00rootroot00000000000000 This is not a valid message. chasquid-1.15.0/internal/dkim/testdata/04-bad_dkim_signature_header.msg000066400000000000000000000011631474251645300260400ustar00rootroot00000000000000DKIM-Signature: v=8; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/04-bad_dkim_signature_header.readme000066400000000000000000000002251474251645300265050ustar00rootroot00000000000000Check that we reject invalid DKIM signature headers. In this case, we force this by taking an otherwise valid header, but using v=8 instead of v=1. chasquid-1.15.0/internal/dkim/testdata/04-bad_dkim_signature_header.result000066400000000000000000000010361474251645300265670ustar00rootroot00000000000000{ "Found": 1, "Valid": 0, "Results": [ { "Error": "invalid version", "SignatureHeader": " v=8; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "", "Selector": "", "B": "", "State": "PERMFAIL" } ] }chasquid-1.15.0/internal/dkim/testdata/05-dns_temp_error.dns000066400000000000000000000005171474251645300237400ustar00rootroot00000000000000brisbane._domainkey.football.example.com: TEMPERROR test._domainkey.football.example.com: \ v=DKIM1; k=rsa; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB chasquid-1.15.0/internal/dkim/testdata/05-dns_temp_error.msg000066400000000000000000000020471474251645300237420ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/05-dns_temp_error.result000066400000000000000000000026371474251645300244770ustar00rootroot00000000000000{ "Found": 2, "Valid": 1, "Results": [ { "Error": "lookup : temporary error (for testing)", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "TEMPFAIL" }, { "Error": "", "SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "Domain": "football.example.com", "Selector": "test", "B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/06-dns_perm_error.dns000066400000000000000000000005171474251645300237370ustar00rootroot00000000000000brisbane._domainkey.football.example.com: PERMERROR test._domainkey.football.example.com: \ v=DKIM1; k=rsa; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB chasquid-1.15.0/internal/dkim/testdata/06-dns_perm_error.msg000066400000000000000000000020471474251645300237410ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/06-dns_perm_error.result000066400000000000000000000026371474251645300244760ustar00rootroot00000000000000{ "Found": 2, "Valid": 1, "Results": [ { "Error": "lookup : permanent error (for testing)", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "PERMFAIL" }, { "Error": "", "SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "Domain": "football.example.com", "Selector": "test", "B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/07-algo_mismatch.dns000066400000000000000000000012561474251645300235300ustar00rootroot00000000000000brisbane._domainkey.football.example.com: \ v=DKIM1; k=rsa; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB brisbane._domainkey.football.example.com: \ v=DKIM1; k=ed25519; \ p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo= test._domainkey.football.example.com: \ v=DKIM1; k=rsa; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB chasquid-1.15.0/internal/dkim/testdata/07-algo_mismatch.msg00007770000000000000000000000000147425164530025546201-rfc8463.msgustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/07-algo_mismatch.readme000066400000000000000000000001631474251645300241750ustar00rootroot00000000000000In this test, one of the selectors has two valid TXT records with different key types. Only one of them is valid. chasquid-1.15.0/internal/dkim/testdata/07-algo_mismatch.result00007770000000000000000000000000147425164530027026201-rfc8463.resultustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/08-our_signature.dns000066400000000000000000000007711474251645300236110ustar00rootroot00000000000000selector._domainkey.example.com: \ v=DKIM1; k=ed25519; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ= brisbane._domainkey.football.example.com: \ v=DKIM1; k=ed25519; \ p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo= test._domainkey.football.example.com: \ v=DKIM1; k=rsa; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB chasquid-1.15.0/internal/dkim/testdata/08-our_signature.msg000066400000000000000000000025711474251645300236130ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=example.com; s=selector; t=1709341950; h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==; DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/08-our_signature.result000066400000000000000000000036521474251645300243440ustar00rootroot00000000000000{ "Found": 3, "Valid": 3, "Results": [ { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709341950;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==;", "Domain": "example.com", "Selector": "selector", "B": "Vut85AtCKBtJOWSgGA8uyVCLttKitiUcKI3xD+45B2HQi2uc4fWcPbSGW6djkcgJhu0zRexvE/YvnVkIDVoOAg==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "Domain": "football.example.com", "Selector": "test", "B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/09-limited_body.dns00007770000000000000000000000000147425164530027177208-our_signature.dnsustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/09-limited_body.msg000066400000000000000000000025771474251645300234000ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=example.com; s=selector; t=1709368031; h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id; l=17; bh=2Lb+x7ZAi8ljletRVg9Cn+VSkE36HadUTTOwsYyzZJg=; b=2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==; DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/09-limited_body.readme000066400000000000000000000001641474251645300240350ustar00rootroot00000000000000This test a DKIM signature that uses an l= tag. It was constructed using an ad-hoc modified version of the signer. chasquid-1.15.0/internal/dkim/testdata/09-limited_body.result000066400000000000000000000036601474251645300241220ustar00rootroot00000000000000{ "Found": 3, "Valid": 3, "Results": [ { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709368031;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:in-reply-to:message-id;\r\n l=17; bh=2Lb+x7ZAi8ljletRVg9Cn+VSkE36HadUTTOwsYyzZJg=;\r\n b=2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==;", "Domain": "example.com", "Selector": "selector", "B": "2wsAeUZad5CdbyqNEuUswkD/PJb+trZ8ICldEFX/FpmfdVOtAsCR0flp0EhT7GUTY9b6Q2JvkBICSyvYyojnBQ==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "Domain": "football.example.com", "Selector": "test", "B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/10-strict_domain_check_pass.dns000066400000000000000000000006321474251645300257320ustar00rootroot00000000000000brisbane._domainkey.football.example.com: \ v=DKIM1; k=ed25519; t=s; \ p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo= test._domainkey.football.example.com: \ v=DKIM1; k=rsa; t=s; \ p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWRiGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/byYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKrM3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K4w3QIDAQAB chasquid-1.15.0/internal/dkim/testdata/10-strict_domain_check_pass.msg00007770000000000000000000000000147425164530027753201-rfc8463.msgustar00rootroot00000000000000chasquid-1.15.0/internal/dkim/testdata/10-strict_domain_check_pass.result000066400000000000000000000025701474251645300264670ustar00rootroot00000000000000{ "Found": 2, "Valid": 2, "Results": [ { "Error": "", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=brisbane; t=1528637909; h=from : to :\r\n subject : date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus\r\n Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "Domain": "football.example.com", "Selector": "brisbane", "B": "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", "State": "SUCCESS" }, { "Error": "", "SignatureHeader": " v=1; a=rsa-sha256; c=relaxed/relaxed;\r\n d=football.example.com; i=@football.example.com;\r\n q=dns/txt; s=test; t=1528637909; h=from : to : subject :\r\n date : message-id : from : subject : date;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3\r\n DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz\r\n dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "Domain": "football.example.com", "Selector": "test", "B": "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=", "State": "SUCCESS" } ] }chasquid-1.15.0/internal/dkim/testdata/11-strict_domain_check_fail.dns000066400000000000000000000001551474251645300257000ustar00rootroot00000000000000selector._domainkey.example.com: \ v=DKIM1; k=ed25519; t=s; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ= chasquid-1.15.0/internal/dkim/testdata/11-strict_domain_check_fail.msg000066400000000000000000000011661474251645300257050ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=example.com; s=selector; t=1709466347; i=test@sub.example.com; h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSq b/xGMFTFmpSbNeCg==; From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. chasquid-1.15.0/internal/dkim/testdata/11-strict_domain_check_fail.readme000066400000000000000000000004121474251645300263450ustar00rootroot00000000000000Strict domain check is enabled, but fails. This test has a DNS key with t=s, but the DKIM signature's i= is different than d= (but it is a subdomain, which is enforced at parsing time as per RFC). It was constructed using an ad-hoc modified version of the signer. chasquid-1.15.0/internal/dkim/testdata/11-strict_domain_check_fail.result000066400000000000000000000012201474251645300264240ustar00rootroot00000000000000{ "Found": 1, "Valid": 0, "Results": [ { "Error": "verification failed", "SignatureHeader": " v=1; a=ed25519-sha256; c=relaxed/relaxed;\r\n d=example.com; s=selector; t=1709466347;\r\n i=test@sub.example.com;\r\n h=from:subject:date:to:message-id:from:subject:date:to:cc:message-id;\r\n bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=;\r\n b=NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSq\r\n b/xGMFTFmpSbNeCg==;", "Domain": "example.com", "Selector": "selector", "B": "NDV3SShyaF7fXYoOx9GnBQjFIfsr5bTJUtAwRTk2sTq+5wl/r0uTN1zaSfUWuxYnMIMoSqb/xGMFTFmpSbNeCg==", "State": "PERMFAIL" } ] }chasquid-1.15.0/internal/dkim/verify.go000066400000000000000000000214101474251645300200030ustar00rootroot00000000000000package dkim import ( "bytes" "context" "crypto" "encoding/base64" "errors" "fmt" "net" "regexp" "slices" "strings" ) // These two errors are returned when the verification fails, but the header // is considered valid. var ( ErrBodyHashMismatch = errors.New("body hash mismatch") ErrVerificationFailed = errors.New("verification failed") ) // Evaluation states, as per // https://datatracker.ietf.org/doc/html/rfc6376#section-3.9. type EvaluationState string const ( SUCCESS EvaluationState = "SUCCESS" PERMFAIL EvaluationState = "PERMFAIL" TEMPFAIL EvaluationState = "TEMPFAIL" ) type VerifyResult struct { // How many signatures were found. Found uint // How many signatures were verified successfully. Valid uint // The details for each signature that was found. Results []*OneResult } type OneResult struct { // The raw signature header. SignatureHeader string // Domain and selector from the signature header. Domain string Selector string // Base64-encoded signature. May be missing if it is not present in the // header. B string // The result of the evaluation. State EvaluationState Error error } // Returns the DKIM-specific contents for an Authentication-Results header. // It is just the contents, the header needs to still be constructed. // Note that the output will need to be indented by the caller. // https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1 func (r *VerifyResult) AuthenticationResults() string { // The weird placement of the ";" is due to the specification saying they // have to be before each method, not at the end. // By doing it this way, we can concate the output of this function with // other results. ar := &strings.Builder{} if r.Found == 0 { // https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1 ar.WriteString(";dkim=none\r\n") return ar.String() } for _, res := range r.Results { // Map state to the corresponding result. // https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1 switch res.State { case SUCCESS: ar.WriteString(";dkim=pass") case TEMPFAIL: // The reason must come before the properties, include it here. fmt.Fprintf(ar, ";dkim=temperror reason=%q\r\n", res.Error) case PERMFAIL: // The reason must come before the properties, include it here. if errors.Is(res.Error, ErrVerificationFailed) || errors.Is(res.Error, ErrBodyHashMismatch) { fmt.Fprintf(ar, ";dkim=fail reason=%q\r\n", res.Error) } else { fmt.Fprintf(ar, ";dkim=permerror reason=%q\r\n", res.Error) } } if res.B != "" { // Include a partial b= tag to help identify which signature // is being referred to. // https://datatracker.ietf.org/doc/html/rfc6008#section-4 fmt.Fprintf(ar, " header.b=%.12s", res.B) } ar.WriteString(" header.d=" + res.Domain + "\r\n") } return ar.String() } func VerifyMessage(ctx context.Context, message string) (*VerifyResult, error) { // https://datatracker.ietf.org/doc/html/rfc6376#section-6 headers, body, err := parseMessage(message) if err != nil { trace(ctx, "Error parsing message: %v", err) return nil, err } results := &VerifyResult{ Results: []*OneResult{}, } for i, sig := range headers.FindAll("DKIM-Signature") { trace(ctx, "Found DKIM-Signature header: %s", sig.Value) if i >= maxHeaders(ctx) { // Protect from potential DoS by capping the number of signatures. // https://datatracker.ietf.org/doc/html/rfc6376#section-4.2 // https://datatracker.ietf.org/doc/html/rfc6376#section-8.4 trace(ctx, "Too many DKIM-Signature headers found") break } results.Found++ res := verifySignature(ctx, sig, headers, body) results.Results = append(results.Results, res) if res.State == SUCCESS { results.Valid++ } } trace(ctx, "Found %d signatures, %d valid", results.Found, results.Valid) return results, nil } // Regular expression that matches the "b=" tag. // First capture group is the "b=" part (including any whitespace up to the // '='). var bTag = regexp.MustCompile(`(b[ \t\r\n]*=)[^;]+`) func verifySignature(ctx context.Context, sigH header, headers headers, body string) *OneResult { result := &OneResult{ SignatureHeader: sigH.Value, } sig, err := dkimSignatureFromHeader(sigH.Value) if err != nil { // Header validation errors are a PERMFAIL. // https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.1 result.Error = err result.State = PERMFAIL return result } result.Domain = sig.d result.Selector = sig.s result.B = base64.StdEncoding.EncodeToString(sig.b) // Get the public key. // https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2 pubKeys, err := findPublicKeys(ctx, sig.d, sig.s) if err != nil { result.Error = err // DNS errors when looking up the public key are a TEMPFAIL; all // others are PERMFAIL. // https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.2 if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.Temporary() { result.State = TEMPFAIL } else { result.State = PERMFAIL } return result } // Compute the verification. // https://datatracker.ietf.org/doc/html/rfc6376#section-6.1.3 // Step 1: Prepare a canonicalized version of the body, truncate it to l= // (if present). // https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 bodyC := sig.cB.body(body) if sig.l > 0 { bodyC = bodyC[:sig.l] } // Step 2: Compute the hash of the canonicalized body. bodyH := hashWith(sig.Hash, []byte(bodyC)) // Step 3: Verify the hash of the body by comparing it with bh=. if !bytes.Equal(bodyH, sig.bh) { bodyHStr := base64.StdEncoding.EncodeToString(bodyH) trace(ctx, "Body hash mismatch: %q", bodyHStr) result.Error = fmt.Errorf("%w (got %s)", ErrBodyHashMismatch, bodyHStr) result.State = PERMFAIL return result } trace(ctx, "Body hash matches: %q", base64.StdEncoding.EncodeToString(bodyH)) // Step 4 A: Hash the (canonicalized) headers that appear in the h= tag. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 b := sig.Hash.New() for _, header := range headersToInclude(sigH, sig.h, headers) { hsrc := sig.cH.header(header).Source + "\r\n" trace(ctx, "Hashing header: %q", hsrc) b.Write([]byte(hsrc)) } // Step 4 B: Hash the (canonicalized) DKIM-Signature header itself, but // with an empty b= tag, and without a trailing \r\n. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 sigC := sig.cH.header(sigH) sigCStr := bTag.ReplaceAllString(sigC.Source, "$1") trace(ctx, "Hashing header: %q", sigCStr) b.Write([]byte(sigCStr)) bSum := b.Sum(nil) trace(ctx, "Resulting hash: %q", base64.StdEncoding.EncodeToString(bSum)) // Step 4 C: Validate the signature. for _, pubKey := range pubKeys { if !pubKey.Matches(sig.KeyType, sig.Hash) { trace(ctx, "PK %v: key type or hash mismatch, skipping", pubKey) continue } if sig.i != "" && pubKey.StrictDomainCheck() { _, domain, _ := strings.Cut(sig.i, "@") if domain != sig.d { trace(ctx, "PK %v: Strict domain check failed: %q != %q (%q)", pubKey, sig.d, domain, sig.i) continue } trace(ctx, "PK %v: Strict domain check passed", pubKey) } err := pubKey.verify(sig.Hash, bSum, sig.b) if err != nil { trace(ctx, "PK %v: Verification failed: %v", pubKey, err) continue } trace(ctx, "PK %v: Verification succeeded", pubKey) result.State = SUCCESS return result } result.State = PERMFAIL result.Error = ErrVerificationFailed return result } func headersToInclude(sigH header, hTag []string, headers headers) []header { // Return the actual headers to include in the hash, based on the list // given in the h= tag. // This is complicated because: // - Headers can be included multiple times. In that case, we must pick // the last instance (which hasn't been already included). // https://datatracker.ietf.org/doc/html/rfc6376#section-5.4.2 // - Headers may appear fewer times than they are requested. // - DKIM-Signature header may be included, but we must not include the // one being verified. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 // - Headers may be missing, and that's allowed. // https://datatracker.ietf.org/doc/html/rfc6376#section-5.4 seen := map[string]int{} include := []header{} for _, h := range hTag { all := headers.FindAll(h) slices.Reverse(all) // We keep track of the last instance of each header that we // included, and find the next one every time it appears in h=. // We have to be careful because the header itself may not be present, // or we may be asked to include it more times than it appears. lh := strings.ToLower(h) i := seen[lh] if i >= len(all) { continue } seen[lh]++ selected := all[i] if selected == sigH { continue } include = append(include, selected) } return include } func hashWith(a crypto.Hash, data []byte) []byte { h := a.New() h.Write(data) return h.Sum(nil) } chasquid-1.15.0/internal/dkim/verify_test.go000066400000000000000000000307201474251645300210460ustar00rootroot00000000000000package dkim import ( "context" "net" "strings" "testing" "github.com/google/go-cmp/cmp" ) func toCRLF(s string) string { return strings.ReplaceAll(s, "\n", "\r\n") } func makeLookupTXT(results map[string][]string) lookupTXTFunc { return func(ctx context.Context, domain string) ([]string, error) { return results[domain], nil } } func TestVerifyRF6376CExample(t *testing.T) { ctx := context.Background() ctx = WithTraceFunc(ctx, t.Logf) // Use the public key from the example in RFC 6376 appendix C. // https://datatracker.ietf.org/doc/html/rfc6376#appendix-C ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{ "brisbane._domainkey.example.com": []string{ "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" + "KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" + "IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" + "/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" + "tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB", }, })) // Note that the examples in the RFC text have multiple issues: // - The double space in "game. Are" should be a single // space. Otherwise, the body hash does not match. // https://www.rfc-editor.org/errata/eid3192 // - The header indentation is incorrect. This causes // signature validation failure (because the example uses simple // canonicalization, which leaves the indentation untouched). // https://www.rfc-editor.org/errata/eid4926 message := toCRLF( `DKIM-Signature: v=1; a=rsa-sha256; s=brisbane; d=example.com; c=simple/simple; q=dns/txt; i=joe@football.example.com; h=Received : From : To : Subject : Date : Message-ID; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=AuUoFEfDxTDkHlLXSZEpZj79LICEps6eda7W3deTVFOk4yAUoqOB 4nujc7YopdG5dWLSdNg6xNAZpOPr+kHxt1IrE+NahM6L/LbvaHut KVdkLLkpVaVVQPzeRDI009SO2Il5Lu7rDNH6mZckBdrIx0orEtZV 4bmp/YzhwvcubU4=; Received: from client1.football.example.com [192.0.2.1] by submitserver.example.com with SUBMISSION; Fri, 11 Jul 2003 21:01:54 -0700 (PDT) From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. `) res, err := VerifyMessage(ctx, message) if res.Valid != 1 || err != nil { t.Errorf("VerifyMessage: wanted 1 valid / nil; got %v / %v", res, err) } // Extend the message, check it does not pass validation. res, err = VerifyMessage(ctx, message+"Extra line.\r\n") if res.Valid != 0 || err != nil { t.Errorf("VerifyMessage: wanted 0 valid / nil; got %v / %v", res, err) } // Alter a header, check it does not pass validation. res, err = VerifyMessage(ctx, strings.Replace(message, "Subject", "X-Subject", 1)) if res.Valid != 0 || err != nil { t.Errorf("VerifyMessage: wanted 0 valid / nil; got %v / %v", res, err) } } func TestVerifyRFC8463Example(t *testing.T) { ctx := context.Background() ctx = WithTraceFunc(ctx, t.Logf) // Use the public keys from the example in RFC 8463 appendix A.2. // https://datatracker.ietf.org/doc/html/rfc6376#appendix-C ctx = WithLookupTXTFunc(ctx, makeLookupTXT(map[string][]string{ "brisbane._domainkey.football.example.com": []string{ "v=DKIM1; k=ed25519; " + "p=11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo="}, "test._domainkey.football.example.com": []string{ "v=DKIM1; k=rsa; " + "p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDkHlOQoBTzWR" + "iGs5V6NpP3idY6Wk08a5qhdR6wy5bdOKb2jLQiY/J16JYi0Qvx/b" + "yYzCNb3W91y3FutACDfzwQ/BC/e/8uBsCR+yz1Lxj+PL6lHvqMKr" + "M3rG4hstT5QjvHO9PzoxZyVYLzBfO2EeC3Ip3G+2kryOTIKT+l/K" + "4w3QIDAQAB"}, })) message := toCRLF( `DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw== DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8= From: Joe SixPack To: Suzie Q Subject: Is dinner ready? Date: Fri, 11 Jul 2003 21:00:37 -0700 (PDT) Message-ID: <20030712040037.46341.5F8J@football.example.com> Hi. We lost the game. Are you hungry yet? Joe. `) expected := &VerifyResult{ Found: 2, Valid: 2, Results: []*OneResult{ { SignatureHeader: toCRLF( ` v=1; a=ed25519-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=brisbane; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11Bus Fa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==`), Domain: "football.example.com", Selector: "brisbane", B: "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11" + "BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", State: SUCCESS, Error: nil, }, { SignatureHeader: toCRLF( ` v=1; a=rsa-sha256; c=relaxed/relaxed; d=football.example.com; i=@football.example.com; q=dns/txt; s=test; t=1528637909; h=from : to : subject : date : message-id : from : subject : date; bh=2jUSOH9NhtVGCQWNr9BrIAPreKQjO6Sn7XIkfJVOzv8=; b=F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe3 DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefOsk2Jz dA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZQ4FADY+8=`), Domain: "football.example.com", Selector: "test", B: "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe" + "3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefO" + "sk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZ" + "Q4FADY+8=", State: SUCCESS, Error: nil, }, }, } res, err := VerifyMessage(ctx, message) if err != nil { t.Fatalf("VerifyMessage returned error: %v", err) } if diff := cmp.Diff(expected, res); diff != "" { t.Errorf("VerifyMessage diff (-want +got):\n%s", diff) } // Extend the message, check it does not pass validation. res, err = VerifyMessage(ctx, message+"Extra line.\r\n") if res.Found != 2 || res.Valid != 0 || err != nil { t.Errorf("VerifyMessage: wanted 2 found, 0 valid / nil; got %v / %v", res, err) } // Alter a header, check it does not pass validation. res, err = VerifyMessage(ctx, strings.Replace(message, "Subject", "X-Subject", 1)) if res.Found != 2 || res.Valid != 0 || err != nil { t.Errorf("VerifyMessage: wanted 2 found, 0 valid / nil; got %v / %v", res, err) } } func TestHeadersToInclude(t *testing.T) { // Test that headersToInclude returns the expected headers. cases := []struct { sigH header hTag []string headers headers want []header }{ // Check that if a header appears more than once, we pick the latest // first. { sigH: header{ Name: "DKIM-Signature", Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;", }, hTag: []string{"From", "To", "Subject"}, headers: headers{ {Name: "From", Value: "from1"}, {Name: "To", Value: "to1"}, {Name: "Subject", Value: "subject1"}, {Name: "From", Value: "from2"}, }, want: []header{ {Name: "From", Value: "from2"}, {Name: "To", Value: "to1"}, {Name: "Subject", Value: "subject1"}, }, }, // Check that if a header is requested twice but only appears once, we // only return it once. // This is a common technique suggested by the RFC to make signatures // fail if a header is added. { sigH: header{ Name: "DKIM-Signature", Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;", }, hTag: []string{"From", "From", "To", "Subject"}, headers: headers{ {Name: "From", Value: "from1"}, {Name: "To", Value: "to1"}, {Name: "Subject", Value: "subject1"}, }, want: []header{ {Name: "From", Value: "from1"}, {Name: "To", Value: "to1"}, {Name: "Subject", Value: "subject1"}, }, }, // Check that if DKIM-Signature is included, we do *not* include the // one we're currently verifying in the headers to include. // https://datatracker.ietf.org/doc/html/rfc6376#section-3.7 { sigH: header{ Name: "DKIM-Signature", Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;", }, hTag: []string{"From", "From", "DKIM-Signature", "DKIM-Signature"}, headers: headers{ {Name: "From", Value: "from1"}, {Name: "To", Value: "to1"}, { Name: "DKIM-Signature", Value: "v=1; a=rsa-sha256; s=sidney; d=example.com;", }, { Name: "DKIM-Signature", Value: "v=1; a=rsa-sha256; s=brisbane; d=example.com;", }, }, want: []header{ {Name: "From", Value: "from1"}, { Name: "DKIM-Signature", Value: "v=1; a=rsa-sha256; s=sidney; d=example.com;", }, }, }, } for _, c := range cases { got := headersToInclude(c.sigH, c.hTag, c.headers) if diff := cmp.Diff(c.want, got); diff != "" { t.Errorf("headersToInclude(%q, %v, %v) diff (-want +got):\n%s", c.sigH, c.hTag, c.headers, diff) } } } func TestAuthenticationResults(t *testing.T) { resBrisbane := &OneResult{ Domain: "football.example.com", Selector: "brisbane", B: "/gCrinpcQOoIfuHNQIbq4pgh9kyIK3AQUdt9OdqQehSwhEIug4D11" + "BusFa3bT3FY5OsU7ZbnKELq+eXdp1Q1Dw==", State: SUCCESS, Error: nil, } resTest := &OneResult{ Domain: "football.example.com", Selector: "test", B: "F45dVWDfMbQDGHJFlXUNB2HKfbCeLRyhDXgFpEL8GwpsRe0IeIixNTe" + "3DhCVlUrSjV4BwcVcOF6+FF3Zo9Rpo1tFOeS9mPYQTnGdaSGsgeefO" + "sk2JzdA+L10TeYt9BgDfQNZtKdN1WO//KgIqXP7OdEFE4LjFYNcUxZ" + "Q4FADY+8=", State: SUCCESS, Error: nil, } resFail := &OneResult{ Domain: "football.example.com", Selector: "paris", B: "slfkdMSDFeslif39seFfjl93sljisdsdlif923l", State: PERMFAIL, Error: ErrVerificationFailed, } resPermFail := &OneResult{ Domain: "football.example.com", Selector: "paris", // No B tag on purpose. State: PERMFAIL, Error: errMissingRequiredTag, } resTempFail := &OneResult{ Domain: "football.example.com", Selector: "paris", B: "shorty", // Less than 12 characters to check we include it well. State: TEMPFAIL, Error: &net.DNSError{ Err: "dns temp error (for testing)", IsTemporary: true, }, } cases := []struct { results *VerifyResult want string }{ { results: &VerifyResult{}, want: ";dkim=none\r\n", }, { results: &VerifyResult{ Found: 1, Valid: 1, Results: []*OneResult{resBrisbane}, }, want: ";dkim=pass" + " header.b=/gCrinpcQOoI header.d=football.example.com\r\n", }, { results: &VerifyResult{ Found: 2, Valid: 2, Results: []*OneResult{resBrisbane, resTest}, }, want: ";dkim=pass" + " header.b=/gCrinpcQOoI header.d=football.example.com\r\n" + ";dkim=pass" + " header.b=F45dVWDfMbQD header.d=football.example.com\r\n", }, { results: &VerifyResult{ Found: 2, Valid: 2, Results: []*OneResult{resBrisbane, resTest}, }, want: ";dkim=pass" + " header.b=/gCrinpcQOoI header.d=football.example.com\r\n" + ";dkim=pass" + " header.b=F45dVWDfMbQD header.d=football.example.com\r\n", }, { results: &VerifyResult{ Found: 2, Valid: 1, Results: []*OneResult{resFail, resTest}, }, want: ";dkim=fail reason=\"verification failed\"\r\n" + " header.b=slfkdMSDFesl header.d=football.example.com\r\n" + ";dkim=pass" + " header.b=F45dVWDfMbQD header.d=football.example.com\r\n", }, { results: &VerifyResult{ Found: 1, Results: []*OneResult{resPermFail}, }, want: ";dkim=permerror reason=\"missing required tag\"\r\n" + " header.d=football.example.com\r\n", }, { results: &VerifyResult{ Found: 1, Results: []*OneResult{resTempFail}, }, want: ";dkim=temperror reason=\"lookup : dns temp error (for testing)\"\r\n" + " header.b=shorty header.d=football.example.com\r\n", }, } for i, c := range cases { got := c.results.AuthenticationResults() if diff := cmp.Diff(c.want, got); diff != "" { t.Errorf("case %d: AuthenticationResults() diff (-want +got):\n%s", i, diff) } } } chasquid-1.15.0/internal/domaininfo/000077500000000000000000000000001474251645300173515ustar00rootroot00000000000000chasquid-1.15.0/internal/domaininfo/domaininfo.go000066400000000000000000000101071474251645300220220ustar00rootroot00000000000000// 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=. --go_opt=paths=source_relative domaininfo.proto // DB represents the persistent domain information database. type DB struct { // Persistent store with the list of domains we know. store *protoio.Store info map[string]*Domain sync.Mutex } // New opens a domain information database on the given dir, creating it if // necessary. The returned database will not be loaded. func New(dir string) (*DB, error) { st, err := protoio.NewStore(dir) if err != nil { return nil, err } l := &DB{ store: st, info: map[string]*Domain{}, } err = l.Reload() if err != nil { return nil, err } return l, nil } // Reload the database from disk. func (db *DB) Reload() error { tr := trace.New("DomainInfo.Reload", "reload") defer tr.Finish() db.Lock() defer db.Unlock() // Clear the map, in case it has data. db.info = map[string]*Domain{} ids, err := db.store.ListIDs() if err != nil { tr.Error(err) return err } for _, id := range ids { d := &Domain{} _, err := db.store.Get(id, d) if err != nil { tr.Errorf("id %q: %v", id, err) return fmt.Errorf("error loading %q: %v", id, err) } db.info[d.Name] = d } tr.Debugf("loaded %d domains", len(ids)) return nil } func (db *DB) write(tr *trace.Trace, d *Domain) error { tr = tr.NewChild("DomainInfo.write", d.Name) defer tr.Finish() err := db.store.Put(d.Name, d) if err != nil { tr.Error(err) } else { tr.Debugf("saved") } return err } // IncomingSecLevel checks an incoming security level for the domain. // Returns true if allowed, false otherwise. func (db *DB) IncomingSecLevel(tr *trace.Trace, domain string, level SecLevel) bool { tr = tr.NewChild("DomainInfo.Incoming", domain) defer tr.Finish() tr.Debugf("incoming at level %s", level) db.Lock() defer db.Unlock() d, exists := db.info[domain] if !exists { d = &Domain{Name: domain} db.info[domain] = d defer db.write(tr, d) } if level < d.IncomingSecLevel { tr.Errorf("%s incoming denied: %s < %s", d.Name, level, d.IncomingSecLevel) return false } else if level == d.IncomingSecLevel { tr.Debugf("%s incoming allowed: %s == %s", d.Name, level, d.IncomingSecLevel) return true } else { tr.Printf("%s incoming level raised: %s > %s", d.Name, level, d.IncomingSecLevel) d.IncomingSecLevel = level if exists { defer db.write(tr, d) } return true } } // OutgoingSecLevel checks an incoming security level for the domain. // Returns true if allowed, false otherwise. func (db *DB) OutgoingSecLevel(tr *trace.Trace, domain string, level SecLevel) bool { tr = tr.NewChild("DomainInfo.Outgoing", domain) defer tr.Finish() tr.Debugf("outgoing at level %s", level) db.Lock() defer db.Unlock() d, exists := db.info[domain] if !exists { d = &Domain{Name: domain} db.info[domain] = d defer db.write(tr, d) } if level < d.OutgoingSecLevel { tr.Errorf("%s outgoing denied: %s < %s", d.Name, level, d.OutgoingSecLevel) return false } else if level == d.OutgoingSecLevel { tr.Debugf("%s outgoing allowed: %s == %s", d.Name, level, d.OutgoingSecLevel) return true } else { tr.Printf("%s outgoing level raised: %s > %s", d.Name, level, d.OutgoingSecLevel) d.OutgoingSecLevel = level if exists { defer db.write(tr, d) } return true } } // Clear sets the security level for the given domain to plain. // This can be used for manual overrides in case there's an operational need // to do so. func (db *DB) Clear(tr *trace.Trace, domain string) bool { tr = tr.NewChild("DomainInfo.SetToPlain", domain) defer tr.Finish() db.Lock() defer db.Unlock() d, exists := db.info[domain] if !exists { tr.Debugf("does not exist") return false } d.IncomingSecLevel = SecLevel_PLAIN d.OutgoingSecLevel = SecLevel_PLAIN db.write(tr, d) tr.Printf("set to plain") return true } chasquid-1.15.0/internal/domaininfo/domaininfo.pb.go000066400000000000000000000173031474251645300224270ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.30.0 // protoc v3.21.12 // source: domaininfo.proto package domaininfo import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) 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 ) // Enum value maps for SecLevel. var ( SecLevel_name = map[int32]string{ 0: "PLAIN", 1: "TLS_CLIENT", 2: "TLS_INSECURE", 3: "TLS_SECURE", } SecLevel_value = map[string]int32{ "PLAIN": 0, "TLS_CLIENT": 1, "TLS_INSECURE": 2, "TLS_SECURE": 3, } ) func (x SecLevel) Enum() *SecLevel { p := new(SecLevel) *p = x return p } func (x SecLevel) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (SecLevel) Descriptor() protoreflect.EnumDescriptor { return file_domaininfo_proto_enumTypes[0].Descriptor() } func (SecLevel) Type() protoreflect.EnumType { return &file_domaininfo_proto_enumTypes[0] } func (x SecLevel) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use SecLevel.Descriptor instead. func (SecLevel) EnumDescriptor() ([]byte, []int) { return file_domaininfo_proto_rawDescGZIP(), []int{0} } type Domain struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` // Security level for mail coming from this domain (they send to us). IncomingSecLevel SecLevel `protobuf:"varint,2,opt,name=incoming_sec_level,json=incomingSecLevel,proto3,enum=domaininfo.SecLevel" json:"incoming_sec_level,omitempty"` // Security level for mail going to this domain (we send to them). OutgoingSecLevel SecLevel `protobuf:"varint,3,opt,name=outgoing_sec_level,json=outgoingSecLevel,proto3,enum=domaininfo.SecLevel" json:"outgoing_sec_level,omitempty"` } func (x *Domain) Reset() { *x = Domain{} if protoimpl.UnsafeEnabled { mi := &file_domaininfo_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Domain) String() string { return protoimpl.X.MessageStringOf(x) } func (*Domain) ProtoMessage() {} func (x *Domain) ProtoReflect() protoreflect.Message { mi := &file_domaininfo_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Domain.ProtoReflect.Descriptor instead. func (*Domain) Descriptor() ([]byte, []int) { return file_domaininfo_proto_rawDescGZIP(), []int{0} } func (x *Domain) GetName() string { if x != nil { return x.Name } return "" } func (x *Domain) GetIncomingSecLevel() SecLevel { if x != nil { return x.IncomingSecLevel } return SecLevel_PLAIN } func (x *Domain) GetOutgoingSecLevel() SecLevel { if x != nil { return x.OutgoingSecLevel } return SecLevel_PLAIN } var File_domaininfo_proto protoreflect.FileDescriptor var file_domaininfo_proto_rawDesc = []byte{ 0x0a, 0x10, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x66, 0x6f, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0a, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x66, 0x6f, 0x22, 0xa4, 0x01, 0x0a, 0x06, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x42, 0x0a, 0x12, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x65, 0x63, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x66, 0x6f, 0x2e, 0x53, 0x65, 0x63, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x10, 0x69, 0x6e, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x63, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x42, 0x0a, 0x12, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x65, 0x63, 0x5f, 0x6c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x66, 0x6f, 0x2e, 0x53, 0x65, 0x63, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x10, 0x6f, 0x75, 0x74, 0x67, 0x6f, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x63, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x2a, 0x47, 0x0a, 0x08, 0x53, 0x65, 0x63, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x50, 0x4c, 0x41, 0x49, 0x4e, 0x10, 0x00, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x4c, 0x53, 0x5f, 0x43, 0x4c, 0x49, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x54, 0x4c, 0x53, 0x5f, 0x49, 0x4e, 0x53, 0x45, 0x43, 0x55, 0x52, 0x45, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x54, 0x4c, 0x53, 0x5f, 0x53, 0x45, 0x43, 0x55, 0x52, 0x45, 0x10, 0x03, 0x42, 0x30, 0x5a, 0x2e, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72, 0x69, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73, 0x71, 0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x69, 0x6e, 0x66, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_domaininfo_proto_rawDescOnce sync.Once file_domaininfo_proto_rawDescData = file_domaininfo_proto_rawDesc ) func file_domaininfo_proto_rawDescGZIP() []byte { file_domaininfo_proto_rawDescOnce.Do(func() { file_domaininfo_proto_rawDescData = protoimpl.X.CompressGZIP(file_domaininfo_proto_rawDescData) }) return file_domaininfo_proto_rawDescData } var file_domaininfo_proto_enumTypes = make([]protoimpl.EnumInfo, 1) var file_domaininfo_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_domaininfo_proto_goTypes = []interface{}{ (SecLevel)(0), // 0: domaininfo.SecLevel (*Domain)(nil), // 1: domaininfo.Domain } var file_domaininfo_proto_depIdxs = []int32{ 0, // 0: domaininfo.Domain.incoming_sec_level:type_name -> domaininfo.SecLevel 0, // 1: domaininfo.Domain.outgoing_sec_level:type_name -> domaininfo.SecLevel 2, // [2:2] is the sub-list for method output_type 2, // [2:2] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name } func init() { file_domaininfo_proto_init() } func file_domaininfo_proto_init() { if File_domaininfo_proto != nil { return } if !protoimpl.UnsafeEnabled { file_domaininfo_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Domain); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_domaininfo_proto_rawDesc, NumEnums: 1, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_domaininfo_proto_goTypes, DependencyIndexes: file_domaininfo_proto_depIdxs, EnumInfos: file_domaininfo_proto_enumTypes, MessageInfos: file_domaininfo_proto_msgTypes, }.Build() File_domaininfo_proto = out.File file_domaininfo_proto_rawDesc = nil file_domaininfo_proto_goTypes = nil file_domaininfo_proto_depIdxs = nil } chasquid-1.15.0/internal/domaininfo/domaininfo.proto000066400000000000000000000011231474251645300225560ustar00rootroot00000000000000 syntax = "proto3"; package domaininfo; option go_package = "blitiri.com.ar/go/chasquid/internal/domaininfo"; enum SecLevel { // Does not do TLS. PLAIN = 0; // TLS client connection (no certificate validation). TLS_CLIENT = 1; // TLS, but with invalid certificates. TLS_INSECURE = 2; // TLS, with valid certificates. TLS_SECURE = 3; } message Domain { string name = 1; // Security level for mail coming from this domain (they send to us). SecLevel incoming_sec_level = 2; // Security level for mail going to this domain (we send to them). SecLevel outgoing_sec_level = 3; } chasquid-1.15.0/internal/domaininfo/domaininfo_test.go000066400000000000000000000124421474251645300230650ustar00rootroot00000000000000package domaininfo import ( "errors" "os" "testing" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/trace" ) func TestBasic(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } tr := trace.New("test", "basic") defer tr.Finish() // IncomingSecLevel checks. if !db.IncomingSecLevel(tr, "d1", SecLevel_PLAIN) { t.Errorf("incoming: new domain as plain not allowed") } if !db.IncomingSecLevel(tr, "d1", SecLevel_TLS_SECURE) { t.Errorf("incoming: increment to tls-secure not allowed") } if db.IncomingSecLevel(tr, "d1", SecLevel_TLS_INSECURE) { t.Errorf("incoming: decrement to tls-insecure was allowed") } // OutgoingSecLevel checks. if !db.OutgoingSecLevel(tr, "d1", SecLevel_PLAIN) { t.Errorf("outgoing: new domain as plain not allowed") } if !db.OutgoingSecLevel(tr, "d1", SecLevel_TLS_SECURE) { t.Errorf("outgoing: increment to tls-secure not allowed") } if db.OutgoingSecLevel(tr, "d1", SecLevel_TLS_INSECURE) { t.Errorf("outgoing: decrement to tls-insecure was allowed") } // Check that it was added to the store and a new db sees it. db2, err := New(dir) if err != nil { t.Fatal(err) } if db2.IncomingSecLevel(tr, "d1", SecLevel_TLS_INSECURE) { t.Errorf("decrement to tls-insecure was allowed in new DB") } // Check that Clear resets the entry back to plain. ok := db.Clear(tr, "d1") if !ok { t.Errorf("Clear(d1) did not find the domain") } if !db.IncomingSecLevel(tr, "d1", SecLevel_PLAIN) { t.Errorf("Clear did not reset the domain back to plain (incoming)") } if !db.OutgoingSecLevel(tr, "d1", SecLevel_PLAIN) { t.Errorf("Clear did not reset the domain back to plain (outgoing)") } // Check that Clear returns false if the domain does not exist. ok = db.Clear(tr, "notexist") if ok { t.Errorf("Clear(notexist) returned true") } } func TestNewDomain(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } tr := trace.New("test", "newdomain") defer tr.Finish() cases := []struct { domain string level SecLevel }{ {"plain", SecLevel_PLAIN}, {"insecure", SecLevel_TLS_INSECURE}, {"secure", SecLevel_TLS_SECURE}, } for _, c := range cases { // The other tests do an incoming check first, so new domains would get // created via that path. We switch the order here to exercise that // OutgoingSecLevel also handles new domains successfully. if !db.OutgoingSecLevel(tr, c.domain, c.level) { t.Errorf("domain %q not allowed (out) at %s", c.domain, c.level) } if !db.IncomingSecLevel(tr, c.domain, c.level) { t.Errorf("domain %q not allowed (in) 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) } tr := trace.New("test", "progressions") defer tr.Finish() 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(tr, 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(tr, c.domain, c.lvl); ok != c.ok { t.Errorf("%2d %q out attempt for %s failed: got %v, expected %v", i, c.domain, c.lvl, ok, c.ok) } } } func TestErrors(t *testing.T) { // Non-existent directory. _, err := New("/doesnotexists") if err == nil { t.Error("could create a DB on a non-existent directory") } // Corrupt/invalid file. dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir) if err != nil { t.Fatal(err) } tr := trace.New("test", "errors") defer tr.Finish() if !db.IncomingSecLevel(tr, "d1", SecLevel_TLS_SECURE) { t.Errorf("increment to tls-secure not allowed") } testlib.Rewrite(t, dir+"/s:d1", "invalid-text-protobuf-contents") err = db.Reload() if err == nil { t.Errorf("no error when reloading db with invalid file") } // Creating a db with an invalid file should also result in an error. _, err = New(dir) if err == nil { t.Errorf("no error when creating db with invalid file") } } func TestDirectoryErrors(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) db, err := New(dir + "/db") if err != nil { t.Fatal(err) } tr := trace.New("test", "direrrors") defer tr.Finish() // We want to cause store.ListIDs to return an error. To do so, we will // cause Readdir to fail by removing the underlying db directory. err = os.Remove(dir + "/db") if err != nil { t.Fatal(err) } err = db.Reload() if !errors.Is(err, os.ErrNotExist) { t.Errorf("got %v, expected %v", err, os.ErrNotExist) } // We expect write() to also fail to store data in this scenario. d := Domain{Name: "d1"} err = db.write(tr, &d) if !errors.Is(err, os.ErrNotExist) { t.Errorf("got %v, expected %v", err, os.ErrNotExist) } } chasquid-1.15.0/internal/dovecot/000077500000000000000000000000001474251645300166715ustar00rootroot00000000000000chasquid-1.15.0/internal/dovecot/dovecot.go000066400000000000000000000157151474251645300206740ustar00rootroot00000000000000// 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" "sync" "time" "unicode" ) // DefaultTimeout to use. We expect Dovecot to be quite fast, but don't want // to hang forever if something gets stuck. const DefaultTimeout = 5 * time.Second var ( errUsernameNotSafe = errors.New("username not safe (contains spaces)") errFailedToConnect = errors.New("failed to connect to dovecot") errNoUserdbSocket = errors.New("unable to find userdb socket") errNoClientSocket = errors.New("unable to find client socket") ) 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 { addr struct { mu *sync.Mutex userdb string client 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 { a := &Auth{} a.addr.mu = &sync.Mutex{} a.addr.userdb = userdb a.addr.client = client a.Timeout = DefaultTimeout return a } // String representation of this Auth, for human consumption. func (a *Auth) String() string { a.addr.mu.Lock() defer a.addr.mu.Unlock() return fmt.Sprintf("DovecotAuth(%q, %q)", a.addr.userdb, a.addr.client) } // Check to see if this auth is functional. func (a *Auth) Check() error { u, c, err := a.getAddrs() if err != nil { return err } if !(a.canDial(u) && a.canDial(c)) { return errFailedToConnect } return nil } // Exists returns true if the user exists, false otherwise. func (a *Auth) Exists(user string) (bool, error) { if !isUsernameSafe(user) { return false, errUsernameNotSafe } userdbAddr, _, err := a.getAddrs() if err != nil { return false, err } conn, err := a.dial("unix", userdbAddr) if err != nil { return false, err } defer conn.Close() // Dovecot greets us with version and server pid. // VERSION\t\t // SPID\t err = expect(conn, "VERSION\t1") if err != nil { return false, fmt.Errorf("error receiving version: %v", err) } err = expect(conn, "SPID\t") if err != nil { return false, fmt.Errorf("error receiving SPID: %v", err) } // Send our version, and then the request. err = write(conn, "VERSION\t1\t1\n") if err != nil { return false, err } err = write(conn, fmt.Sprintf("USER\t1\t%s\tservice=smtp\n", user)) if err != nil { return false, err } // Get the response, and we're done. resp, err := conn.ReadLine() if err != nil { return false, fmt.Errorf("error receiving response: %v", err) } else if strings.HasPrefix(resp, "USER\t1\t") { return true, nil } else if strings.HasPrefix(resp, "NOTFOUND\t") { return false, nil } return false, fmt.Errorf("invalid response: %q", resp) } // Authenticate returns true if the password is valid for the user, false // otherwise. func (a *Auth) Authenticate(user, passwd string) (bool, error) { if !isUsernameSafe(user) { return false, errUsernameNotSafe } _, clientAddr, err := a.getAddrs() if err != nil { return false, err } conn, err := a.dial("unix", clientAddr) if err != nil { return false, err } defer conn.Close() // Send our version, and then our PID. err = write(conn, fmt.Sprintf("VERSION\t1\t1\nCPID\t%d\n", os.Getpid())) if err != nil { return false, err } // Read the server-side handshake. We don't care about the contents // really, so just read all lines until we see the DONE. for { resp, err := conn.ReadLine() if err != nil { return false, fmt.Errorf("error receiving handshake: %v", err) } if resp == "DONE" { break } } // We only support PLAIN authentication, so construct the request. // Note we set the "secured" option, with the assumpition that we got the // password via a secure channel (like TLS). This is always true for // chasquid by design, and simplifies the API. // TODO: does dovecot handle utf8 domains well? do we need to encode them // in IDNA first? resp := base64.StdEncoding.EncodeToString( []byte(fmt.Sprintf("%s\x00%s\x00%s", user, user, passwd))) err = write(conn, fmt.Sprintf( "AUTH\t1\tPLAIN\tservice=smtp\tsecured\tno-penalty\tnologin\tresp=%s\n", resp)) if err != nil { return false, err } // Get the response, and we're done. resp, err = conn.ReadLine() if err != nil { return false, fmt.Errorf("error receiving response: %v", err) } else if strings.HasPrefix(resp, "OK\t1") { return true, nil } else if strings.HasPrefix(resp, "FAIL\t1") { return false, nil } return false, fmt.Errorf("invalid response: %q", resp) } // Reload the authenticator. It's a no-op for dovecot, but it is needed to // conform with the auth.Backend interface. func (a *Auth) Reload() error { return nil } func (a *Auth) dial(network, addr string) (*textproto.Conn, error) { nc, err := net.DialTimeout(network, addr, a.Timeout) if err != nil { return nil, err } nc.SetDeadline(time.Now().Add(a.Timeout)) return textproto.NewConn(nc), nil } func expect(conn *textproto.Conn, prefix string) error { resp, err := conn.ReadLine() if err != nil { return err } if !strings.HasPrefix(resp, prefix) { return fmt.Errorf("got %q", resp) } return nil } func write(conn *textproto.Conn, msg string) error { _, err := conn.W.Write([]byte(msg)) if err != nil { return err } return conn.W.Flush() } // isUsernameSafe to use in the dovecot protocol? // Unfortunately 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 } // getAddrs returns the addresses to the userdb and client sockets. func (a *Auth) getAddrs() (string, string, error) { a.addr.mu.Lock() defer a.addr.mu.Unlock() if a.addr.userdb == "" { for _, u := range defaultUserdbPaths { if a.canDial(u) { a.addr.userdb = u break } } if a.addr.userdb == "" { return "", "", errNoUserdbSocket } } if a.addr.client == "" { for _, c := range defaultClientPaths { if a.canDial(c) { a.addr.client = c break } } if a.addr.client == "" { return "", "", errNoClientSocket } } return a.addr.userdb, a.addr.client, nil } func (a *Auth) canDial(path string) bool { conn, err := a.dial("unix", path) if err != nil { return false } conn.Close() return true } chasquid-1.15.0/internal/dovecot/dovecot_test.go000066400000000000000000000077341474251645300217350ustar00rootroot00000000000000package 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) { // Check on a pair that does not exist. a := NewAuth("uDoesNotExist", "cDoesNotExist") err := a.Check() if err != errFailedToConnect { t.Errorf("Expected failure to connect, got %v", err) } // 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 = NewAuth("", "") err = a.Check() if err != errNoUserdbSocket { t.Errorf("Expected failure to find userdb socket, got %v", err) } ok, err := a.Exists("user") if ok != false || err != errNoUserdbSocket { t.Errorf("Expected {false, no userdb socket}, got {%v, %v}", ok, err) } ok, err = a.Authenticate("user", "password") if ok != false || err != errNoUserdbSocket { t.Errorf("Expected {false, no userdb socket}, got {%v, %v}", ok, err) } // 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) // Autodetect finds the user, but fails to find the client. defaultUserdbPaths = []string{"/dev/null", userdb} defaultClientPaths = []string{"/dev/null"} a = NewAuth("", "") err = a.Check() if err != errNoClientSocket { t.Errorf("Expected failure to find userdb socket, got %v", err) } // Autodetect should pick the suggestions passed as parameters (if // possible). defaultUserdbPaths = []string{"/dev/null"} defaultClientPaths = []string{"/dev/null", client} a = NewAuth(userdb, "") err = a.Check() if err != nil { t.Errorf("Expected successful check, got %v", err) } if a.addr.userdb != userdb || a.addr.client != client { t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}", userdb, client, a.addr.userdb, a.addr.client) } // Successful autodetection against open sockets. defaultUserdbPaths = append(defaultUserdbPaths, userdb) defaultClientPaths = append(defaultClientPaths, client) a = NewAuth("", "") err = a.Check() if err != nil { t.Errorf("Expected successful check, got %v", err) } // Close the two sockets, and re-do the check: now we have pinned the // paths, and check should fail to connect. // We need to tell Go to keep the socket files around explicitly, as the // default is to delete them since they were created by the net library. uL.SetUnlinkOnClose(false) uL.Close() err = a.Check() if err != errFailedToConnect { t.Errorf("Expected failed to connect, got %v", err) } cL.SetUnlinkOnClose(false) cL.Close() err = a.Check() if err != errFailedToConnect { t.Errorf("Expected failed to connect, got %v", err) } } func TestReload(t *testing.T) { // Make sure Reload does not fail. a := Auth{} if err := a.Reload(); err != nil { t.Errorf("Reload failed") } } func mustListen(t *testing.T, path string) *net.UnixListener { addr, err := net.ResolveUnixAddr("unix", path) if err != nil { t.Fatalf("failed to resolve unix addr %q: %v", path, err) } l, err := net.ListenUnix("unix", addr) if err != nil { t.Fatalf("failed to listen on %q: %v", path, err) } return l } chasquid-1.15.0/internal/envelope/000077500000000000000000000000001474251645300170435ustar00rootroot00000000000000chasquid-1.15.0/internal/envelope/envelope.go000066400000000000000000000023071474251645300212110ustar00rootroot00000000000000// Package envelope implements functions related to handling email envelopes // (basically tuples of (from, to, data). package envelope import ( "fmt" "strings" "blitiri.com.ar/go/chasquid/internal/set" ) // Split an user@domain address into user and domain. func Split(addr string) (string, string) { ps := strings.SplitN(addr, "@", 2) if len(ps) != 2 { return addr, "" } return ps[0], ps[1] } // UserOf user@domain returns user. func UserOf(addr string) string { user, _ := Split(addr) return user } // DomainOf user@domain returns domain. func DomainOf(addr string) string { _, domain := Split(addr) return domain } // DomainIn checks that the domain of the address is on the given set. func DomainIn(addr string, locals *set.String) bool { domain := DomainOf(addr) if domain == "" { return true } return locals.Has(domain) } // AddHeader adds (prepends) a MIME header to the message. func AddHeader(data []byte, k, v string) []byte { if len(v) > 0 { // If the value contains newlines, indent them properly. if v[len(v)-1] == '\n' { v = v[:len(v)-1] } v = strings.Replace(v, "\n", "\n\t", -1) } header := []byte(fmt.Sprintf("%s: %s\n", k, v)) return append(header, data...) } chasquid-1.15.0/internal/envelope/envelope_test.go000066400000000000000000000031601474251645300222460ustar00rootroot00000000000000package envelope import ( "testing" "blitiri.com.ar/go/chasquid/internal/set" ) func TestSplit(t *testing.T) { cases := []struct { addr, user, domain string }{ {"lalala@lelele", "lalala", "lelele"}, } for _, c := range cases { if user := UserOf(c.addr); user != c.user { t.Errorf("%q: expected user %q, got %q", c.addr, c.user, user) } if domain := DomainOf(c.addr); domain != c.domain { t.Errorf("%q: expected domain %q, got %q", c.addr, c.domain, domain) } } } func TestDomainIn(t *testing.T) { ls := set.NewString("domain1", "domain2") cases := []struct { addr string in bool }{ {"u@domain1", true}, {"u@domain2", true}, {"u@domain3", false}, {"u", true}, } for _, c := range cases { if in := DomainIn(c.addr, ls); in != c.in { t.Errorf("%q: expected %v, got %v", c.addr, c.in, in) } } } func TestAddHeader(t *testing.T) { cases := []struct { data, k, v, expected string }{ {"", "Key", "Value", "Key: Value\n"}, {"data", "Key", "Value", "Key: Value\ndata"}, {"data", "Key", "Value\n", "Key: Value\ndata"}, {"data", "Key", "L1\nL2", "Key: L1\n\tL2\ndata"}, {"data", "Key", "L1\nL2\n", "Key: L1\n\tL2\ndata"}, // Degenerate cases: we don't expect to ever produce these, and the // output is admittedly not nice, but they should at least not cause // chasquid to crash. {"data", "Key", "", "Key: \ndata"}, {"data", "", "", ": \ndata"}, {"", "", "", ": \n"}, } for i, c := range cases { got := string(AddHeader([]byte(c.data), c.k, c.v)) if got != c.expected { t.Errorf("%d (%q -> %q): expected %q, got %q", i, c.k, c.v, c.expected, got) } } } chasquid-1.15.0/internal/expvarom/000077500000000000000000000000001474251645300170675ustar00rootroot00000000000000chasquid-1.15.0/internal/expvarom/expvarom.go000066400000000000000000000133661474251645300212700ustar00rootroot00000000000000// Package expvarom implements an OpenMetrics HTTP exporter for the variables // from the expvar package. // // This is useful for small servers that want to support both packages with // simple enough variables, without introducing any dependencies beyond the // standard library. // // Some functions to add descriptions and map labels are exported for // convenience, but their usage is optional. // // For more complex usage (like histograms, counters vs. gauges, etc.), use // the OpenMetrics libraries directly. // // The exporter uses the text-based format, as documented in: // https://prometheus.io/docs/instrumenting/exposition_formats/#text-based-format // https://github.com/OpenObservability/OpenMetrics/blob/master/specification/OpenMetrics.md // // Note the adoption of that format as OpenMetrics' one isn't finalized yet, // and it is possible that it will change in the future. // // Backwards compatibility is NOT guaranteed, until the format is fully // standardized. package expvarom import ( "expvar" "fmt" "io" "net/http" "sort" "strconv" "strings" "sync" "unicode/utf8" ) type exportedVar struct { Name string Desc string LabelName string I *expvar.Int F *expvar.Float M *expvar.Map } var ( infoMu = sync.Mutex{} descriptions = map[string]string{} mapLabelNames = map[string]string{} ) // MetricsHandler implements an http.HandlerFunc which serves the registered // metrics, using the OpenMetrics text-based format. func MetricsHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/openmetrics-text; version=1.0.0; charset=utf-8") vars := []exportedVar{} ignored := []string{} expvar.Do(func(kv expvar.KeyValue) { evar := exportedVar{ Name: metricNameToOM(kv.Key), } switch value := kv.Value.(type) { case *expvar.Int: evar.I = value case *expvar.Float: evar.F = value case *expvar.Map: evar.M = value default: // Unsupported type, ignore this variable. ignored = append(ignored, evar.Name) return } infoMu.Lock() evar.Desc = descriptions[kv.Key] evar.LabelName = mapLabelNames[kv.Key] infoMu.Unlock() // OM maps need a label name, while expvar ones do not. If we weren't // told what to use, use a generic "key". if evar.LabelName == "" { evar.LabelName = "key" } vars = append(vars, evar) }) // Sort the variables for reproducibility and readability. sort.Slice(vars, func(i, j int) bool { return vars[i].Name < vars[j].Name }) for _, v := range vars { writeVar(w, &v) } fmt.Fprintf(w, "# Generated by expvarom\n") fmt.Fprintf(w, "# EXPERIMENTAL - Format is not fully standard yet\n") fmt.Fprintf(w, "# Ignored variables: %q\n", ignored) fmt.Fprintf(w, "# EOF\n") // Mandated by the standard. } func writeVar(w io.Writer, v *exportedVar) { if v.Desc != "" { fmt.Fprintf(w, "# HELP %s %s\n", v.Name, v.Desc) } if v.I != nil { fmt.Fprintf(w, "%s %d\n\n", v.Name, v.I.Value()) return } if v.F != nil { fmt.Fprintf(w, "%s %g\n\n", v.Name, v.F.Value()) return } if v.M != nil { count := 0 v.M.Do(func(kv expvar.KeyValue) { vs := "" switch value := kv.Value.(type) { case *expvar.Int: vs = strconv.FormatInt(value.Value(), 10) case *expvar.Float: vs = strconv.FormatFloat(value.Value(), 'g', -1, 64) default: // We only support Int and Float in maps. return } labelValue := quoteLabelValue(kv.Key) fmt.Fprintf(w, "%s{%s=%s} %s\n", v.Name, v.LabelName, labelValue, vs) count++ }) if count > 0 { fmt.Fprintf(w, "\n") } } } // metricNameToOM converts an expvar metric name into an OpenMetrics-compliant // metric name. The latter is more restrictive, as it must match the regexp // "[a-zA-Z_:][a-zA-Z0-9_:]*", AND the ':' is not allowed for a direct // exporter. // // https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels func metricNameToOM(name string) string { n := "" for _, c := range name { if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' { n += string(c) } else { n += "_" } } // If it begins with a number, prepend 'i' as a compromise. if len(n) > 0 && n[0] >= '0' && n[0] <= '9' { n = "i" + n } return n } // According to the spec, we only need to replace these 3 characters in label // values. var labelValueReplacer = strings.NewReplacer( `\`, `\\`, `"`, `\"`, "\n", `\n`) // quoteLabelValue takes an arbitrary string, and quotes it so it can be // used as a label value. Output includes the wrapping `"`. func quoteLabelValue(v string) string { // The spec requires label values to be valid UTF8, with `\`, `"` and "\n" // escaped. If it's invalid UTF8, hard-quote it first. This will result // in uglier looking values, but they will be well formed. if !utf8.ValidString(v) { v = strconv.QuoteToASCII(v) v = v[1 : len(v)-1] } return `"` + labelValueReplacer.Replace(v) + `"` } // NewInt registers a new expvar.Int variable, with the given description. func NewInt(name, desc string) *expvar.Int { infoMu.Lock() descriptions[name] = desc infoMu.Unlock() return expvar.NewInt(name) } // NewFloat registers a new expvar.Float variable, with the given description. func NewFloat(name, desc string) *expvar.Float { infoMu.Lock() descriptions[name] = desc infoMu.Unlock() return expvar.NewFloat(name) } // NewMap registers a new expvar.Map variable, with the given label // name and description. func NewMap(name, labelName, desc string) *expvar.Map { // Prevent accidents when using the description as the label name. if strings.Contains(labelName, " ") { panic(fmt.Sprintf( "label name has spaces, mix up with the description? %q", labelName)) } infoMu.Lock() descriptions[name] = desc mapLabelNames[name] = labelName infoMu.Unlock() return expvar.NewMap(name) } chasquid-1.15.0/internal/expvarom/expvarom_test.go000066400000000000000000000053721474251645300223250ustar00rootroot00000000000000package expvarom import ( "expvar" "io" "net/http/httptest" "testing" "github.com/google/go-cmp/cmp" ) var ( testI1 = NewInt("testI1", "int test var") testI2 = expvar.NewInt("testI2") testF = NewFloat("testF", "float test var") testMI = NewMap("testMI", "label", "int map test var") testMF = NewMap("testMF", "label", "float map test var") testMXI = expvar.NewMap("testMXI") testMXF = expvar.NewMap("testMXF") //lint:ignore U1000 Intentionally unused, should not be exported. testMEmpty = expvar.NewMap("testMEmpty") //nolint testMOther = expvar.NewMap("testMOther") testS = expvar.NewString("testS") // Naming test cases. testN1 = expvar.NewInt("name/1z") testN2 = NewInt("name$2", "name with $") testN3 = expvar.NewInt("3name") testN4 = expvar.NewInt("nAme_4Z") testN5 = expvar.NewInt("Ãąame_5") ) const expected string = `_ame_5 5 i3name 3 nAme_4Z 4 name_1z 1 # HELP name_2 name with $ name_2 2 # HELP testF float test var testF 3.43434 # HELP testI1 int test var testI1 1 testI2 2 # HELP testMF float map test var testMF{label="key2.0"} 6.6 testMF{label="key2.1"} 6.61 testMF{label="key2.2-Ãąaca"} 6.62 testMF{label="key2.3-a\\b"} 6.63 testMF{label="key2.4- "} 6.64 testMF{label="key2.5-a\nb"} 6.65 testMF{label="key2.6-a\"b"} 6.66 testMF{label="key2.7-\\u00f1aca-A\\t\\xff\\xfe\\xfdB"} 6.67 # HELP testMI int map test var testMI{label="key1"} 5 testMXF{key="key4"} 8e-08 testMXI{key="key3"} 7 # Generated by expvarom # EXPERIMENTAL - Format is not fully standard yet # Ignored variables: ["cmdline" "memstats" "testS"] # EOF ` func TestHandler(t *testing.T) { testI1.Add(1) testI2.Add(2) testF.Add(3.43434) testMI.Add("key1", 5) // Test some strange keys in this map to check they're escaped properly. testMF.AddFloat("key2.0", 6.60) testMF.AddFloat("key2.1", 6.61) testMF.AddFloat("key2.2-Ãąaca", 6.62) testMF.AddFloat(`key2.3-a\b`, 6.63) testMF.AddFloat("key2.4-\t", 6.64) testMF.AddFloat("key2.5-a\nb", 6.65) testMF.AddFloat(`key2.6-a"b`, 6.66) testMF.AddFloat("key2.7-Ãąaca-A\t\xff\xfe\xfdB", 6.67) // Invalid utf8. testMXI.Add("key3", 7) testMXF.AddFloat("key4", 8e-8) testS.Set("lalala") testN1.Add(1) testN2.Add(2) testN3.Add(3) testN4.Add(4) testN5.Add(5) // Map with an unsupported type. testMOther.Set("keyX", &expvar.String{}) req := httptest.NewRequest("get", "/metrics", nil) w := httptest.NewRecorder() MetricsHandler(w, req) resp := w.Result() body, _ := io.ReadAll(resp.Body) if diff := cmp.Diff(expected, string(body)); diff != "" { t.Errorf("MetricsHandler() mismatch (-want +got):\n%s", diff) } } func TestMapLabelAccident(t *testing.T) { defer func() { if r := recover(); r == nil { t.Errorf("NewMap did not panic as expected") } }() NewMap("name", "label with spaces", "description") } chasquid-1.15.0/internal/haproxy/000077500000000000000000000000001474251645300167205ustar00rootroot00000000000000chasquid-1.15.0/internal/haproxy/haproxy.go000066400000000000000000000037241474251645300207470ustar00rootroot00000000000000// Package haproxy implements the handshake for the HAProxy client protocol // version 1, as described in // https://www.haproxy.org/download/1.8/doc/proxy-protocol.txt. package haproxy import ( "bufio" "errors" "net" "strconv" "strings" ) var ( errInvalidProtoID = errors.New("invalid protocol identifier") errUnkProtocol = errors.New("unknown protocol") errInvalidFields = errors.New("invalid number of fields") errInvalidSrcIP = errors.New("invalid src ip") errInvalidDstIP = errors.New("invalid dst ip") errInvalidSrcPort = errors.New("invalid src port") errInvalidDstPort = errors.New("invalid dst port") ) // Handshake performs the HAProxy protocol v1 handshake on the given reader, // which is expected to be backed by a network connection. // It returns the source and destination addresses, or an error if the // handshake could not complete. // Note that any timeouts or limits must be set by the caller on the // underlying connection, this is helper only to perform the handshake. func Handshake(r *bufio.Reader) (src, dst net.Addr, err error) { line, err := r.ReadString('\n') if err != nil { return nil, nil, err } fields := strings.Fields(line) if len(fields) < 2 || fields[0] != "PROXY" { return nil, nil, errInvalidProtoID } switch fields[1] { case "TCP4", "TCP6": // Allowed to continue, nothing to do. default: return nil, nil, errUnkProtocol } if len(fields) != 6 { return nil, nil, errInvalidFields } srcIP := net.ParseIP(fields[2]) if srcIP == nil { return nil, nil, errInvalidSrcIP } dstIP := net.ParseIP(fields[3]) if dstIP == nil { return nil, nil, errInvalidDstIP } srcPort, err := strconv.ParseUint(fields[4], 10, 16) if err != nil { return nil, nil, errInvalidSrcPort } dstPort, err := strconv.ParseUint(fields[5], 10, 16) if err != nil { return nil, nil, errInvalidDstPort } src = &net.TCPAddr{IP: srcIP, Port: int(srcPort)} dst = &net.TCPAddr{IP: dstIP, Port: int(dstPort)} return src, dst, nil } chasquid-1.15.0/internal/haproxy/haproxy_test.go000066400000000000000000000045521474251645300220060ustar00rootroot00000000000000package haproxy import ( "bufio" "io" "net" "strings" "testing" ) func TestNoNewline(t *testing.T) { r := bufio.NewReader(strings.NewReader("PROXY ")) _, _, err := Handshake(r) if err != io.EOF { t.Errorf("expected EOF, got %v", err) } } func TestBasic(t *testing.T) { var ( src4, _ = net.ResolveTCPAddr("tcp", "1.1.1.1:3333") dst4, _ = net.ResolveTCPAddr("tcp", "2.2.2.2:4444") src6, _ = net.ResolveTCPAddr("tcp", "[5::5]:7777") dst6, _ = net.ResolveTCPAddr("tcp", "[6::6]:8888") ) cases := []struct { str string src, dst net.Addr err error }{ // Early line errors. {"", nil, nil, errInvalidProtoID}, {"lalala", nil, nil, errInvalidProtoID}, {"PROXY", nil, nil, errInvalidProtoID}, {"PROXY lalala", nil, nil, errUnkProtocol}, {"PROXY UNKNOWN", nil, nil, errUnkProtocol}, // Number of field errors. {"PROXY TCP4", nil, nil, errInvalidFields}, {"PROXY TCP4 a", nil, nil, errInvalidFields}, {"PROXY TCP4 a b", nil, nil, errInvalidFields}, {"PROXY TCP4 a b c", nil, nil, errInvalidFields}, // Parsing of ipv4 addresses. {"PROXY TCP4 a b c d", nil, nil, errInvalidSrcIP}, {"PROXY TCP4 1.1.1.1 b c d", nil, nil, errInvalidDstIP}, {"PROXY TCP4 1.1.1.1 2.2.2.2 c d", nil, nil, errInvalidSrcPort}, {"PROXY TCP4 1.1.1.1 2.2.2.2 3333 d", nil, nil, errInvalidDstPort}, {"PROXY TCP4 1.1.1.1 2.2.2.2 3333 4444", src4, dst4, nil}, // Parsing of ipv6 addresses. {"PROXY TCP6 a b c d", nil, nil, errInvalidSrcIP}, {"PROXY TCP6 5::5 b c d", nil, nil, errInvalidDstIP}, {"PROXY TCP6 5::5 6::6 c d", nil, nil, errInvalidSrcPort}, {"PROXY TCP6 5::5 6::6 7777 d", nil, nil, errInvalidDstPort}, {"PROXY TCP6 5::5 6::6 7777 8888", src6, dst6, nil}, } for i, c := range cases { t.Logf("testing %d: %v", i, c.str) src, dst, err := Handshake(newR(c.str)) if !addrEq(src, c.src) { t.Errorf("%d: got src %v, expected %v", i, src, c.src) } if !addrEq(dst, c.dst) { t.Errorf("%d: got dst %v, expected %v", i, dst, c.dst) } if err != c.err { t.Errorf("%d: got error %v, expected %v", i, err, c.err) } } } func newR(s string) *bufio.Reader { return bufio.NewReader(strings.NewReader(s + "\r\n")) } func addrEq(a, b net.Addr) bool { if a == nil || b == nil { return a == nil && b == nil } ta := a.(*net.TCPAddr) tb := b.(*net.TCPAddr) return ta.IP.Equal(tb.IP) && ta.Port == tb.Port } chasquid-1.15.0/internal/localrpc/000077500000000000000000000000001474251645300170255ustar00rootroot00000000000000chasquid-1.15.0/internal/localrpc/client_test.go000066400000000000000000000031711474251645300216730ustar00rootroot00000000000000package localrpc import ( "bufio" "errors" "io/fs" "net" "net/textproto" "os" "path/filepath" "testing" ) func NewFakeServer(t *testing.T, path, output string) { t.Helper() lis, err := net.Listen("unix", path) if err != nil { panic(err) } for { conn, err := lis.Accept() if err != nil { panic(err) } t.Logf("FakeServer %v: accepted ", conn) name, inS, err := readRequest( textproto.NewReader(bufio.NewReader(conn))) t.Logf("FakeServer %v: readRequest: %q %q / %v", conn, name, inS, err) n, err := conn.Write([]byte(output)) t.Logf("FakeServer %v: writeMessage(%q): %d %v", conn, output, n, err) t.Logf("FakeServer %v: closing", conn) conn.Close() } } func TestBadServer(t *testing.T) { tmpDir, err := os.MkdirTemp("", "rpc-test-*") if err != nil { t.Fatal(err) } defer os.RemoveAll(tmpDir) socketPath := filepath.Join(tmpDir, "rpc.sock") // textproto client expects a numeric code, this should cause ReadCodeLine // to fail with textproto.ProtocolError. go NewFakeServer(t, socketPath, "xxx") waitForServer(t, socketPath) client := NewClient(socketPath) _, err = client.Call("Echo") if err == nil { t.Fatal("expected error") } var protoErr textproto.ProtocolError if !errors.As(err, &protoErr) { t.Errorf("wanted textproto.ProtocolError, got: %v (%T)", err, err) } } func TestBadSocket(t *testing.T) { c := NewClient("/does/not/exist") _, err := c.Call("Echo") opErr, ok := err.(*net.OpError) if !ok { t.Fatalf("expected net.OpError, got %q (%T)", err, err) } if !errors.Is(err, fs.ErrNotExist) { t.Errorf("wanted ErrNotExist, got: %q (%T)", opErr.Err, opErr.Err) } } chasquid-1.15.0/internal/localrpc/e2e_test.go000066400000000000000000000055231474251645300210730ustar00rootroot00000000000000package localrpc import ( "errors" "net" "net/url" "os" "testing" "time" "blitiri.com.ar/go/chasquid/internal/trace" "github.com/google/go-cmp/cmp" ) func Echo(tr *trace.Trace, input url.Values) (url.Values, error) { return input, nil } func Hola(tr *trace.Trace, input url.Values) (url.Values, error) { output := url.Values{} output.Set("greeting", "Hola "+input.Get("name")) return output, nil } var testErr = errors.New("test error") func HolaErr(tr *trace.Trace, input url.Values) (url.Values, error) { return nil, testErr } type testServer struct { dir string sock string *Server } func newTestServer(t *testing.T) *testServer { t.Helper() tmpDir, err := os.MkdirTemp("", "rpc-test-*") if err != nil { t.Fatal(err) } tsrv := &testServer{ dir: tmpDir, sock: tmpDir + "/sock", Server: NewServer(), } tsrv.Register("Echo", Echo) tsrv.Register("Hola", Hola) tsrv.Register("HolaErr", HolaErr) go tsrv.ListenAndServe(tsrv.sock) waitForServer(t, tsrv.sock) return tsrv } func (tsrv *testServer) Cleanup() { tsrv.Close() os.RemoveAll(tsrv.dir) } func mkV(args ...string) url.Values { v := url.Values{} for i := 0; i < len(args); i += 2 { v.Set(args[i], args[i+1]) } return v } func TestEndToEnd(t *testing.T) { srv := newTestServer(t) defer srv.Cleanup() // Run the client. client := NewClient(srv.sock) cases := []struct { method string input url.Values output url.Values err error }{ {"Echo", nil, mkV(), nil}, {"Echo", mkV("msg", "hola"), mkV("msg", "hola"), nil}, {"Hola", mkV("name", "marola"), mkV("greeting", "Hola marola"), nil}, {"HolaErr", nil, nil, testErr}, {"UnknownMethod", nil, nil, errUnknownMethod}, } for _, c := range cases { t.Run(c.method, func(t *testing.T) { resp, err := client.CallWithValues(c.method, c.input) if diff := cmp.Diff(c.err, err, transformErrors); diff != "" { t.Errorf("error mismatch (-want +got):\n%s", diff) } if diff := cmp.Diff(c.output, resp); diff != "" { t.Errorf("output mismatch (-want +got):\n%s", diff) } }) } // Check Call too. output, err := client.Call("Hola", "name", "marola") if err != nil { t.Errorf("unexpected error: %v", err) } if diff := cmp.Diff(mkV("greeting", "Hola marola"), output); diff != "" { t.Errorf("output mismatch (-want +got):\n%s", diff) } } func waitForServer(t *testing.T, path string) { t.Helper() for i := 0; i < 100; i++ { time.Sleep(10 * time.Millisecond) conn, err := net.Dial("unix", path) if conn != nil { conn.Close() } if err == nil { return } } t.Fatal("server didn't start") } // Allow us to compare errors with cmp.Diff by their string content (since the // instances/types don't carry across RPC boundaries). var transformErrors = cmp.Transformer( "error", func(err error) string { if err == nil { return "" } return err.Error() }) chasquid-1.15.0/internal/localrpc/localrpc.go000066400000000000000000000100241474251645300211500ustar00rootroot00000000000000// Local RPC package. // // This is a simple RPC package that uses a line-oriented protocol for // encoding and decoding, and Unix sockets for transport. It is meant to be // used for lightweight occasional communication between processes on the // same machine. package localrpc import ( "errors" "net" "net/textproto" "net/url" "os" "strings" "time" "blitiri.com.ar/go/chasquid/internal/trace" ) // Handler is the type of RPC request handlers. type Handler func(tr *trace.Trace, input url.Values) (url.Values, error) // // Server // // Server represents the RPC server. type Server struct { handlers map[string]Handler lis net.Listener } // NewServer creates a new local RPC server. func NewServer() *Server { return &Server{ handlers: make(map[string]Handler), } } var errUnknownMethod = errors.New("unknown method") // Register a handler for the given name. func (s *Server) Register(name string, handler Handler) { s.handlers[name] = handler } // ListenAndServe starts the server. func (s *Server) ListenAndServe(path string) error { tr := trace.New("LocalRPC.Server", path) defer tr.Finish() // Previous instances of the server may have shut down uncleanly, leaving // behind the socket file. Remove it just in case. os.Remove(path) var err error s.lis, err = net.Listen("unix", path) if err != nil { return err } tr.Printf("Listening") for { conn, err := s.lis.Accept() if err != nil { tr.Errorf("Accept error: %v", err) return err } go s.handleConn(tr, conn) } } // Close stops the server. func (s *Server) Close() error { return s.lis.Close() } func (s *Server) handleConn(tr *trace.Trace, conn net.Conn) { tr = tr.NewChild("LocalRPC.Handle", conn.RemoteAddr().String()) defer tr.Finish() // Set a generous deadline to prevent client issues from tying up a server // goroutine indefinitely. conn.SetDeadline(time.Now().Add(5 * time.Second)) tconn := textproto.NewConn(conn) defer tconn.Close() // Read the request. name, inS, err := readRequest(&tconn.Reader) if err != nil { tr.Debugf("error reading request: %v", err) return } tr.Debugf("<- %s %s", name, inS) // Find the handler. handler, ok := s.handlers[name] if !ok { writeError(tr, tconn, errUnknownMethod) return } // Unmarshal the input. inV, err := url.ParseQuery(inS) if err != nil { writeError(tr, tconn, err) return } // Call the handler. outV, err := handler(tr, inV) if err != nil { writeError(tr, tconn, err) return } // Send the response. outS := outV.Encode() tr.Debugf("-> 200 %s", outS) tconn.PrintfLine("200 %s", outS) } func readRequest(r *textproto.Reader) (string, string, error) { line, err := r.ReadLine() if err != nil { return "", "", err } sp := strings.SplitN(line, " ", 2) if len(sp) == 1 { return sp[0], "", nil } return sp[0], sp[1], nil } func writeError(tr *trace.Trace, tconn *textproto.Conn, err error) { tr.Errorf("-> 500 %s", err.Error()) tconn.PrintfLine("500 %s", err.Error()) } // Default server. This is a singleton server that can be used for // convenience. var DefaultServer = NewServer() // // Client // // Client for the localrpc server. type Client struct { path string } // NewClient creates a new client for the given path. func NewClient(path string) *Client { return &Client{path: path} } // CallWithValues calls the given method. func (c *Client) CallWithValues(name string, input url.Values) (url.Values, error) { conn, err := textproto.Dial("unix", c.path) if err != nil { return nil, err } defer conn.Close() err = conn.PrintfLine("%s %s", name, input.Encode()) if err != nil { return nil, err } code, msg, err := conn.ReadCodeLine(0) if err != nil { return nil, err } if code != 200 { return nil, errors.New(msg) } return url.ParseQuery(msg) } // Call the given method. The arguments are key-value strings, and must be // provided in pairs. func (c *Client) Call(name string, args ...string) (url.Values, error) { v := url.Values{} for i := 0; i < len(args); i += 2 { v.Set(args[i], args[i+1]) } return c.CallWithValues(name, v) } chasquid-1.15.0/internal/localrpc/server_test.go000066400000000000000000000033531474251645300217250ustar00rootroot00000000000000package localrpc import ( "bufio" "bytes" "net" "net/textproto" "strings" "testing" "blitiri.com.ar/go/chasquid/internal/trace" ) func TestListenError(t *testing.T) { server := NewServer() err := server.ListenAndServe("/dev/null") if err == nil { t.Errorf("ListenAndServe(/dev/null) = nil, want error") } } // Test that the server can handle a broken client sending a bad request. func TestServerBadRequest(t *testing.T) { server := NewServer() server.Register("Echo", Echo) srvConn, cliConn := net.Pipe() defer srvConn.Close() defer cliConn.Close() // Client sends an invalid request. go cliConn.Write([]byte("Echo this is an ; invalid ; query\n")) // Servers will handle the connection, and should return an error. tr := trace.New("test", "TestBadRequest") defer tr.Finish() go server.handleConn(tr, srvConn) // Read the error that the server should have sent. code, msg, err := textproto.NewConn(cliConn).ReadResponse(0) if err != nil { t.Errorf("ReadResponse error: %q", err) } if code != 500 { t.Errorf("ReadResponse code %d, expected 500", code) } if !strings.Contains(msg, "invalid semicolon separator") { t.Errorf("ReadResponse message %q, does not contain 'invalid semicolon separator'", msg) } } func TestShortReadRequest(t *testing.T) { // This request is too short, it does not have any arguments. // This does not happen with the real client, but just in case. buf := bufio.NewReader(bytes.NewReader([]byte("Method\n"))) method, args, err := readRequest(textproto.NewReader(buf)) if err != nil { t.Errorf("readRequest error: %v", err) } if method != "Method" { t.Errorf("readRequest method %q, expected 'Method'", method) } if args != "" { t.Errorf("readRequest args %q, expected ''", args) } } chasquid-1.15.0/internal/maillog/000077500000000000000000000000001474251645300166525ustar00rootroot00000000000000chasquid-1.15.0/internal/maillog/maillog.go000066400000000000000000000111751474251645300206320ustar00rootroot00000000000000// Package maillog implements a log specifically for email. package maillog import ( "fmt" "io" "log/syslog" "net" "sync" "time" "blitiri.com.ar/go/chasquid/internal/trace" "blitiri.com.ar/go/log" ) // Global event logs. var ( authLog = trace.New("Authentication", "Incoming SMTP") ) // Logger contains a backend used to log data to, such as a file or syslog. // It implements various user-friendly methods for logging mail information to // it. type Logger struct { inner *log.Logger once sync.Once } // New creates a new Logger which will write messages to the given writer. func New(w io.WriteCloser) *Logger { inner := log.New(w) // Don't include level or caller in the output, it doesn't add value for // this type of log. inner.LogLevel = false inner.LogCaller = false return &Logger{inner: inner} } // NewFile creates a new Logger which will write messages to the file at the // given path. func NewFile(path string) (*Logger, error) { inner, err := log.NewFile(path) if err != nil { return nil, err } // Don't include level or caller in the output, it doesn't add value for // this type of log. inner.LogLevel = false inner.LogCaller = false return &Logger{inner: inner}, nil } // NewSyslog creates a new Logger which will write messages to syslog. func NewSyslog() (*Logger, error) { inner, err := log.NewSyslog(syslog.LOG_INFO|syslog.LOG_MAIL, "chasquid") if err != nil { return nil, err } // Like NewFile, we skip level and caller. In addition, we skip time, as // syslog usually adds that on its own. inner.LogLevel = false inner.LogCaller = false inner.LogTime = false return &Logger{inner: inner}, nil } func (l *Logger) printf(format string, args ...interface{}) { err := l.inner.Log(log.Info, 2, 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)") }) } } // Reopen the underlying logger. func (l *Logger) Reopen() error { return l.inner.Reopen() } // Listening logs that the daemon is listening on the given address. func (l *Logger) Listening(a string) { l.printf("daemon listening on %s\n", a) } // Auth logs an authentication request. func (l *Logger) Auth(netAddr net.Addr, user string, successful bool) { res := "succeeded" if !successful { res = "failed" } msg := fmt.Sprintf("%s auth %s for %s\n", netAddr, res, user) l.printf(msg) authLog.Debugf(msg) } // Rejected logs that we've rejected an email. func (l *Logger) Rejected(netAddr net.Addr, from string, to []string, err string) { if from != "" { from = fmt.Sprintf(" from=%s", from) } toStr := "" if len(to) > 0 { toStr = fmt.Sprintf(" to=%v", to) } l.printf("%s rejected%s%s - %v\n", netAddr, from, toStr, err) } // Queued logs that we have queued an email. func (l *Logger) Queued(netAddr net.Addr, from string, to []string, id string) { l.printf("%s from=%s queued ip=%s to=%v\n", id, from, netAddr, to) } // SendAttempt logs that we have attempted to send an email. func (l *Logger) SendAttempt(id, from, to string, err error, permanent bool) { if err == nil { l.printf("%s from=%s to=%s sent\n", id, from, to) } else { t := "(temporary)" if permanent { t = "(permanent)" } l.printf("%s from=%s to=%s failed %s: %v\n", id, from, to, t, err) } } // QueueLoop logs that we have completed a queue loop. func (l *Logger) QueueLoop(id, from string, nextDelay time.Duration) { if nextDelay > 0 { l.printf("%s from=%s completed loop, next in %v\n", id, from, nextDelay) } else { l.printf("%s from=%s all done\n", id, from) } } type nopCloser struct { io.Writer } func (nopCloser) Close() error { return nil } // Default logger, used in the following top-level functions. var Default *Logger = New(nopCloser{io.Discard}) // Listening logs that the daemon is listening on the given address. func Listening(a string) { Default.Listening(a) } // Auth logs an authentication request. func Auth(netAddr net.Addr, user string, successful bool) { Default.Auth(netAddr, user, successful) } // Rejected logs that we've rejected an email. func Rejected(netAddr net.Addr, from string, to []string, err string) { Default.Rejected(netAddr, from, to, err) } // Queued logs that we have queued an email. func Queued(netAddr net.Addr, from string, to []string, id string) { Default.Queued(netAddr, from, to, id) } // SendAttempt logs that we have attempted to send an email. func SendAttempt(id, from, to string, err error, permanent bool) { Default.SendAttempt(id, from, to, err, permanent) } // QueueLoop logs that we have completed a queue loop. func QueueLoop(id, from string, nextDelay time.Duration) { Default.QueueLoop(id, from, nextDelay) } chasquid-1.15.0/internal/maillog/maillog_test.go000066400000000000000000000102411474251645300216620ustar00rootroot00000000000000package maillog import ( "bytes" "fmt" "io" "net" "regexp" "strings" "testing" "time" "blitiri.com.ar/go/log" ) var netAddr = &net.TCPAddr{ IP: net.ParseIP("1.2.3.4"), Port: 4321, } func expect(t *testing.T, buf *bytes.Buffer, s string) { t.Helper() re := regexp.MustCompile(`^....-..-.. ..:..:..\.\d{6} ` + s + "\n") if !re.Match(buf.Bytes()) { t.Errorf("mismatch:\n regexp: %q\n string: %q", re, buf.String()) } } func TestLogger(t *testing.T) { buf := &bytes.Buffer{} l := New(nopCloser{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(nopCloser{buf}) Listening("1.2.3.4:4321") expect(t, buf, "daemon listening on 1.2.3.4:4321") buf.Reset() Auth(netAddr, "user@domain", false) expect(t, buf, "1.2.3.4:4321 auth failed for user@domain") buf.Reset() Auth(netAddr, "user@domain", true) expect(t, buf, "1.2.3.4:4321 auth succeeded for user@domain") buf.Reset() Rejected(netAddr, "from", []string{"to1", "to2"}, "error") expect(t, buf, `1.2.3.4:4321 rejected from=from to=\[to1 to2\] - error`) buf.Reset() Queued(netAddr, "from", []string{"to1", "to2"}, "qid") expect(t, buf, `qid from=from queued ip=1.2.3.4:4321 to=\[to1 to2\]`) buf.Reset() SendAttempt("qid", "from", "to", nil, false) expect(t, buf, "qid from=from to=to sent") buf.Reset() SendAttempt("qid", "from", "to", fmt.Errorf("error"), false) expect(t, buf, `qid from=from to=to failed \(temporary\): error`) buf.Reset() SendAttempt("qid", "from", "to", fmt.Errorf("error"), true) expect(t, buf, `qid from=from to=to failed \(permanent\): error`) buf.Reset() QueueLoop("qid", "from", 17*time.Second) expect(t, buf, "qid from=from completed loop, next in 17s") buf.Reset() QueueLoop("qid", "from", 0) expect(t, buf, "qid from=from all done") buf.Reset() } // io.Writer that fails all write operations, for testing. type failedWriter struct{} func (w *failedWriter) Write(p []byte) (int, error) { return 0, fmt.Errorf("test error") } func (w *failedWriter) Close() error { return nil } // Test that we complain (only once) when we can't log. func TestFailedLogger(t *testing.T) { // Set up a test logger, that will write to a buffer for us to check. buf := &bytes.Buffer{} log.Default = log.New(nopCloser{io.Writer(buf)}) // Set up a maillog that will use a writer which always fail, to trigger // the condition. failedw := &failedWriter{} l := New(failedw) // Log something, which should fail. Then verify that the error message // appears in the log. l.printf("123 testing") s := buf.String() if !strings.Contains(s, "failed to write to maillog: test error") { t.Errorf("log did not contain expected message. Log: %#v", s) } // Further attempts should not generate any other errors. buf.Reset() l.printf("123 testing") s = buf.String() if s != "" { t.Errorf("expected second attempt to not log, but log had: %#v", s) } } chasquid-1.15.0/internal/nettrace/000077500000000000000000000000001474251645300170335ustar00rootroot00000000000000chasquid-1.15.0/internal/nettrace/bench_test.go000066400000000000000000000020501474251645300214750ustar00rootroot00000000000000package nettrace import ( "testing" ) // Our benchmark loop is similar to the one from golang.org/x/net/trace, so we // can compare results. func runBench(b *testing.B, events int) { nTraces := (b.N + events + 1) / events for i := 0; i < nTraces; i++ { tr := New("bench", "test") for j := 0; j < events; j++ { tr.Printf("%d", j) } tr.Finish() } } func BenchmarkTrace_2(b *testing.B) { runBench(b, 2) } func BenchmarkTrace_10(b *testing.B) { runBench(b, 10) } func BenchmarkTrace_100(b *testing.B) { runBench(b, 100) } func BenchmarkTrace_1000(b *testing.B) { runBench(b, 1000) } func BenchmarkTrace_10000(b *testing.B) { runBench(b, 10000) } func BenchmarkNewAndFinish(b *testing.B) { for i := 0; i < b.N; i++ { tr := New("bench", "test") tr.Finish() } } func BenchmarkPrintf(b *testing.B) { tr := New("bench", "test") defer tr.Finish() b.ResetTimer() for i := 0; i < b.N; i++ { // Keep this without any formatting, so we measure our code instead of // the performance of fmt.Sprintf. tr.Printf("this is printf") } } chasquid-1.15.0/internal/nettrace/context.go000066400000000000000000000020671474251645300210530ustar00rootroot00000000000000package nettrace import "context" type ctxKeyT string const ctxKey ctxKeyT = "blitiri.com.ar/go/srv/nettrace" // NewContext returns a new context with the given trace attached. func NewContext(ctx context.Context, tr Trace) context.Context { return context.WithValue(ctx, ctxKey, tr) } // FromContext returns the trace attached to the given context (if any). func FromContext(ctx context.Context) (Trace, bool) { tr, ok := ctx.Value(ctxKey).(Trace) return tr, ok } // FromContextOrNew returns the trace attached to the given context, or a new // trace if there is none. func FromContextOrNew(ctx context.Context, family, title string) (Trace, context.Context) { tr, ok := FromContext(ctx) if ok { return tr, ctx } tr = New(family, title) return tr, NewContext(ctx, tr) } // ChildFromContext returns a new trace that is a child of the one attached to // the context (if any). func ChildFromContext(ctx context.Context, family, title string) Trace { parent, ok := FromContext(ctx) if ok { return parent.NewChild(family, title) } return New(family, title) } chasquid-1.15.0/internal/nettrace/context_test.go000066400000000000000000000027651474251645300221170ustar00rootroot00000000000000package nettrace import ( "context" "testing" ) func TestContext(t *testing.T) { tr := New("TestContext", "trace") defer tr.Finish() // Attach the trace to a new context. ctx := NewContext(context.Background(), tr) // Get the trace back from the context. { tr2, ok := FromContext(ctx) if !ok { t.Errorf("Context with trace returned not found") } if tr != tr2 { t.Errorf("Trace from context is different: %v != %v", tr, tr2) } } // Create a child trace from the context. { tr3 := ChildFromContext(ctx, "TestContext", "child") if p := tr3.(*trace).Parent; p != tr { t.Errorf("Child doesn't have the right parent: %v != %v", p, tr) } tr3.Finish() } // FromContextOrNew returns the one from the context. { tr4, ctx4 := FromContextOrNew(ctx, "TestContext", "from-ctx") if ctx4 != ctx { t.Errorf("Got new context: %v != %v", ctx4, ctx) } if tr4 != tr { t.Errorf("Context with trace returned new trace: %v != %v", tr4, tr) } } // FromContextOrNew needs to create a new one. { tr5, ctx5 := FromContextOrNew( context.Background(), "TestContext", "tr5") if tr, _ := FromContext(ctx5); tr != tr5 { t.Errorf("Context with trace returned the wrong trace: %v != %v", tr, tr5) } tr5.Finish() } // Child from a context that has no trace attached. { tr6 := ChildFromContext( context.Background(), "TestContext", "child") tr6.Finish() if p := tr6.(*trace).Parent; p != nil { t.Errorf("Expected orphan trace, it has a parent: %v", p) } } } chasquid-1.15.0/internal/nettrace/evtring.go000066400000000000000000000013351474251645300210420ustar00rootroot00000000000000package nettrace import "time" type evtRing struct { evts []event max int pos int // Points to the latest element. firstDrop time.Time } func newEvtRing(n int) *evtRing { return &evtRing{ max: n, pos: -1, } } func (r *evtRing) Add(e *event) { if len(r.evts) < r.max { r.evts = append(r.evts, *e) r.pos++ return } r.pos = (r.pos + 1) % r.max // Record the first drop as the time of the first dropped message. if r.firstDrop.IsZero() { r.firstDrop = r.evts[r.pos].When } r.evts[r.pos] = *e } func (r *evtRing) Do(f func(e *event)) { for i := 0; i < len(r.evts); i++ { // Go from older to newer by starting at (r.pos+1). pos := (r.pos + 1 + i) % len(r.evts) f(&r.evts[pos]) } } chasquid-1.15.0/internal/nettrace/histogram.go000066400000000000000000000023011474251645300213530ustar00rootroot00000000000000package nettrace import ( "time" ) type histogram struct { count [nBuckets]uint64 totalQ uint64 totalT time.Duration min time.Duration max time.Duration } func (h *histogram) Add(bucket int, latency time.Duration) { if h.totalQ == 0 || h.min > latency { h.min = latency } if h.max < latency { h.max = latency } h.count[bucket]++ h.totalQ++ h.totalT += latency } type histSnapshot struct { Counts map[time.Duration]line Count uint64 Avg, Min, Max time.Duration } type line struct { Start time.Duration BucketIdx int Count uint64 Percent float32 CumPct float32 } func (h *histogram) Snapshot() *histSnapshot { s := &histSnapshot{ Counts: map[time.Duration]line{}, Count: h.totalQ, Min: h.min, Max: h.max, } if h.totalQ > 0 { s.Avg = time.Duration(uint64(h.totalT) / h.totalQ) } var cumCount uint64 for i := 0; i < nBuckets; i++ { cumCount += h.count[i] l := line{ Start: buckets[i], BucketIdx: i, Count: h.count[i], } if h.totalQ > 0 { l.Percent = float32(h.count[i]) / float32(h.totalQ) * 100 l.CumPct = float32(cumCount) / float32(h.totalQ) * 100 } s.Counts[buckets[i]] = l } return s } chasquid-1.15.0/internal/nettrace/histogram_test.go000066400000000000000000000011351474251645300224160ustar00rootroot00000000000000package nettrace import ( "testing" "time" ) func TestHistogramBasic(t *testing.T) { h := histogram{} h.Add(1, 1*time.Millisecond) snap := h.Snapshot() if snap.Count != 1 || snap.Min != 1*time.Millisecond || snap.Max != 1*time.Millisecond || snap.Avg != 1*time.Millisecond { t.Errorf("expected snapshot with only 1 sample, got %v", snap) } } func TestHistogramEmpty(t *testing.T) { h := histogram{} snap := h.Snapshot() if len(snap.Counts) != nBuckets || snap.Count != 0 || snap.Avg != 0 || snap.Min != 0 || snap.Max != 0 { t.Errorf("expected zero snapshot, got %v", snap) } } chasquid-1.15.0/internal/nettrace/http.go000066400000000000000000000140731474251645300203460ustar00rootroot00000000000000package nettrace import ( "bytes" "embed" "fmt" "hash/crc32" "html/template" "math" "net/http" "sort" "strconv" "time" ) //go:embed "templates/*.tmpl" "templates/*.css" var templatesFS embed.FS var top *template.Template func init() { top = template.Must( template.New("_top").Funcs(template.FuncMap{ "stripZeros": stripZeros, "roundSeconds": roundSeconds, "roundDuration": roundDuration, "colorize": colorize, "depthspan": depthspan, "shorttitle": shorttitle, "traceemoji": traceemoji, }).ParseFS(templatesFS, "templates/*")) } // RegisterHandler registers a the trace handler in the given ServeMux, on // `/debug/traces`. func RegisterHandler(mux *http.ServeMux) { mux.HandleFunc("/debug/traces", RenderTraces) } // RenderTraces is an http.Handler that renders the tracing information. func RenderTraces(w http.ResponseWriter, req *http.Request) { data := &struct { Buckets *[]time.Duration FamTraces map[string]*familyTraces // When displaying traces for a specific family. Family string Bucket int BucketStr string AllGT bool Traces []*trace // When displaying latencies for a specific family. Latencies *histSnapshot // When displaying a specific trace. Trace *trace AllEvents []traceAndEvent // Error to show to the user. Error string }{} // Reference the common buckets, no need to copy them. data.Buckets = &buckets // Copy the family traces map, so we don't have to keep it locked for too // long. We'll still need to lock individual entries. data.FamTraces = copyFamilies() // Default to showing greater-than. data.AllGT = true if all := req.FormValue("all"); all != "" { data.AllGT, _ = strconv.ParseBool(all) } // Fill in the family related parameters. if fam := req.FormValue("fam"); fam != "" { if _, ok := data.FamTraces[fam]; !ok { data.Family = "" data.Error = "Unknown family" w.WriteHeader(http.StatusNotFound) goto render } data.Family = fam if bs := req.FormValue("b"); bs != "" { i, err := strconv.Atoi(bs) if err != nil { data.Error = "Invalid bucket (not a number)" w.WriteHeader(http.StatusBadRequest) goto render } else if i < -2 || i >= nBuckets { data.Error = "Invalid bucket number" w.WriteHeader(http.StatusBadRequest) goto render } data.Bucket = i data.Traces = data.FamTraces[data.Family].TracesFor(i, data.AllGT) switch i { case -2: data.BucketStr = "errors" case -1: data.BucketStr = "active" default: data.BucketStr = buckets[i].String() } } } if lat := req.FormValue("lat"); data.Family != "" && lat != "" { data.Latencies = data.FamTraces[data.Family].Latencies() } if traceID := req.FormValue("trace"); traceID != "" { refID := req.FormValue("ref") tr := findInFamilies(id(traceID), id(refID)) if tr == nil { data.Error = "Trace not found" w.WriteHeader(http.StatusNotFound) goto render } data.Trace = tr data.Family = tr.Family data.AllEvents = allEvents(tr) } render: // Write into a buffer, to avoid accidentally holding a lock on http // writes. It shouldn't happen, but just to be extra safe. bw := &bytes.Buffer{} bw.Grow(16 * 1024) err := top.ExecuteTemplate(bw, "index.html.tmpl", data) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) panic(err) } w.Write(bw.Bytes()) } type traceAndEvent struct { Trace *trace Event event Depth uint } // allEvents gets all the events for the trace and its children/linked traces; // and returns them sorted by timestamp. func allEvents(tr *trace) []traceAndEvent { // Map tracking all traces we've seen, to avoid loops. seen := map[id]bool{} // Recursively gather all events. evts := appendAllEvents(tr, []traceAndEvent{}, seen, 0) // Sort them by time. sort.Slice(evts, func(i, j int) bool { return evts[i].Event.When.Before(evts[j].Event.When) }) return evts } func appendAllEvents(tr *trace, evts []traceAndEvent, seen map[id]bool, depth uint) []traceAndEvent { if seen[tr.ID] { return evts } seen[tr.ID] = true subTraces := []*trace{} // Append all events of this trace. trevts := tr.Events() for _, e := range trevts { evts = append(evts, traceAndEvent{tr, e, depth}) if e.Ref != nil { subTraces = append(subTraces, e.Ref) } } for _, t := range subTraces { evts = appendAllEvents(t, evts, seen, depth+1) } return evts } func stripZeros(d time.Duration) string { if d < time.Second { _, frac := math.Modf(d.Seconds()) return fmt.Sprintf(" .%6d", int(frac*1000000)) } return fmt.Sprintf("%.6f", d.Seconds()) } func roundSeconds(d time.Duration) string { return fmt.Sprintf("%.6f", d.Seconds()) } func roundDuration(d time.Duration) time.Duration { return d.Round(time.Millisecond) } func colorize(depth uint, id id) template.CSS { if depth == 0 { return template.CSS("rgba(var(--text-color))") } if depth > 3 { depth = 3 } // Must match the number of nested color variables in the CSS. colori := crc32.ChecksumIEEE([]byte(id)) % 6 return template.CSS( fmt.Sprintf("var(--nested-d%02d-c%02d)", depth, colori)) } func depthspan(depth uint) template.HTML { s := `` switch depth { case 0: case 1: s += "¡ " case 2: s += "¡ ¡ " case 3: s += "¡ ¡ ¡ " case 4: s += "¡ ¡ ¡ ¡ " default: s += fmt.Sprintf("¡ (%d) ¡ ", depth) } s += `` return template.HTML(s) } // Hand-picked emojis that have enough visual differences in most common // renderings, and are common enough to be able to easily describe them. var emojids = []rune(`đŸ˜€đŸ¤ŖđŸ˜‡đŸĨ°đŸ¤§đŸ˜ˆđŸ¤ĄđŸ‘ģđŸ‘Ŋ🤖👋✊đŸĻ´đŸ‘…` + `🐒🐕đŸĻŠđŸąđŸ¯đŸŽđŸ„đŸˇđŸ‘đŸđŸĒđŸĻ’đŸ˜đŸ€đŸĻ‡đŸ“đŸĻ†đŸĻšđŸĻœđŸĸ🐍đŸĻ–đŸ‹đŸŸđŸĻˆđŸ™` + `đŸĻ‹đŸœđŸđŸĒ˛đŸŒģ🌲🍉🍌🍍🍎🍑đŸĨ•đŸ„` + `🧀đŸĻ🍰🧉🚂🚗🚜đŸ›ĩ🚲đŸ›ŧđŸĒ‚đŸš€đŸŒžđŸŒˆđŸŒŠâšŊ`) func shorttitle(tr *trace) string { all := tr.Family + " - " + tr.Title if len(all) > 20 { all = "..." + all[len(all)-17:] } return all } func traceemoji(id id) string { i := crc32.ChecksumIEEE([]byte(id)) % uint32(len(emojids)) return string(emojids[i]) } chasquid-1.15.0/internal/nettrace/http_test.go000066400000000000000000000135371474251645300214110ustar00rootroot00000000000000package nettrace import ( "fmt" "io" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) func getValues(t *testing.T, vs url.Values, code int) string { t.Helper() req := httptest.NewRequest("GET", "/debug/traces?"+vs.Encode(), nil) w := httptest.NewRecorder() RenderTraces(w, req) resp := w.Result() body, _ := io.ReadAll(resp.Body) if resp.StatusCode != code { t.Errorf("expected %d, got %v", code, resp) } return string(body) } type v struct { fam, b, lat, trace, ref, all string } func getCode(t *testing.T, vs v, code int) string { t.Helper() u := url.Values{} if vs.fam != "" { u.Add("fam", vs.fam) } if vs.b != "" { u.Add("b", vs.b) } if vs.lat != "" { u.Add("lat", vs.lat) } if vs.trace != "" { u.Add("trace", vs.trace) } if vs.ref != "" { u.Add("ref", vs.ref) } if vs.all != "" { u.Add("all", vs.all) } return getValues(t, u, code) } func get(t *testing.T, fam, b, lat, trace, ref, all string) string { t.Helper() return getCode(t, v{fam, b, lat, trace, ref, all}, 200) } func getErr(t *testing.T, fam, b, lat, trace, ref, all string, code int, err string) string { t.Helper() body := getCode(t, v{fam, b, lat, trace, ref, all}, code) if !strings.Contains(body, err) { t.Errorf("Body does not contain error message %q", err) t.Logf("Body: %v", body) } return body } func checkContains(t *testing.T, body, s string) { t.Helper() if !strings.Contains(body, s) { t.Errorf("Body does not contain %q", s) t.Logf("Body: %v", body) } } func TestHTTP(t *testing.T) { tr := New("TestHTTP", "http") tr.Printf("entry #1") tr.Finish() tr = New("TestHTTP", "http") tr.Printf("entry #2") tr.Finish() tr = New("TestHTTP", "http") tr.Errorf("entry #3 (error)") tr.Finish() tr = New("TestHTTP", "http") tr.Printf("hola marola") tr.Printf("entry #4") // This one is active until the end. defer tr.Finish() // Get the plain index. body := get(t, "", "", "", "", "", "") checkContains(t, body, "TestHTTP") // Get a specific family, but no bucket. body = get(t, "TestHTTP", "", "", "", "", "") checkContains(t, body, "TestHTTP") // Get a family and active bucket. body = get(t, "TestHTTP", "-1", "", "", "", "") checkContains(t, body, "hola marola") // Get a family and error bucket. body = get(t, "TestHTTP", "-2", "", "", "", "") checkContains(t, body, "entry #3 (error)") // Get a family and first bucket. body = get(t, "TestHTTP", "0", "", "", "", "") checkContains(t, body, "entry #2") // Latency view. There are 3 events because the 4th is active. body = get(t, "TestHTTP", "", "lat", "", "", "") checkContains(t, body, "Count: 3") // Get a specific trace. No family given, since it shouldn't be needed (we // take it from the id). body = get(t, "", "", "", string(tr.(*trace).ID), "", "") checkContains(t, body, "hola marola") // Check the "all=true" views. body = get(t, "TestHTTP", "0", "", "", "", "true") checkContains(t, body, "entry #2") checkContains(t, body, "?fam=TestHTTP&b=-2&all=true") tr.Finish() } func TestHTTPLong(t *testing.T) { // Test a long trace. tr := New("TestHTTPLong", "verbose") for i := 0; i < 1000; i++ { tr.Printf("entry #%d", i) } tr.Finish() get(t, "TestHTTPLong", "", "", string(tr.(*trace).ID), "", "") } func TestHTTPErrors(t *testing.T) { tr := New("TestHTTPErrors", "http") tr.Printf("entry #1") tr.Finish() // Unknown family. getErr(t, "unkfamily", "", "", "", "", "", 404, "Unknown family") // Invalid bucket. getErr(t, "TestHTTPErrors", "abc", "", "", "", "", 400, "Invalid bucket") getErr(t, "TestHTTPErrors", "-3", "", "", "", "", 400, "Invalid bucket") getErr(t, "TestHTTPErrors", "9", "", "", "", "", 400, "Invalid bucket") // Unknown trace id (malformed). getErr(t, "TestHTTPErrors", "", "", "unktrace", "", "", 404, "Trace not found") // Unknown trace id. getErr(t, "TestHTTPErrors", "", "", string(tr.(*trace).ID)+"xxx", "", "", 404, "Trace not found") // Check that the trace is actually there. get(t, "", "", "", string(tr.(*trace).ID), "", "") } func TestHTTPUroboro(t *testing.T) { trA := New("TestHTTPUroboro", "trA") defer trA.Finish() trA.Printf("this is trace A") trB := New("TestHTTPUroboro", "trB") defer trB.Finish() trB.Printf("this is trace B") trA.Link(trB, "B is my friend") trB.Link(trA, "A is my friend") // Check that we handle cross-linked events well. get(t, "TestHTTPUroboro", "", "", "", "", "") get(t, "TestHTTPUroboro", "-1", "", "", "", "") get(t, "", "", "", string(trA.(*trace).ID), "", "") get(t, "", "", "", string(trB.(*trace).ID), "", "") } func TestHTTPDeep(t *testing.T) { tr := New("TestHTTPDeep", "level-0") defer tr.Finish() ts := []Trace{tr} for i := 1; i <= 9; i++ { tr = tr.NewChild("TestHTTPDeep", fmt.Sprintf("level-%d", i)) defer tr.Finish() ts = append(ts, tr) } // Active view. body := get(t, "TestHTTPDeep", "-1", "", "", "", "") checkContains(t, body, "level-9") // Recursive view. body = get(t, "TestHTTPDeep", "", "", string(ts[0].(*trace).ID), "", "") checkContains(t, body, "level-9") } func TestStripZeros(t *testing.T) { cases := []struct { d time.Duration exp string }{ {0 * time.Second, " . 0"}, {1 * time.Millisecond, " . 1000"}, {5 * time.Millisecond, " . 5000"}, {1 * time.Second, "1.000000"}, {1*time.Second + 8*time.Millisecond, "1.008000"}, } for _, c := range cases { if got := stripZeros(c.d); got != c.exp { t.Errorf("stripZeros(%s) got %q, expected %q", c.d, got, c.exp) } } } func TestRegisterHandler(t *testing.T) { mux := http.NewServeMux() RegisterHandler(mux) req := httptest.NewRequest("GET", "/debug/traces", nil) w := httptest.NewRecorder() mux.ServeHTTP(w, req) resp := w.Result() if resp.StatusCode != 200 { t.Errorf("expected 200, got %v", resp) } body, _ := io.ReadAll(resp.Body) if !strings.Contains(string(body), "

Traces

") { t.Errorf("unexpected body: %s", body) } } chasquid-1.15.0/internal/nettrace/templates/000077500000000000000000000000001474251645300210315ustar00rootroot00000000000000chasquid-1.15.0/internal/nettrace/templates/_latency.html.tmpl000066400000000000000000000013241474251645300244700ustar00rootroot00000000000000
Count: {{.Latencies.Count}} Avg: {{.Latencies.Avg | roundDuration}} Min: {{.Latencies.Min | roundDuration}} Max: {{.Latencies.Max | roundDuration}}

{{range .Latencies.Counts}} {{end}}
BucketCount%Cumulative
≥{{.Start}} {{.Count}} {{.Percent | printf "%5.2f"}}% {{.Percent | printf "%.2f"}}% {{.CumPct | printf "%5.2f"}}%
chasquid-1.15.0/internal/nettrace/templates/_recursive.html.tmpl000066400000000000000000000026101474251645300250370ustar00rootroot00000000000000{{if .Trace.Parent}} Parent: {{.Trace.Parent.Family}} - {{.Trace.Parent.Title}}

{{end}} {{$prev := .Trace.Start}} {{range .AllEvents}} {{$prev = .Event.When}} {{end}}
Trace Timestamp Elapsed (s) Message
{{shorttitle .Trace}} {{.Event.When.Format "15:04:05.000000"}} {{(.Event.When.Sub $prev) | stripZeros}}
{{traceemoji .Trace.ID}}
{{- depthspan .Depth -}} {{- if .Event.Type.IsLog -}} {{.Event.Msg}} {{- else if .Event.Type.IsChild -}} new child: {{.Event.Ref.Family}} - {{.Event.Ref.Title}} {{- else if .Event.Type.IsLink -}} {{.Event.Msg}} {{- else if .Event.Type.IsDrop -}} [ events dropped ] {{- else -}} [ unknown event type ] {{- end -}}
chasquid-1.15.0/internal/nettrace/templates/_single.html.tmpl000066400000000000000000000016621474251645300243170ustar00rootroot00000000000000 {{.Start.Format "2006-01-02 15:04:05.000000"}} {{.Duration | roundSeconds}} {{.Title}} {{if .Parent}}(parent: {{.Parent.Family}} - {{.Parent.Title}}) {{end}} {{$prev := .Start}} {{range .Events}} {{.When.Format "15:04:05.000000"}} {{(.When.Sub $prev) | stripZeros}} {{- if .Type.IsLog -}} {{.Msg}} {{- else if .Type.IsChild -}} new child {{.Ref.Family}} {{.Ref.Title}} {{- else if .Type.IsLink -}} {{.Msg}} {{- else if .Type.IsDrop -}} [ events dropped ] {{- else -}} [ unknown event type ] {{- end -}} {{$prev = .When}} {{end}}   chasquid-1.15.0/internal/nettrace/templates/index.html.tmpl000066400000000000000000000052761474251645300240130ustar00rootroot00000000000000 {{if .Trace}}{{.Trace.Family}} - {{.Trace.Title}} {{else if .BucketStr}}{{.Family}} - {{.BucketStr}} {{else if .Latencies}}{{.Family}} - latency {{else}}Traces {{end}}

Traces

{{range $name, $ftr := .FamTraces}} {{range $i, $b := $.Buckets}} {{end}} {{end}}
{{if eq $name $.Family}}{{end}} {{$name}} {{if eq $name $.Family}}{{end}} {{$n := $ftr.LenActive}} {{if and (eq $name $.Family) (eq "active" $.BucketStr)}}{{end}} {{$n}} active {{if and (eq $name $.Family) (eq "active" $.BucketStr)}}{{end}} {{$n := $ftr.LenBucket $i}} {{if and (eq $name $.Family) (eq $b.String $.BucketStr)}}{{end}} ≥{{$b}} {{if and (eq $name $.Family) (eq $b.String $.BucketStr)}}{{end}} {{$n := $ftr.LenErrors}} {{if and (eq $name $.Family) (eq "errors" $.BucketStr)}}{{end}} errors {{if and (eq $name $.Family) (eq "errors" $.BucketStr)}}{{end}} [latency]

Show: {{if not .AllGT}}{{end}} Only in bucket {{if not .AllGT}}{{end}} / {{if .AllGT}}{{end}} All ≥ bucket {{if .AllGT}}{{end}}

{{if .Error}}

Error: {{.Error}}

{{end}} {{if .BucketStr}}

{{.Family}} - {{.BucketStr}}

{{range .Traces}} {{template "_single.html.tmpl" .}}

{{end}}

Timestamp Elapsed (s) Message
 

{{end}} {{if .Latencies}}

{{.Family}} - latency

{{template "_latency.html.tmpl" .}}

{{end}} {{if .Trace}}

{{.Trace.Family}} - {{.Trace.Title}}

{{template "_recursive.html.tmpl" .}}

{{end}} chasquid-1.15.0/internal/nettrace/templates/style.css000066400000000000000000000070351474251645300227100ustar00rootroot00000000000000:root { --text-color: black; --bg-color: #fffff7; --zebra-bg-color: #eeeee7; --muted-color: #444; --xmuted-color: #a1a1a1; --link-color: #39c; --link-hover: #069; --underline-color: grey; --error-color: red; /* Colors for the nested zebras. */ --nested-d01-c00: #ffebee; --nested-d01-c01: #ede7f6; --nested-d01-c02: #e3f2fd; --nested-d01-c03: #e8f5e9; --nested-d01-c04: #fff8e1; --nested-d01-c05: #efebe9; --nested-d02-c00: #f0dcdf; --nested-d02-c01: #ded8e7; --nested-d02-c02: #d4e3ee; --nested-d02-c03: #d9e6da; --nested-d02-c04: #f0e9d2; --nested-d02-c05: #e0dcda; --nested-d03-c00: #e1cdd0; --nested-d03-c01: #cfc9d8; --nested-d03-c02: #c5d4df; --nested-d03-c03: #cad7cb; --nested-d03-c04: #e1dac3; --nested-d03-c05: #d1cdcb; } @media (prefers-color-scheme: dark) { :root { --text-color: rgba(255, 255, 255, 0.90); --bg-color: #121212; --zebra-bg-color: #222222; --muted-color: #aaaaaa; --xmuted-color: #a1a1a1; --link-color: #44b4ec; --link-hover: #7fc9ee; --underline-color: grey; --error-color: #dd4747; /* Colors for the nested zebras. */ --nested-d01-c00: #220212; --nested-d01-c01: #1c1c22; --nested-d01-c02: #001e20; --nested-d01-c03: #0f0301; --nested-d01-c04: #201d06; --nested-d01-c05: #00192b; --nested-d02-c00: #311121; --nested-d02-c01: #2b2b31; --nested-d02-c02: #0f2d2f; --nested-d02-c03: #1e1210; --nested-d02-c04: #2f2c15; --nested-d02-c05: #0f283a; --nested-d03-c00: #402030; --nested-d03-c01: #3a3a40; --nested-d03-c02: #1e3c3e; --nested-d03-c03: #2d211f; --nested-d03-c04: #3e3b24; --nested-d03-c05: #1e3749; } } body { font-family: sans-serif; color: var(--text-color); background: var(--bg-color); } p.error { color: var(--error-color); font-size: large; } a { color: var(--link-color); text-decoration: none; } a:hover { color: var(--link-hover); } .family a { color: var(--text-color); } u { text-decoration-color: var(--underline-color); } table.index { border-collapse: collapse; } table.index tr:nth-child(odd) { background-color: var(--zebra-bg-color); } table.index td { padding: 0.25em 0.5em; } table.index td.bucket { min-width: 2em; text-align: center; } table.index td.active { /* Make the "active" column wider so there's less jumping on refresh. */ min-width: 5em; text-align: right; } table.index a { text-decoration: none; } a.muted { color: var(--muted-color); } table.trace { font-family: monospace; border-collapse: collapse; } table.trace thead { border-bottom: 1px solid var(--text-color); } table.trace th { text-align: left; padding: 0.1em 1em; } table.trace tr.title { font-weight: bold; } table.trace td { padding: 0.1em 1em; } table.trace td.when { text-align: right; } table.trace td.duration { text-align: right; white-space: pre; } table.trace td.msg { white-space: pre-wrap; } span.depth { color: var(--xmuted-color); } div.emoji { /* Emojis sometimes are rendered excessively tall. */ /* This ensures they're sized appropriately. */ max-height: 1.3em; overflow: hidden; } table.latencies { text-align: right; } table.latencies td { padding: 0 0.3em; } table.latencies th { text-align: center; } meter { width: 15em; } chasquid-1.15.0/internal/nettrace/test_server.go000066400000000000000000000126401474251645300217320ustar00rootroot00000000000000//go:build ignore package main import ( "flag" "fmt" "math/rand" "net/http" "time" _ "net/http/pprof" "blitiri.com.ar/go/srv/nettrace" ) func main() { addr := flag.String("addr", ":8080", "listening address") flag.Parse() go RandomEvents("random-one") go RandomEvents("random-two") go RandomEvents("random-three") go RandomLongEvent("random-long", "long-one") go RandomLongEvent("random-long", "long-two") go RandomLongEvent("random-long", "long-three") go RandomBunny("random-bunny", "first 🐇") go RandomNested("random-nested") go RandomLazy("random-lazy") http.DefaultServeMux.Handle("/", WithLogging(http.HandlerFunc(HandleRoot))) http.DefaultServeMux.Handle("/debug/traces", WithLogging(http.HandlerFunc(nettrace.RenderTraces))) fmt.Printf("listening on %s\n", *addr) http.ListenAndServe(*addr, nil) } func RandomEvents(family string) { for i := 0; ; i++ { tr := nettrace.New(family, fmt.Sprintf("evt-%d", i)) randomTrace(family, tr) } } func randomTrace(family string, tr nettrace.Trace) { tr.Printf("this is a random event") tr.Printf("and it has a random delay") delay := time.Duration(rand.Intn(1000)) * time.Millisecond tr.Printf("sleeping %v", delay) time.Sleep(delay) if rand.Intn(100) < 1 { tr.Printf("this unlucky one is an error") tr.SetError() } if rand.Intn(100) < 10 { tr.Printf("this one got super verbose!") for j := 0; j < 100; j++ { tr.Printf("message %d", j) } } if rand.Intn(100) < 30 { tr.Printf("this one had a child") c := tr.NewChild(family, "achild") go randomTrace(family, c) } tr.Printf("all done") tr.Finish() } func RandomLongEvent(family, title string) { tr := nettrace.New(family, title) tr.Printf("this is a random long event") for i := 0; ; i++ { delay := time.Duration(rand.Intn(100)) * time.Millisecond time.Sleep(delay) tr.Printf("message %d, slept %v", i, delay) } tr.Finish() } func RandomBunny(family, title string) { tr := nettrace.New(family, title) tr.SetMaxEvents(100) tr.Printf("this is the top 🐇") for i := 0; ; i++ { delay := time.Duration(rand.Intn(100)) * time.Millisecond time.Sleep(delay) tr.Printf("message %d, slept %v", i, delay) if rand.Intn(100) < 40 { c := tr.NewChild(family, fmt.Sprintf("child-%d", i)) go randomTrace(family, c) } if rand.Intn(100) < 40 { n := nettrace.New(family, fmt.Sprintf("linked-%d", i)) go randomTrace(family, n) tr.Link(n, "linking with this guy") } } tr.Finish() } func randomNested(family string, depth int, parent nettrace.Trace) { tr := parent.NewChild(family, fmt.Sprintf("nested-%d", depth)) defer tr.Finish() tr.Printf("I am a spoiled child") delay := time.Duration(rand.Intn(100)) * time.Millisecond time.Sleep(delay) tr.Printf("slept %v", delay) if depth > 10 { tr.Printf("I went too far") return } // If we make this < 50, then it grows forever. if rand.Intn(100) < 75 { tr.Printf("I sang really well") return } max := rand.Intn(5) for i := 0; i < max; i++ { tr.Printf("spawning %d", i) go randomNested(family, depth+1, tr) } } func RandomNested(family string) { tr := nettrace.New(family, "nested-0") for i := 0; ; i++ { randomNested(family, 1, tr) } } func RandomLazy(family string) { for i := 0; ; i++ { tr := nettrace.New(family, fmt.Sprintf("evt-%d", i)) tr.Printf("I am very lazy and do little work") tr.Finish() time.Sleep(500 * time.Millisecond) } } func HandleRoot(w http.ResponseWriter, r *http.Request) { if delayS := r.FormValue("delay"); delayS != "" { delay, err := time.ParseDuration(delayS) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } time.Sleep(delay) } if withError := r.FormValue("error"); withError != "" { tr, _ := nettrace.FromContext(r.Context()) tr.SetError() } w.Write([]byte(rootHTML)) } func WithLogging(parent http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { tr := nettrace.New("http", r.URL.String()) defer tr.Finish() // Associate the trace with this request. r = r.WithContext(nettrace.NewContext(r.Context(), tr)) tr.Printf("%s %s %s %s", r.RemoteAddr, r.Proto, r.Method, r.URL.String()) start := time.Now() parent.ServeHTTP(w, r) latency := time.Since(start) tr.Printf("handler took %s", latency) // 1.2.3.4:34575 HTTP/2.0 GET /path = 1.2ms fmt.Printf("%s %s %s %s = %s\n", r.RemoteAddr, r.Proto, r.Method, r.URL.String(), latency) }) } const rootHTML = ` Traces

Delay: 0s 0.1s 0.2s 0.5s 1s
+ error: 0s 0.1s 0.2s 0.5s 1s

` chasquid-1.15.0/internal/nettrace/trace.go000066400000000000000000000251131474251645300204620ustar00rootroot00000000000000// Package nettrace implements tracing of requests. Traces are created by // nettrace.New, and can then be viewed over HTTP on /debug/traces. package nettrace import ( "container/ring" "fmt" "math/rand" "sort" "strconv" "strings" "sync" "time" ) // IDs are of the form "family!timestamp!unique identifier", which allows for // sorting them by time much easily, and also some convenient optimizations // when looking up an id across all the known ones. // Family is not escaped. It should not contain the separator. // It is not expected to be stable, for internal use only. type id string func newID(family string, ts int64) id { return id( family + "!" + strconv.FormatInt(ts, 10) + "!" + strconv.FormatUint(rand.Uint64(), 10)) } func (id id) Family() string { sp := strings.SplitN(string(id), "!", 2) if len(sp) != 2 { return string(id) } return sp[0] } // Trace represents a single request trace. type Trace interface { // NewChild creates a new trace, that is a child of this one. NewChild(family, title string) Trace // Link to another trace with the given message. Link(other Trace, msg string) // SetMaxEvents sets the maximum number of events that will be stored in // the trace. It must be called right after initialization. SetMaxEvents(n int) // SetError marks that the trace was for an error event. SetError() // Printf adds a message to the trace. Printf(format string, a ...interface{}) // Errorf adds a message to the trace, marks it as an error, and returns // an error for it. Errorf(format string, a ...interface{}) error // Finish marks the trace as complete. // The trace should not be used after calling this method. Finish() } // A single trace. Can be active or inactive. // Exported fields are allowed to be accessed directly, e.g. by the HTTP // handler. Private ones are mutex protected. type trace struct { ID id Family string Title string Parent *trace Start time.Time // Fields below are mu-protected. // We keep them unexported so they're not accidentally accessed in html // templates. mu sync.Mutex end time.Time isError bool maxEvents int // We keep two separate groups: the first ~1/3rd events in a simple slice, // and the last 2/3rd in a ring so we can drop events without losing the // first ones. cutoff int firstEvents []event lastEvents *evtRing } type evtType uint8 const ( evtLOG = evtType(1 + iota) evtCHILD evtLINK evtDROP ) func (t evtType) IsLog() bool { return t == evtLOG } func (t evtType) IsChild() bool { return t == evtCHILD } func (t evtType) IsLink() bool { return t == evtLINK } func (t evtType) IsDrop() bool { return t == evtDROP } type event struct { When time.Time Type evtType Ref *trace Msg string } const defaultMaxEvents = 30 func newTrace(family, title string) *trace { start := time.Now() tr := &trace{ ID: newID(family, start.UnixNano()), Family: family, Title: title, Start: start, maxEvents: defaultMaxEvents, cutoff: defaultMaxEvents / 3, } // Pre-allocate a couple of events to speed things up. // Don't allocate lastEvents, that can be expensive and it is not always // needed. No need to slow down trace creation just for it. tr.firstEvents = make([]event, 0, 4) familiesMu.Lock() ft, ok := families[family] if !ok { ft = newFamilyTraces() families[family] = ft } familiesMu.Unlock() ft.mu.Lock() ft.active[tr.ID] = tr ft.mu.Unlock() return tr } // New creates a new trace with the given family and title. func New(family, title string) Trace { return newTrace(family, title) } func (tr *trace) append(evt *event) { tr.mu.Lock() defer tr.mu.Unlock() if len(tr.firstEvents) < tr.cutoff { tr.firstEvents = append(tr.firstEvents, *evt) return } if tr.lastEvents == nil { // The ring holds the last 2/3rds of the events. tr.lastEvents = newEvtRing(tr.maxEvents - tr.cutoff) } tr.lastEvents.Add(evt) } // String is for debugging only. func (tr *trace) String() string { return fmt.Sprintf("trace{%s, %s, %q, %d}", tr.Family, tr.Title, tr.ID, len(tr.Events())) } func (tr *trace) NewChild(family, title string) Trace { c := newTrace(family, title) c.Parent = tr // Add the event to the parent. evt := &event{ When: time.Now(), Type: evtCHILD, Ref: c, } tr.append(evt) return c } func (tr *trace) Link(other Trace, msg string) { evt := &event{ When: time.Now(), Type: evtLINK, Ref: other.(*trace), Msg: msg, } tr.append(evt) } func (tr *trace) SetMaxEvents(n int) { // Set a minimum of 6, so the truncation works without running into // issues. if n < 6 { n = 6 } tr.mu.Lock() tr.maxEvents = n tr.cutoff = n / 3 tr.mu.Unlock() } func (tr *trace) SetError() { tr.mu.Lock() tr.isError = true tr.mu.Unlock() } func (tr *trace) Printf(format string, a ...interface{}) { evt := &event{ When: time.Now(), Type: evtLOG, Msg: fmt.Sprintf(format, a...), } tr.append(evt) } func (tr *trace) Errorf(format string, a ...interface{}) error { tr.SetError() err := fmt.Errorf(format, a...) tr.Printf(err.Error()) return err } func (tr *trace) Finish() { tr.mu.Lock() tr.end = time.Now() tr.mu.Unlock() familiesMu.Lock() ft := families[tr.Family] familiesMu.Unlock() ft.finalize(tr) } // Duration of this trace. func (tr *trace) Duration() time.Duration { tr.mu.Lock() start, end := tr.Start, tr.end tr.mu.Unlock() if end.IsZero() { return time.Since(start) } return end.Sub(start) } // Events returns a copy of the trace events. func (tr *trace) Events() []event { tr.mu.Lock() defer tr.mu.Unlock() evts := make([]event, len(tr.firstEvents)) copy(evts, tr.firstEvents) if tr.lastEvents == nil { return evts } if !tr.lastEvents.firstDrop.IsZero() { evts = append(evts, event{ When: tr.lastEvents.firstDrop, Type: evtDROP, }) } tr.lastEvents.Do(func(e *event) { evts = append(evts, *e) }) return evts } func (tr *trace) IsError() bool { tr.mu.Lock() defer tr.mu.Unlock() return tr.isError } // // Trace hierarchy // // Each trace belongs to a family. For each family, we have all active traces, // and then N traces that finished <1s, N that finished <2s, etc. // We keep this many buckets of finished traces. const nBuckets = 8 // Buckets to use. Length must match nBuckets. // "Traces with a latency >= $duration". var buckets = []time.Duration{ time.Duration(0), 5 * time.Millisecond, 10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, 300 * time.Millisecond, 1 * time.Second, 10 * time.Second, } func findBucket(latency time.Duration) int { for i, d := range buckets { if latency >= d { continue } return i - 1 } return nBuckets - 1 } // How many traces we keep per bucket. const tracesInBucket = 10 type traceRing struct { ring *ring.Ring max int l int } func newTraceRing(n int) *traceRing { return &traceRing{ ring: ring.New(n), max: n, } } func (r *traceRing) Add(tr *trace) { r.ring.Value = tr r.ring = r.ring.Next() if r.l < r.max { r.l++ } } func (r *traceRing) Len() int { return r.l } func (r *traceRing) Do(f func(tr *trace)) { r.ring.Do(func(x interface{}) { if x == nil { return } f(x.(*trace)) }) } type familyTraces struct { mu sync.Mutex // All active ones. active map[id]*trace // The ones we decided to keep. // Each bucket is a ring-buffer, finishedHead keeps the head pointer. finished [nBuckets]*traceRing // The ones that errored have their own bucket. errors *traceRing // Histogram of latencies. latencies histogram } func newFamilyTraces() *familyTraces { ft := &familyTraces{} ft.active = map[id]*trace{} for i := 0; i < nBuckets; i++ { ft.finished[i] = newTraceRing(tracesInBucket) } ft.errors = newTraceRing(tracesInBucket) return ft } func (ft *familyTraces) LenActive() int { ft.mu.Lock() defer ft.mu.Unlock() return len(ft.active) } func (ft *familyTraces) LenErrors() int { ft.mu.Lock() defer ft.mu.Unlock() return ft.errors.Len() } func (ft *familyTraces) LenBucket(b int) int { ft.mu.Lock() defer ft.mu.Unlock() return ft.finished[b].Len() } func (ft *familyTraces) TracesFor(b int, allgt bool) []*trace { ft.mu.Lock() defer ft.mu.Unlock() trs := []*trace{} appendTrace := func(tr *trace) { trs = append(trs, tr) } if b == -2 { ft.errors.Do(appendTrace) } else if b == -1 { for _, tr := range ft.active { appendTrace(tr) } } else if b < nBuckets { ft.finished[b].Do(appendTrace) if allgt { for i := b + 1; i < nBuckets; i++ { ft.finished[i].Do(appendTrace) } } } // Sort them by start, newer first. This is the order that will be used // when displaying them. sort.Slice(trs, func(i, j int) bool { return trs[i].Start.After(trs[j].Start) }) return trs } func (ft *familyTraces) find(id id) *trace { ft.mu.Lock() defer ft.mu.Unlock() if tr, ok := ft.active[id]; ok { return tr } var found *trace for _, bs := range ft.finished { bs.Do(func(tr *trace) { if tr.ID == id { found = tr } }) if found != nil { return found } } ft.errors.Do(func(tr *trace) { if tr.ID == id { found = tr } }) if found != nil { return found } return nil } func (ft *familyTraces) finalize(tr *trace) { latency := tr.end.Sub(tr.Start) b := findBucket(latency) ft.mu.Lock() // Delete from the active list. delete(ft.active, tr.ID) // Add it to the corresponding finished bucket, based on the trace // latency. ft.finished[b].Add(tr) // Errors go on their own list, in addition to the above. if tr.isError { ft.errors.Add(tr) } ft.latencies.Add(b, latency) ft.mu.Unlock() } func (ft *familyTraces) Latencies() *histSnapshot { ft.mu.Lock() defer ft.mu.Unlock() return ft.latencies.Snapshot() } // // Global state // var ( familiesMu sync.Mutex families = map[string]*familyTraces{} ) func copyFamilies() map[string]*familyTraces { n := map[string]*familyTraces{} familiesMu.Lock() for f, trs := range families { n[f] = trs } familiesMu.Unlock() return n } func findInFamilies(traceID id, refID id) *trace { // First, try to find it via the family. family := traceID.Family() familiesMu.Lock() fts, ok := families[family] familiesMu.Unlock() if ok { tr := fts.find(traceID) if tr != nil { return tr } } // If that fail and we have a reference, try finding via it. // The reference can be a parent or a child. if refID != id("") { ref := findInFamilies(refID, "") if ref == nil { return nil } // Is the reference's parent the one we're looking for? if ref.Parent != nil && ref.Parent.ID == traceID { return ref.Parent } // Try to find it in the ref's events. for _, e := range ref.Events() { if e.Ref != nil && e.Ref.ID == traceID { return e.Ref } } } return nil } chasquid-1.15.0/internal/nettrace/trace_test.go000066400000000000000000000134321474251645300215220ustar00rootroot00000000000000package nettrace import ( "fmt" "strings" "testing" "time" ) func expectEvents(t *testing.T, tr Trace, n int) { t.Helper() if evts := tr.(*trace).Events(); len(evts) != n { t.Errorf("expected %d events, got %d", n, len(evts)) t.Logf("%v", evts) } } func TestBasic(t *testing.T) { var tr Trace = New("TestBasic", "basic") defer tr.Finish() tr.Printf("hola marola") tr.Printf("hola marola 2") c1 := tr.NewChild("TestBasic", "basic child") c1.Printf("hijo de la noche") expectEvents(t, tr, 3) if s := tr.(*trace).String(); !strings.Contains(s, "TestBasic, basic") { t.Errorf("tr.String does not contain family and title: %q", s) } } func TestLong(t *testing.T) { tr := New("TestLong", "long") defer tr.Finish() tr.SetMaxEvents(100) // First 90 events, no drop. for i := 0; i < 90; i++ { tr.Printf("evt %d", i) } expectEvents(t, tr, 90) // Up to 99, still no drop. for i := 0; i < 9; i++ { tr.Printf("evt %d", i) } expectEvents(t, tr, 99) // Note that we go up to 101 due to rounding errors, we're ok with it. tr.Printf("evt 100") expectEvents(t, tr, 100) tr.Printf("evt 101") expectEvents(t, tr, 101) tr.Printf("evt 102") expectEvents(t, tr, 101) // Add more events, expect none of them to exceed 101. for i := 0; i < 9; i++ { tr.Printf("evt %d", i) expectEvents(t, tr, 101) } } func TestIsError(t *testing.T) { tr := New("TestIsError", "long") defer tr.Finish() if tr.(*trace).IsError() != false { tr.Errorf("new trace is error") } tr.Errorf("this is an error") if tr.(*trace).IsError() != true { tr.Errorf("error not recorded properly") } } func TestFindViaRef(t *testing.T) { parent := New("TestFindViaRef", "parent") parentID := parent.(*trace).ID defer parent.Finish() child := parent.NewChild("TestFindViaRef", "child") childID := child.(*trace).ID defer child.Finish() // Basic check that both can be directly found. if f := findInFamilies(parentID, id("")); f != parent { t.Errorf("didn't find parent directly, found: %v", f) } if f := findInFamilies(childID, id("")); f != child { t.Errorf("didn't find child directly, found: %v", f) } // Hackily remove child from the active traces, to force a reference // lookup when needed. familiesMu.Lock() delete(families["TestFindViaRef"].active, child.(*trace).ID) familiesMu.Unlock() // Now the child should not be findable directly anymore. if f := findInFamilies(childID, id("")); f != nil { t.Errorf("child should not be findable directly, found: %v", f) } // But we still should be able to get to it via the parent. if f := findInFamilies(childID, parentID); f != child { t.Errorf("didn't find child via parent, found: %v", f) } } func TestMaxEvents(t *testing.T) { tr := trace{} // Test that we keep a minimum, and that the cutoff behaves as expected. cases := []struct{ me, exp, cutoffExp int }{ {0, 6, 2}, {5, 6, 2}, {6, 6, 2}, {7, 7, 2}, {8, 8, 2}, {9, 9, 3}, {12, 12, 4}, } for _, c := range cases { tr.SetMaxEvents(c.me) if got := tr.maxEvents; got != c.exp { t.Errorf("set max events to %d, expected %d, got %d", c.me, c.exp, got) } if got := tr.cutoff; got != c.cutoffExp { t.Errorf("set max events to %d, expected cutoff %d, got %d", c.me, c.cutoffExp, got) } } } func TestFind(t *testing.T) { // Make sure we find the right bucket, including for latencies above the // last one. for i, d := range buckets { found := findBucket(d + 1*time.Millisecond) if found != i { t.Errorf("find bucket [%s + 1ms] got %d, expected %d", d, found, i) } } // Create a family, populate it with traces in all buckets. finished := [nBuckets]*trace{} for i, d := range buckets { lat := d + 1*time.Millisecond tr := newTrace("TestFind", fmt.Sprintf("evt-%s", lat)) tr.end = tr.Start.Add(lat) families[tr.Family].finalize(tr) finished[i] = tr } // Also have an active trace. activeTr := newTrace("TestFind", "active") // And add an error trace, which isn't on any of the other buckets (to // simulate that they've been rotated out of the latency buckets, but are // still around in errors) errTr := newTrace("TestFind", "evt-err") errTr.end = errTr.Start.Add(666 * time.Millisecond) errTr.SetError() delete(families[errTr.Family].active, errTr.ID) families[errTr.Family].errors.Add(errTr) // Find all of them. for i := range buckets { found := findInFamilies(finished[i].ID, "") if found != finished[i] { t.Errorf("finding trace %d on bucket %s, expected %v, got %v", i, buckets[i], finished[i], found) } } if found := findInFamilies(activeTr.ID, ""); found != activeTr { t.Errorf("finding active trace, expected %v, got %v", activeTr, found) } if found := findInFamilies(errTr.ID, ""); found != errTr { t.Errorf("finding error trace, expected %v, got %v", errTr, found) } // Non-existent traces. if found := findInFamilies("does!notexist", ""); found != nil { t.Errorf("finding non-existent trace, expected nil, got %v", found) } if found := findInFamilies("does!notexist", "does!notexist"); found != nil { t.Errorf("finding non-existent trace w/ref, expected nil, got %v", found) } } func TestFindParent(t *testing.T) { // Direct parent finding. // If the ref is the parent, we should find it even if the target trace // isn't known to the family (e.g. the child is there, but the parent has // been rotated and is no longer referenced). parent := newTrace("TestFindParent", "parent") child := parent.NewChild("TestFindParent", "child").(*trace) // Remove the parent from the active list. delete(families[parent.Family].active, parent.ID) if found := findInFamilies(parent.ID, ""); found != nil { t.Errorf("finding parent without ref, expected nil, got %v", found) } if found := findInFamilies(parent.ID, child.ID); found != parent { t.Errorf("finding parent with ref, expected %v, got %v", parent, found) } } chasquid-1.15.0/internal/normalize/000077500000000000000000000000001474251645300172265ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/normalize.go000066400000000000000000000074571474251645300215720ustar00rootroot00000000000000// Package normalize contains functions to normalize usernames, domains and // addresses. package normalize import ( "bytes" "strings" "blitiri.com.ar/go/chasquid/internal/envelope" "golang.org/x/net/idna" "golang.org/x/text/secure/precis" "golang.org/x/text/unicode/norm" ) // User normalizes an username using PRECIS. // On error, it will also return the original username to simplify callers. func User(user string) (string, error) { norm, err := precis.UsernameCaseMapped.String(user) if err != nil { return user, err } return norm, nil } // Domain normalizes a DNS domain into a cleaned UTF-8 form. // On error, it will also return the original domain to simplify callers. func Domain(domain string) (string, error) { // For now, we just convert them to lower case and make sure it's in NFC // form for consistency. // There are other possible transformations (like nameprep) but for our // purposes these should be enough. // https://tools.ietf.org/html/rfc5891#section-5.2 // https://blog.golang.org/normalization d, err := idna.ToUnicode(domain) if err != nil { return domain, err } d = norm.NFC.String(d) d = strings.ToLower(d) return d, nil } // Addr normalizes an email address, applying User and Domain to its // respective components. // On error, it will also return the original address to simplify callers. func Addr(addr string) (string, error) { user, domain := envelope.Split(addr) user, err := User(user) if err != nil { return addr, err } domain, err = Domain(domain) if err != nil { return addr, err } return user + "@" + domain, nil } // DomainToUnicode takes an address with an ASCII domain, and convert it to // Unicode as per IDNA, including basic normalization. // The user part is unchanged. func DomainToUnicode(addr string) (string, error) { if addr == "<>" { return addr, nil } user, domain := envelope.Split(addr) domain, err := Domain(domain) return user + "@" + domain, err } // ToCRLF converts the given buffer to CRLF line endings. If a line has a // preexisting CRLF, it leaves it be. It assumes that CR is never used on its // own. func ToCRLF(in []byte) []byte { b := bytes.Buffer{} b.Grow(len(in)) // We go line by line, but beware: // Split("a\nb", "\n") -> ["a", "b"] // Split("a\nb\n", "\n") -> ["a", "b", ""] // So we handle the last line separately. lines := bytes.Split(in, []byte("\n")) for i, line := range lines { b.Write(line) if i == len(lines)-1 { // Do not add newline to the last line: // - If the string ends with a newline, we already added it in // the previous-to-last line, and this line is "". // - If the string does NOT end with a newline, this preserves // that property. break } if !bytes.HasSuffix(line, []byte("\r")) { // Missing the CR. b.WriteByte('\r') } b.WriteByte('\n') } return b.Bytes() } // StringToCRLF is like ToCRLF, but operates on strings. func StringToCRLF(in string) string { // We implement it the same way as ToCRLF, but with string versions. // This is significantly faster than converting the string to a byte // slice, calling ToCRLF, and converting it back. b := strings.Builder{} b.Grow(len(in)) // We go line by line, but beware: // Split("a\nb", "\n") -> ["a", "b"] // Split("a\nb\n", "\n") -> ["a", "b", ""] // So we handle the last line separately. lines := strings.Split(in, "\n") for i, line := range lines { b.WriteString(line) if i == len(lines)-1 { // Do not add newline to the last line: // - If the string ends with a newline, we already added it in // the previous-to-last line, and this line is "". // - If the string does NOT end with a newline, this preserves // that property. break } if !strings.HasSuffix(line, "\r") { // Missing the CR. b.WriteByte('\r') } b.WriteByte('\n') } return b.String() } chasquid-1.15.0/internal/normalize/normalize_test.go000066400000000000000000000111701474251645300226140ustar00rootroot00000000000000package normalize import ( "bytes" "strings" "testing" ) func TestUser(t *testing.T) { valid := []struct{ user, norm string }{ {"ÑAndÚ", "ÃąandÃē"}, {"PingÃŧino", "pingÃŧino"}, } for _, c := range valid { nu, err := User(c.user) if nu != c.norm { t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm) } if err != nil { t.Errorf("%q error: %v", c.user, err) } } invalid := []string{ "ÃĄ Ê", "a\te", "x ", "x\xa0y", "x\x85y", "x\vy", "x\fy", "x\ry", "henry\u2163", "\u265a", "\u00b9", } for _, u := range invalid { nu, err := User(u) if err == nil { t.Errorf("expected User(%+q) to fail, but did not", u) } if nu != u { t.Errorf("%+q failed norm, but returned %+q", u, nu) } } } func TestDomain(t *testing.T) { valid := []struct{ user, norm string }{ {"ÑAndÚ", "ÃąandÃē"}, {"PingÃŧino", "pingÃŧino"}, {"xn--aca-6ma", "Ãąaca"}, {"xn--lca", "Ãą"}, // Punycode is for 'Ñ'. {"e\u0301", "Ê"}, // Transform to NFC form. } for _, c := range valid { nu, err := Domain(c.user) if nu != c.norm { t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm) } if err != nil { t.Errorf("%q error: %v", c.user, err) } } invalid := []string{"xn---", "xn--xyz-Ãą"} for _, u := range invalid { nu, err := Domain(u) if err == nil { t.Errorf("expected Domain(%+q) to fail, but did not", u) } if nu != u { t.Errorf("%+q failed norm, but returned %+q", u, nu) } } } func TestAddr(t *testing.T) { valid := []struct{ user, norm string }{ {"ÑAndÚ@pampa", "ÃąandÃē@pampa"}, {"PingÃŧino@patagonia", "pingÃŧino@patagonia"}, {"pe\u0301@le\u0301a", "pÊ@lÊa"}, // Transform to NFC form. } for _, c := range valid { nu, err := Addr(c.user) if nu != c.norm { t.Errorf("%q normalized to %q, expected %q", c.user, nu, c.norm) } if err != nil { t.Errorf("%q error: %v", c.user, err) } } invalid := []string{ "ÃĄ Ê@i", "henry\u2163@throne", "a@xn---", } for _, u := range invalid { nu, err := Addr(u) if err == nil { t.Errorf("expected Addr(%+q) to fail, but did not", u) } if nu != u { t.Errorf("%+q failed norm, but returned %+q", u, nu) } } } func TestDomainToUnicode(t *testing.T) { valid := []struct{ domain, expected string }{ {"<>", "<>"}, {"a@b", "a@b"}, {"a@Ñ", "a@Ãą"}, {"xn--lca@xn--lca", "xn--lca@Ãą"}, // Punycode is for 'Ñ'. {"a@e\u0301", "a@Ê"}, // Transform to NFC form. // Degenerate case, we don't expect to ever produce this; at least // check it does not crash. {"", "@"}, } for _, c := range valid { got, err := DomainToUnicode(c.domain) if got != c.expected { t.Errorf("DomainToUnicode(%q) = %q, expected %q", c.domain, got, c.expected) } if err != nil { t.Errorf("DomainToUnicode(%q) error: %v", c.domain, err) } } invalid := []string{"a@xn---", "a@xn--xyz-Ãą"} for _, u := range invalid { got, err := DomainToUnicode(u) if err == nil { t.Errorf("expected DomainToUnicode(%+q) to fail, but did not", u) } if got != u { t.Errorf("%+q failed norm, but returned %+q", u, got) } } } func TestToCRLF(t *testing.T) { cases := []struct { in, out string }{ {"", ""}, {"a", "a"}, // Does not end in newline. {"a\n", "a\r\n"}, {"a\nb", "a\r\nb"}, {"a\r\nb", "a\r\nb"}, // Ends in newline. {"a\nb\n", "a\r\nb\r\n"}, {"a\r\nb\n", "a\r\nb\r\n"}, {"a\r\nb\r\n", "a\r\nb\r\n"}, {"a\r\nb\n\n", "a\r\nb\r\n\r\n"}, {"a\r\nb\r\n\r\n", "a\r\nb\r\n\r\n"}, } for _, c := range cases { got := string(ToCRLF([]byte(c.in))) if got != c.out { t.Errorf("ToCRLF(%q) = %q, expected %q", c.in, got, c.out) } got = StringToCRLF(c.in) if got != c.out { t.Errorf("StringToCRLF(%q) = %q, expected %q", c.in, got, c.out) } } } func FuzzUser(f *testing.F) { f.Fuzz(func(t *testing.T, user string) { User(user) }) } func FuzzDomain(f *testing.F) { f.Fuzz(func(t *testing.T, domain string) { Domain(domain) }) } func FuzzAddr(f *testing.F) { f.Fuzz(func(t *testing.T, addr string) { Addr(addr) }) } func FuzzDomainToUnicode(f *testing.F) { f.Fuzz(func(t *testing.T, addr string) { DomainToUnicode(addr) }) } func BenchmarkToCRLF(b *testing.B) { // Generate a 1000-line message. bb := bytes.Buffer{} for i := 0; i < 1000; i++ { bb.WriteString("this is a very pretty line 🐅\n") } buf := bb.Bytes() b.ResetTimer() for i := 0; i < b.N; i++ { ToCRLF(buf) } } func BenchmarkStringToCRLF(b *testing.B) { // Generate a 1000-line message. sb := strings.Builder{} for i := 0; i < 1000; i++ { sb.WriteString("this is a very pretty line 🐅\n") } s := sb.String() b.ResetTimer() for i := 0; i < b.N; i++ { StringToCRLF(s) } } chasquid-1.15.0/internal/normalize/testdata/000077500000000000000000000000001474251645300210375ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/000077500000000000000000000000001474251645300220355ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzAddr/000077500000000000000000000000001474251645300235665ustar00rootroot0000000000000031400a53be6363c91bf6585789663189fa30b16181c1d18f19708acccc85f4a1000066400000000000000000000000411474251645300337270ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzAddrgo test fuzz v1 string("ÑAndÚ")7aba1e0ef80990ccac3731800dbb0267c4c8b7156d4da3b8a5f1b57a570adfb8000066400000000000000000000000511474251645300345600ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzAddrgo test fuzz v1 string("henryâ…Ŗ@throne")ccde73fe7b7352806a87cece8eb81867bdeb177019b69a4bb3c7bb5a277b9c32000066400000000000000000000000411474251645300346270ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzAddrgo test fuzz v1 string("ÃąandÃē")d8637022b61fb5c4df4e153063564accd6331debaafdd594405c320a5e9f2e70000066400000000000000000000000441474251645300343460ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzAddrgo test fuzz v1 string("peˁ@leˁa")dc0204d8e2ab058a763873d2a5fede806e95235771ecdd96b56c906886822c19000066400000000000000000000000431474251645300341200ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzAddrgo test fuzz v1 string("PingÃŧino")chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomain/000077500000000000000000000000001474251645300241235ustar00rootroot00000000000000263da65bb5a59369f294d26a64a36a989a9a36ed5c60950b123e395bedbe881c000066400000000000000000000000501474251645300346040ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomaingo test fuzz v1 string("henryâ…Ŗthrone")31400a53be6363c91bf6585789663189fa30b16181c1d18f19708acccc85f4a1000066400000000000000000000000411474251645300342640ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomaingo test fuzz v1 string("ÑAndÚ")6d603c8b9fbe8b9aa021dbde499ec1b3a00922b9338c68b2984cd314c3d5e633000066400000000000000000000000431474251645300347240ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomaingo test fuzz v1 string("peˁleˁa")ccde73fe7b7352806a87cece8eb81867bdeb177019b69a4bb3c7bb5a277b9c32000066400000000000000000000000411474251645300351640ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomaingo test fuzz v1 string("ÃąandÃē")dc0204d8e2ab058a763873d2a5fede806e95235771ecdd96b56c906886822c19000066400000000000000000000000431474251645300344550ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomaingo test fuzz v1 string("PingÃŧino")chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomainToUnicode/000077500000000000000000000000001474251645300257355ustar00rootroot0000000000000031400a53be6363c91bf6585789663189fa30b16181c1d18f19708acccc85f4a1000066400000000000000000000000411474251645300360760ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomainToUnicodego test fuzz v1 string("ÑAndÚ")7aba1e0ef80990ccac3731800dbb0267c4c8b7156d4da3b8a5f1b57a570adfb8000066400000000000000000000000511474251645300367270ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomainToUnicodego test fuzz v1 string("henryâ…Ŗ@throne")ccde73fe7b7352806a87cece8eb81867bdeb177019b69a4bb3c7bb5a277b9c32000066400000000000000000000000411474251645300367760ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomainToUnicodego test fuzz v1 string("ÃąandÃē")d8637022b61fb5c4df4e153063564accd6331debaafdd594405c320a5e9f2e70000066400000000000000000000000441474251645300365150ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomainToUnicodego test fuzz v1 string("peˁ@leˁa")dc0204d8e2ab058a763873d2a5fede806e95235771ecdd96b56c906886822c19000066400000000000000000000000431474251645300362670ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzDomainToUnicodego test fuzz v1 string("PingÃŧino")chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzUser/000077500000000000000000000000001474251645300236325ustar00rootroot00000000000000263da65bb5a59369f294d26a64a36a989a9a36ed5c60950b123e395bedbe881c000066400000000000000000000000501474251645300343130ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzUsergo test fuzz v1 string("henryâ…Ŗthrone")31400a53be6363c91bf6585789663189fa30b16181c1d18f19708acccc85f4a1000066400000000000000000000000411474251645300337730ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzUsergo test fuzz v1 string("ÑAndÚ")6d603c8b9fbe8b9aa021dbde499ec1b3a00922b9338c68b2984cd314c3d5e633000066400000000000000000000000431474251645300344330ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzUsergo test fuzz v1 string("peˁleˁa")ccde73fe7b7352806a87cece8eb81867bdeb177019b69a4bb3c7bb5a277b9c32000066400000000000000000000000411474251645300346730ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzUsergo test fuzz v1 string("ÃąandÃē")dc0204d8e2ab058a763873d2a5fede806e95235771ecdd96b56c906886822c19000066400000000000000000000000431474251645300341640ustar00rootroot00000000000000chasquid-1.15.0/internal/normalize/testdata/fuzz/FuzzUsergo test fuzz v1 string("PingÃŧino")chasquid-1.15.0/internal/protoio/000077500000000000000000000000001474251645300167215ustar00rootroot00000000000000chasquid-1.15.0/internal/protoio/protoio.go000066400000000000000000000053121474251645300207440ustar00rootroot00000000000000// Package protoio contains I/O functions for protocol buffers. package protoio import ( "net/url" "os" "strings" "blitiri.com.ar/go/chasquid/internal/safeio" "google.golang.org/protobuf/encoding/prototext" "google.golang.org/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 := os.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 := os.ReadFile(fname) if err != nil { return err } return prototext.Unmarshal(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) } var textOpts = prototext.MarshalOptions{ Multiline: true, } // WriteTextMessage marshals pb in text format and atomically writes it into // fname. func WriteTextMessage(fname string, pb proto.Message, perm os.FileMode) error { out, err := textOpts.Marshal(pb) if err != nil { return err } return safeio.WriteFile(fname, out, perm) } /////////////////////////////////////////////////////////////// // Store represents a persistent protocol buffer message store. type Store struct { // Directory where the store is. dir string } // NewStore returns a new Store instance. It will create dir if needed. func NewStore(dir string) (*Store, error) { s := &Store{dir} err := os.MkdirAll(dir, 0770) return s, err } const storeIDPrefix = "s:" // idToFname takes a generic id and returns the corresponding file for it // (which may or may not exist). func (s *Store) idToFname(id string) string { return s.dir + "/" + storeIDPrefix + url.QueryEscape(id) } // Put a message into the store. func (s *Store) Put(id string, m proto.Message) error { return WriteTextMessage(s.idToFname(id), m, 0660) } // Get a message from the store. func (s *Store) Get(id string, m proto.Message) (bool, error) { err := ReadTextMessage(s.idToFname(id), m) if os.IsNotExist(err) { return false, nil } return err == nil, err } // ListIDs in the store. func (s *Store) ListIDs() ([]string, error) { ids := []string{} entries, err := os.ReadDir(s.dir) if err != nil { return nil, err } for _, e := range entries { if !strings.HasPrefix(e.Name(), storeIDPrefix) { continue } id := e.Name()[len(storeIDPrefix):] id, err = url.QueryUnescape(id) if err != nil { continue } ids = append(ids, id) } return ids, nil } chasquid-1.15.0/internal/protoio/protoio_test.go000066400000000000000000000070101474251645300220000ustar00rootroot00000000000000package protoio import ( "os" "testing" "blitiri.com.ar/go/chasquid/internal/protoio/testpb" "blitiri.com.ar/go/chasquid/internal/testlib" ) func TestBin(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) pb := &testpb.M{Content: "hola"} if err := WriteMessage("f", pb, 0600); err != nil { t.Error(err) } pb2 := &testpb.M{} if err := ReadMessage("f", pb2); err != nil { t.Error(err) } if pb.Content != pb2.Content { t.Errorf("content mismatch, got %q, expected %q", pb2.Content, pb.Content) } } func TestText(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) pb := &testpb.M{Content: "hola"} if err := WriteTextMessage("f", pb, 0600); err != nil { t.Error(err) } pb2 := &testpb.M{} if err := ReadTextMessage("f", pb2); err != nil { t.Error(err) } if pb.Content != pb2.Content { t.Errorf("content mismatch, got %q, expected %q", pb2.Content, pb.Content) } } func TestStore(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) st, err := NewStore(dir + "/store") if err != nil { t.Fatalf("failed to create store: %v", err) } if ids, err := st.ListIDs(); len(ids) != 0 || err != nil { t.Errorf("expected no ids, got %v - %v", ids, err) } pb := &testpb.M{Content: "hola"} if err := st.Put("f", pb); err != nil { t.Error(err) } pb2 := &testpb.M{} if ok, err := st.Get("f", pb2); err != nil || !ok { t.Errorf("Get(f): %v - %v", ok, err) } if pb.Content != pb2.Content { t.Errorf("content mismatch, got %q, expected %q", pb2.Content, pb.Content) } if ok, err := st.Get("notexists", pb2); err != nil || ok { t.Errorf("Get(notexists): %v - %v", ok, err) } // Add an extraneous file, which ListIDs should ignore. mustCreate(t, dir+"/store/"+"somefile") // Add a file that is not properly query-escaped, and should be ignored. mustCreate(t, dir+"/store/"+"s:somefile%N") if ids, err := st.ListIDs(); len(ids) != 1 || ids[0] != "f" || err != nil { t.Errorf("expected [f], got %v - %v", ids, err) } } func mustCreate(t *testing.T, fname string) { t.Helper() f, err := os.Create(fname) if f != nil { f.Close() } if err != nil { t.Fatalf("failed to create file %q: %v", fname, err) } } func TestFileErrors(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) pb := &testpb.M{Content: "hola"} if err := WriteMessage("/proc/doesnotexist", pb, 0600); err == nil { t.Errorf("write to /proc/doesnotexist worked, expected error") } if err := WriteTextMessage("/proc/doesnotexist", pb, 0600); err == nil { t.Errorf("text write to /proc/doesnotexist worked, expected error") } if err := ReadMessage("/doesnotexist", pb); err == nil { t.Errorf("read from /doesnotexist worked, expected error") } if err := ReadTextMessage("/doesnotexist", pb); err == nil { t.Errorf("text read from /doesnotexist worked, expected error") } s := &Store{dir: "/doesnotexist"} if ids, err := s.ListIDs(); !(ids == nil && err != nil) { t.Errorf("list /doesnotexist worked (%v, %v), expected error", ids, err) } } func TestMarshalErrors(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) // The marshaller enforces that strings are well-formed utf8. So to create // a marshalling error, we use a non-utf8 string. pb := &testpb.M{Content: "\xc3\x28"} if err := WriteMessage("f", pb, 0600); err == nil { t.Errorf("write worked, expected error") } if err := WriteTextMessage("ft", pb, 0600); err == nil { t.Errorf("text write worked, expected error") } } chasquid-1.15.0/internal/protoio/testpb/000077500000000000000000000000001474251645300202225ustar00rootroot00000000000000chasquid-1.15.0/internal/protoio/testpb/dummy.go000066400000000000000000000001341474251645300217020ustar00rootroot00000000000000package testpb //go:generate protoc --go_out=. --go_opt=paths=source_relative testpb.proto chasquid-1.15.0/internal/protoio/testpb/testpb.pb.go000066400000000000000000000100411474251645300224460ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.30.0 // protoc v3.21.12 // source: testpb.proto package testpb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type M struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields Content string `protobuf:"bytes,1,opt,name=content,proto3" json:"content,omitempty"` } func (x *M) Reset() { *x = M{} if protoimpl.UnsafeEnabled { mi := &file_testpb_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *M) String() string { return protoimpl.X.MessageStringOf(x) } func (*M) ProtoMessage() {} func (x *M) ProtoReflect() protoreflect.Message { mi := &file_testpb_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use M.ProtoReflect.Descriptor instead. func (*M) Descriptor() ([]byte, []int) { return file_testpb_proto_rawDescGZIP(), []int{0} } func (x *M) GetContent() string { if x != nil { return x.Content } return "" } var File_testpb_proto protoreflect.FileDescriptor var file_testpb_proto_rawDesc = []byte{ 0x0a, 0x0c, 0x74, 0x65, 0x73, 0x74, 0x70, 0x62, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, 0x74, 0x65, 0x73, 0x74, 0x70, 0x62, 0x22, 0x1d, 0x0a, 0x01, 0x4d, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x42, 0x34, 0x5a, 0x32, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72, 0x69, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73, 0x71, 0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x69, 0x6f, 0x2f, 0x74, 0x65, 0x73, 0x74, 0x70, 0x62, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_testpb_proto_rawDescOnce sync.Once file_testpb_proto_rawDescData = file_testpb_proto_rawDesc ) func file_testpb_proto_rawDescGZIP() []byte { file_testpb_proto_rawDescOnce.Do(func() { file_testpb_proto_rawDescData = protoimpl.X.CompressGZIP(file_testpb_proto_rawDescData) }) return file_testpb_proto_rawDescData } var file_testpb_proto_msgTypes = make([]protoimpl.MessageInfo, 1) var file_testpb_proto_goTypes = []interface{}{ (*M)(nil), // 0: testpb.M } var file_testpb_proto_depIdxs = []int32{ 0, // [0:0] is the sub-list for method output_type 0, // [0:0] is the sub-list for method input_type 0, // [0:0] is the sub-list for extension type_name 0, // [0:0] is the sub-list for extension extendee 0, // [0:0] is the sub-list for field type_name } func init() { file_testpb_proto_init() } func file_testpb_proto_init() { if File_testpb_proto != nil { return } if !protoimpl.UnsafeEnabled { file_testpb_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*M); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_testpb_proto_rawDesc, NumEnums: 0, NumMessages: 1, NumExtensions: 0, NumServices: 0, }, GoTypes: file_testpb_proto_goTypes, DependencyIndexes: file_testpb_proto_depIdxs, MessageInfos: file_testpb_proto_msgTypes, }.Build() File_testpb_proto = out.File file_testpb_proto_rawDesc = nil file_testpb_proto_goTypes = nil file_testpb_proto_depIdxs = nil } chasquid-1.15.0/internal/protoio/testpb/testpb.proto000066400000000000000000000002231474251645300226050ustar00rootroot00000000000000 syntax = "proto3"; package testpb; option go_package = "blitiri.com.ar/go/chasquid/internal/protoio/testpb"; message M { string content = 1; } chasquid-1.15.0/internal/queue/000077500000000000000000000000001474251645300163525ustar00rootroot00000000000000chasquid-1.15.0/internal/queue/dsn.go000066400000000000000000000104701474251645300174670ustar00rootroot00000000000000package queue import ( "bytes" "net/mail" "strings" "text/template" "time" ) // Maximum length of the original message to include in the DSN. // The receiver of the DSN might have a smaller message size than what we // accepted, so we truncate to a value that should be large enough to be // useful, but not problematic for modern deployments. const maxOrigMsgLen = 256 * 1024 // deliveryStatusNotification creates a delivery status notification (DSN) for // the given item, and puts it in the queue. // // References: // - https://tools.ietf.org/html/rfc3464 (DSN) // - https://tools.ietf.org/html/rfc6533 (Internationalized DSN) func deliveryStatusNotification(domainFrom string, item *Item) ([]byte, error) { info := dsnInfo{ OurDomain: domainFrom, Destination: item.From, MessageID: "chasquid-dsn-" + <-newID + "@" + domainFrom, Date: time.Now().Format(time.RFC1123Z), To: item.To, Recipients: item.Rcpt, FailedTo: map[string]string{}, } for _, rcpt := range item.Rcpt { if rcpt.Status != Recipient_SENT { info.FailedTo[rcpt.OriginalAddress] = rcpt.OriginalAddress switch rcpt.Status { case Recipient_FAILED: info.FailedRecipients = append(info.FailedRecipients, rcpt) case Recipient_PENDING: info.PendingRecipients = append(info.PendingRecipients, rcpt) } } } if len(item.Data) > maxOrigMsgLen { info.OriginalMessage = string(item.Data[:maxOrigMsgLen]) } else { info.OriginalMessage = string(item.Data) } info.OriginalMessageID = getMessageID(item.Data) info.Boundary = <-newID buf := &bytes.Buffer{} err := dsnTemplate.Execute(buf, info) return buf.Bytes(), err } func getMessageID(data []byte) string { msg, err := mail.ReadMessage(bytes.NewBuffer(data)) if err != nil { return "" } return msg.Header.Get("Message-ID") } type dsnInfo struct { OurDomain string Destination string MessageID string Date string To []string FailedTo map[string]string Recipients []*Recipient FailedRecipients []*Recipient PendingRecipients []*Recipient OriginalMessage string // Message-ID of the original message. OriginalMessageID string // MIME boundary to use to form the message. Boundary string } // indent s with the given number of spaces. func indent(sp int, s string) string { pad := strings.Repeat(" ", sp) return strings.Replace(s, "\n", "\n"+pad, -1) } var dsnTemplate = template.Must( template.New("dsn").Funcs( template.FuncMap{ "indent": indent, "trim": strings.TrimSpace, }).Parse( `From: Mail Delivery System To: <{{.Destination}}> Subject: Mail delivery failed: returning message to sender Message-ID: <{{.MessageID}}> Date: {{.Date}} In-Reply-To: {{.OriginalMessageID}} References: {{.OriginalMessageID}} X-Failed-Recipients: {{range .FailedTo}}{{.}}, {{end}} Auto-Submitted: auto-replied MIME-Version: 1.0 Content-Type: multipart/report; report-type=delivery-status; boundary="{{.Boundary}}" --{{.Boundary}} Content-Type: text/plain; charset="utf-8" Content-Disposition: inline Content-Description: Notification Content-Transfer-Encoding: 8bit Delivery of your message to the following recipient(s) failed permanently: {{range .FailedTo}} - {{.}} {{end}} Technical details: {{- range .FailedRecipients}} - "{{.Address}}" ({{.Type}}) failed permanently with error: {{.LastFailureMessage | trim | indent 4}} {{- end}} {{- range .PendingRecipients}} - "{{.Address}}" ({{.Type}}) failed repeatedly and timed out, last error: {{.LastFailureMessage | trim | indent 4}} {{- end}} --{{.Boundary}} Content-Type: message/global-delivery-status Content-Description: Delivery Report Content-Transfer-Encoding: 8bit Reporting-MTA: dns; {{.OurDomain}} {{range .FailedRecipients -}} Original-Recipient: utf-8; {{.OriginalAddress}} Final-Recipient: utf-8; {{.Address}} Action: failed Status: 5.0.0 Diagnostic-Code: smtp; {{.LastFailureMessage | trim | indent 4}} {{end -}} {{range .PendingRecipients -}} Original-Recipient: utf-8; {{.OriginalAddress}} Final-Recipient: utf-8; {{.Address}} Action: failed Status: 4.0.0 Diagnostic-Code: smtp; {{.LastFailureMessage | trim | indent 4}} {{end}} --{{.Boundary}} Content-Type: message/rfc822 Content-Description: Undelivered Message Content-Transfer-Encoding: 8bit {{.OriginalMessage}} --{{.Boundary}}-- `)) chasquid-1.15.0/internal/queue/dsn_test.go000066400000000000000000000120301474251645300205200ustar00rootroot00000000000000package queue import ( "fmt" "sort" "strings" "testing" ) const multilineErr = `550 5.7.1 [11:22:33:44::1] Our system has detected that this 5.7.1 message is likely unsolicited mail. To reduce the amount of spam sent 5.7.1 to BlahMail, this message has been blocked. Please visit 5.7.1 https://support.blah/mail/?p=UnsolicitedMessageError 5.7.1 for more information. a1b2c3a1b2c3a1b.123 - bsmtp` const data = `Message-ID: Data Ãąaca. ` func TestDSN(t *testing.T) { item := &Item{ Message: Message{ ID: <-newID, From: "from@from.org", To: []string{"Ãąaca@africa.org", "negra@sosa.org"}, Rcpt: []*Recipient{ mkR("poe@rcpt", Recipient_EMAIL, Recipient_FAILED, "oh! horror!", "Ãąaca@africa.org"), mkR("muchos@rcpt", Recipient_EMAIL, Recipient_FAILED, multilineErr, "pepe@africa.org"), mkR("newman@rcpt", Recipient_EMAIL, Recipient_PENDING, "oh! the humanity!", "Ãąaca@africa.org"), mkR("ant@rcpt", Recipient_EMAIL, Recipient_SENT, "", "negra@sosa.org"), }, Data: []byte(data), }, } msg, err := deliveryStatusNotification("dsnDomain", item) if err != nil { t.Error(err) } if !flexibleEq(expectedDSN, string(msg)) { t.Errorf("generated DSN different than expected") printDiff(func(s string) { t.Error(s) }, expectedDSN, string(msg)) } else { t.Log(string(msg)) } } const expectedDSN = `From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: Date: * In-Reply-To: References: X-Failed-Recipients: pepe@africa.org, Ãąaca@africa.org, Auto-Submitted: auto-replied MIME-Version: 1.0 Content-Type: multipart/report; report-type=delivery-status; boundary="???????????" --??????????? Content-Type: text/plain; charset="utf-8" Content-Disposition: inline Content-Description: Notification Content-Transfer-Encoding: 8bit Delivery of your message to the following recipient(s) failed permanently: - pepe@africa.org - Ãąaca@africa.org Technical details: - "poe@rcpt" (EMAIL) failed permanently with error: oh! horror! - "muchos@rcpt" (EMAIL) failed permanently with error: 550 5.7.1 [11:22:33:44::1] Our system has detected that this 5.7.1 message is likely unsolicited mail. To reduce the amount of spam sent 5.7.1 to BlahMail, this message has been blocked. Please visit 5.7.1 https://support.blah/mail/?p=UnsolicitedMessageError 5.7.1 for more information. a1b2c3a1b2c3a1b.123 - bsmtp - "newman@rcpt" (EMAIL) failed repeatedly and timed out, last error: oh! the humanity! --??????????? Content-Type: message/global-delivery-status Content-Description: Delivery Report Content-Transfer-Encoding: 8bit Reporting-MTA: dns; dsnDomain Original-Recipient: utf-8; Ãąaca@africa.org Final-Recipient: utf-8; poe@rcpt Action: failed Status: 5.0.0 Diagnostic-Code: smtp; oh! horror! Original-Recipient: utf-8; pepe@africa.org Final-Recipient: utf-8; muchos@rcpt Action: failed Status: 5.0.0 Diagnostic-Code: smtp; 550 5.7.1 [11:22:33:44::1] Our system has detected that this 5.7.1 message is likely unsolicited mail. To reduce the amount of spam sent 5.7.1 to BlahMail, this message has been blocked. Please visit 5.7.1 https://support.blah/mail/?p=UnsolicitedMessageError 5.7.1 for more information. a1b2c3a1b2c3a1b.123 - bsmtp Original-Recipient: utf-8; Ãąaca@africa.org Final-Recipient: utf-8; newman@rcpt Action: failed Status: 4.0.0 Diagnostic-Code: smtp; oh! the humanity! --??????????? Content-Type: message/rfc822 Content-Description: Undelivered Message Content-Transfer-Encoding: 8bit Message-ID: Data Ãąaca. --???????????-- ` // flexibleEq compares two strings, supporting wildcards. // Not particularly nice or robust, only useful for testing. func flexibleEq(expected, got string) bool { posG := 0 for i := 0; i < len(expected); i++ { if posG >= len(got) { return false } c := expected[i] if c == '?' { posG++ continue } else if c == '*' { for got[posG] != '\n' { posG++ } continue } else if byte(c) != got[posG] { return false } posG++ } return true } // printDiff prints the difference between the strings using the given // function. This is a _horrible_ implementation, only useful for testing. func printDiff(print func(s string), expected, got string) { lines := []string{} // expected lines and map. eM := map[string]int{} for _, l := range strings.Split(expected, "\n") { eM[l]++ lines = append(lines, l) } // got lines and map. gM := map[string]int{} for _, l := range strings.Split(got, "\n") { gM[l]++ lines = append(lines, l) } // sort the lines, to make it easier to see the differences (this works // ok when there's few, horrible when there's lots). sort.Strings(lines) // print diff of expected vs. got seen := map[string]bool{} print("E G | Line") for _, l := range lines { if !seen[l] && eM[l] != gM[l] { print(fmt.Sprintf("%2d %2d | %q", eM[l], gM[l], l)) seen[l] = true } } } chasquid-1.15.0/internal/queue/queue.go000066400000000000000000000313201474251645300200240ustar00rootroot00000000000000// 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=. --go_opt=paths=source_relative -I=${GOPATH}/src -I. queue.proto import ( "bytes" "context" "encoding/base64" "fmt" "math/rand" "os" "os/exec" "path/filepath" "strings" "sync" "time" "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/expvarom" "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" "golang.org/x/net/idna" ) const ( // Maximum size of the queue; we reject emails when we hit this. maxQueueSize = 200 // Give up sending attempts after this duration. giveUpAfter = 20 * time.Hour // Prefix for item file names. // This is for convenience, versioning, and to be able to tell them apart // temporary files and other cruft. // It's important that it's outside the base64 space so it doesn't get // generated accidentally. itemFilePrefix = "m:" ) var ( errQueueFull = fmt.Errorf("Queue size too big, try again later") ) // Exported variables. var ( putCount = expvarom.NewInt("chasquid/queue/putCount", "count of envelopes attempted to be put in the queue") itemsWritten = expvarom.NewInt("chasquid/queue/itemsWritten", "count of items the queue wrote to disk") dsnQueued = expvarom.NewInt("chasquid/queue/dsnQueued", "count of DSNs that we generated (queued)") deliverAttempts = expvarom.NewMap("chasquid/queue/deliverAttempts", "recipient_type", "attempts to deliver mail, by recipient type") ) // 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, error) { err := os.MkdirAll(path, 0700) q := &Queue{ q: map[string]*Item{}, localC: localC, remoteC: remoteC, localDomains: localDomains, path: path, aliases: aliases, } return q, err } // Load the queue and launch the sending loops on startup. func (q *Queue) Load() error { files, err := filepath.Glob(q.path + "/" + itemFilePrefix + "*") if err != nil { return err } for _, fname := range files { item, err := ItemFromFile(fname) if err != nil { log.Errorf("error loading queue item from %q: %v", fname, err) continue } q.mu.Lock() q.q[item.ID] = item q.mu.Unlock() go item.SendLoop(q) } return nil } // Len returns the number of elements in the queue. func (q *Queue) Len() int { q.mu.RLock() defer q.mu.RUnlock() return len(q.q) } // Put an envelope in the queue. func (q *Queue) Put(tr *trace.Trace, from string, to []string, data []byte) (string, error) { tr = tr.NewChild("Queue.Put", from) defer tr.Finish() if q.Len() >= maxQueueSize { tr.Errorf("queue full") 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(tr, 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 "", tr.Errorf("internal error - unknown alias type") } item.Rcpt = append(item.Rcpt, r) tr.Debugf("recipient: %v", r.Address) } } err := item.WriteTo(q.path) if err != nil { return "", tr.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) tr.Debugf("queued") 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 := "# 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 += "\n" } return s } // An Item in the queue. type Item struct { // Base the item on the protobuf message. // We will use this for serialization, so any fields below are NOT // serialized. Message // Protect the entire item. sync.Mutex // Go-friendly version of Message.CreatedAtTs. CreatedAt time.Time } // ItemFromFile loads an item from the given file. func ItemFromFile(fname string) (*Item, error) { item := &Item{} err := protoio.ReadTextMessage(fname, &item.Message) if err != nil { return nil, err } item.CreatedAt = timeFromProto(item.CreatedAtTs) return item, nil } // WriteTo saves an item to the given directory. func (item *Item) WriteTo(dir string) error { item.Lock() defer item.Unlock() itemsWritten.Add(1) item.CreatedAtTs = timeToProto(item.CreatedAt) path := fmt.Sprintf("%s/%s%s", dir, itemFilePrefix, item.ID) return protoio.WriteTextMessage(path, &item.Message, 0600) } // SendLoop repeatedly attempts to send the item. func (item *Item) SendLoop(q *Queue) { tr := trace.New("Queue.SendLoop", item.ID) defer tr.Finish() tr.Printf("from %s", item.From) for time.Since(item.CreatedAt) < giveUpAfter { // Send to all recipients that are still pending. var wg sync.WaitGroup for _, rcpt := range item.Rcpt { if rcpt.Status != Recipient_PENDING { continue } wg.Add(1) go item.sendOneRcpt(&wg, tr, q, rcpt) } wg.Wait() // If they're all done, no need to wait. if item.countRcpt(Recipient_PENDING) == 0 { break } // TODO: Consider sending a non-final notification after 30m or so, // that some of the messages have been delayed. delay := nextDelay(item.CreatedAt) tr.Printf("waiting for %v", delay) maillog.QueueLoop(item.ID, item.From, delay) time.Sleep(delay) } // Completed to all recipients (some may not have succeeded). if item.countRcpt(Recipient_FAILED, Recipient_PENDING) > 0 && item.From != "<>" { sendDSN(tr, q, item) } tr.Printf("all done") maillog.QueueLoop(item.ID, item.From, 0) q.Remove(item.ID) } // sendOneRcpt, and update it with the results. func (item *Item) sendOneRcpt(wg *sync.WaitGroup, tr *trace.Trace, q *Queue, rcpt *Recipient) { defer wg.Done() to := rcpt.Address tr.Debugf("%s sending", to) err, permanent := item.deliver(q, rcpt) item.Lock() if err != nil { rcpt.LastFailureMessage = err.Error() if permanent { tr.Errorf("%s permanent error: %v", to, err) maillog.SendAttempt(item.ID, item.From, to, err, true) rcpt.Status = Recipient_FAILED } else { tr.Printf("%s temporary error: %v", to, err) maillog.SendAttempt(item.ID, item.From, to, err, false) } } else { tr.Printf("%s sent", to) maillog.SendAttempt(item.ID, item.From, to, nil, false) rcpt.Status = Recipient_SENT } item.Unlock() err = item.WriteTo(q.path) if err != nil { tr.Errorf("failed to write: %v", err) } } // deliver the item to the given recipient, using the couriers from the queue. // Return an error (if any), and whether it is permanent or not. func (item *Item) deliver(q *Queue, rcpt *Recipient) (err error, permanent bool) { if rcpt.Type == Recipient_PIPE { deliverAttempts.Add("pipe", 1) c := strings.Fields(rcpt.Address) if len(c) == 0 { return fmt.Errorf("empty pipe"), true } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() cmd := exec.CommandContext(ctx, c[0], c[1:]...) cmd.Stdin = bytes.NewReader(item.Data) return cmd.Run(), true } // Recipient type is EMAIL. if envelope.DomainIn(rcpt.Address, q.localDomains) { deliverAttempts.Add("email:local", 1) return q.localC.Deliver(item.From, rcpt.Address, item.Data) } deliverAttempts.Add("email:remote", 1) from := item.From if !envelope.DomainIn(item.From, q.localDomains) { // We're sending from a non-local to a non-local. This should // happen only when there's an alias to forward email to a // non-local domain. In this case, using the original From is // problematic, as we may not be an authorized sender for this. // Some MTAs (like Exim) will do it anyway, others (like // gmail) will construct a special address based on the // original address. We go with the latter. // Note this assumes "+" is an alias suffix separator. // We use the IDNA version of the domain if possible, because // we can't know if the other side will support SMTPUTF8. from = fmt.Sprintf("%s+fwd_from=%s@%s", envelope.UserOf(rcpt.OriginalAddress), strings.Replace(from, "@", "=", -1), mustIDNAToASCII(envelope.DomainOf(rcpt.OriginalAddress))) } return q.remoteC.Deliver(from, rcpt.Address, item.Data) } // countRcpt counts how many recipients are in the given status. func (item *Item) countRcpt(statuses ...Recipient_Status) int { c := 0 for _, rcpt := range item.Rcpt { for _, status := range statuses { if rcpt.Status == status { c++ break } } } return c } func sendDSN(tr *trace.Trace, q *Queue, item *Item) { tr.Debugf("sending DSN") // Pick a (local) domain to send the DSN from. We should always find one, // as otherwise we're relaying. domain := "unknown" if item.From != "<>" && envelope.DomainIn(item.From, q.localDomains) { domain = envelope.DomainOf(item.From) } else { for _, rcpt := range item.Rcpt { if envelope.DomainIn(rcpt.OriginalAddress, q.localDomains) { domain = envelope.DomainOf(rcpt.OriginalAddress) break } } } msg, err := deliveryStatusNotification(domain, item) if err != nil { tr.Errorf("failed to build DSN: %v", err) return } // TODO: DKIM signing. id, err := q.Put(tr, "<>", []string{item.From}, msg) if err != nil { tr.Errorf("failed to queue DSN: %v", err) return } tr.Printf("queued DSN: %s", id) dsnQueued.Add(1) } func nextDelay(createdAt time.Time) time.Duration { var delay time.Duration since := time.Since(createdAt) switch { case since < 1*time.Minute: delay = 1 * time.Minute case since < 5*time.Minute: delay = 5 * time.Minute case since < 10*time.Minute: delay = 10 * time.Minute default: delay = 20 * time.Minute } // Perturb the delay, to avoid all queued emails to be retried at the // exact same time after a restart. delay += time.Duration(rand.Intn(60)) * time.Second return delay } func mustIDNAToASCII(s string) string { a, err := idna.ToASCII(s) if err != nil { return a } return s } func timeFromProto(ts *Timestamp) time.Time { return time.Unix(ts.Seconds, int64(ts.Nanos)).UTC() } func timeToProto(t time.Time) *Timestamp { return &Timestamp{ Seconds: t.Unix(), Nanos: int32(t.Nanosecond()), } } chasquid-1.15.0/internal/queue/queue.pb.go000066400000000000000000000357611474251645300204410ustar00rootroot00000000000000// Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.30.0 // protoc v3.21.12 // source: queue.proto package queue import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" ) const ( // Verify that this generated code is sufficiently up-to-date. _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) // Verify that runtime/protoimpl is sufficiently up-to-date. _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) type Recipient_Type int32 const ( Recipient_EMAIL Recipient_Type = 0 Recipient_PIPE Recipient_Type = 1 ) // Enum value maps for Recipient_Type. var ( Recipient_Type_name = map[int32]string{ 0: "EMAIL", 1: "PIPE", } Recipient_Type_value = map[string]int32{ "EMAIL": 0, "PIPE": 1, } ) func (x Recipient_Type) Enum() *Recipient_Type { p := new(Recipient_Type) *p = x return p } func (x Recipient_Type) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Recipient_Type) Descriptor() protoreflect.EnumDescriptor { return file_queue_proto_enumTypes[0].Descriptor() } func (Recipient_Type) Type() protoreflect.EnumType { return &file_queue_proto_enumTypes[0] } func (x Recipient_Type) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Recipient_Type.Descriptor instead. func (Recipient_Type) EnumDescriptor() ([]byte, []int) { return file_queue_proto_rawDescGZIP(), []int{1, 0} } type Recipient_Status int32 const ( Recipient_PENDING Recipient_Status = 0 Recipient_SENT Recipient_Status = 1 Recipient_FAILED Recipient_Status = 2 ) // Enum value maps for Recipient_Status. var ( Recipient_Status_name = map[int32]string{ 0: "PENDING", 1: "SENT", 2: "FAILED", } Recipient_Status_value = map[string]int32{ "PENDING": 0, "SENT": 1, "FAILED": 2, } ) func (x Recipient_Status) Enum() *Recipient_Status { p := new(Recipient_Status) *p = x return p } func (x Recipient_Status) String() string { return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) } func (Recipient_Status) Descriptor() protoreflect.EnumDescriptor { return file_queue_proto_enumTypes[1].Descriptor() } func (Recipient_Status) Type() protoreflect.EnumType { return &file_queue_proto_enumTypes[1] } func (x Recipient_Status) Number() protoreflect.EnumNumber { return protoreflect.EnumNumber(x) } // Deprecated: Use Recipient_Status.Descriptor instead. func (Recipient_Status) EnumDescriptor() ([]byte, []int) { return file_queue_proto_rawDescGZIP(), []int{1, 1} } type Message struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields // Message ID. Uniquely identifies this message, it is used for // auditing and troubleshooting. ID string `protobuf:"bytes,1,opt,name=ID,proto3" json:"ID,omitempty"` // The envelope for this message. From string `protobuf:"bytes,2,opt,name=from,proto3" json:"from,omitempty"` To []string `protobuf:"bytes,3,rep,name=To,proto3" json:"To,omitempty"` Rcpt []*Recipient `protobuf:"bytes,4,rep,name=rcpt,proto3" json:"rcpt,omitempty"` Data []byte `protobuf:"bytes,5,opt,name=data,proto3" json:"data,omitempty"` // Creation timestamp. CreatedAtTs *Timestamp `protobuf:"bytes,6,opt,name=created_at_ts,json=createdAtTs,proto3" json:"created_at_ts,omitempty"` } func (x *Message) Reset() { *x = Message{} if protoimpl.UnsafeEnabled { mi := &file_queue_proto_msgTypes[0] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Message) String() string { return protoimpl.X.MessageStringOf(x) } func (*Message) ProtoMessage() {} func (x *Message) ProtoReflect() protoreflect.Message { mi := &file_queue_proto_msgTypes[0] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Message.ProtoReflect.Descriptor instead. func (*Message) Descriptor() ([]byte, []int) { return file_queue_proto_rawDescGZIP(), []int{0} } func (x *Message) GetID() string { if x != nil { return x.ID } return "" } func (x *Message) GetFrom() string { if x != nil { return x.From } return "" } func (x *Message) GetTo() []string { if x != nil { return x.To } return nil } func (x *Message) GetRcpt() []*Recipient { if x != nil { return x.Rcpt } return nil } func (x *Message) GetData() []byte { if x != nil { return x.Data } return nil } func (x *Message) GetCreatedAtTs() *Timestamp { if x != nil { return x.CreatedAtTs } return nil } type Recipient struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields // Address to send the message to. // This is the final one, after expanding aliases. Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` Type Recipient_Type `protobuf:"varint,2,opt,name=type,proto3,enum=queue.Recipient_Type" json:"type,omitempty"` Status Recipient_Status `protobuf:"varint,3,opt,name=status,proto3,enum=queue.Recipient_Status" json:"status,omitempty"` LastFailureMessage string `protobuf:"bytes,4,opt,name=last_failure_message,json=lastFailureMessage,proto3" json:"last_failure_message,omitempty"` // Address that this recipient was originally intended to. // This is before expanding aliases and only used in very particular // cases. OriginalAddress string `protobuf:"bytes,5,opt,name=original_address,json=originalAddress,proto3" json:"original_address,omitempty"` } func (x *Recipient) Reset() { *x = Recipient{} if protoimpl.UnsafeEnabled { mi := &file_queue_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Recipient) String() string { return protoimpl.X.MessageStringOf(x) } func (*Recipient) ProtoMessage() {} func (x *Recipient) ProtoReflect() protoreflect.Message { mi := &file_queue_proto_msgTypes[1] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Recipient.ProtoReflect.Descriptor instead. func (*Recipient) Descriptor() ([]byte, []int) { return file_queue_proto_rawDescGZIP(), []int{1} } func (x *Recipient) GetAddress() string { if x != nil { return x.Address } return "" } func (x *Recipient) GetType() Recipient_Type { if x != nil { return x.Type } return Recipient_EMAIL } func (x *Recipient) GetStatus() Recipient_Status { if x != nil { return x.Status } return Recipient_PENDING } func (x *Recipient) GetLastFailureMessage() string { if x != nil { return x.LastFailureMessage } return "" } func (x *Recipient) GetOriginalAddress() string { if x != nil { return x.OriginalAddress } return "" } // Timestamp representation, for convenience. // We used to use the well-known type, but the dependency makes packaging much // more convoluted and adds very little value, so we now just include it here. type Timestamp struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields // Represents seconds of UTC time since Unix epoch. Seconds int64 `protobuf:"varint,1,opt,name=seconds,proto3" json:"seconds,omitempty"` // Non-negative fractions of a second at nanosecond resolution. Nanos int32 `protobuf:"varint,2,opt,name=nanos,proto3" json:"nanos,omitempty"` } func (x *Timestamp) Reset() { *x = Timestamp{} if protoimpl.UnsafeEnabled { mi := &file_queue_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } } func (x *Timestamp) String() string { return protoimpl.X.MessageStringOf(x) } func (*Timestamp) ProtoMessage() {} func (x *Timestamp) ProtoReflect() protoreflect.Message { mi := &file_queue_proto_msgTypes[2] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { ms.StoreMessageInfo(mi) } return ms } return mi.MessageOf(x) } // Deprecated: Use Timestamp.ProtoReflect.Descriptor instead. func (*Timestamp) Descriptor() ([]byte, []int) { return file_queue_proto_rawDescGZIP(), []int{2} } func (x *Timestamp) GetSeconds() int64 { if x != nil { return x.Seconds } return 0 } func (x *Timestamp) GetNanos() int32 { if x != nil { return x.Nanos } return 0 } var File_queue_proto protoreflect.FileDescriptor var file_queue_proto_rawDesc = []byte{ 0x0a, 0x0b, 0x71, 0x75, 0x65, 0x75, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x05, 0x71, 0x75, 0x65, 0x75, 0x65, 0x22, 0xad, 0x01, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x49, 0x44, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x49, 0x44, 0x12, 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x54, 0x6f, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x02, 0x54, 0x6f, 0x12, 0x24, 0x0a, 0x04, 0x72, 0x63, 0x70, 0x74, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x2e, 0x52, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x52, 0x04, 0x72, 0x63, 0x70, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x34, 0x0a, 0x0d, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x5f, 0x74, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x54, 0x73, 0x22, 0xa8, 0x02, 0x0a, 0x09, 0x52, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x29, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x2e, 0x52, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x71, 0x75, 0x65, 0x75, 0x65, 0x2e, 0x52, 0x65, 0x63, 0x69, 0x70, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x30, 0x0a, 0x14, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x66, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x6c, 0x61, 0x73, 0x74, 0x46, 0x61, 0x69, 0x6c, 0x75, 0x72, 0x65, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x29, 0x0a, 0x10, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x6f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x61, 0x6c, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x22, 0x1b, 0x0a, 0x04, 0x54, 0x79, 0x70, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x4d, 0x41, 0x49, 0x4c, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x50, 0x49, 0x50, 0x45, 0x10, 0x01, 0x22, 0x2b, 0x0a, 0x06, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x0b, 0x0a, 0x07, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, 0x12, 0x08, 0x0a, 0x04, 0x53, 0x45, 0x4e, 0x54, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x02, 0x22, 0x3b, 0x0a, 0x09, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x03, 0x52, 0x07, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x6e, 0x61, 0x6e, 0x6f, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x6e, 0x61, 0x6e, 0x6f, 0x73, 0x42, 0x2b, 0x5a, 0x29, 0x62, 0x6c, 0x69, 0x74, 0x69, 0x72, 0x69, 0x2e, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x72, 0x2f, 0x67, 0x6f, 0x2f, 0x63, 0x68, 0x61, 0x73, 0x71, 0x75, 0x69, 0x64, 0x2f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x2f, 0x71, 0x75, 0x65, 0x75, 0x65, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( file_queue_proto_rawDescOnce sync.Once file_queue_proto_rawDescData = file_queue_proto_rawDesc ) func file_queue_proto_rawDescGZIP() []byte { file_queue_proto_rawDescOnce.Do(func() { file_queue_proto_rawDescData = protoimpl.X.CompressGZIP(file_queue_proto_rawDescData) }) return file_queue_proto_rawDescData } var file_queue_proto_enumTypes = make([]protoimpl.EnumInfo, 2) var file_queue_proto_msgTypes = make([]protoimpl.MessageInfo, 3) var file_queue_proto_goTypes = []interface{}{ (Recipient_Type)(0), // 0: queue.Recipient.Type (Recipient_Status)(0), // 1: queue.Recipient.Status (*Message)(nil), // 2: queue.Message (*Recipient)(nil), // 3: queue.Recipient (*Timestamp)(nil), // 4: queue.Timestamp } var file_queue_proto_depIdxs = []int32{ 3, // 0: queue.Message.rcpt:type_name -> queue.Recipient 4, // 1: queue.Message.created_at_ts:type_name -> queue.Timestamp 0, // 2: queue.Recipient.type:type_name -> queue.Recipient.Type 1, // 3: queue.Recipient.status:type_name -> queue.Recipient.Status 4, // [4:4] is the sub-list for method output_type 4, // [4:4] is the sub-list for method input_type 4, // [4:4] is the sub-list for extension type_name 4, // [4:4] is the sub-list for extension extendee 0, // [0:4] is the sub-list for field type_name } func init() { file_queue_proto_init() } func file_queue_proto_init() { if File_queue_proto != nil { return } if !protoimpl.UnsafeEnabled { file_queue_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Message); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_queue_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Recipient); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_queue_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Timestamp); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_queue_proto_rawDesc, NumEnums: 2, NumMessages: 3, NumExtensions: 0, NumServices: 0, }, GoTypes: file_queue_proto_goTypes, DependencyIndexes: file_queue_proto_depIdxs, EnumInfos: file_queue_proto_enumTypes, MessageInfos: file_queue_proto_msgTypes, }.Build() File_queue_proto = out.File file_queue_proto_rawDesc = nil file_queue_proto_goTypes = nil file_queue_proto_depIdxs = nil } chasquid-1.15.0/internal/queue/queue.proto000066400000000000000000000023771474251645300205740ustar00rootroot00000000000000 syntax = "proto3"; package queue; option go_package = "blitiri.com.ar/go/chasquid/internal/queue"; 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. 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; } // Timestamp representation, for convenience. // We used to use the well-known type, but the dependency makes packaging much // more convoluted and adds very little value, so we now just include it here. message Timestamp { // Represents seconds of UTC time since Unix epoch. int64 seconds = 1; // Non-negative fractions of a second at nanosecond resolution. int32 nanos = 2; } chasquid-1.15.0/internal/queue/queue_test.go000066400000000000000000000175371474251645300211010ustar00rootroot00000000000000package queue import ( "bytes" "fmt" "strings" "testing" "time" "blitiri.com.ar/go/chasquid/internal/aliases" "blitiri.com.ar/go/chasquid/internal/set" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/trace" ) func allUsersExist(tr *trace.Trace, user, domain string) (bool, error) { return true, nil } func TestBasic(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) localC := testlib.NewTestCourier() remoteC := testlib.NewTestCourier() q, _ := New(dir, set.NewString("loco"), aliases.NewResolver(allUsersExist), localC, remoteC) tr := trace.New("test", "TestBasic") defer tr.Finish() localC.Expect(2) remoteC.Expect(1) id, err := q.Put(tr, "from", []string{"am@loco", "x@remote", "nodomain"}, []byte("data")) if err != nil { t.Fatalf("Put: %v", err) } if len(id) < 6 { t.Errorf("short ID: %v", id) } localC.Wait() remoteC.Wait() // Make sure the delivered items leave the queue. testlib.WaitFor(func() bool { return q.Len() == 0 }, 2*time.Second) if q.Len() != 0 { t.Fatalf("%d items not removed from the queue after delivery", q.Len()) } cases := []struct { courier *testlib.TestCourier expectedTo string }{ {localC, "nodomain"}, {localC, "am@loco"}, {remoteC, "x@remote"}, } for _, c := range cases { req := c.courier.ReqFor[c.expectedTo] if req == nil { t.Errorf("missing request for %q", c.expectedTo) continue } if req.From != "from" || req.To != c.expectedTo || !bytes.Equal(req.Data, []byte("data")) { t.Errorf("wrong request for %q: %v", c.expectedTo, req) } } } func TestDSNOnTimeout(t *testing.T) { localC := testlib.NewTestCourier() remoteC := testlib.NewTestCourier() dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q, _ := New(dir, set.NewString("loco"), aliases.NewResolver(allUsersExist), localC, remoteC) // Insert an expired item in the queue. item := &Item{ Message: Message{ ID: <-newID, From: "from@loco", Rcpt: []*Recipient{ mkR("to@to", Recipient_EMAIL, Recipient_PENDING, "err", "to@to")}, Data: []byte("data"), }, CreatedAt: time.Now().Add(-24 * time.Hour), } q.q[item.ID] = item err := item.WriteTo(q.path) if err != nil { t.Errorf("failed to write item: %v", err) } // Exercise DumpString while at it. q.DumpString() // Launch the sending loop, expect 1 local delivery (the DSN). localC.Expect(1) go item.SendLoop(q) localC.Wait() req := localC.ReqFor["from@loco"] if req == nil { t.Fatal("missing DSN") } if req.From != "<>" || req.To != "from@loco" || !strings.Contains(string(req.Data), "X-Failed-Recipients: to@to,") { t.Errorf("wrong DSN: %q", string(req.Data)) } } func TestAliases(t *testing.T) { localC := testlib.NewTestCourier() remoteC := testlib.NewTestCourier() dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q, _ := New(dir, set.NewString("loco"), aliases.NewResolver(allUsersExist), localC, remoteC) tr := trace.New("test", "TestAliases") defer tr.Finish() q.aliases.AddDomain("loco") q.aliases.AddAliasForTesting("ab@loco", "pq@loco", aliases.EMAIL) q.aliases.AddAliasForTesting("ab@loco", "rs@loco", aliases.EMAIL) q.aliases.AddAliasForTesting("cd@loco", "ata@hualpa", aliases.EMAIL) // Note the pipe aliases are tested below, as they don't use the couriers // and it can be quite inconvenient to test them in this way. localC.Expect(2) remoteC.Expect(1) _, err := q.Put(tr, "from", []string{"ab@loco", "cd@loco"}, []byte("data")) if err != nil { t.Fatalf("Put: %v", err) } localC.Wait() remoteC.Wait() cases := []struct { courier *testlib.TestCourier expectedTo string }{ {localC, "pq@loco"}, {localC, "rs@loco"}, {remoteC, "ata@hualpa"}, } for _, c := range cases { req := c.courier.ReqFor[c.expectedTo] if req == nil { t.Errorf("missing request for %q", c.expectedTo) continue } if req.From != "from" || req.To != c.expectedTo || !bytes.Equal(req.Data, []byte("data")) { t.Errorf("wrong request for %q: %v", c.expectedTo, req) } } } func TestFullQueue(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) q, _ := New(dir, set.NewString(), aliases.NewResolver(allUsersExist), testlib.DumbCourier, testlib.DumbCourier) tr := trace.New("test", "TestFullQueue") defer tr.Finish() // Force-insert maxQueueSize items in the queue. oneID := "" for i := 0; i < maxQueueSize; i++ { item := &Item{ Message: Message{ ID: <-newID, From: fmt.Sprintf("from-%d", i), Rcpt: []*Recipient{ mkR("to", Recipient_EMAIL, Recipient_PENDING, "", "")}, Data: []byte("data"), }, CreatedAt: time.Now(), } q.q[item.ID] = item oneID = item.ID } // This one should fail due to the queue being too big. id, err := q.Put(tr, "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(tr, "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(allUsersExist), testlib.DumbCourier, testlib.DumbCourier) item := &Item{ Message: Message{ ID: <-newID, From: "from", Rcpt: []*Recipient{ mkR("true", Recipient_PIPE, Recipient_PENDING, "", "")}, Data: []byte("data"), }, CreatedAt: time.Now(), } if err, _ := item.deliver(q, item.Rcpt[0]); err != nil { t.Errorf("pipe delivery failed: %v", err) } } func TestBadPath(t *testing.T) { // A new queue will attempt to os.MkdirAll the path. // We expect this path to fail. _, err := New("/proc/doesnotexist", set.NewString("loco"), aliases.NewResolver(allUsersExist), testlib.DumbCourier, testlib.DumbCourier) if err == nil { t.Errorf("could create queue, expected permission denied") } } func TestNextDelay(t *testing.T) { cases := []struct{ since, min time.Duration }{ {10 * time.Second, 1 * time.Minute}, {3 * time.Minute, 5 * time.Minute}, {7 * time.Minute, 10 * time.Minute}, {15 * time.Minute, 20 * time.Minute}, {30 * time.Minute, 20 * time.Minute}, } for _, c := range cases { // Repeat each case a few times to exercise the perturbation a bit. for i := 0; i < 10; i++ { delay := nextDelay(time.Now().Add(-c.since)) max := c.min + 1*time.Minute if delay < c.min || delay > max { t.Errorf("since:%v expected [%v, %v], got %v", c.since, c.min, max, delay) } } } } func TestSerialization(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) // Save an item in the queue directory. item := &Item{ Message: Message{ ID: <-newID, From: "from@loco", Rcpt: []*Recipient{ mkR("to@to", Recipient_EMAIL, Recipient_PENDING, "err", "to@to")}, Data: []byte("data"), }, CreatedAt: time.Now().Add(-1 * time.Hour), } err := item.WriteTo(dir) if err != nil { t.Errorf("failed to write item: %v", err) } // Create the queue; should load the remoteC := testlib.NewTestCourier() remoteC.Expect(1) q, _ := New(dir, set.NewString("loco"), aliases.NewResolver(allUsersExist), testlib.DumbCourier, remoteC) q.Load() // Launch the sending loop, expect 1 remote delivery for the item we saved. remoteC.Wait() req := remoteC.ReqFor["to@to"] if req == nil { t.Fatal("email not delivered") } if req.From != "from@loco" || req.To != "to@to" { t.Errorf("wrong email: %v", req) } } func mkR(a string, t Recipient_Type, s Recipient_Status, m, o string) *Recipient { return &Recipient{ Address: a, Type: t, Status: s, LastFailureMessage: m, OriginalAddress: o, } } chasquid-1.15.0/internal/safeio/000077500000000000000000000000001474251645300164745ustar00rootroot00000000000000chasquid-1.15.0/internal/safeio/safeio.go000066400000000000000000000046351474251645300203010ustar00rootroot00000000000000// Package safeio implements convenient I/O routines that provide additional // levels of safety in the presence of unexpected failures. package safeio import ( "os" "path" "syscall" ) // osFile is an interface to the methods of os.File that we need, so we can // simulate failures in tests. type osFile interface { Name() string Chmod(os.FileMode) error Chown(int, int) error Write([]byte) (int, error) Close() error } var createTemp func(dir, pattern string) (osFile, error) = func( dir, pattern string) (osFile, error) { return os.CreateTemp(dir, pattern) } // 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 os.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 := createTemp(path.Dir(filename), "."+path.Base(filename)) if err != nil { return err } if err = tmpf.Chmod(perm); err != nil { tmpf.Close() os.Remove(tmpf.Name()) return err } if uid, gid := getOwner(filename); uid >= 0 { if err = tmpf.Chown(uid, gid); err != nil { tmpf.Close() os.Remove(tmpf.Name()) return err } } if _, err = tmpf.Write(data); err != nil { tmpf.Close() os.Remove(tmpf.Name()) return err } if err = tmpf.Close(); err != nil { os.Remove(tmpf.Name()) return err } for _, op := range ops { if err = op(tmpf.Name()); err != nil { os.Remove(tmpf.Name()) return err } } return os.Rename(tmpf.Name(), filename) } func getOwner(fname string) (uid, gid int) { uid = -1 gid = -1 stat, err := os.Stat(fname) if err == nil { if sysstat, ok := stat.Sys().(*syscall.Stat_t); ok { uid = int(sysstat.Uid) gid = int(sysstat.Gid) } } return } chasquid-1.15.0/internal/safeio/safeio_test.go000066400000000000000000000130221474251645300213260ustar00rootroot00000000000000package safeio import ( "bytes" "errors" "fmt" "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 := os.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) } } type testFile struct { t *testing.T name string expectChmod os.FileMode chmodErr error expectChownUid, expectChownGid int chownErr error expectWrite []byte writeN int writeErr error closeErr error } func (f *testFile) Name() string { return f.name } func (f *testFile) Chmod(perm os.FileMode) error { if f.expectChmod != perm { f.t.Errorf("unexpected Chmod(%v), expected Chmod(%v)", perm, f.expectChmod) } return f.chmodErr } func (f *testFile) Chown(uid, gid int) error { if f.expectChownUid != uid || f.expectChownGid != gid { f.t.Errorf("unexpected Chown(%v, %v), expected Chown(%v, %v)", uid, gid, f.expectChownUid, f.expectChownGid) } return f.chownErr } func (f *testFile) Write(b []byte) (int, error) { if !bytes.Equal(b, f.expectWrite) { f.t.Errorf("unexpected Write(%q), expected Write(%q)", b, f.expectWrite) } return f.writeN, f.writeErr } func (f *testFile) Close() error { return f.closeErr } var _ osFile = &testFile{} func TestErrors(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) oldCreateTemp := createTemp defer func() { createTemp = oldCreateTemp }() // createTemp failure. ctError := errors.New("createTemp error") createTemp = func(dir, pattern string) (osFile, error) { return nil, ctError } err := WriteFile("fname", []byte("new content"), 0660) if err != ctError { t.Errorf("expected %v, got %v", ctError, err) } // Have a real backing file for some of the operations, like getting the // owner. fname := dir + "/file1" // Test file to simulate failures on. tf := &testFile{name: fname, t: t} createTemp = func(dir, pattern string) (osFile, error) { return tf, nil } // Test Chmod error. testlib.Rewrite(t, fname, "old content") tf.expectChmod = 0660 tf.chmodErr = errors.New("chmod error") err = WriteFile(fname, []byte("new content"), 0660) if err != tf.chmodErr { t.Errorf("expected %v, got %v", tf.chmodErr, err) } checkNotExists(t, fname) // Test Chown error. testlib.Rewrite(t, fname, "old content") tf.chmodErr = nil tf.expectChownUid, tf.expectChownGid = getOwner(fname) if tf.expectChownUid < 0 { t.Fatalf("error getting owner of %v", fname) } tf.chownErr = errors.New("chown error") err = WriteFile(fname, []byte("new content"), 0660) if err != tf.chownErr { t.Errorf("expected %v, got %v", tf.chownErr, err) } checkNotExists(t, fname) // Test Write error. testlib.Rewrite(t, fname, "old content") tf.chownErr = nil tf.expectWrite = []byte("new content") tf.writeErr = errors.New("write error") err = WriteFile(fname, []byte("new content"), 0660) if err != tf.writeErr { t.Errorf("expected %v, got %v", tf.writeErr, err) } checkNotExists(t, fname) // Test Close error. testlib.Rewrite(t, fname, "old content") tf.writeErr = nil tf.writeN = len(tf.expectWrite) tf.closeErr = errors.New("close error") err = WriteFile(fname, []byte("new content"), 0660) if err != tf.closeErr { t.Errorf("expected %v, got %v", tf.closeErr, err) } checkNotExists(t, fname) } func checkNotExists(t *testing.T, fname string) { t.Helper() if _, err := os.Stat(fname); err == nil { t.Fatalf("file %v exists", fname) } } chasquid-1.15.0/internal/set/000077500000000000000000000000001474251645300160215ustar00rootroot00000000000000chasquid-1.15.0/internal/set/set.go000066400000000000000000000014401474251645300171420ustar00rootroot00000000000000// Package set implement sets for various types. Well, only string for now :) package set // String set. type String struct { m map[string]struct{} } // NewString returns a new string set, with the given values in it. func NewString(values ...string) *String { s := &String{} s.Add(values...) return s } // Add values to the string set. func (s *String) Add(values ...string) { if s.m == nil { s.m = map[string]struct{}{} } for _, v := range values { s.m[v] = struct{}{} } } // Has checks if the set has the given value. func (s *String) Has(value string) bool { // We explicitly allow s to be nil *in this function* to simplify callers' // code. Note that Add will not tolerate it, and will panic. if s == nil || s.m == nil { return false } _, ok := s.m[value] return ok } chasquid-1.15.0/internal/set/set_test.go000066400000000000000000000014501474251645300202020ustar00rootroot00000000000000package set import "testing" func TestString(t *testing.T) { s1 := &String{} // Test that Has works on a new set. if s1.Has("x") { t.Error("'x' is in the empty set") } s1.Add("a") s1.Add("b", "ccc") expectStrings(s1, []string{"a", "b", "ccc"}, []string{"notin"}, t) s2 := NewString("a", "b", "c") expectStrings(s2, []string{"a", "b", "c"}, []string{"notin"}, t) // Test that Has works (and not panics) on a nil set. var s3 *String if s3.Has("x") { t.Error("'x' is in the nil set") } } func expectStrings(s *String, in []string, notIn []string, t *testing.T) { for _, str := range in { if !s.Has(str) { t.Errorf("String %q not in set, it should be", str) } } for _, str := range notIn { if s.Has(str) { t.Errorf("String %q is in the set, should not be", str) } } } chasquid-1.15.0/internal/smtp/000077500000000000000000000000001474251645300162115ustar00rootroot00000000000000chasquid-1.15.0/internal/smtp/smtp.go000066400000000000000000000103641474251645300175270ustar00rootroot00000000000000// Package smtp implements the Simple Mail Transfer Protocol as defined in RFC // 5321. It extends net/smtp as follows: // // - Supports SMTPUTF8, via MailAndRcpt. // - Adds IsPermanent. package smtp import ( "bufio" "io" "net" "net/smtp" "net/textproto" "unicode" "blitiri.com.ar/go/chasquid/internal/envelope" "golang.org/x/net/idna" ) // A Client represents a client connection to an SMTP server. type Client struct { *smtp.Client } // NewClient uses the given connection to create a new Client. func NewClient(conn net.Conn, host string) (*Client, error) { c, err := smtp.NewClient(conn, host) if err != nil { return nil, err } // Wrap the textproto.Conn reader so we are not exposed to a memory // exhaustion DoS on very long replies from the server. // Limit to 2 MiB total (all replies through the lifetime of the client), // which should be plenty for our uses of SMTP. lr := &io.LimitedReader{R: c.Text.Reader.R, N: 2 * 1024 * 1024} c.Text.Reader.R = bufio.NewReader(lr) return &Client{c}, nil } // cmd sends a command and returns the response over the text connection. // Based on Go's method of the same name. func (c *Client) cmd(expectCode int, format string, args ...interface{}) (int, string, error) { id, err := c.Text.Cmd(format, args...) if err != nil { return 0, "", err } c.Text.StartResponse(id) defer c.Text.EndResponse(id) return c.Text.ReadResponse(expectCode) } // MailAndRcpt issues MAIL FROM and RCPT TO commands, in sequence. // It will check the addresses, decide if SMTPUTF8 is needed, and apply the // necessary transformations. func (c *Client) MailAndRcpt(from string, to string) error { from, fromNeeds, err := c.prepareForSMTPUTF8(from) if err != nil { return err } to, toNeeds, err := c.prepareForSMTPUTF8(to) if err != nil { return err } smtputf8Needed := fromNeeds || toNeeds cmdStr := "MAIL FROM:<%s>" if ok, _ := c.Extension("8BITMIME"); ok { cmdStr += " BODY=8BITMIME" } if smtputf8Needed { cmdStr += " SMTPUTF8" } _, _, err = c.cmd(250, cmdStr, from) if err != nil { return err } _, _, err = c.cmd(25, "RCPT TO:<%s>", to) return err } // prepareForSMTPUTF8 prepares the address for SMTPUTF8. // It returns: // - The address to use. It is based on addr, and possibly modified to make // it not need the extension, if the server does not support it. // - Whether the address needs the extension or not. // - An error if the address needs the extension, but the client does not // support it. func (c *Client) prepareForSMTPUTF8(addr string) (string, bool, error) { // ASCII address pass through. if isASCII(addr) { return addr, false, nil } // Non-ASCII address also pass through if the server supports the // extension. // Note there's a chance the server wants the domain in IDNA anyway, but // it could also require it to be UTF8. We assume that if it supports // SMTPUTF8 then it knows what its doing. if ok, _ := c.Extension("SMTPUTF8"); ok { return addr, true, nil } // Something is not ASCII, and the server does not support SMTPUTF8: // - If it's the local part, there's no way out and is required. // - If it's the domain, use IDNA. user, domain := envelope.Split(addr) if !isASCII(user) { return addr, true, &textproto.Error{Code: 599, Msg: "local part is not ASCII but server does not support SMTPUTF8"} } // If it's only the domain, convert to IDNA and move on. domain, err := idna.ToASCII(domain) if err != nil { // The domain is not IDNA compliant, which is odd. // Fail with a permanent error, not ideal but this should not // happen. return addr, true, &textproto.Error{ Code: 599, Msg: "non-ASCII domain is not IDNA safe"} } return user + "@" + domain, false, nil } // isASCII returns true if all the characters in s are ASCII, false otherwise. func isASCII(s string) bool { for _, c := range s { if c > unicode.MaxASCII { return false } } return true } // IsPermanent returns true if the error is permanent, and false otherwise. // If it can't tell, it returns false. func IsPermanent(err error) bool { terr, ok := err.(*textproto.Error) if !ok { return false } // Error codes 5yz are permanent. // https://tools.ietf.org/html/rfc5321#section-4.2.1 if terr.Code >= 500 && terr.Code < 600 { return true } return false } chasquid-1.15.0/internal/smtp/smtp_test.go000066400000000000000000000127141474251645300205670ustar00rootroot00000000000000package smtp import ( "bufio" "bytes" "fmt" "io" "net" "net/textproto" "strings" "testing" "time" ) func TestIsPermanent(t *testing.T) { cases := []struct { err error permanent bool }{ {&textproto.Error{Code: 499, Msg: ""}, false}, {&textproto.Error{Code: 500, Msg: ""}, true}, {&textproto.Error{Code: 599, Msg: ""}, true}, {&textproto.Error{Code: 600, Msg: ""}, false}, {fmt.Errorf("something"), false}, } for _, c := range cases { if p := IsPermanent(c.err); p != c.permanent { t.Errorf("%v: expected %v, got %v", c.err, c.permanent, p) } } } func TestIsASCII(t *testing.T) { cases := []struct { str string ascii bool }{ {"", true}, {"<>", true}, {"lalala", true}, {"Ãąaca", false}, {"aÃąo", false}, } for _, c := range cases { if ascii := isASCII(c.str); ascii != c.ascii { t.Errorf("%q: expected %v, got %v", c.str, c.ascii, ascii) } } } func mustNewClient(t *testing.T, nc net.Conn) *Client { t.Helper() c, err := NewClient(nc, "") if err != nil { t.Fatalf("failed to create client: %v", err) } return c } func TestBasic(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO a_test < 250-server replies your hello < 250-SIZE 35651584 < 250-SMTPUTF8 < 250-8BITMIME < 250 HELP > MAIL FROM: BODY=8BITMIME < 250 MAIL FROM is fine > RCPT TO: < 250 RCPT TO is fine `) c := mustNewClient(t, fake) if err := c.Hello("a_test"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("from@from", "to@to"); err != nil { t.Fatalf("MailAndRcpt failed: %v", err) } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestSMTPUTF8(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO araÃąa < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-SMTPUTF8 < 250-8BITMIME < 250 HELP > MAIL FROM: BODY=8BITMIME SMTPUTF8 < 250 MAIL FROM is fine > RCPT TO:<Ãąaca@ÃąoÃąo> < 250 RCPT TO is fine `) c := mustNewClient(t, fake) if err := c.Hello("araÃąa"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("aÃąo@Ãąudo", "Ãąaca@ÃąoÃąo"); err != nil { t.Fatalf("MailAndRcpt failed: %v\nDialog: %s", err, fake.Client()) } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestSMTPUTF8NotSupported(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO araÃąa < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-8BITMIME < 250 HELP `) c := mustNewClient(t, fake) if err := c.Hello("araÃąa"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("aÃąo@Ãąudo", "Ãąaca@ÃąoÃąo"); err != nil { terr, ok := err.(*textproto.Error) if !ok || terr.Code != 599 { t.Fatalf("MailAndRcpt failed with unexpected error: %v\nDialog: %s", err, fake.Client()) } } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestFallbackToIDNA(t *testing.T) { fake, client := fakeDialog(`< 220 welcome > EHLO araÃąa < 250-chasquid replies your hello < 250-SIZE 35651584 < 250-8BITMIME < 250 HELP > MAIL FROM: BODY=8BITMIME < 250 MAIL FROM is fine > RCPT TO: < 250 RCPT TO is fine `) c := mustNewClient(t, fake) if err := c.Hello("araÃąa"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.MailAndRcpt("gran@Ãąudo", "alto@ÃąoÃąo"); err != nil { terr, ok := err.(*textproto.Error) if !ok || terr.Code != 599 { t.Fatalf("MailAndRcpt failed with unexpected error: %v\nDialog: %s", err, fake.Client()) } } cmds := fake.Client() if client != cmds { t.Fatalf("Got:\n%s\nExpected:\n%s", cmds, client) } } func TestLineTooLong(t *testing.T) { // Fake the server sending a >2MiB reply. dialog := `< 220 welcome > EHLO araÃąa < 250 HELP > NOOP < 250 longreply:` + fmt.Sprintf("%2097152s", "x") + `: > NOOP < 250 ok ` fake, client := fakeDialog(dialog) c := mustNewClient(t, fake) if err := c.Hello("araÃąa"); err != nil { t.Fatalf("Hello failed: %v", err) } if err := c.Noop(); err != nil { t.Errorf("Noop failed: %v", err) } if err := c.Noop(); err != io.EOF { t.Errorf("Expected EOF, got: %v", err) } cmds := fake.Client() if client != cmds { t.Errorf("Got:\n%s\nExpected:\n%s", cmds, client) } } type faker struct { buf *bytes.Buffer *bufio.ReadWriter } func (f faker) Close() error { return nil } func (f faker) LocalAddr() net.Addr { return nil } func (f faker) RemoteAddr() net.Addr { return nil } func (f faker) SetDeadline(time.Time) error { return nil } func (f faker) SetReadDeadline(time.Time) error { return nil } func (f faker) SetWriteDeadline(time.Time) error { return nil } func (f faker) Client() string { f.ReadWriter.Writer.Flush() return f.buf.String() } var _ net.Conn = faker{} // Takes a dialog, returns the corresponding faker and expected client // messages. Ideally we would check this interactively, and it's not that // difficult, but this is good enough for now. func fakeDialog(dialog string) (faker, string) { var client, server string for _, l := range strings.Split(dialog, "\n") { if strings.HasPrefix(l, "< ") { server += l[2:] + "\r\n" } else if strings.HasPrefix(l, "> ") { client += l[2:] + "\r\n" } } fake := faker{} fake.buf = &bytes.Buffer{} fake.ReadWriter = bufio.NewReadWriter( bufio.NewReader(strings.NewReader(server)), bufio.NewWriter(fake.buf)) return fake, client } chasquid-1.15.0/internal/smtpsrv/000077500000000000000000000000001474251645300167445ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/conn.go000066400000000000000000001112641474251645300202350ustar00rootroot00000000000000package smtpsrv import ( "bufio" "bytes" "context" "crypto/tls" "flag" "fmt" "io" "math/rand" "net" "net/mail" "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/dkim" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/envelope" "blitiri.com.ar/go/chasquid/internal/expvarom" "blitiri.com.ar/go/chasquid/internal/haproxy" "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 = expvarom.NewMap("chasquid/smtpIn/commandCount", "command", "count of SMTP commands received, by command") responseCodeCount = expvarom.NewMap("chasquid/smtpIn/responseCodeCount", "code", "response codes returned to SMTP commands") spfResultCount = expvarom.NewMap("chasquid/smtpIn/spfResultCount", "result", "SPF result count") loopsDetected = expvarom.NewInt("chasquid/smtpIn/loopsDetected", "count of loops detected") tlsCount = expvarom.NewMap("chasquid/smtpIn/tlsCount", "status", "count of TLS usage in incoming connections") slcResults = expvarom.NewMap("chasquid/smtpIn/securityLevelChecks", "result", "incoming security level check results") hookResults = expvarom.NewMap("chasquid/smtpIn/hookResults", "result", "count of hook invocations, by result") wrongProtoCount = expvarom.NewMap("chasquid/smtpIn/wrongProtoCount", "command", "count of commands for other protocols") dkimSigned = expvarom.NewInt("chasquid/smtpIn/dkimSigned", "count of successful DKIM signs") dkimSignErrors = expvarom.NewInt("chasquid/smtpIn/dkimSignErrors", "count of DKIM sign errors") dkimVerifyFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyFound", "count of messages with at least one DKIM signature") dkimVerifyNotFound = expvarom.NewInt("chasquid/smtpIn/dkimVerifyNotFound", "count of messages with no DKIM signatures") dkimVerifyValid = expvarom.NewInt("chasquid/smtpIn/dkimVerifyValid", "count of messages with at least one valid DKIM signature") dkimVerifyErrors = expvarom.NewInt("chasquid/smtpIn/dkimVerifyErrors", "count of DKIM verification errors") ) var ( maxReceivedHeaders = flag.Int("testing__max_received_headers", 50, "max Received headers, for loop detection; ONLY FOR TESTING") // Some go tests disable SPF, to avoid leaking DNS lookups. disableSPFForTesting = false ) // SocketMode represents the mode for a socket (listening or connection). // We keep them distinct, as policies can differ between them. type SocketMode struct { // Is this mode submission? IsSubmission bool // Is this mode TLS-wrapped? That means that we don't use STARTTLS, the // connection is directly established over TLS (like HTTPS). TLS bool } func (mode SocketMode) String() string { s := "SMTP" if mode.IsSubmission { s = "submission" } if mode.TLS { s += "+TLS" } return s } // Valid socket modes. var ( ModeSMTP = SocketMode{IsSubmission: false, TLS: false} ModeSubmission = SocketMode{IsSubmission: true, TLS: false} ModeSubmissionTLS = SocketMode{IsSubmission: true, TLS: true} ) // Conn represents an incoming SMTP connection. type Conn struct { // Main hostname, used for display only. hostname string // Maximum data size. maxDataSize int64 // Post-DATA hook location. postDataHook string // Connection information. conn net.Conn mode SocketMode tlsConnState *tls.ConnectionState remoteAddr net.Addr // Reader and text writer, so we can control limits. reader *bufio.Reader writer *bufio.Writer // Tracer to use. tr *trace.Trace // TLS configuration. tlsConfig *tls.Config // Domain given at HELO/EHLO. ehloDomain string // Envelope. mailFrom string rcptTo []string data []byte // SPF results. spfResult spf.Result spfError error // DKIM verification results. dkimVerifyResult *dkim.VerifyResult // Are we using TLS? onTLS bool // Have we used EHLO? isESMTP bool // Authenticator, aliases and local domains, taken from the server at // creation time. authr *auth.Authenticator localDomains *set.String aliasesR *aliases.Resolver dinfo *domaininfo.DB // Map of domain -> DKIM signers. Taken from the server at creation time. dkimSigners map[string][]*dkim.Signer // Have we successfully completed AUTH? completedAuth bool // 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 // Enable HAProxy on incoming connections. haproxyEnabled bool } // Close the connection. func (c *Conn) Close() { c.conn.Close() } // Handle implements the main protocol loop (reading commands, sending // replies). func (c *Conn) Handle() { defer c.Close() c.tr = trace.New("SMTP.Conn", c.conn.RemoteAddr().String()) defer c.tr.Finish() c.tr.Debugf("Connected, mode: %s", c.mode) // Set the first deadline, which covers possibly the TLS handshake and // then our initial greeting. c.conn.SetDeadline(time.Now().Add(c.commandTimeout)) if tc, ok := c.conn.(*tls.Conn); ok { // For TLS connections, complete the handshake and get the state, so // it can be used when we say hello below. err := tc.Handshake() if err != nil { c.tr.Errorf("error completing TLS handshake: %v", err) return } cstate := tc.ConnectionState() c.tlsConnState = &cstate if name := c.tlsConnState.ServerName; name != "" { c.hostname = name } } // Set up a buffered reader and writer from the conn. // They will be used to do line-oriented, limited I/O. c.reader = bufio.NewReader(c.conn) c.writer = bufio.NewWriter(c.conn) c.remoteAddr = c.conn.RemoteAddr() if c.haproxyEnabled { src, dst, err := haproxy.Handshake(c.reader) if err != nil { c.tr.Errorf("error in haproxy handshake: %v", err) return } c.remoteAddr = src c.tr.Debugf("haproxy handshake: %v -> %v", src, dst) } c.printfLine("220 %s ESMTP chasquid", c.hostname) var cmd, params string var err error var errCount int loop: for { if time.Since(c.deadline) > 0 { err = fmt.Errorf("connection deadline exceeded") c.tr.Error(err) break } c.conn.SetDeadline(time.Now().Add(c.commandTimeout)) cmd, params, err = c.readCommand() if err != nil { c.printfLine("554 error reading command: %v", err) break } if cmd == "AUTH" { c.tr.Debugf("-> AUTH ") } else { c.tr.Debugf("-> %s %s", cmd, params) } var code int var msg string switch cmd { case "HELO": code, msg = c.HELO(params) case "EHLO": code, msg = c.EHLO(params) case "HELP": code, msg = c.HELP(params) case "NOOP": code, msg = c.NOOP(params) case "RSET": code, msg = c.RSET(params) case "VRFY": code, msg = c.VRFY(params) case "EXPN": code, msg = c.EXPN(params) case "MAIL": code, msg = c.MAIL(params) case "RCPT": code, msg = c.RCPT(params) case "DATA": // DATA handles the whole sequence. code, msg = c.DATA(params) case "STARTTLS": code, msg = c.STARTTLS(params) case "AUTH": code, msg = c.AUTH(params) case "QUIT": _ = c.writeResponse(221, "2.0.0 Be seeing you...") break loop case "GET", "POST", "CONNECT": // HTTP protocol detection, to prevent cross-protocol attacks // (e.g. https://alpaca-attack.com/). wrongProtoCount.Add(cmd, 1) c.tr.Errorf("http command, closing connection") _ = c.writeResponse(502, "5.7.0 You hear someone cursing shoplifters") 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<%.6q>", cmd) code = 500 msg = "5.5.1 Unknown command" } commandCount.Add(cmd, 1) if code > 0 { c.tr.Debugf("<- %d %s", code, msg) if code >= 400 { // Be verbose about errors, to help troubleshooting. c.tr.Errorf("%s failed: %d %s", cmd, code, msg) // Close the connection after 3 errors. // This helps prevent cross-protocol attacks. errCount++ if errCount >= 3 { // https://tools.ietf.org/html/rfc5321#section-4.3.2 c.tr.Errorf("too many errors, breaking connection") _ = c.writeResponse(421, "4.5.0 Too many errors, bye") break } } err = c.writeResponse(code, msg) if err != nil { break } } else if code < 0 { // Negative code means that we have to break the connection. // TODO: This is hacky, it's probably worth it at this point to // refactor this into using a custom response type. c.tr.Errorf("%s closed the connection: %s", cmd, msg) break } } if err != nil { if err == io.EOF { c.tr.Debugf("client closed the connection") } else { c.tr.Errorf("exiting with error: %v", err) } } } // HELO SMTP command handler. func (c *Conn) HELO(params string) (code int, msg string) { if len(strings.TrimSpace(params)) == 0 { return 501, "Invisible customers are not welcome!" } c.ehloDomain = strings.Fields(params)[0] types := []string{ "general store", "used armor dealership", "second-hand bookstore", "liquor emporium", "antique weapons outlet", "delicatessen", "jewelers", "quality apparel and accessories", "hardware", "rare books", "lighting store"} t := types[rand.Int()%len(types)] msg = fmt.Sprintf("Hello my friend, welcome to chasqui's %s!", t) return 250, msg } // EHLO SMTP command handler. func (c *Conn) EHLO(params string) (code int, msg string) { if len(strings.TrimSpace(params)) == 0 { return 501, "Invisible customers are not welcome!" } c.ehloDomain = strings.Fields(params)[0] c.isESMTP = true buf := bytes.NewBuffer(nil) fmt.Fprintf(buf, c.hostname+" - Your hour of destiny has come.\n") fmt.Fprintf(buf, "8BITMIME\n") fmt.Fprintf(buf, "PIPELINING\n") fmt.Fprintf(buf, "SMTPUTF8\n") fmt.Fprintf(buf, "ENHANCEDSTATUSCODES\n") fmt.Fprintf(buf, "SIZE %d\n", c.maxDataSize) if c.onTLS { fmt.Fprintf(buf, "AUTH PLAIN\n") } else { fmt.Fprintf(buf, "STARTTLS\n") } fmt.Fprintf(buf, "HELP\n") return 250, buf.String() } // HELP SMTP command handler. func (c *Conn) HELP(params string) (code int, msg string) { return 214, "2.0.0 Hoy por ti, maÃąana por mi" } // RSET SMTP command handler. func (c *Conn) RSET(params string) (code int, msg string) { c.resetEnvelope() msgs := []string{ "Who was that Maud person anyway?", "Thinking of Maud you forget everything else.", "Your mind releases itself from mundane concerns.", "As your mind turns inward on itself, you forget everything else.", } return 250, "2.0.0 " + msgs[rand.Int()%len(msgs)] } // VRFY SMTP command handler. func (c *Conn) VRFY(params string) (code int, msg string) { // We intentionally don't implement this command. return 502, "5.5.1 You have a strange feeling for a moment, then it passes." } // EXPN SMTP command handler. func (c *Conn) EXPN(params string) (code int, msg string) { // We intentionally don't implement this command. return 502, "5.5.1 You feel disoriented for a moment." } // NOOP SMTP command handler. func (c *Conn) NOOP(params string) (code int, msg string) { return 250, "2.0.0 You hear a faint typing noise." } // MAIL SMTP command handler. func (c *Conn) MAIL(params string) (code int, msg string) { // params should be: "FROM:", and possibly followed by // options such as "BODY=8BITMIME" (which we ignore). // Check that it begins with "FROM:" first, it's mandatory. if !strings.HasPrefix(strings.ToLower(params), "from:") { return 500, "5.5.2 Unknown command" } if c.mode.IsSubmission && !c.completedAuth { return 550, "5.7.9 Mail to submission port must be authenticated" } rawAddr := "" _, err := fmt.Sscanf(params[5:], "%s ", &rawAddr) if err != nil { return 500, "5.5.4 Malformed command: " + err.Error() } // Note some servers check (and fail) if we had a previous MAIL command, // but that's not according to the RFC. We reset the envelope instead. c.resetEnvelope() // Special case a null reverse-path, which is explicitly allowed and used // for notification messages. // It should be written "<>", we check for that and remove spaces just to // be more flexible. addr := "" if strings.Replace(rawAddr, " ", "", -1) == "<>" { addr = "<>" } else { e, err := mail.ParseAddress(rawAddr) if err != nil || e.Address == "" { return 501, "5.1.7 Sender address malformed" } addr = e.Address if !strings.Contains(addr, "@") { return 501, "5.1.8 Sender address must contain a domain" } // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3 if len(addr) > 256 { return 501, "5.1.7 Sender address too long" } // SPF check - https://tools.ietf.org/html/rfc7208#section-2.4 // We opt not to fail on errors, to avoid accidents from preventing // delivery. c.spfResult, c.spfError = c.checkSPF(addr) if c.spfResult == spf.Fail { // https://tools.ietf.org/html/rfc7208#section-8.4 maillog.Rejected(c.remoteAddr, addr, nil, fmt.Sprintf("failed SPF: %v", c.spfError)) return 550, fmt.Sprintf( "5.7.23 SPF check failed: %v", c.spfError) } if !c.secLevelCheck(addr) { maillog.Rejected(c.remoteAddr, addr, nil, "security level check failed") return 550, "5.7.3 Security level check failed" } addr, err = normalize.DomainToUnicode(addr) if err != nil { maillog.Rejected(c.remoteAddr, addr, nil, fmt.Sprintf("malformed address: %v", err)) return 501, "5.1.8 Malformed sender domain (IDNA conversion failed)" } } c.mailFrom = addr return 250, "2.1.5 You feel like you are being watched" } // checkSPF for the given address, based on the current connection. func (c *Conn) checkSPF(addr string) (spf.Result, error) { // Does not apply to authenticated connections, they're allowed regardless. if c.completedAuth { return "", nil } if disableSPFForTesting { return "", nil } if tcp, ok := c.remoteAddr.(*net.TCPAddr); ok { spfTr := c.tr.NewChild("SPF", tcp.IP.String()) defer spfTr.Finish() res, err := spf.CheckHostWithSender( tcp.IP, envelope.DomainOf(addr), addr, spf.WithTraceFunc(func(f string, a ...interface{}) { spfTr.Debugf(f, a...) })) 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(c.tr, domain, level) if ok { slcResults.Add("pass", 1) c.tr.Debugf("security level check for %s passed (%s)", domain, level) } else { slcResults.Add("fail", 1) c.tr.Errorf("security level check for %s failed (%s)", domain, level) } return ok } // RCPT SMTP command handler. func (c *Conn) RCPT(params string) (code int, msg string) { // params should be: "TO:", and possibly followed by options // such as "NOTIFY=SUCCESS,DELAY" (which we ignore). // Check that it begins with "TO:" first, it's mandatory. if !strings.HasPrefix(strings.ToLower(params), "to:") { return 500, "5.5.2 Unknown command" } if c.mailFrom == "" { return 503, "5.5.1 Sender not yet given" } rawAddr := "" _, err := fmt.Sscanf(params[3:], "%s ", &rawAddr) if err != nil { return 500, "5.5.4 Malformed command: " + err.Error() } // RFC says 100 is the minimum limit for this, but it seems excessive. // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.8 if len(c.rcptTo) > 100 { return 452, "4.5.3 Too many recipients" } e, err := mail.ParseAddress(rawAddr) if err != nil || e.Address == "" { return 501, "5.1.3 Malformed destination address" } addr, err := normalize.DomainToUnicode(e.Address) if err != nil { return 501, "5.1.2 Malformed destination domain (IDNA conversion failed)" } // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.3 if len(addr) > 256 { return 501, "5.1.3 Destination address too long" } localDst := envelope.DomainIn(addr, c.localDomains) if !localDst && !c.completedAuth { maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr}, "relay not allowed") return 503, "5.7.1 Relay not allowed" } if localDst { addr, err = normalize.Addr(addr) if err != nil { maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr}, fmt.Sprintf("invalid address: %v", err)) return 550, "5.1.3 Destination address is invalid" } ok, err := c.localUserExists(addr) if err != nil { c.tr.Errorf("error checking if user %q exists: %v", addr, err) maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr}, fmt.Sprintf("error checking if user exists: %v", err)) return 451, "4.4.3 Temporary error checking address" } if !ok { maillog.Rejected(c.remoteAddr, c.mailFrom, []string{addr}, "local user does not exist") return 550, "5.1.1 Destination address is unknown (user does not exist)" } } c.rcptTo = append(c.rcptTo, addr) return 250, "2.1.5 You have an eerie feeling..." } // DATA SMTP command handler. func (c *Conn) DATA(params string) (code int, msg string) { if c.ehloDomain == "" { return 503, "5.5.1 Invisible customers are not welcome!" } if c.mailFrom == "" { return 503, "5.5.1 Sender not yet given" } if len(c.rcptTo) == 0 { return 503, "5.5.1 Need an address to send to" } // We're going ahead. err := c.writeResponse(354, "You suddenly realize it is unnaturally quiet") if err != nil { return 554, fmt.Sprintf("5.4.0 Error writing DATA response: %v", err) } c.tr.Debugf("<- 354 You experience a strange sense of peace") if c.onTLS { tlsCount.Add("tls", 1) } else { tlsCount.Add("plain", 1) } // Increase the deadline for the data transfer to the connection-level // one, we don't want the command timeout to interfere. c.conn.SetDeadline(c.deadline) // Read the data. Enforce CRLF correctness, and maximum size. c.data, err = readUntilDot(c.reader, c.maxDataSize) if err != nil { if err == errMessageTooLarge { // Message is too big; excess data has already been discarded. return 552, "5.3.4 Message too big" } if err == errInvalidLineEnding { // We can't properly recover from this, so we have to drop the // connection. c.writeResponse(521, "5.5.2 Error reading DATA: invalid line ending") return -1, "Invalid line ending, closing connection" } return 554, fmt.Sprintf("5.4.0 Error reading DATA: %v", err) } c.tr.Debugf("-> ... %d bytes of data", len(c.data)) if err := checkData(c.data); err != nil { maillog.Rejected(c.remoteAddr, c.mailFrom, c.rcptTo, err.Error()) return 554, err.Error() } if c.completedAuth { err = c.dkimSign() if err != nil { // If we failed to sign, then reject to prevent sending unsigned // messages. Treat the failure as temporary. c.tr.Errorf("DKIM failed: %v", err) return 451, "4.3.0 DKIM signing failed" } } else { c.dkimVerify() } c.addReceivedHeader() hookOut, permanent, err := c.runPostDataHook(c.data) if err != nil { maillog.Rejected(c.remoteAddr, c.mailFrom, c.rcptTo, err.Error()) if permanent { return 554, err.Error() } return 451, err.Error() } c.data = append(hookOut, c.data...) // There are no partial failures here: we put it in the queue, and then if // individual deliveries fail, we report via email. // If we fail to queue, return a transient error. msgID, err := c.queue.Put(c.tr, c.mailFrom, c.rcptTo, c.data) if err != nil { return 451, fmt.Sprintf("4.3.0 Failed to queue message: %v", err) } c.tr.Printf("Queued from %s to %s - %s", c.mailFrom, c.rcptTo, msgID) maillog.Queued(c.remoteAddr, c.mailFrom, c.rcptTo, msgID) // It is very important that we reset the envelope before returning, // so clients can send other emails right away without needing to RSET. c.resetEnvelope() msgs := []string{ "You offer the Amulet of Yendor to Anhur...", "An invisible choir sings, and you are bathed in radiance...", "The voice of Anhur booms out: Congratulations, mortal!", "In return to thy service, I grant thee the gift of Immortality!", "You ascend to the status of Demigod(dess)...", } return 250, "2.0.0 " + msgs[rand.Int()%len(msgs)] } func (c *Conn) addReceivedHeader() { var received string // Format is semi-structured, defined by // https://tools.ietf.org/html/rfc5321#section-4.4 if c.completedAuth { // For authenticated users, only show the EHLO domain they gave; // explicitly hide their network address. received += fmt.Sprintf("from %s\n", c.ehloDomain) } else { // For non-authenticated users we show the real address as canonical, // and then the given EHLO domain for convenience and // troubleshooting. received += fmt.Sprintf("from [%s] (%s)\n", addrLiteral(c.remoteAddr), c.ehloDomain) } received += fmt.Sprintf("by %s (chasquid) ", c.hostname) // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#mail-parameters-7 with := "SMTP" if c.isESMTP { with = "ESMTP" } if c.onTLS { with += "S" } if c.completedAuth { with += "A" } received += fmt.Sprintf("with %s\n", with) if c.tlsConnState != nil { // https://tools.ietf.org/html/rfc8314#section-4.3 received += fmt.Sprintf("tls %s\n", tlsconst.CipherSuiteName(c.tlsConnState.CipherSuite)) } received += fmt.Sprintf("(over %s, ", c.mode) if c.tlsConnState != nil { received += fmt.Sprintf("%s, ", tlsconst.VersionName(c.tlsConnState.Version)) } else { received += "plain text!, " } // Note we must NOT include c.rcptTo, that would leak BCCs. received += 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 received += fmt.Sprintf("; %s\n", time.Now().Format(time.RFC1123Z)) c.data = envelope.AddHeader(c.data, "Received", received) // Add Authentication-Results header too, but only if there's anything to // report. We add it above the Received header, so it can easily be // associated and traced to it, even though it is not a hard requirement. // Note we include results even if they're "none" or "neutral", as that // allows MUAs to know that the message was checked. arHdr := c.hostname + "\r\n" includeAR := false if c.spfResult != "" { // https://tools.ietf.org/html/rfc7208#section-9.1 received = fmt.Sprintf("%s (%v)", c.spfResult, c.spfError) c.data = envelope.AddHeader(c.data, "Received-SPF", received) // https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.2 arHdr += fmt.Sprintf(";spf=%s (%v)\r\n", c.spfResult, c.spfError) includeAR = true } if c.dkimVerifyResult != nil { // https://datatracker.ietf.org/doc/html/rfc8601#section-2.7.1 arHdr += c.dkimVerifyResult.AuthenticationResults() + "\r\n" includeAR = true } if includeAR { // Only include the Authentication-Results header if we have something // to report. c.data = envelope.AddHeader(c.data, "Authentication-Results", strings.TrimSpace(arHdr)) } } // addrLiteral converts a net.Addr (must be TCP) into a string for use as // address literal, compliant with // https://tools.ietf.org/html/rfc5321#section-4.1.3. func addrLiteral(addr net.Addr) string { tcp, ok := addr.(*net.TCPAddr) if !ok { // Fall back to Go's string representation; non-compliant but // better than anything for our purposes. return addr.String() } // IPv6 addresses take the "IPv6:" prefix. // IPv4 addresses are used literally. s := tcp.IP.String() if strings.Contains(s, ":") { return "IPv6:" + s } return s } // checkData performs very basic checks on the body of the email, to help // detect very broad problems like email loops. It does not fully check the // sanity of the headers or the structure of the payload. func checkData(data []byte) error { msg, err := mail.ReadMessage(bytes.NewBuffer(data)) if err != nil { return fmt.Errorf("5.6.0 Error parsing message: %v", err) } // This serves as a basic form of loop prevention. It's not infallible but // should catch most instances of accidental looping. // https://tools.ietf.org/html/rfc5321#section-6.3 if len(msg.Header["Received"]) > *maxReceivedHeaders { loopsDetected.Add(1) return fmt.Errorf("5.4.6 Loop detected (%d hops)", *maxReceivedHeaders) } return nil } // Sanitize HELO/EHLO domain. // RFC is extremely flexible with EHLO domain values, allowing all printable // ASCII characters. They can be tricky to use in shell scripts (commonly used // as post-data hooks), so this function sanitizes the value to make it // shell-safe. func sanitizeEHLODomain(s string) string { n := "" for _, c := range s { // Allow a-zA-Z0-9 and []-.: // That's enough for all domains, IPv4 and IPv6 literals, and also // shell-safe. // Non-ASCII are forbidden as EHLO domains per RFC. switch { case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9', c == '-', c == '.', c == '[', c == ']', c == ':': n += string(c) } } return n } // 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.remoteAddr.String()) defer tr.Finish() ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) defer cancel() cmd := exec.CommandContext(ctx, c.postDataHook) cmd.Stdin = bytes.NewReader(data) // Prepare the environment, copying some common variables so the hook has // something 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.remoteAddr.String()) cmd.Env = append(cmd.Env, "EHLO_DOMAIN="+sanitizeEHLODomain(c.ehloDomain)) cmd.Env = append(cmd.Env, "EHLO_DOMAIN_RAW="+c.ehloDomain) cmd.Env = append(cmd.Env, "MAIL_FROM="+c.mailFrom) cmd.Env = append(cmd.Env, "RCPT_TO="+strings.Join(c.rcptTo, " ")) if c.completedAuth { cmd.Env = append(cmd.Env, "AUTH_AS="+c.authUser+"@"+c.authDomain) } else { cmd.Env = append(cmd.Env, "AUTH_AS=") } cmd.Env = append(cmd.Env, "ON_TLS="+boolToStr(c.onTLS)) cmd.Env = append(cmd.Env, "FROM_LOCAL_DOMAIN="+boolToStr( envelope.DomainIn(c.mailFrom, c.localDomains))) cmd.Env = append(cmd.Env, "SPF_PASS="+boolToStr(c.spfResult == spf.Pass)) out, err := cmd.Output() tr.Debugf("stdout: %q", out) if err != nil { hookResults.Add("post-data:fail", 1) tr.Error(err) permanent := false if ee, ok := err.(*exec.ExitError); ok { tr.Printf("stderr: %q", string(ee.Stderr)) if status, ok := ee.Sys().(syscall.WaitStatus); ok { permanent = status.ExitStatus() == 20 } } // The error contains the last line of stdout, so filters can pass // some rejection information back to the sender. err = fmt.Errorf(lastLine(string(out))) return nil, permanent, err } // Check that output looks like headers, to avoid breaking the email // contents. If it does not, just skip it. if !isHeader(out) { hookResults.Add("post-data:badoutput", 1) tr.Errorf("error parsing post-data output: %q", out) return nil, false, nil } tr.Debugf("success") hookResults.Add("post-data:success", 1) return out, false, nil } // isHeader checks if the given buffer is a valid MIME header. func isHeader(b []byte) bool { s := string(b) if len(s) == 0 { return true } // If it is just a \n, or contains two \n, then it's not a header. if s == "\n" || strings.Contains(s, "\n\n") { return false } // If it does not end in \n, not a header. if s[len(s)-1] != '\n' { return false } // Each line must either start with a space or have a ':'. seen := false for _, line := range strings.SplitAfter(s, "\n") { if line == "" { continue } if strings.HasPrefix(line, " ") || strings.HasPrefix(line, "\t") { if !seen { // Continuation without a header first (invalid). return false } continue } if !strings.Contains(line, ":") { return false } seen = true } return true } func lastLine(s string) string { l := strings.Split(s, "\n") if len(l) < 2 { return "" } return l[len(l)-2] } func boolToStr(b bool) string { if b { return "1" } return "0" } func (c *Conn) dkimSign() error { // We only sign if the user authenticated. However, the authenticated user // and the MAIL FROM address may be different; even the domain may be // different. // We explicitly let this happen and trust authenticated users. // So for DKIM signing purposes, we use the MAIL FROM domain: this // prevents leaking the authenticated user's domain, and is more in line // with expectations around signatures. domain := envelope.DomainOf(c.mailFrom) signers := c.dkimSigners[domain] if len(signers) == 0 { return nil } tr := c.tr.NewChild("DKIM.Sign", domain) defer tr.Finish() ctx := context.Background() ctx = dkim.WithTraceFunc(ctx, tr.Debugf) for _, signer := range signers { sig, err := signer.Sign(ctx, normalize.StringToCRLF(string(c.data))) if err != nil { dkimSignErrors.Add(1) return err } // The signature is returned with \r\n; however, our internal // representation uses \n, so normalize it. sig = strings.ReplaceAll(sig, "\r\n", "\n") c.data = envelope.AddHeader(c.data, "DKIM-Signature", sig) } dkimSigned.Add(1) return nil } func (c *Conn) dkimVerify() { tr := c.tr.NewChild("DKIM.Verify", c.mailFrom) defer tr.Finish() var err error ctx := context.Background() ctx = dkim.WithTraceFunc(ctx, tr.Debugf) c.dkimVerifyResult, err = dkim.VerifyMessage( ctx, string(normalize.ToCRLF(c.data))) if err != nil { // The only error we expect is because of a malformed mail, which is // checked before this is invoked. tr.Errorf("Error verifying DKIM: %v", err) dkimVerifyErrors.Add(1) } if c.dkimVerifyResult != nil { if c.dkimVerifyResult.Found > 0 { dkimVerifyFound.Add(1) } else { dkimVerifyNotFound.Add(1) } if c.dkimVerifyResult.Valid > 0 { dkimVerifyValid.Add(1) } } // Note we don't fail emails because they failed to verify, in line // with RFC recommendations. // DMARC policies may cause it to fail at some point, but that is not // implemented yet, and would happen separately. // The results will get included in the Authentication-Results header, see // addReceivedHeader for more details. } // STARTTLS SMTP command handler. func (c *Conn) STARTTLS(params string) (code int, msg string) { if c.onTLS { return 503, "5.5.1 You are already wearing that!" } err := c.writeResponse(220, "2.0.0 You experience a strange sense of peace") if err != nil { return 554, fmt.Sprintf("5.4.0 Error writing STARTTLS response: %v", err) } c.tr.Debugf("<- 220 You experience a strange sense of peace") server := tls.Server(c.conn, c.tlsConfig) err = server.Handshake() if err != nil { return 554, fmt.Sprintf("5.5.0 Error in TLS handshake: %v", err) } c.tr.Debugf("<> ... jump to TLS was successful") // Override the connection. We don't need the older one anymore. c.conn = server c.reader = bufio.NewReader(c.conn) c.writer = bufio.NewWriter(c.conn) // Take the connection state, so we can use it later for logging and // tracing purposes. cstate := server.ConnectionState() c.tlsConnState = &cstate // Reset the envelope; clients must start over after switching to TLS. c.resetEnvelope() c.onTLS = true // If the client requested a specific server and we complied, that's our // identity from now on. if name := c.tlsConnState.ServerName; name != "" { c.hostname = name } // 0 indicates not to send back a reply. return 0, "" } // AUTH SMTP command handler. func (c *Conn) AUTH(params string) (code int, msg string) { if !c.onTLS { return 503, "5.7.10 You feel vulnerable" } if c.completedAuth { // After a successful AUTH command completes, a server MUST reject // any further AUTH commands with a 503 reply. // https://tools.ietf.org/html/rfc4954#section-4 return 503, "5.5.1 You are already wearing that!" } // We only support PLAIN for now, so no need to make this too complicated. // Params should be either "PLAIN" or "PLAIN ". // If the response is not there, we reply with 334, and expect the // response back from the client in the next message. sp := strings.SplitN(params, " ", 2) if len(sp) < 1 || sp[0] != "PLAIN" { // As we only offer plain, this should not really happen. return 534, "5.7.9 Asmodeus demands 534 zorkmids for safe passage" } // Note we use more "serious" error messages from now own, as these may // find their way to the users in some circumstances. // Get the response, either from the message or interactively. response := "" if len(sp) == 2 { response = sp[1] } else { // Reply 334 and expect the user to provide it. // In this case, the text IS relevant, as it is taken as the // server-side SASL challenge (empty for PLAIN). // https://tools.ietf.org/html/rfc4954#section-4 err := c.writeResponse(334, "") if err != nil { return 554, fmt.Sprintf("5.4.0 Error writing AUTH 334: %v", err) } response, err = c.readLine() if err != nil { return 554, fmt.Sprintf("5.4.0 Error reading AUTH response: %v", err) } } user, domain, passwd, err := auth.DecodeResponse(response) if err != nil { // https://tools.ietf.org/html/rfc4954#section-4 return 501, fmt.Sprintf("5.5.2 Error decoding AUTH response: %v", err) } // https://tools.ietf.org/html/rfc4954#section-6 authOk, err := c.authr.Authenticate(c.tr, user, domain, passwd) if err != nil { c.tr.Errorf("error authenticating %q@%q: %v", user, domain, err) maillog.Auth(c.remoteAddr, user+"@"+domain, false) return 454, "4.7.0 Temporary authentication failure" } if authOk { c.authUser = user c.authDomain = domain c.completedAuth = true maillog.Auth(c.remoteAddr, user+"@"+domain, true) return 235, "2.7.0 Authentication successful" } maillog.Auth(c.remoteAddr, user+"@"+domain, false) return 535, "5.7.8 Incorrect user or password" } func (c *Conn) resetEnvelope() { c.mailFrom = "" c.rcptTo = nil c.data = nil c.spfResult = "" c.spfError = nil } func (c *Conn) localUserExists(addr string) (bool, error) { if c.aliasesR.Exists(c.tr, addr) { return true, nil } // Remove the drop chars and suffixes, if any, so the database lookup is // on a "clean" address. addr = c.aliasesR.RemoveDropsAndSuffix(addr) user, domain := envelope.Split(addr) return c.authr.Exists(c.tr, user, domain) } func (c *Conn) readCommand() (cmd, params string, err error) { msg, err := c.readLine() if err != nil { return "", "", err } sp := strings.SplitN(msg, " ", 2) cmd = strings.ToUpper(sp[0]) if len(sp) > 1 { params = sp[1] } return cmd, params, err } func (c *Conn) readLine() (line string, err error) { // The bufio reader's ReadLine will only read up to the buffer size, which // prevents DoS due to memory exhaustion on extremely long lines. l, more, err := c.reader.ReadLine() if err != nil { return "", err } // As per RFC, the maximum length of a text line is 1000 octets. // https://tools.ietf.org/html/rfc5321#section-4.5.3.1.6 if len(l) > 1000 || more { // Keep reading to maintain the protocol status, but discard the data. for more && err == nil { _, more, err = c.reader.ReadLine() } return "", fmt.Errorf("line too long") } return string(l), nil } func (c *Conn) writeResponse(code int, msg string) error { defer c.writer.Flush() responseCodeCount.Add(strconv.Itoa(code), 1) return writeResponse(c.writer, code, msg) } func (c *Conn) printfLine(format string, args ...interface{}) { fmt.Fprintf(c.writer, format+"\r\n", args...) c.writer.Flush() } // writeResponse writes a multi-line response to the given writer. // This is the writing version of textproto.Reader.ReadResponse(). func writeResponse(w io.Writer, code int, msg string) error { var i int lines := strings.Split(msg, "\n") // The first N-1 lines use "-". for i = 0; i < len(lines)-2; i++ { _, err := w.Write([]byte(fmt.Sprintf("%d-%s\r\n", code, lines[i]))) if err != nil { return err } } // The last line uses " ". _, err := w.Write([]byte(fmt.Sprintf("%d %s\r\n", code, lines[i]))) if err != nil { return err } return nil } chasquid-1.15.0/internal/smtpsrv/conn_test.go000066400000000000000000000067261474251645300213020ustar00rootroot00000000000000package smtpsrv import ( "net" "os" "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. // Create the directory by hand because we don't want to automatically // chdir into it (it affects the fuzzing infrastructure). dir, err := os.MkdirTemp("", "testlib_") if err != nil { t.Fatalf("Failed to create temp dir: %v\n", dir) } defer testlib.RemoveIfOk(t, dir) dinfo, err := domaininfo.New(dir) if err != nil { t.Fatalf("Failed to create domain info: %v", err) } c := &Conn{ tr: trace.New("testconn", "testconn"), dinfo: dinfo, } // No SPF, skip security checks. c.spfResult = spf.None c.onTLS = true if !c.secLevelCheck("from@slc") { t.Fatalf("TLS seclevel failed") } c.onTLS = false if !c.secLevelCheck("from@slc") { t.Fatalf("plain seclevel failed, even though SPF does not exist") } // Now the real checks, once SPF passes. c.spfResult = spf.Pass if !c.secLevelCheck("from@slc") { t.Fatalf("plain seclevel failed") } c.onTLS = true if !c.secLevelCheck("from@slc") { t.Fatalf("TLS seclevel failed") } c.onTLS = false if c.secLevelCheck("from@slc") { t.Fatalf("plain seclevel worked, downgrade was allowed") } } func TestIsHeader(t *testing.T) { no := []string{ "a", "\n", "\n\n", " \n", " ", "a:b", "a: b\nx: y", "\na:b\n", " a\nb:c\n", } for _, s := range no { if isHeader([]byte(s)) { t.Errorf("%q accepted as header, should be rejected", s) } } yes := []string{ "", "a:b\n", "X-Post-Data: success\n", } for _, s := range yes { if !isHeader([]byte(s)) { t.Errorf("%q rejected as header, should be accepted", s) } } } func TestAddrLiteral(t *testing.T) { // TCP addresses. casesTCP := []struct { addr net.IP expected string }{ {net.IPv4(1, 2, 3, 4), "1.2.3.4"}, {net.IPv4(0, 0, 0, 0), "0.0.0.0"}, {net.ParseIP("1.2.3.4"), "1.2.3.4"}, {net.ParseIP("2001:db8::68"), "IPv6:2001:db8::68"}, {net.ParseIP("::1"), "IPv6:::1"}, } for _, c := range casesTCP { tcp := &net.TCPAddr{ IP: c.addr, Port: 12345, } s := addrLiteral(tcp) if s != c.expected { t.Errorf("%v: expected %q, got %q", tcp, c.expected, s) } } // Non-TCP addresses. We expect these to match addr.String(). casesOther := []net.Addr{ &net.UDPAddr{ IP: net.ParseIP("1.2.3.4"), Port: 12345, }, } for _, addr := range casesOther { s := addrLiteral(addr) if s != addr.String() { t.Errorf("%v: expected %q, got %q", addr, addr.String(), s) } } } func TestSanitizeEHLODomain(t *testing.T) { equal := []string{ "domain", "do.main", "do-main", "1.2.3.4", "a:b:c", "[a:b:c]", "abz", "AbZ", } for _, str := range equal { if got := sanitizeEHLODomain(str); got != str { t.Errorf("sanitizeEHLODomain(%q) returned %q, expected %q", str, got, str) } } invalid := []struct { str string expected string }{ {"Ãąaca", "aca"}, {"a\nb", "ab"}, {"a\x00b", "ab"}, {"a\x7fb", "ab"}, {"a/z", "az"}, {"a;b", "ab"}, {"a$b", "ab"}, {"a^b", "ab"}, {"a b", "ab"}, {"a+b", "ab"}, {"a@b", "ab"}, {`a"b`, "ab"}, {`a\b`, "ab"}, } for _, c := range invalid { if got := sanitizeEHLODomain(c.str); got != c.expected { t.Errorf("sanitizeEHLODomain(%q) returned %q, expected %q", c.str, got, c.expected) } } } chasquid-1.15.0/internal/smtpsrv/dotreader.go000066400000000000000000000060231474251645300212450ustar00rootroot00000000000000package smtpsrv import ( "bufio" "bytes" "errors" "io" ) var ( // TODO: Include the line number and specific error, and have the // caller add them to the trace. errMessageTooLarge = errors.New("message too large") errInvalidLineEnding = errors.New("invalid line ending") ) // readUntilDot reads from r until it encounters a dot-terminated line, or we // read max bytes. It enforces that input lines are terminated by "\r\n", and // that there are not "lonely" "\r" or "\n"s in the input. // It returns \n-terminated lines, which is what we use for our internal // representation for convenience (same as textproto DotReader does). func readUntilDot(r *bufio.Reader, max int64) ([]byte, error) { buf := make([]byte, 0, 1024) n := int64(0) // Little state machine. const ( prevOther = iota prevCR prevCRLF ) // Start as if we just came from a '\r\n'; that way we avoid the need // for special-casing the dot-stuffing at the very beginning. prev := prevCRLF last4 := make([]byte, 4) skip := false loop: for { b, err := r.ReadByte() if err == io.EOF { return buf, io.ErrUnexpectedEOF } else if err != nil { return buf, err } n++ switch b { case '\r': if prev == prevCR { return buf, errInvalidLineEnding } prev = prevCR // We return a LF-terminated line, so skip the CR. This simplifies // internal representation and makes it easier/less error prone to // work with. It is converted back to CRLF on endpoints (e.g. in // the couriers). skip = true case '\n': if prev != prevCR { return buf, errInvalidLineEnding } // If we come from a '\r\n.\r', we're done. if bytes.Equal(last4, []byte("\r\n.\r")) { break loop } // If we are only starting and see ".\r\n", we're also done; in // that case the message is empty. if n == 3 && bytes.Equal(last4, []byte("\x00\x00.\r")) { return []byte{}, nil } prev = prevCRLF default: if prev == prevCR { return buf, errInvalidLineEnding } if b == '.' && prev == prevCRLF { // We come from "\r\n" and got a "."; as per dot-stuffing // rules, we should skip that '.' in the output. // https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2 skip = true } prev = prevOther } // Keep the last 4 bytes separately, because they may not be in buf on // messages that are too large. copy(last4, last4[1:]) last4[3] = b if int64(len(buf)) < max && !skip { buf = append(buf, b) } skip = false } // Return an error if the message is too large. It is important to do this // _outside_ the loop, because we need to keep reading until we get to the // final "." before we return an error, so the SMTP dialog can continue // properly after that. // If we return too early, the remainder of the email is interpreted as // part of the SMTP dialog (and exposing ourselves to smuggling attacks). if n > max { return buf, errMessageTooLarge } // If we made it this far, buf naturally ends in "\n" because we skipped // the '.' due to dot-stuffing, and skip "\r"s. return buf, nil } chasquid-1.15.0/internal/smtpsrv/dotreader_test.go000066400000000000000000000047111474251645300223060ustar00rootroot00000000000000package smtpsrv import ( "bufio" "bytes" "io" "strings" "testing" ) func TestReadUntilDot(t *testing.T) { cases := []struct { input string max int64 want string wantErr error }{ // EOF before any input -> unexpected EOF. {"", 0, "", io.ErrUnexpectedEOF}, {"", 1, "", io.ErrUnexpectedEOF}, // EOF after exceeding max -> unexpected EOF. {"abcdef", 2, "ab", io.ErrUnexpectedEOF}, // \n at the beginning of the buffer are just as invalid, and the // error takes precedence over the unexpected EOF. {"\n", 0, "", errInvalidLineEnding}, {"\n", 1, "", errInvalidLineEnding}, {"\n", 2, "", errInvalidLineEnding}, {"\n\r\n.\r\n", 10, "", errInvalidLineEnding}, // \r and then EOF -> unexpected EOF, because we never had a chance to // assess if the line ending is valid or not. {"\r", 2, "", io.ErrUnexpectedEOF}, // Lonely \r -> invalid line ending. {"abc\rdef", 10, "abc", errInvalidLineEnding}, {"abc\r\rdef", 10, "abc", errInvalidLineEnding}, // Lonely \n -> invalid line ending. {"abc\ndef", 10, "abc", errInvalidLineEnding}, // Various valid cases. {"abc\r\n.\r\n", 10, "abc\n", nil}, {"\r\n.\r\n", 10, "\n", nil}, // Start with the final dot - the smallest "message" (empty). {".\r\n", 10, "", nil}, // Max bytes reached -> message too large. {"abc\r\n.\r\n", 5, "abc\n", errMessageTooLarge}, {"abcdefg\r\n.\r\n", 5, "abcde", errMessageTooLarge}, {"ab\r\ncdefg\r\n.\r\n", 5, "ab\ncd", errMessageTooLarge}, // Dot-stuffing. // https://www.rfc-editor.org/rfc/rfc5321#section-4.5.2 {"abc\r\n.def\r\n.\r\n", 20, "abc\ndef\n", nil}, {"abc\r\n..def\r\n.\r\n", 20, "abc\n.def\n", nil}, {"abc\r\n..\r\n.\r\n", 20, "abc\n.\n", nil}, {".x\r\n.\r\n", 20, "x\n", nil}, {"..\r\n.\r\n", 20, ".\n", nil}, } for i, c := range cases { r := bufio.NewReader(strings.NewReader(c.input)) got, err := readUntilDot(r, c.max) if err != c.wantErr { t.Errorf("case %d %q: got error %v, want %v", i, c.input, err, c.wantErr) } if !bytes.Equal(got, []byte(c.want)) { t.Errorf("case %d %q: got %q, want %q", i, c.input, got, c.want) } } } type badBuffer bytes.Buffer func (b *badBuffer) Read(p []byte) (int, error) { // Return an arbitrary non-EOF error for testing. return 0, io.ErrNoProgress } func TestReadUntilDotReadError(t *testing.T) { r := bufio.NewReader(&badBuffer{}) _, err := readUntilDot(r, 10) if err != io.ErrNoProgress { t.Errorf("got error %v, want %v", err, io.ErrNoProgress) } } chasquid-1.15.0/internal/smtpsrv/fuzz_test.go000066400000000000000000000034411474251645300213320ustar00rootroot00000000000000// Fuzz testing for package smtpsrv. Based on server_test. package smtpsrv import ( "bufio" "bytes" "crypto/tls" "fmt" "net" "net/textproto" "strings" "testing" ) func fuzzConnection(t *testing.T, modeI int, data []byte) { var mode SocketMode addr := "" switch modeI { case 0: mode = ModeSMTP addr = smtpAddr case 1: mode = ModeSubmission addr = submissionAddr case 2: mode = ModeSubmissionTLS addr = submissionTLSAddr default: mode = ModeSMTP addr = smtpAddr } var err error var conn net.Conn if mode.TLS { conn, err = tls.Dial("tcp", addr, tlsConfig) } else { conn, err = net.Dial("tcp", addr) } if err != nil { panic(fmt.Errorf("failed to dial: %v", err)) } defer conn.Close() tconn := textproto.NewConn(conn) defer tconn.Close() scanner := bufio.NewScanner(bytes.NewBuffer(data)) for scanner.Scan() { line := scanner.Text() cmd := strings.TrimSpace(strings.ToUpper(line)) // Skip STARTTLS if it happens on a non-TLS connection - the jump is // not going to happen via fuzzer, it will just cause a timeout (which // is considered a crash). if cmd == "STARTTLS" && !mode.TLS { continue } if err = tconn.PrintfLine(line); err != nil { break } if _, _, err = tconn.ReadResponse(-1); err != nil { break } if cmd == "DATA" { // We just sent DATA and got a response; send the contents. err = exchangeData(scanner, tconn) if err != nil { break } } } } func FuzzConnection(f *testing.F) { f.Fuzz(fuzzConnection) } func exchangeData(scanner *bufio.Scanner, tconn *textproto.Conn) error { for scanner.Scan() { line := scanner.Text() if err := tconn.PrintfLine(line); err != nil { return err } if line == "." { break } } // Read the "." response. _, _, err := tconn.ReadResponse(-1) return err } chasquid-1.15.0/internal/smtpsrv/server.go000066400000000000000000000222641474251645300206070ustar00rootroot00000000000000// Package smtpsrv implements chasquid's SMTP server and connection handler. package smtpsrv import ( "crypto" "crypto/ed25519" "crypto/rsa" "crypto/tls" "crypto/x509" "encoding/pem" "flag" "fmt" "net" "net/http" "net/url" "os" "path" "strings" "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/dkim" "blitiri.com.ar/go/chasquid/internal/domaininfo" "blitiri.com.ar/go/chasquid/internal/localrpc" "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/trace" "blitiri.com.ar/go/chasquid/internal/userdb" "blitiri.com.ar/go/log" ) var ( // Reload frequency. // We should consider making this a proper option if there's interest in // changing it, but until then, it's a test-only flag for simplicity. reloadEvery = flag.Duration("testing__reload_every", 30*time.Second, "how often to reload, ONLY FOR TESTING") ) // Server represents an SMTP server instance. type Server struct { // Main hostname, used for display only. Hostname string // Maximum data size. MaxDataSize int64 // Addresses. addrs map[SocketMode][]string // Listeners (that came via systemd). listeners map[SocketMode][]net.Listener // TLS config (including loaded certificates). tlsConfig *tls.Config // Use HAProxy on incoming connections. HAProxyEnabled bool // Local domains. localDomains *set.String // User databases (per domain). // Authenticator. authr *auth.Authenticator // Aliases resolver. aliasesR *aliases.Resolver // Domain info database. dinfo *domaininfo.DB // Map of domain -> DKIM signers. dkimSigners map[string][]*dkim.Signer // Time before we give up on a connection, even if it's sending data. connTimeout time.Duration // Time we wait for command round-trips (excluding DATA). commandTimeout time.Duration // Queue where we put incoming mail. queue *queue.Queue // Path to the hooks. HookPath string } // NewServer returns a new empty Server. func NewServer() *Server { authr := auth.NewAuthenticator() aliasesR := aliases.NewResolver(authr.Exists) 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: authr, aliasesR: aliasesR, dkimSigners: map[string][]*dkim.Signer{}, } } // AddCerts (TLS) to the server. func (s *Server) AddCerts(certPath, keyPath string) error { cert, err := tls.LoadX509KeyPair(certPath, keyPath) if err != nil { return err } s.tlsConfig.Certificates = append(s.tlsConfig.Certificates, cert) return nil } // AddAddr adds an address for the server to listen on. func (s *Server) AddAddr(a string, m SocketMode) { s.addrs[m] = append(s.addrs[m], a) } // AddListeners adds listeners for the server to listen on. func (s *Server) AddListeners(ls []net.Listener, m SocketMode) { s.listeners[m] = append(s.listeners[m], ls...) } // AddDomain adds a local domain to the server. func (s *Server) AddDomain(d string) { s.localDomains.Add(d) s.aliasesR.AddDomain(d) } // AddUserDB adds a userdb file as backend for the domain. func (s *Server) AddUserDB(domain, f string) (int, error) { // Load the userdb, and register it unconditionally (so reload works even // if there are errors right now). udb, err := userdb.Load(f) s.authr.Register(domain, auth.WrapNoErrorBackend(udb)) return udb.Len(), err } // AddAliasesFile adds an aliases file for the given domain. func (s *Server) AddAliasesFile(domain, f string) (int, error) { return s.aliasesR.AddAliasesFile(domain, f) } var ( errDecodingPEMBlock = fmt.Errorf("error decoding PEM block") errUnsupportedBlockType = fmt.Errorf("unsupported block type") errUnsupportedKeyType = fmt.Errorf("unsupported key type") ) // AddDKIMSigner for the given domain and selector. func (s *Server) AddDKIMSigner(domain, selector, keyPath string) error { key, err := os.ReadFile(keyPath) if err != nil { return err } block, _ := pem.Decode(key) if block == nil { return errDecodingPEMBlock } if strings.ToUpper(block.Type) != "PRIVATE KEY" { return fmt.Errorf("%w: %s", errUnsupportedBlockType, block.Type) } signer, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { return err } switch k := signer.(type) { case *rsa.PrivateKey, ed25519.PrivateKey: // These are supported, nothing to do. default: return fmt.Errorf("%w: %T", errUnsupportedKeyType, k) } s.dkimSigners[domain] = append(s.dkimSigners[domain], &dkim.Signer{ Domain: domain, Selector: selector, Signer: signer.(crypto.Signer), }) return nil } // SetAuthFallback sets the authentication backend to use as fallback. func (s *Server) SetAuthFallback(be auth.Backend) { s.authr.Fallback = be } // SetAliasesConfig sets the aliases configuration options. func (s *Server) SetAliasesConfig(suffixSep, dropChars string) { s.aliasesR.SuffixSep = suffixSep s.aliasesR.DropChars = dropChars s.aliasesR.ResolveHook = path.Join(s.HookPath, "alias-resolve") } // SetDomainInfo sets the domain info database to use. func (s *Server) SetDomainInfo(dinfo *domaininfo.DB) { s.dinfo = dinfo } // InitQueue initializes the queue. func (s *Server) InitQueue(path string, localC, remoteC courier.Courier) { q, err := queue.New(path, s.localDomains, s.aliasesR, localC, remoteC) if err != nil { log.Fatalf("Error initializing queue: %v", err) } 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())) }) } func (s *Server) aliasResolveRPC(tr *trace.Trace, req url.Values) (url.Values, error) { rcpts, err := s.aliasesR.Resolve(tr, req.Get("Address")) if err != nil { return nil, err } v := url.Values{} for _, rcpt := range rcpts { v.Add(string(rcpt.Type), rcpt.Addr) } return v, nil } func (s *Server) dinfoClearRPC(tr *trace.Trace, req url.Values) (url.Values, error) { domain := req.Get("Domain") exists := s.dinfo.Clear(tr, domain) if !exists { return nil, fmt.Errorf("does not exist") } return nil, nil } // periodicallyReload some of the server's information that can be changed // without the server knowing, such as aliases and the user databases. func (s *Server) periodicallyReload() { if reloadEvery == nil { return } //lint:ignore SA1015 This lasts the program's lifetime. for range time.Tick(*reloadEvery) { s.Reload() } } func (s *Server) Reload() { // Note that any error while reloading is fatal: this way, if there is an // unexpected error it can be detected (and corrected) quickly, instead of // much later (e.g. upon restart) when it might be harder to debug. if err := s.aliasesR.Reload(); err != nil { log.Fatalf("Error reloading aliases: %v", err) } if err := s.authr.Reload(); err != nil { log.Fatalf("Error reloading authenticators: %v", err) } } // ListenAndServe on the addresses and listeners that were previously added. // This function will not return. func (s *Server) ListenAndServe() { if len(s.tlsConfig.Certificates) == 0 { // chasquid assumes there's at least one valid certificate (for things // like STARTTLS, user authentication, etc.), so we fail if none was // found. log.Errorf("No SSL/TLS certificates found") log.Errorf("Ideally there should be a certificate for each MX you act as") log.Fatalf("At least one valid certificate is needed") } localrpc.DefaultServer.Register("AliasResolve", s.aliasResolveRPC) localrpc.DefaultServer.Register("DomaininfoClear", s.dinfoClearRPC) go s.periodicallyReload() for m, addrs := range s.addrs { for _, addr := range addrs { l, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("Error listening: %v", err) } log.Infof("Server listening on %s (%v)", addr, m) maillog.Listening(addr) go s.serve(l, m) } } for m, ls := range s.listeners { for _, l := range ls { log.Infof("Server listening on %s (%v, via systemd)", l.Addr(), m) maillog.Listening(l.Addr().String()) go s.serve(l, m) } } // Never return. If the serve goroutines have problems, they will abort // execution. for { time.Sleep(24 * time.Hour) } } func (s *Server) serve(l net.Listener, mode SocketMode) { // If this mode is expected to be TLS-wrapped, make it so. if mode.TLS { l = tls.NewListener(l, s.tlsConfig) } pdhook := path.Join(s.HookPath, "post-data") for { conn, err := l.Accept() if err != nil { log.Fatalf("Error accepting: %v", err) } sc := &Conn{ hostname: s.Hostname, maxDataSize: s.MaxDataSize, postDataHook: pdhook, conn: conn, mode: mode, tlsConfig: s.tlsConfig, haproxyEnabled: s.HAProxyEnabled, onTLS: mode.TLS, authr: s.authr, aliasesR: s.aliasesR, localDomains: s.localDomains, dinfo: s.dinfo, dkimSigners: s.dkimSigners, deadline: time.Now().Add(s.connTimeout), commandTimeout: s.commandTimeout, queue: s.queue, } go sc.Handle() } } chasquid-1.15.0/internal/smtpsrv/server_test.go000066400000000000000000000410561474251645300216460ustar00rootroot00000000000000package smtpsrv import ( "crypto/tls" "errors" "flag" "fmt" "net" "net/smtp" "os" "strings" "testing" "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/maillog" "blitiri.com.ar/go/chasquid/internal/testlib" "blitiri.com.ar/go/chasquid/internal/userdb" ) // Flags. var ( externalSMTPAddr = flag.String("external_smtp_addr", "", "SMTP server address to test (defaults to use internal)") externalSubmissionAddr = flag.String("external_submission_addr", "", "submission server address to test (defaults to use internal)") externalSubmissionTLSAddr = flag.String("external_submission_tls_addr", "", "submission+TLS server address to test (defaults to use internal)") ) var ( // Server addresses. Will be filled in at init time. // We default to internal ones, but may get overridden via flags. smtpAddr = "" submissionAddr = "" submissionTLSAddr = "" // TLS configuration to use in the clients. // Will contain the generated server certificate as root CA. tlsConfig *tls.Config // Test couriers, so we can validate that emails got sent. localC = testlib.NewTestCourier() remoteC = testlib.NewTestCourier() // Max data size, in MiB. maxDataSizeMiB = 5 ) // // === Tests === // func mustDial(tb testing.TB, mode SocketMode, startTLS bool) *smtp.Client { addr := "" switch mode { case ModeSMTP: addr = smtpAddr case ModeSubmission: addr = submissionAddr case ModeSubmissionTLS: addr = submissionTLSAddr } var err error var conn net.Conn if mode.TLS { conn, err = tls.Dial("tcp", addr, tlsConfig) } else { conn, err = net.Dial("tcp", addr) } if err != nil { tb.Fatalf("(net||tls).Dial: %v", err) } c, err := smtp.NewClient(conn, "127.0.0.1") if err != nil { tb.Fatalf("smtp.Dial: %v", err) } if err = c.Hello("test"); err != nil { tb.Fatalf("c.Hello: %v", err) } if startTLS { if ok, _ := c.Extension("STARTTLS"); !ok { tb.Fatalf("STARTTLS not advertised in EHLO") } if err = c.StartTLS(tlsConfig); err != nil { tb.Fatalf("StartTLS: %v", err) } } return c } func sendEmail(tb testing.TB, c *smtp.Client) { sendEmailWithAuth(tb, c, nil) } func sendEmailWithAuth(tb testing.TB, c *smtp.Client, auth smtp.Auth) { var err error from := "from@from" if auth != nil { if err = c.Auth(auth); err != nil { tb.Errorf("Auth: %v", err) } // If we authenticated, we must use the user as from, as the server // checks otherwise. from = "testuser@localhost" } if err = c.Mail(from); err != nil { tb.Errorf("Mail: %v", err) } if err = c.Rcpt("to@localhost"); err != nil { tb.Errorf("Rcpt: %v", err) } w, err := c.Data() if err != nil { tb.Fatalf("Data: %v", err) } msg := []byte("Subject: Hi!\n\n This is an email\n") if _, err = w.Write(msg); err != nil { tb.Errorf("Data write: %v", err) } localC.Expect(1) if err = w.Close(); err != nil { tb.Errorf("Data close: %v", err) } localC.Wait() } func TestSimple(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() sendEmail(t, c) } func TestSimpleTLS(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() sendEmail(t, c) } func TestManyEmails(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() sendEmail(t, c) sendEmail(t, c) sendEmail(t, c) } func TestAuth(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") sendEmailWithAuth(t, c, auth) } func TestSubmissionWithoutAuth(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() if err := c.Mail("from@from"); err == nil { t.Errorf("Mail not failed as expected") } } func TestAuthOnTLS(t *testing.T) { c := mustDial(t, ModeSubmissionTLS, false) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") sendEmailWithAuth(t, c, auth) } func TestAuthOnSMTP(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") // At least for now, we allow AUTH over the SMTP port to avoid unnecessary // complexity, so we expect it to work. sendEmailWithAuth(t, c, auth) } func TestBrokenAuth(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() auth := smtp.PlainAuth("", "user@broken", "passwd", "127.0.0.1") err := c.Auth(auth) if err == nil { t.Errorf("Broken auth succeeded") } else if err.Error() != "454 4.7.0 Temporary authentication failure" { t.Errorf("Broken auth returned unexpected error %q", err.Error()) } } func TestWrongMailParsing(t *testing.T) { addrs := []string{"from", "a b c", "a @ b", "", "", "><"} for _, addr := range addrs { c := mustDial(t, ModeSMTP, false) 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) } } c.Close() } } func TestNullMailFrom(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() addrs := []string{"<>", " <>", "<> OPTION"} for _, addr := range addrs { simpleCmd(t, c, fmt.Sprintf("MAIL FROM:%s", addr), 250) } } func TestRcptBeforeMail(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() if err := c.Rcpt("to@to"); err == nil { t.Errorf("Rcpt not failed as expected") } } func TestRcptOption(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() if err := c.Mail("from@localhost"); err != nil { t.Fatalf("Mail: %v", err) } params := []string{ "", " ", " OPTION"} for _, p := range params { simpleCmd(t, c, fmt.Sprintf("RCPT TO:%s", p), 250) } } func TestRelayForbidden(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() if err := c.Mail("from@somewhere"); err != nil { t.Errorf("Mail: %v", err) } if err := c.Rcpt("to@somewhere"); err == nil { t.Errorf("Accepted relay email") } } func TestTooManyRecipients(t *testing.T) { c := mustDial(t, ModeSubmission, true) defer c.Close() auth := smtp.PlainAuth("", "testuser@localhost", "testpasswd", "127.0.0.1") if err := c.Auth(auth); err != nil { t.Fatalf("Auth: %v", err) } if err := c.Mail("testuser@localhost"); err != nil { t.Fatalf("Mail: %v", err) } for i := 0; i < 101; i++ { if err := c.Rcpt(fmt.Sprintf("to%d@somewhere", i)); err != nil { t.Fatalf("Rcpt: %v", err) } } err := c.Rcpt("to102@somewhere") if err == nil || err.Error() != "452 4.5.3 Too many recipients" { t.Errorf("Expected too many recipients, got: %v", err) } } func TestRcptBrokenExists(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() if err := c.Mail("from@localhost"); err != nil { t.Fatalf("Mail: %v", err) } err := c.Rcpt("to@broken") if err == nil { t.Errorf("Accepted RCPT with broken Exists") } expect := "451 4.4.3 Temporary error checking address" if err.Error() != expect { t.Errorf("RCPT returned unexpected error %q", err.Error()) } } func TestRcptUserDoesNotExist(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() if err := c.Mail("from@localhost"); err != nil { t.Fatalf("Mail: %v", err) } err := c.Rcpt("doesnotexist@localhost") if err == nil { t.Errorf("Accepted RCPT for non-existent user") } expect := "550 5.1.1 Destination address is unknown (user does not exist)" if err.Error() != expect { t.Errorf("RCPT returned unexpected error %q", err.Error()) } } var str1MiB string func sendLargeEmail(tb testing.TB, c *smtp.Client, sizeMiB int) error { tb.Helper() if err := c.Mail("from@from"); err != nil { tb.Fatalf("Mail: %v", err) } if err := c.Rcpt("to@localhost"); err != nil { tb.Fatalf("Rcpt: %v", err) } w, err := c.Data() if err != nil { tb.Fatalf("Data: %v", err) } if _, err := w.Write([]byte("Subject: I ate too much\n\n")); err != nil { tb.Fatalf("Data write: %v", err) } // Write the 1 MiB string sizeMiB times. for i := 0; i < sizeMiB; i++ { if _, err := w.Write([]byte(str1MiB)); err != nil { tb.Fatalf("Data write: %v", err) } } return w.Close() } func TestTooMuchData(t *testing.T) { c := mustDial(t, ModeSMTP, true) defer c.Close() localC.Expect(1) err := sendLargeEmail(t, c, maxDataSizeMiB-1) if err != nil { t.Errorf("Error sending large but ok email: %v", err) } localC.Wait() // Repeat the test - we want to check that the limit applies to each // message, not the entire connection. localC.Expect(1) err = sendLargeEmail(t, c, maxDataSizeMiB-1) if err != nil { t.Errorf("Error sending large but ok email: %v", err) } localC.Wait() err = sendLargeEmail(t, c, maxDataSizeMiB+1) if err == nil || err.Error() != "552 5.3.4 Message too big" { t.Fatalf("Expected message too big, got: %v", err) } // Repeat the test once again, the limit should not prevent connection // from continuing. localC.Expect(1) err = sendLargeEmail(t, c, maxDataSizeMiB-1) if err != nil { t.Errorf("Error sending large but ok email: %v", err) } localC.Wait() } func simpleCmd(t *testing.T, c *smtp.Client, cmd string, expected int) string { t.Helper() if err := c.Text.PrintfLine(cmd); err != nil { t.Fatalf("Failed to write %s: %v", cmd, err) } _, msg, err := c.Text.ReadResponse(expected) if err != nil { t.Errorf("Incorrect %s response: %v", cmd, err) } return msg } func TestSimpleCommands(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() simpleCmd(t, c, "HELP", 214) simpleCmd(t, c, "NOOP", 250) simpleCmd(t, c, "VRFY", 502) simpleCmd(t, c, "EXPN", 502) } func TestLongLines(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() // Send a not-too-long line. simpleCmd(t, c, fmt.Sprintf("%1000s", "x"), 500) // Send a very long line, expect an error. msg := simpleCmd(t, c, fmt.Sprintf("%1001s", "x"), 554) if msg != "error reading command: line too long" { t.Errorf("Expected 'line too long', got %v", msg) } } func TestReset(t *testing.T) { c := mustDial(t, ModeSMTP, false) defer c.Close() if err := c.Mail("from@plain"); err != nil { t.Fatalf("MAIL FROM: %v", err) } if err := c.Reset(); err != nil { t.Errorf("RSET: %v", err) } if err := c.Mail("from@plain"); err != nil { t.Errorf("MAIL after RSET: %v", err) } } func TestRepeatedStartTLS(t *testing.T) { c, err := smtp.Dial(smtpAddr) if err != nil { t.Fatalf("smtp.Dial: %v", err) } if err = c.StartTLS(tlsConfig); err != nil { t.Fatalf("StartTLS: %v", err) } if err = c.StartTLS(tlsConfig); err == nil { t.Errorf("Second STARTTLS did not fail as expected") } } // Test that STARTTLS fails on a TLS connection. func TestStartTLSOnTLS(t *testing.T) { c := mustDial(t, ModeSubmissionTLS, false) defer c.Close() if err := c.StartTLS(tlsConfig); err == nil { t.Errorf("STARTTLS did not fail as expected") } } func TestAddDKIMSigner(t *testing.T) { s := NewServer() err := s.AddDKIMSigner("example.com", "selector", "keyfile-does-not-exist") if !os.IsNotExist(err) { t.Errorf("AddDKIMSigner: expected not exist, got %v", err) } tmpDir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, tmpDir) // Invalid PEM file. kf1 := tmpDir + "/key1-bad_pem.pem" testlib.Rewrite(t, kf1, "not a valid PEM file") err = s.AddDKIMSigner("example.com", "selector", kf1) if !errors.Is(err, errDecodingPEMBlock) { t.Errorf("AddDKIMSigner: expected %v, got %v", errDecodingPEMBlock, err) } // Unsupported block type. kf2 := tmpDir + "/key2.pem" testlib.Rewrite(t, kf2, "-----BEGIN TEST KEY-----\n-----END TEST KEY-----") err = s.AddDKIMSigner("example.com", "selector", kf2) if !errors.Is(err, errUnsupportedBlockType) { t.Errorf("AddDKIMSigner: expected %v, got %v", errUnsupportedBlockType, err) } // x509 error: this is an ed448 key, which is not supported. kf3 := tmpDir + "/key3.pem" testlib.Rewrite(t, kf3, `-----BEGIN PRIVATE KEY----- MEcCAQAwBQYDK2VxBDsEOSBHT9DNG6/FNBnRGrLay+jIrK8WrViiVMz9AoXqYSb6 ghwTZSd3E0X8oIFTgs9ch3pxJM1KDrs4NA== -----END PRIVATE KEY-----`) err = s.AddDKIMSigner("example.com", "selector", kf3) if !strings.Contains(err.Error(), "x509: PKCS#8 wrapping contained private key with unknown algorithm") { t.Errorf("AddDKIMSigner: expected x509 error, got %q", err.Error()) } // Unsupported key type: X25519. kf4 := tmpDir + "/key4.pem" testlib.Rewrite(t, kf4, `-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VuBCIEIKBUDwEDc5cCv/yEvnA93yk0gXyiTZe7Qip8QU3rJuZC -----END PRIVATE KEY-----`) err = s.AddDKIMSigner("example.com", "selector", kf4) if !errors.Is(err, errUnsupportedKeyType) { t.Errorf("AddDKIMSigner: expected %v, got %v", errUnsupportedKeyType, err) } // Successful. kf5 := tmpDir + "/key5.pem" testlib.Rewrite(t, kf5, `-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS -----END PRIVATE KEY-----`) err = s.AddDKIMSigner("example.com", "selector", kf5) if err != nil { t.Errorf("AddDKIMSigner: %v", err) } } // // === Benchmarks === // func BenchmarkManyEmails(b *testing.B) { c := mustDial(b, ModeSMTP, false) defer c.Close() b.ResetTimer() for i := 0; i < b.N; i++ { sendEmail(b, c) } } func BenchmarkManyEmailsParallel(b *testing.B) { b.RunParallel(func(pb *testing.PB) { c := mustDial(b, ModeSMTP, false) defer c.Close() for pb.Next() { sendEmail(b, c) } }) } // // === Test environment === // // 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") } type brokenAuthBE struct{} func (b brokenAuthBE) Authenticate(user, password string) (bool, error) { return false, fmt.Errorf("failed to auth") } func (b brokenAuthBE) Exists(user string) (bool, error) { return false, fmt.Errorf("failed to check if user exists") } func (b brokenAuthBE) Reload() error { return fmt.Errorf("failed to reload") } // realMain is the real main function, which returns the value to pass to // os.Exit(). We have to do this so we can use defer. func realMain(m *testing.M) int { flag.Parse() // Create a 1MiB string, which the large message tests use. buf := make([]byte, 1024*1024) for i := 0; i < len(buf); i++ { buf[i] = 'a' } str1MiB = string(buf) // Set up the mail log to stdout, which is captured by the test runner, // so we have better debugging information on failures. maillog.Default = maillog.New(os.Stdout) if *externalSMTPAddr != "" { smtpAddr = *externalSMTPAddr submissionAddr = *externalSubmissionAddr submissionTLSAddr = *externalSubmissionTLSAddr tlsConfig = &tls.Config{ InsecureSkipVerify: true, } } else { // Generate certificates in a temporary directory. tmpDir, err := os.MkdirTemp("", "chasquid_test:") if err != nil { fmt.Printf("Failed to create temp dir: %v\n", tmpDir) return 1 } defer os.RemoveAll(tmpDir) tlsConfig, err = testlib.GenerateCert(tmpDir) if err != nil { fmt.Printf("Failed to generate cert for testing: %v\n", err) return 1 } smtpAddr = testlib.GetFreePort() submissionAddr = testlib.GetFreePort() submissionTLSAddr = testlib.GetFreePort() s := NewServer() s.Hostname = "localhost" s.MaxDataSize = int64(maxDataSizeMiB) * 1024 * 1024 s.AddCerts(tmpDir+"/cert.pem", tmpDir+"/key.pem") s.AddAddr(smtpAddr, ModeSMTP) s.AddAddr(submissionAddr, ModeSubmission) s.AddAddr(submissionTLSAddr, ModeSubmissionTLS) s.InitQueue(tmpDir+"/queue", localC, remoteC) dinfo, err := domaininfo.New(tmpDir + "/domaininfo") if err != nil { fmt.Printf("Error initializing domaininfo: %v", err) return 1 } s.SetDomainInfo(dinfo) udb := userdb.New("/dev/null") udb.AddUser("testuser", "testpasswd") s.aliasesR.AddAliasForTesting( "to@localhost", "testuser@localhost", aliases.EMAIL) s.authr.Register("localhost", auth.WrapNoErrorBackend(udb)) s.AddDomain("localhost") s.AddDomain("broken") s.authr.Register("broken", &brokenAuthBE{}) // Disable SPF lookups, to avoid leaking DNS queries. disableSPFForTesting = true // Disable reloading. reloadEvery = nil go s.ListenAndServe() } waitForServer(smtpAddr) waitForServer(submissionAddr) waitForServer(submissionTLSAddr) return m.Run() } func TestMain(m *testing.M) { os.Exit(realMain(m)) } chasquid-1.15.0/internal/smtpsrv/testdata/000077500000000000000000000000001474251645300205555ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/000077500000000000000000000000001474251645300215535ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnection/000077500000000000000000000000001474251645300245315ustar00rootroot000000000000003d7e992212e817da7afdb7a4e769ceec1d4047a2e630bec4b35ecd4d55560424000066400000000000000000000007421474251645300354200ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("HELO localhost\nMAIL FROM:\nRCPT LALA: <>\nRCPT TO:\nRCPT TO:\nRCPT TO:\nRCPT TO:\nRCPT TO:\n") 68d8c7b5f149996ffd46ad9a15852165d8c1cbd6c03cceb9382e5add16415c94000066400000000000000000000003431474251645300353710ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("DATA\nHELO localhost\nDATA\nMAIL FROM:\nRCPT TO: user@testserver\nDATA\nFrom: Mailer daemon \nSubject: I've come to haunt you\nBad header\n\nMuahahahaha\n\n\n.\nQUIT\n") 79e51b30c215fb19a29855deebf2ed8299b35ca6f14db9681ee504e216c44a7f000066400000000000000000000003431474251645300353530ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("EHLO localhost\nMAIL FROM: <>\nRCPT TO: user@testserver\nDATA\nFrom: Mailer daemon \nSubject: I've come to haunt you\nMessage-ID: \n\nÑaÃąaÃąaÃąaÃąa!\n\n\n.\nQUIT\n") 83ab02fccf91c1b9c0c972de745dc2a45d23dc3236f9027e605c3e017d8898fe000066400000000000000000000006571474251645300353540ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("HELO localhost\nMAIL LALA: <>\nMAIL FROM:\nMAIL FROM:\nMAIL FROM:\nMAIL FROM:\n") a24124ade554d7a25de538f2cbbced6245ba60e90d221e51590456e222c80359000066400000000000000000000000761474251645300350740ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("HELO\nEHLO\nHELO localhost\n") b896b41db27f6e36e4e727ac4f7b3d02fad34d217855c0d433ea3a325951b3bf000066400000000000000000000000701474251645300353230ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("HELO localhost\nQUIT\n") bf15e6fb937795251090940ac60a37705b36a13e71a9557e7aaf0618ea2cf661000066400000000000000000000003111474251645300347500ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("EHLO localhost\nMAIL FROM: <>\nRCPT TO: user@testserver\nDATA\nFrom: Mailer daemon \nSubject: I've come to haunt you\n\nMuahahahaha\n\n\n.\nQUIT\n") d1b1ccbbb380c53282cc2689c4bd9ff0d03a03698e9be55371739ef95d7dd671000066400000000000000000000002401474251645300353500ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("EHLO localhost\nAUTH PLAIN something\nAUTH PLAIN something\nAUTH PLAIN something\nAUTH PLAIN something\nAUTH PLAIN something\n") dc70e53325976a3a1067feb0b0c956c5a9abec1c867f8198808ccff83f594ded000066400000000000000000000000761474251645300354530ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("EHLO localhost\nWHATISTHIS\n") e7682fde78ce0d78ddc7a818f151b6f04466a2c122197a2e4e8048d194ed72c2000066400000000000000000000000761474251645300352210ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(0) []byte("EHLO localhost\nAUTH PLAIN\n") fd41d0c11b1bb7f89825934b2ec51db1df166e34b4610e8089549eedf2e3635c000066400000000000000000000004261474251645300352620ustar00rootroot00000000000000chasquid-1.15.0/internal/smtpsrv/testdata/fuzz/FuzzConnectiongo test fuzz v1 int(2) []byte("EHLO localhost\nAUTH SOMETHINGELSE\nAUTH PLAIN\ndXNlckB0ZXN0c2VydmVyAHlalala==\nAUTH PLAIN\ndXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgB3cm9uZ3Bhc3N3b3Jk\nAUTH PLAIN\ndXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgBzZWNyZXRwYXNzd29yZA==\nAUTH PLAIN\n") chasquid-1.15.0/internal/sts/000077500000000000000000000000001474251645300160375ustar00rootroot00000000000000chasquid-1.15.0/internal/sts/sts.go000066400000000000000000000334711474251645300172070ustar00rootroot00000000000000// Package sts implements the MTA-STS (Strict Transport Security), RFC 8461. // // Note that "report" mode is not supported. // // Reference: https://tools.ietf.org/html/rfc8461 package sts import ( "bufio" "bytes" "context" "encoding/json" "errors" "fmt" "io" "mime" "net" "net/http" "os" "strconv" "strings" "time" "blitiri.com.ar/go/chasquid/internal/expvarom" "blitiri.com.ar/go/chasquid/internal/safeio" "blitiri.com.ar/go/chasquid/internal/trace" "golang.org/x/net/context/ctxhttp" "golang.org/x/net/idna" ) // Exported variables. var ( cacheFetches = expvarom.NewInt("chasquid/sts/cache/fetches", "count of total fetches in the STS cache") cacheHits = expvarom.NewInt("chasquid/sts/cache/hits", "count of hits in the STS cache") cacheExpired = expvarom.NewInt("chasquid/sts/cache/expired", "count of expired entries in the STS cache") cacheIOErrors = expvarom.NewInt("chasquid/sts/cache/ioErrors", "count of I/O errors when maintaining STS cache") cacheFailedFetch = expvarom.NewInt("chasquid/sts/cache/failedFetch", "count of failed fetches in the STS cache") cacheInvalid = expvarom.NewInt("chasquid/sts/cache/invalid", "count of invalid policies in the STS cache") cacheMarshalErrors = expvarom.NewInt("chasquid/sts/cache/marshalErrors", "count of marshalling errors when maintaining STS cache") cacheUnmarshalErrors = expvarom.NewInt("chasquid/sts/cache/unmarshalErrors", "count of unmarshalling errors in STS cache") cacheRefreshCycles = expvarom.NewInt("chasquid/sts/cache/refreshCycles", "count of STS cache refresh cycles") cacheRefreshes = expvarom.NewInt("chasquid/sts/cache/refreshes", "count of STS cache refreshes") cacheRefreshErrors = expvarom.NewInt("chasquid/sts/cache/refreshErrors", "count of STS cache refresh errors") ) // Policy represents a parsed policy. // https://tools.ietf.org/html/rfc8461#section-3.2 // The json annotations are used for serializing for caching purposes. type Policy struct { Version string `json:"version"` Mode Mode `json:"mode"` MXs []string `json:"mx"` MaxAge time.Duration `json:"max_age"` } // The Mode of a policy. Valid values (according to the standard) are // constants below. type Mode string // Valid modes. const ( Enforce = Mode("enforce") Testing = Mode("testing") None = Mode("none") ) // parsePolicy parses a text representation of the policy (as specified in the // RFC), and returns the corresponding Policy structure. func parsePolicy(raw []byte) (*Policy, error) { p := &Policy{} scanner := bufio.NewScanner(bytes.NewReader(raw)) for scanner.Scan() { sp := strings.SplitN(scanner.Text(), ":", 2) if len(sp) != 2 { continue } key := strings.TrimSpace(sp[0]) value := strings.TrimSpace(sp[1]) // Only care for the keys we recognize. switch key { case "version": p.Version = value case "mode": p.Mode = Mode(value) case "max_age": // On error, p.MaxAge will be 0 which is invalid. maxAge, _ := strconv.Atoi(value) p.MaxAge = time.Duration(maxAge) * time.Second case "mx": p.MXs = append(p.MXs, value) } } if err := scanner.Err(); err != nil { return nil, err } return p, nil } // Check errors. var ( ErrUnknownVersion = errors.New("unknown policy version") ErrInvalidMaxAge = errors.New("invalid max_age") ErrInvalidMode = errors.New("invalid mode") ErrInvalidMX = errors.New("invalid mx") ) // Fetch errors. var ( ErrInvalidMediaType = errors.New("invalid HTTP media type") ) // Check that the policy contents are valid. func (p *Policy) Check() error { if p.Version != "STSv1" { return ErrUnknownVersion } // A 0 max age is invalid (could also represent an Atoi error), and so is // one greater than 31557600 (1 year), as per // https://tools.ietf.org/html/rfc8461#section-3.2. if p.MaxAge <= 0 || p.MaxAge > 31557600*time.Second { return ErrInvalidMaxAge } if p.Mode != Enforce && p.Mode != Testing && p.Mode != None { return ErrInvalidMode } // "mx" field is required, and the policy is invalid if it's not present. // https://mailarchive.ietf.org/arch/msg/uta/Omqo1Bw6rJbrTMl2Zo69IJr35Qo if len(p.MXs) == 0 { return ErrInvalidMX } return nil } // MXIsAllowed checks if the given MX is allowed, according to the policy. // https://tools.ietf.org/html/rfc8461#section-4.1 func (p *Policy) MXIsAllowed(mx string) bool { if p.Mode != Enforce { return true } for _, pattern := range p.MXs { if matchDomain(mx, pattern) { return true } } return false } // UncheckedFetch fetches and parses the policy, but does NOT check it. // This can be useful for debugging and troubleshooting, but you should always // call Check on the policy before using it. func UncheckedFetch(ctx context.Context, domain string) (*Policy, error) { // Convert the domain to ascii form, as httpGet does not support IDNs in // any other way. domain, err := idna.ToASCII(domain) if err != nil { return nil, err } ok, err := hasSTSRecord(domain) if err != nil { return nil, err } if !ok { return nil, fmt.Errorf("MTA-STS TXT record missing") } url := urlForDomain(domain) rawPolicy, err := httpGet(ctx, url) if err != nil { return nil, err } return parsePolicy(rawPolicy) } // Fake URL for testing purposes, so we can do more end-to-end tests, // including the HTTP fetching code. var fakeURLForTesting string func urlForDomain(domain string) string { if fakeURLForTesting != "" { return fakeURLForTesting + "/" + domain } // URL composed from the domain, as explained in: // https://tools.ietf.org/html/rfc8461#section-3.3 // https://tools.ietf.org/html/rfc8461#section-3.2 return "https://mta-sts." + domain + "/.well-known/mta-sts.txt" } // Fetch a policy for the given domain. Note this results in various network // lookups and HTTPS GETs, so it can be slow. // The returned policy is parsed and sanity-checked (using Policy.Check), so // it should be safe to use. func Fetch(ctx context.Context, domain string) (*Policy, error) { p, err := UncheckedFetch(ctx, domain) if err != nil { return nil, err } err = p.Check() if err != nil { return nil, err } return p, nil } // httpGet performs an HTTP GET of the given URL, using the context and // rejecting redirects, as per the standard. func httpGet(ctx context.Context, url string) ([]byte, error) { client := &http.Client{ // We MUST NOT follow redirects, see // https://tools.ietf.org/html/rfc8461#section-3.3 CheckRedirect: rejectRedirect, } resp, err := ctxhttp.Get(ctx, client, url) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("HTTP response status code: %v", resp.StatusCode) } // Media type must be "text/plain" to guard against cases where webservers // allow untrusted users to host non-text content (like HTML or images) at // a user-defined path. // https://tools.ietf.org/html/rfc8461#section-3.2 mt, _, err := mime.ParseMediaType(resp.Header.Get("Content-type")) if err != nil { return nil, fmt.Errorf("HTTP media type error: %v", err) } if mt != "text/plain" { return nil, ErrInvalidMediaType } // Read but up to 10k; policies should be way smaller than that, and // having a limit prevents abuse/accidents with very large replies. return io.ReadAll(&io.LimitedReader{R: resp.Body, N: 10 * 1024}) } var errRejectRedirect = errors.New("redirects not allowed in MTA-STS") func rejectRedirect(req *http.Request, via []*http.Request) error { return errRejectRedirect } // matchDomain checks if the domain matches the given pattern, according to // from https://tools.ietf.org/html/rfc8461#section-4.1 // (based on https://tools.ietf.org/html/rfc6125#section-6.4). func matchDomain(domain, pattern string) bool { domain, dErr := domainToASCII(domain) pattern, pErr := domainToASCII(pattern) if dErr != nil || pErr != nil { // Domains should already have been checked and normalized by the // caller, exposing this is not worth the API complexity in this case. return false } // Simplify the case of a literal match. if domain == pattern { return true } // For wildcards, skip the first part of the domain and match the rest. // Note that if the pattern is malformed this might fail, but we are ok // with that. if strings.HasPrefix(pattern, "*.") { parts := strings.SplitN(domain, ".", 2) if len(parts) > 1 && parts[1] == pattern[2:] { return true } } return false } // domainToASCII converts the domain to ASCII form, similar to idna.ToASCII // but with some preprocessing convenient for our use cases. func domainToASCII(domain string) (string, error) { domain = strings.TrimSuffix(domain, ".") domain = strings.ToLower(domain) return idna.ToASCII(domain) } // Function that we override for testing purposes. // In the future we will override net.DefaultResolver, but we don't do that // yet for backwards compatibility. var lookupTXT = net.LookupTXT // hasSTSRecord checks if there is a valid MTA-STS TXT record for the domain. // We don't do full parsing and don't care about the "id=" field, as it is // unused in this implementation. func hasSTSRecord(domain string) (bool, error) { txts, err := lookupTXT("_mta-sts." + domain) if err != nil { return false, err } for _, txt := range txts { if strings.HasPrefix(txt, "v=STSv1;") { return true, nil } } return false, nil } // PolicyCache is a caching layer for fetching policies. // // Policies are cached by domain, and stored in a single directory. // The files will have as mtime the time when the policy expires, this makes // the store simpler, as it can avoid keeping additional metadata. // // There is no in-memory caching. This may be added in the future, but for // now disk is good enough for our purposes. type PolicyCache struct { dir string } // NewCache creates an instance of PolicyCache using the given directory as // backing storage. The directory will be created if it does not exist. func NewCache(dir string) (*PolicyCache, error) { c := &PolicyCache{ dir: dir, } err := os.MkdirAll(dir, 0770) return c, err } const pathPrefix = "pol:" func (c *PolicyCache) domainPath(domain string) string { // We assume the domain is well formed, sanity check just in case. if strings.Contains(domain, "/") { panic("domain contains slash") } return c.dir + "/" + pathPrefix + domain } var errExpired = errors.New("cache entry expired") func (c *PolicyCache) load(domain string) (*Policy, error) { fname := c.domainPath(domain) fi, err := os.Stat(fname) if err != nil { return nil, err } if time.Since(fi.ModTime()) > 0 { cacheExpired.Add(1) return nil, errExpired } data, err := os.ReadFile(fname) if err != nil { cacheIOErrors.Add(1) return nil, err } p := &Policy{} err = json.Unmarshal(data, p) if err != nil { cacheUnmarshalErrors.Add(1) return nil, err } // The policy should always be valid, as we marshalled it ourselves; // however, check it just to be safe. if err := p.Check(); err != nil { cacheInvalid.Add(1) return nil, fmt.Errorf( "%s unmarshalled invalid policy %v: %v", domain, p, err) } return p, nil } func (c *PolicyCache) store(domain string, p *Policy) error { data, err := json.Marshal(p) if err != nil { cacheMarshalErrors.Add(1) return fmt.Errorf("%s failed to marshal policy %v, error: %v", domain, p, err) } // Change the modification time to the future, when the policy expires. // load will check for this to detect expired cache entries, see above for // the details. expires := time.Now().Add(p.MaxAge) chTime := func(fname string) error { return os.Chtimes(fname, expires, expires) } fname := c.domainPath(domain) err = safeio.WriteFile(fname, data, 0640, chTime) if err != nil { cacheIOErrors.Add(1) } return err } // Fetch a policy for the given domain, using the cache. func (c *PolicyCache) Fetch(ctx context.Context, domain string) (*Policy, error) { cacheFetches.Add(1) tr := trace.New("STSCache.Fetch", domain) defer tr.Finish() p, err := c.load(domain) if err == nil { tr.Debugf("cache hit: %v", p) cacheHits.Add(1) return p, nil } p, err = Fetch(ctx, domain) if err != nil { tr.Debugf("failed to fetch: %v", err) cacheFailedFetch.Add(1) return nil, err } tr.Debugf("fetched: %v", p) // We could do this asynchronously, as we got the policy to give to the // caller. However, to make troubleshooting easier and the cost of storing // entries easier to track down, we store synchronously. // Note that even if the store returns an error, we pass on the policy: at // this point we rather use the policy even if we couldn't store it in the // cache. err = c.store(domain, p) if err != nil { tr.Errorf("failed to store: %v", err) } else { tr.Debugf("stored") } return p, nil } // PeriodicallyRefresh the cache, by re-fetching all entries. func (c *PolicyCache) PeriodicallyRefresh(ctx context.Context) { for ctx.Err() == nil { c.refresh(ctx) cacheRefreshCycles.Add(1) // Wait 10 minutes between passes; this is a background refresh and // there's no need to poke the servers very often. time.Sleep(10 * time.Minute) } } func (c *PolicyCache) refresh(ctx context.Context) { tr := trace.New("STSCache.Refresh", c.dir) defer tr.Finish() entries, err := os.ReadDir(c.dir) if err != nil { tr.Errorf("failed to list directory %q: %v", c.dir, err) return } tr.Debugf("%d entries", len(entries)) for _, e := range entries { if !strings.HasPrefix(e.Name(), pathPrefix) { continue } domain := e.Name()[len(pathPrefix):] cacheRefreshes.Add(1) tr.Debugf("%v: refreshing", domain) fetchCtx, cancel := context.WithTimeout(ctx, 30*time.Second) p, err := Fetch(fetchCtx, domain) cancel() if err != nil { tr.Debugf("%v: failed to fetch: %v", domain, err) cacheRefreshErrors.Add(1) continue } tr.Debugf("%v: fetched", domain) err = c.store(domain, p) if err != nil { tr.Errorf("%v: failed to store: %v", domain, err) } else { tr.Debugf("%v: stored", domain) } } tr.Debugf("refresh done") } chasquid-1.15.0/internal/sts/sts_test.go000066400000000000000000000367231474251645300202510ustar00rootroot00000000000000package sts import ( "context" "expvar" "fmt" "net/http" "net/http/httptest" "os" "strings" "testing" "time" "blitiri.com.ar/go/chasquid/internal/testlib" ) // Override the lookup function to control its results. var txtResults = map[string][]string{ "dom1": nil, "dom2": {}, "dom3": {"abc", "def"}, "dom4": {"abc", "v=STSv1; id=blah;"}, // Matching policyForDomain below. "_mta-sts.domain.com": {"v=STSv1; id=blah;"}, "_mta-sts.policy404": {"v=STSv1; id=blah;"}, "_mta-sts.version99": {"v=STSv1; id=blah;"}, } var errTest = fmt.Errorf("error for testing purposes") var txtErrors = map[string]error{ "_mta-sts.domErr": errTest, } func testLookupTXT(domain string) ([]string, error) { return txtResults[domain], txtErrors[domain] } // Test policy for each of the requested domains. Will be served by the test // HTTP server. var policyForDomain = map[string]string{ // domain.com -> valid, with reasonable policy. "domain.com": ` version: STSv1 mode: enforce mx: *.mail.domain.com max_age: 3600 `, // version99 -> invalid policy (unknown version). "version99": ` version: STSv99 mode: enforce mx: *.mail.version99 max_age: 999 `, } func testHTTPHandler(w http.ResponseWriter, r *http.Request) { // For testing, the domain in the path (see urlForDomain). policy, ok := policyForDomain[r.URL.Path[1:]] if !ok { http.Error(w, "not found", 404) return } fmt.Fprintln(w, policy) } func TestMain(m *testing.M) { lookupTXT = testLookupTXT // Create a test HTTP server, used by the more end-to-end tests. httpServer := httptest.NewServer(http.HandlerFunc(testHTTPHandler)) fakeURLForTesting = httpServer.URL os.Exit(m.Run()) } func TestParsePolicy(t *testing.T) { const pol1 = ` version: STSv1 mode: enforce mx: *.mail.example.com max_age: 123456 ` p, err := parsePolicy([]byte(pol1)) if err != nil { t.Errorf("failed to parse policy: %v", err) } t.Logf("pol1: %+v", p) } func TestCheckPolicy(t *testing.T) { validPs := []Policy{ {Version: "STSv1", Mode: "enforce", MaxAge: 1 * time.Hour, MXs: []string{"mx1", "mx2"}}, {Version: "STSv1", Mode: "testing", MaxAge: 1 * time.Hour, MXs: []string{"mx1"}}, {Version: "STSv1", Mode: "none", MaxAge: 1 * time.Hour, MXs: []string{"mx1"}}, {Version: "STSv1", Mode: "none", MaxAge: 31557600 * time.Second, MXs: []string{"mx1"}}, } for i, p := range validPs { if err := p.Check(); err != nil { t.Errorf("%d policy %v failed check: %v", i, p, err) } } invalid := []struct { p Policy expected error }{ {Policy{Version: "STSv2"}, ErrUnknownVersion}, {Policy{Version: "STSv1"}, ErrInvalidMaxAge}, {Policy{Version: "STSv1", MaxAge: 31557601 * time.Second}, ErrInvalidMaxAge}, {Policy{Version: "STSv1", MaxAge: 1, Mode: "blah"}, ErrInvalidMode}, {Policy{Version: "STSv1", MaxAge: 1, Mode: "enforce"}, ErrInvalidMX}, {Policy{Version: "STSv1", MaxAge: 1, Mode: "enforce", MXs: []string{}}, ErrInvalidMX}, } for i, c := range invalid { if err := c.p.Check(); err != c.expected { t.Errorf("%d policy %v check: expected %v, got %v", i, c.p, c.expected, err) } } } func TestMatchDomain(t *testing.T) { cases := []struct { domain, pattern string expected bool }{ {"lalala", "lalala", true}, {"a.b.", "a.b", true}, {"a.b", "a.b.", true}, {"abc.com", "*.com", true}, {"abc.com", "abc.*.com", false}, {"abc.com", "x.abc.com", false}, {"x.abc.com", "*.*.com", false}, {"abc.def.com", "abc.*.com", false}, {"Ãąaca.com", "Ãąaca.com", true}, {"Ñaca.com", "Ãąaca.com", true}, {"Ãąaca.com", "Ñaca.com", true}, {"x.Ãąaca.com", "x.xn--aca-6ma.com", true}, {"x.naca.com", "x.xn--aca-6ma.com", false}, // Triggers errors in domainToASCII. {strings.Repeat("x", 65536) + "\uff00", "x.com", false}, // Examples from the RFC. {"mail.example.com", "*.example.com", true}, {"example.com", "*.example.com", false}, {"foo.bar.example.com", "*.example.com", false}, // Missing "*" (invalid, seen in the wild). {"aa.b.cc.com", ".aa.b.cc.com", false}, {"zz.aa.b.cc.com", ".aa.b.cc.com", false}, {"zz.aa.b.cc.com", "*.aa.b.cc.com", true}, } for _, c := range cases { if r := matchDomain(c.domain, c.pattern); r != c.expected { t.Errorf("matchDomain(%q, %q) = %v, expected %v", c.domain, c.pattern, r, c.expected) } } } func TestMXIsAllowed(t *testing.T) { p := Policy{Version: "STSv1", Mode: "enforce", MaxAge: 1 * time.Hour, MXs: []string{"mx1", "mx2"}} if p.MXIsAllowed("notamx") { t.Errorf("notamx should not be allowed") } if !p.MXIsAllowed("mx1") { t.Errorf("mx1 should be allowed") } if !p.MXIsAllowed("mx2") { t.Errorf("mx2 should be allowed") } p = Policy{Version: "STSv1", Mode: "testing", MaxAge: 1 * time.Hour, MXs: []string{"mx1"}} if !p.MXIsAllowed("notamx") { t.Errorf("notamx should be allowed (policy not enforced)") } } func TestFetch(t *testing.T) { // Note the data "fetched" for each domain comes from policyForDomain, // defined in TestMain above. See httpGet for more details. // Normal fetch, all valid. p, err := Fetch(context.Background(), "domain.com") if err != nil { t.Errorf("failed to fetch policy: %v", err) } t.Logf("domain.com: %+v", p) // Domain without a policy (HTTP get fails). p, err = Fetch(context.Background(), "policy404") if err == nil { t.Errorf("fetched unknown policy: %v", p) } t.Logf("policy404: got error as expected: %v", err) // Domain with an invalid policy (unknown version). p, err = Fetch(context.Background(), "version99") if err != ErrUnknownVersion { t.Errorf("expected error %v, got %v (and policy: %v)", ErrUnknownVersion, err, p) } t.Logf("version99: got expected error: %v", err) // Error fetching TXT record for this domain. p, err = Fetch(context.Background(), "domErr") if err != errTest { t.Errorf("expected error %v, got %v (and policy: %v)", errTest, err, p) } t.Logf("domErr: got expected error: %v", err) } func TestPolicyTooBig(t *testing.T) { // Construct a valid but very large JSON as a policy. raw := `{"version": "STSv1", "mode": "enforce", "mx": [` for i := 0; i < 2000; i++ { raw += fmt.Sprintf("\"mx%d\", ", i) } raw += `"mxlast"], "max_age": 100}` policyForDomain["toobig"] = raw _, err := Fetch(context.Background(), "toobig") if err == nil { t.Errorf("fetch worked, but should have failed") } t.Logf("got error as expected: %v", err) } // Tests for the policy cache. func expvarMustEq(t *testing.T, name string, v *expvar.Int, expected int64) { if v.Value() != expected { t.Errorf("%s is %d, expected %d", name, v.Value(), expected) } } func TestCacheBasics(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) c, err := NewCache(dir) if err != nil { t.Fatal(err) } // Note the data "fetched" for each domain comes from policyForDomain, // defined in TestMain above. See httpGet for more details. // Reset the expvar counters that we use to validate hits, misses, etc. cacheFetches.Set(0) cacheHits.Set(0) ctx := context.Background() // Fetch domain.com, check we get a reasonable policy, and that it's a // cache miss. p, err := c.Fetch(ctx, "domain.com") if err != nil || p.Check() != nil || p.MXs[0] != "*.mail.domain.com" { t.Errorf("unexpected fetch result - policy = %v ; error = %v", p, err) } t.Logf("cache fetched domain.com: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 1) expvarMustEq(t, "cacheHits", cacheHits, 0) // Fetch domain.com again, this time we should see a cache hit. p, err = c.Fetch(ctx, "domain.com") if err != nil || p.Check() != nil || p.MXs[0] != "*.mail.domain.com" { t.Errorf("unexpected fetch result - policy = %v ; error = %v", p, err) } t.Logf("cache fetched domain.com: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 2) expvarMustEq(t, "cacheHits", cacheHits, 1) // Simulate an expired cache entry by changing the mtime of domain.com's // entry to the past. expires := time.Now().Add(-1 * time.Minute) os.Chtimes(c.domainPath("domain.com"), expires, expires) // Do a third fetch, check that we don't get a cache hit. p, err = c.Fetch(ctx, "domain.com") if err != nil || p.Check() != nil || p.MXs[0] != "*.mail.domain.com" { t.Errorf("unexpected fetch result - policy = %v ; error = %v", p, err) } t.Logf("cache fetched domain.com: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 3) expvarMustEq(t, "cacheHits", cacheHits, 1) // Fetch for a domain without policy. p, err = c.Fetch(ctx, "domErr") if err == nil || p != nil { t.Errorf("expected failure, got: policy = %v ; error = %v", p, err) } t.Logf("cache fetched domErr: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 4) expvarMustEq(t, "cacheHits", cacheHits, 1) expvarMustEq(t, "cacheFailedFetch", cacheFailedFetch, 1) } // Test how the cache behaves when the files are corrupt. func TestCacheBadData(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) c, err := NewCache(dir) if err != nil { t.Fatal(err) } ctx := context.Background() cacheUnmarshalErrors.Set(0) cacheInvalid.Set(0) cases := []string{ // Case 1: A file with invalid json, which will fail unmarshalling. "this is not valid json", // Case 2: A file with a parseable but invalid policy. `{"version": "STSv1", "mode": "INVALID", "mx": ["mx"], "max_age": 1}`, } for _, badContent := range cases { // Reset the expvar counters that we use to validate hits, misses, etc. cacheFetches.Set(0) cacheHits.Set(0) // Fetch domain.com, should result in the file being added to the // cache. p, err := c.Fetch(ctx, "domain.com") if err != nil { t.Fatalf("Fetch failed: %v", err) } t.Logf("cache fetched domain.com: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 1) expvarMustEq(t, "cacheHits", cacheHits, 0) // Edit the file, filling it with the bad content for this case. fname := c.domainPath("domain.com") mustRewriteAndChtime(t, fname, badContent) // We now expect Fetch to fall back to getting the policy from the // network (in our case, from policyForDomain). p, err = c.Fetch(ctx, "domain.com") if err != nil { t.Fatalf("Fetch failed: %v", err) } t.Logf("cache fetched domain.com: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 2) expvarMustEq(t, "cacheHits", cacheHits, 0) // And now the file should be fine, resulting in a cache hit. p, err = c.Fetch(ctx, "domain.com") if err != nil { t.Fatalf("Fetch failed: %v", err) } t.Logf("cache fetched domain.com: %v", p) expvarMustEq(t, "cacheFetches", cacheFetches, 3) expvarMustEq(t, "cacheHits", cacheHits, 1) // Remove the file, to start with a clean slate for the next case. os.Remove(fname) } expvarMustEq(t, "cacheUnmarshalErrors", cacheUnmarshalErrors, 1) expvarMustEq(t, "cacheInvalid", cacheInvalid, 1) } func (c *PolicyCache) mustFetch(ctx context.Context, t *testing.T, d string) *Policy { p, err := c.Fetch(ctx, d) if err != nil { t.Fatalf("Fetch %q failed: %v", d, err) } t.Logf("Fetch %q: %v", d, p) return p } func mustRewriteAndChtime(t *testing.T, fname, content string) { testlib.Rewrite(t, fname, content) // Advance the expiration time to the future, so the rewritten policy is // not considered expired. expires := time.Now().Add(10 * time.Second) err := os.Chtimes(fname, expires, expires) if err != nil { t.Fatalf("failed to chtime %q to the past: %v", fname, err) } } func TestCacheRefresh(t *testing.T) { dir := testlib.MustTempDir(t) defer testlib.RemoveIfOk(t, dir) c, err := NewCache(dir) if err != nil { t.Fatal(err) } ctx := context.Background() txtResults["_mta-sts.refresh-test"] = []string{"v=STSv1; id=blah;"} policyForDomain["refresh-test"] = ` version: STSv1 mode: enforce mx: mx max_age: 100` p := c.mustFetch(ctx, t, "refresh-test") if p.MaxAge != 100*time.Second { t.Fatalf("policy.MaxAge is %v, expected 100s", p.MaxAge) } // Change the "published" policy, check that we see the old version at // fetch (should be cached), and a new version after a refresh. policyForDomain["refresh-test"] = ` version: STSv1 mode: enforce mx: mx max_age: 200` p = c.mustFetch(ctx, t, "refresh-test") if p.MaxAge != 100*time.Second { t.Fatalf("policy.MaxAge is %v, expected 100s", p.MaxAge) } // Launch background refreshes, and wait for one to complete. cacheRefreshCycles.Set(0) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) defer cancel() go c.PeriodicallyRefresh(ctx) for cacheRefreshCycles.Value() == 0 { time.Sleep(5 * time.Millisecond) } p = c.mustFetch(ctx, t, "refresh-test") if p.MaxAge != 200*time.Second { t.Fatalf("policy.MaxAge is %v, expected 200s", p.MaxAge) } } func TestCacheSlashSafe(t *testing.T) { dir := testlib.MustTempDir(t) c, err := NewCache(dir) if err != nil { t.Fatal(err) } defer func() { if r := recover(); r != nil { t.Logf("recovered: %v", r) } else { t.Fatalf("check did not panic as expected") } }() c.domainPath("a/b") } func TestURLForDomain(t *testing.T) { // This function will behave differently if fakeURLForTesting is set, so // temporarily unset it. oldURL := fakeURLForTesting fakeURLForTesting = "" defer func() { fakeURLForTesting = oldURL }() got := urlForDomain("a-test-domain") expected := "https://mta-sts.a-test-domain/.well-known/mta-sts.txt" if got != expected { t.Errorf("got %q, expected %q", got, expected) } } func TestHasSTSRecord(t *testing.T) { txtResults["_mta-sts.dom1"] = nil txtResults["_mta-sts.dom2"] = []string{} txtResults["_mta-sts.dom3"] = []string{"abc", "def"} txtResults["_mta-sts.dom4"] = []string{"abc", "v=STSv1; id=blah;"} cases := []struct { domain string ok bool err error }{ {"", false, nil}, {"dom1", false, nil}, {"dom2", false, nil}, {"dom3", false, nil}, {"dom4", true, nil}, {"domErr", false, errTest}, } for _, c := range cases { ok, err := hasSTSRecord(c.domain) if ok != c.ok || err != c.err { t.Errorf("%s: expected {%v, %v}, got {%v, %v}", c.domain, c.ok, c.err, ok, err) } } } func TestHTTPGet(t *testing.T) { // Basic test, it should work. srv1 := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(policyForDomain["domain.com"])) })) defer srv1.Close() ctx := context.Background() raw, err := httpGet(ctx, srv1.URL) if err != nil { t.Errorf("GET failed: got %q, %v", raw, err) } // Test that redirects are rejected. srv2 := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, fakeURLForTesting, http.StatusMovedPermanently) })) defer srv2.Close() raw, err = httpGet(ctx, srv2.URL) if err == nil { t.Errorf("redirect allowed, should have failed: got %q, %v", raw, err) } // Content type != text/plain should be rejected. srv3 := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/json") w.Write([]byte(policyForDomain["domain.com"])) })) defer srv3.Close() raw, err = httpGet(ctx, srv3.URL) if err != ErrInvalidMediaType { t.Errorf("content type != text/plain was allowed: got %q, %v", raw, err) } // Invalid (unparsable) media type. srv4 := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "invalid/content/type") w.Write([]byte(policyForDomain["domain.com"])) })) defer srv4.Close() raw, err = httpGet(ctx, srv4.URL) if err == nil || err == ErrInvalidMediaType { t.Errorf("invalid content type was allowed: got %q, %v", raw, err) } } chasquid-1.15.0/internal/testlib/000077500000000000000000000000001474251645300166745ustar00rootroot00000000000000chasquid-1.15.0/internal/testlib/testlib.go000066400000000000000000000112051474251645300206700ustar00rootroot00000000000000// Package testlib provides common test utilities. package testlib import ( "crypto/rand" "crypto/rsa" "crypto/tls" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "math/big" "net" "os" "strings" "sync" "testing" "time" ) // MustTempDir creates a temporary directory, or dies trying. func MustTempDir(t *testing.T) string { dir, err := os.MkdirTemp("", "testlib_") if err != nil { t.Fatal(err) } err = os.Chdir(dir) if err != nil { t.Fatal(err) } t.Logf("test directory: %q", dir) return dir } // RemoveIfOk removes the given directory, but only if we have not failed. We // want to keep the failed directories for debugging. func RemoveIfOk(t *testing.T, dir string) { // Safeguard, to make sure we only remove test directories. // This should help prevent accidental deletions. if !strings.Contains(dir, "testlib_") { panic("invalid/dangerous directory") } if !t.Failed() { os.RemoveAll(dir) } } // Rewrite a file with the given contents. func Rewrite(t *testing.T, path, contents string) error { // Safeguard, to make sure we only mess with test files. if !strings.Contains(path, "testlib_") { panic("invalid/dangerous path") } err := os.WriteFile(path, []byte(contents), 0600) if err != nil { t.Errorf("failed to rewrite file: %v", err) } return err } // GetFreePort returns a free TCP port. This is hacky and not race-free, but // it works well enough for testing purposes. func GetFreePort() string { l, err := net.Listen("tcp", "localhost:0") if err != nil { panic(err) } defer l.Close() return l.Addr().String() } // WaitFor f to return true (returns true), or d to pass (returns false). func WaitFor(f func() bool, d time.Duration) bool { start := time.Now() for time.Since(start) < d { if f() { return true } time.Sleep(20 * time.Millisecond) } return false } type deliverRequest struct { From string To string Data []byte } // TestCourier never fails, and always remembers everything. type TestCourier struct { wg sync.WaitGroup Requests []*deliverRequest ReqFor map[string]*deliverRequest sync.Mutex } // Deliver the given mail (saving it in tc.Requests). 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 } // Expect i mails to be delivered. func (tc *TestCourier) Expect(i int) { tc.wg.Add(i) } // Wait until all mails have been delivered. func (tc *TestCourier) Wait() { tc.wg.Wait() } // NewTestCourier returns a new, empty TestCourier instance. func NewTestCourier() *TestCourier { return &TestCourier{ ReqFor: map[string]*deliverRequest{}, } } type dumbCourier struct{} func (c dumbCourier) Deliver(from string, to string, data []byte) (error, bool) { return nil, false } // DumbCourier always succeeds delivery, and ignores everything. var DumbCourier = dumbCourier{} // 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) (*tls.Config, 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 nil, err } derBytes, err := x509.CreateCertificate( rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv) if err != nil { return nil, err } // Create a global config for convenience. srvCert, err := x509.ParseCertificate(derBytes) if err != nil { return nil, 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 nil, err } defer certOut.Close() err = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) if err != nil { return nil, err } keyOut, err := os.OpenFile( path+"/key.pem", os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600) if err != nil { return nil, err } defer keyOut.Close() block := &pem.Block{ Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv), } err = pem.Encode(keyOut, block) return tlsConfig, err } chasquid-1.15.0/internal/testlib/testlib_test.go000066400000000000000000000047341474251645300217400ustar00rootroot00000000000000package testlib import ( "os" "testing" "time" ) func TestBasic(t *testing.T) { dir := MustTempDir(t) if err := os.WriteFile(dir+"/file", nil, 0660); err != nil { t.Fatalf("could not create file in %s: %v", dir, err) } wd, err := os.Getwd() if err != nil { t.Fatalf("could not get working directory: %v", err) } if wd != dir { t.Errorf("MustTempDir did not change directory") t.Errorf(" expected %q, got %q", dir, wd) } RemoveIfOk(t, dir) if _, err := os.Stat(dir); !os.IsNotExist(err) { t.Fatalf("%s existed, should have been deleted: %v", dir, err) } } func TestRemoveCheck(t *testing.T) { defer func() { if r := recover(); r != nil { t.Logf("recovered: %v", r) } else { t.Fatalf("check did not panic as expected") } }() RemoveIfOk(t, "/tmp/something") } func TestLeaveDirOnError(t *testing.T) { myt := &testing.T{} dir := MustTempDir(myt) myt.Errorf("something bad happened") RemoveIfOk(myt, dir) if _, err := os.Stat(dir); os.IsNotExist(err) { t.Fatalf("%s was removed, should have been kept", dir) } // Remove the directory for real this time. RemoveIfOk(t, dir) } func TestRewriteSafeguard(t *testing.T) { myt := &testing.T{} defer func() { if r := recover(); r != nil { t.Logf("recovered: %v", r) } else { t.Fatalf("check did not panic as expected") } }() Rewrite(myt, "/something", "test") } func TestRewrite(t *testing.T) { dir := MustTempDir(t) defer RemoveIfOk(t, dir) myt := &testing.T{} Rewrite(myt, dir+"/file", "hola") if myt.Failed() { t.Errorf("basic rewrite failed") } } func TestGetFreePort(t *testing.T) { p := GetFreePort() if p == "" { t.Errorf("failed to get free port") } } func TestWaitFor(t *testing.T) { ok := WaitFor(func() bool { return true }, 20*time.Millisecond) if !ok { t.Errorf("WaitFor(true) timed out") } ok = WaitFor(func() bool { return false }, 20*time.Millisecond) if ok { t.Errorf("WaitFor(false) worked") } } func TestGenerateCert(t *testing.T) { dir := MustTempDir(t) defer os.RemoveAll(dir) conf, err := GenerateCert(dir) if err != nil { t.Errorf("GenerateCert returned error: %v", err) } if conf.ServerName != "localhost" { t.Errorf("Config server name %q != localhost", conf.ServerName) } if conf.RootCAs == nil { t.Errorf("Config had an empty RootCAs pool") } } func TestGenerateCertBadDir(t *testing.T) { conf, err := GenerateCert("/doesnotexist/") if err == nil || conf != nil { t.Fatalf("GenerateCert returned non-error: %v / %v", conf, err) } } chasquid-1.15.0/internal/tlsconst/000077500000000000000000000000001474251645300170775ustar00rootroot00000000000000chasquid-1.15.0/internal/tlsconst/ciphers.go000066400000000000000000000403321474251645300210650ustar00rootroot00000000000000package tlsconst // AUTOGENERATED - DO NOT EDIT // // This file was autogenerated by generate-ciphers.py. var cipherSuiteName = map[uint16]string{ 0x0000: "TLS_NULL_WITH_NULL_NULL", 0x0001: "TLS_RSA_WITH_NULL_MD5", 0x0002: "TLS_RSA_WITH_NULL_SHA", 0x0003: "TLS_RSA_EXPORT_WITH_RC4_40_MD5", 0x0004: "TLS_RSA_WITH_RC4_128_MD5", 0x0005: "TLS_RSA_WITH_RC4_128_SHA", 0x0006: "TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5", 0x0007: "TLS_RSA_WITH_IDEA_CBC_SHA", 0x0008: "TLS_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x0009: "TLS_RSA_WITH_DES_CBC_SHA", 0x000a: "TLS_RSA_WITH_3DES_EDE_CBC_SHA", 0x000b: "TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA", 0x000c: "TLS_DH_DSS_WITH_DES_CBC_SHA", 0x000d: "TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA", 0x000e: "TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x000f: "TLS_DH_RSA_WITH_DES_CBC_SHA", 0x0010: "TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA", 0x0011: "TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA", 0x0012: "TLS_DHE_DSS_WITH_DES_CBC_SHA", 0x0013: "TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA", 0x0014: "TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", 0x0015: "TLS_DHE_RSA_WITH_DES_CBC_SHA", 0x0016: "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", 0x0017: "TLS_DH_anon_EXPORT_WITH_RC4_40_MD5", 0x0018: "TLS_DH_anon_WITH_RC4_128_MD5", 0x0019: "TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA", 0x001a: "TLS_DH_anon_WITH_DES_CBC_SHA", 0x001b: "TLS_DH_anon_WITH_3DES_EDE_CBC_SHA", 0x001e: "TLS_KRB5_WITH_DES_CBC_SHA", 0x001f: "TLS_KRB5_WITH_3DES_EDE_CBC_SHA", 0x0020: "TLS_KRB5_WITH_RC4_128_SHA", 0x0021: "TLS_KRB5_WITH_IDEA_CBC_SHA", 0x0022: "TLS_KRB5_WITH_DES_CBC_MD5", 0x0023: "TLS_KRB5_WITH_3DES_EDE_CBC_MD5", 0x0024: "TLS_KRB5_WITH_RC4_128_MD5", 0x0025: "TLS_KRB5_WITH_IDEA_CBC_MD5", 0x0026: "TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA", 0x0027: "TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA", 0x0028: "TLS_KRB5_EXPORT_WITH_RC4_40_SHA", 0x0029: "TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5", 0x002a: "TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5", 0x002b: "TLS_KRB5_EXPORT_WITH_RC4_40_MD5", 0x002c: "TLS_PSK_WITH_NULL_SHA", 0x002d: "TLS_DHE_PSK_WITH_NULL_SHA", 0x002e: "TLS_RSA_PSK_WITH_NULL_SHA", 0x002f: "TLS_RSA_WITH_AES_128_CBC_SHA", 0x0030: "TLS_DH_DSS_WITH_AES_128_CBC_SHA", 0x0031: "TLS_DH_RSA_WITH_AES_128_CBC_SHA", 0x0032: "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", 0x0033: "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", 0x0034: "TLS_DH_anon_WITH_AES_128_CBC_SHA", 0x0035: "TLS_RSA_WITH_AES_256_CBC_SHA", 0x0036: "TLS_DH_DSS_WITH_AES_256_CBC_SHA", 0x0037: "TLS_DH_RSA_WITH_AES_256_CBC_SHA", 0x0038: "TLS_DHE_DSS_WITH_AES_256_CBC_SHA", 0x0039: "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", 0x003a: "TLS_DH_anon_WITH_AES_256_CBC_SHA", 0x003b: "TLS_RSA_WITH_NULL_SHA256", 0x003c: "TLS_RSA_WITH_AES_128_CBC_SHA256", 0x003d: "TLS_RSA_WITH_AES_256_CBC_SHA256", 0x003e: "TLS_DH_DSS_WITH_AES_128_CBC_SHA256", 0x003f: "TLS_DH_RSA_WITH_AES_128_CBC_SHA256", 0x0040: "TLS_DHE_DSS_WITH_AES_128_CBC_SHA256", 0x0041: "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0042: "TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA", 0x0043: "TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0044: "TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA", 0x0045: "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA", 0x0046: "TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA", 0x0067: "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", 0x0068: "TLS_DH_DSS_WITH_AES_256_CBC_SHA256", 0x0069: "TLS_DH_RSA_WITH_AES_256_CBC_SHA256", 0x006a: "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256", 0x006b: "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", 0x006c: "TLS_DH_anon_WITH_AES_128_CBC_SHA256", 0x006d: "TLS_DH_anon_WITH_AES_256_CBC_SHA256", 0x0084: "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0085: "TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA", 0x0086: "TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0087: "TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA", 0x0088: "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA", 0x0089: "TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA", 0x008a: "TLS_PSK_WITH_RC4_128_SHA", 0x008b: "TLS_PSK_WITH_3DES_EDE_CBC_SHA", 0x008c: "TLS_PSK_WITH_AES_128_CBC_SHA", 0x008d: "TLS_PSK_WITH_AES_256_CBC_SHA", 0x008e: "TLS_DHE_PSK_WITH_RC4_128_SHA", 0x008f: "TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA", 0x0090: "TLS_DHE_PSK_WITH_AES_128_CBC_SHA", 0x0091: "TLS_DHE_PSK_WITH_AES_256_CBC_SHA", 0x0092: "TLS_RSA_PSK_WITH_RC4_128_SHA", 0x0093: "TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA", 0x0094: "TLS_RSA_PSK_WITH_AES_128_CBC_SHA", 0x0095: "TLS_RSA_PSK_WITH_AES_256_CBC_SHA", 0x0096: "TLS_RSA_WITH_SEED_CBC_SHA", 0x0097: "TLS_DH_DSS_WITH_SEED_CBC_SHA", 0x0098: "TLS_DH_RSA_WITH_SEED_CBC_SHA", 0x0099: "TLS_DHE_DSS_WITH_SEED_CBC_SHA", 0x009a: "TLS_DHE_RSA_WITH_SEED_CBC_SHA", 0x009b: "TLS_DH_anon_WITH_SEED_CBC_SHA", 0x009c: "TLS_RSA_WITH_AES_128_GCM_SHA256", 0x009d: "TLS_RSA_WITH_AES_256_GCM_SHA384", 0x009e: "TLS_DHE_RSA_WITH_AES_128_GCM_SHA256", 0x009f: "TLS_DHE_RSA_WITH_AES_256_GCM_SHA384", 0x00a0: "TLS_DH_RSA_WITH_AES_128_GCM_SHA256", 0x00a1: "TLS_DH_RSA_WITH_AES_256_GCM_SHA384", 0x00a2: "TLS_DHE_DSS_WITH_AES_128_GCM_SHA256", 0x00a3: "TLS_DHE_DSS_WITH_AES_256_GCM_SHA384", 0x00a4: "TLS_DH_DSS_WITH_AES_128_GCM_SHA256", 0x00a5: "TLS_DH_DSS_WITH_AES_256_GCM_SHA384", 0x00a6: "TLS_DH_anon_WITH_AES_128_GCM_SHA256", 0x00a7: "TLS_DH_anon_WITH_AES_256_GCM_SHA384", 0x00a8: "TLS_PSK_WITH_AES_128_GCM_SHA256", 0x00a9: "TLS_PSK_WITH_AES_256_GCM_SHA384", 0x00aa: "TLS_DHE_PSK_WITH_AES_128_GCM_SHA256", 0x00ab: "TLS_DHE_PSK_WITH_AES_256_GCM_SHA384", 0x00ac: "TLS_RSA_PSK_WITH_AES_128_GCM_SHA256", 0x00ad: "TLS_RSA_PSK_WITH_AES_256_GCM_SHA384", 0x00ae: "TLS_PSK_WITH_AES_128_CBC_SHA256", 0x00af: "TLS_PSK_WITH_AES_256_CBC_SHA384", 0x00b0: "TLS_PSK_WITH_NULL_SHA256", 0x00b1: "TLS_PSK_WITH_NULL_SHA384", 0x00b2: "TLS_DHE_PSK_WITH_AES_128_CBC_SHA256", 0x00b3: "TLS_DHE_PSK_WITH_AES_256_CBC_SHA384", 0x00b4: "TLS_DHE_PSK_WITH_NULL_SHA256", 0x00b5: "TLS_DHE_PSK_WITH_NULL_SHA384", 0x00b6: "TLS_RSA_PSK_WITH_AES_128_CBC_SHA256", 0x00b7: "TLS_RSA_PSK_WITH_AES_256_CBC_SHA384", 0x00b8: "TLS_RSA_PSK_WITH_NULL_SHA256", 0x00b9: "TLS_RSA_PSK_WITH_NULL_SHA384", 0x00ba: "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bb: "TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256", 0x00bc: "TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bd: "TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256", 0x00be: "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0x00bf: "TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256", 0x00c0: "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c1: "TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256", 0x00c2: "TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c3: "TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256", 0x00c4: "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256", 0x00c5: "TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256", 0x00c6: "TLS_SM4_GCM_SM3", 0x00c7: "TLS_SM4_CCM_SM3", 0x00ff: "TLS_EMPTY_RENEGOTIATION_INFO_SCSV", 0x1301: "TLS_AES_128_GCM_SHA256", 0x1302: "TLS_AES_256_GCM_SHA384", 0x1303: "TLS_CHACHA20_POLY1305_SHA256", 0x1304: "TLS_AES_128_CCM_SHA256", 0x1305: "TLS_AES_128_CCM_8_SHA256", 0x1306: "TLS_AEGIS_256_SHA512", 0x1307: "TLS_AEGIS_128L_SHA256", 0x5600: "TLS_FALLBACK_SCSV", 0xc001: "TLS_ECDH_ECDSA_WITH_NULL_SHA", 0xc002: "TLS_ECDH_ECDSA_WITH_RC4_128_SHA", 0xc003: "TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA", 0xc004: "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA", 0xc005: "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA", 0xc006: "TLS_ECDHE_ECDSA_WITH_NULL_SHA", 0xc007: "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", 0xc008: "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", 0xc009: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", 0xc00a: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", 0xc00b: "TLS_ECDH_RSA_WITH_NULL_SHA", 0xc00c: "TLS_ECDH_RSA_WITH_RC4_128_SHA", 0xc00d: "TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA", 0xc00e: "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA", 0xc00f: "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA", 0xc010: "TLS_ECDHE_RSA_WITH_NULL_SHA", 0xc011: "TLS_ECDHE_RSA_WITH_RC4_128_SHA", 0xc012: "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", 0xc013: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", 0xc014: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", 0xc015: "TLS_ECDH_anon_WITH_NULL_SHA", 0xc016: "TLS_ECDH_anon_WITH_RC4_128_SHA", 0xc017: "TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA", 0xc018: "TLS_ECDH_anon_WITH_AES_128_CBC_SHA", 0xc019: "TLS_ECDH_anon_WITH_AES_256_CBC_SHA", 0xc01a: "TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA", 0xc01b: "TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA", 0xc01c: "TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA", 0xc01d: "TLS_SRP_SHA_WITH_AES_128_CBC_SHA", 0xc01e: "TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA", 0xc01f: "TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA", 0xc020: "TLS_SRP_SHA_WITH_AES_256_CBC_SHA", 0xc021: "TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA", 0xc022: "TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA", 0xc023: "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", 0xc024: "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", 0xc025: "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256", 0xc026: "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384", 0xc027: "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", 0xc028: "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", 0xc029: "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256", 0xc02a: "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384", 0xc02b: "TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256", 0xc02c: "TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384", 0xc02d: "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256", 0xc02e: "TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384", 0xc02f: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", 0xc030: "TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384", 0xc031: "TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256", 0xc032: "TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384", 0xc033: "TLS_ECDHE_PSK_WITH_RC4_128_SHA", 0xc034: "TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA", 0xc035: "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA", 0xc036: "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA", 0xc037: "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256", 0xc038: "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384", 0xc039: "TLS_ECDHE_PSK_WITH_NULL_SHA", 0xc03a: "TLS_ECDHE_PSK_WITH_NULL_SHA256", 0xc03b: "TLS_ECDHE_PSK_WITH_NULL_SHA384", 0xc03c: "TLS_RSA_WITH_ARIA_128_CBC_SHA256", 0xc03d: "TLS_RSA_WITH_ARIA_256_CBC_SHA384", 0xc03e: "TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256", 0xc03f: "TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384", 0xc040: "TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256", 0xc041: "TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384", 0xc042: "TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256", 0xc043: "TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384", 0xc044: "TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256", 0xc045: "TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384", 0xc046: "TLS_DH_anon_WITH_ARIA_128_CBC_SHA256", 0xc047: "TLS_DH_anon_WITH_ARIA_256_CBC_SHA384", 0xc048: "TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256", 0xc049: "TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384", 0xc04a: "TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256", 0xc04b: "TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384", 0xc04c: "TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256", 0xc04d: "TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384", 0xc04e: "TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256", 0xc04f: "TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384", 0xc050: "TLS_RSA_WITH_ARIA_128_GCM_SHA256", 0xc051: "TLS_RSA_WITH_ARIA_256_GCM_SHA384", 0xc052: "TLS_DHE_RSA_WITH_ARIA_128_GCM_SHA256", 0xc053: "TLS_DHE_RSA_WITH_ARIA_256_GCM_SHA384", 0xc054: "TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256", 0xc055: "TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384", 0xc056: "TLS_DHE_DSS_WITH_ARIA_128_GCM_SHA256", 0xc057: "TLS_DHE_DSS_WITH_ARIA_256_GCM_SHA384", 0xc058: "TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256", 0xc059: "TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384", 0xc05a: "TLS_DH_anon_WITH_ARIA_128_GCM_SHA256", 0xc05b: "TLS_DH_anon_WITH_ARIA_256_GCM_SHA384", 0xc05c: "TLS_ECDHE_ECDSA_WITH_ARIA_128_GCM_SHA256", 0xc05d: "TLS_ECDHE_ECDSA_WITH_ARIA_256_GCM_SHA384", 0xc05e: "TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256", 0xc05f: "TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384", 0xc060: "TLS_ECDHE_RSA_WITH_ARIA_128_GCM_SHA256", 0xc061: "TLS_ECDHE_RSA_WITH_ARIA_256_GCM_SHA384", 0xc062: "TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256", 0xc063: "TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384", 0xc064: "TLS_PSK_WITH_ARIA_128_CBC_SHA256", 0xc065: "TLS_PSK_WITH_ARIA_256_CBC_SHA384", 0xc066: "TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256", 0xc067: "TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384", 0xc068: "TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256", 0xc069: "TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384", 0xc06a: "TLS_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06b: "TLS_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06c: "TLS_DHE_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06d: "TLS_DHE_PSK_WITH_ARIA_256_GCM_SHA384", 0xc06e: "TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256", 0xc06f: "TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384", 0xc070: "TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256", 0xc071: "TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384", 0xc072: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc073: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc074: "TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc075: "TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc076: "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc077: "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc078: "TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256", 0xc079: "TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384", 0xc07a: "TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07b: "TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07c: "TLS_DHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07d: "TLS_DHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc07e: "TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc07f: "TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc080: "TLS_DHE_DSS_WITH_CAMELLIA_128_GCM_SHA256", 0xc081: "TLS_DHE_DSS_WITH_CAMELLIA_256_GCM_SHA384", 0xc082: "TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256", 0xc083: "TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384", 0xc084: "TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256", 0xc085: "TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384", 0xc086: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc087: "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc088: "TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc089: "TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08a: "TLS_ECDHE_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc08b: "TLS_ECDHE_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08c: "TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256", 0xc08d: "TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384", 0xc08e: "TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc08f: "TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc090: "TLS_DHE_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc091: "TLS_DHE_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc092: "TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256", 0xc093: "TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384", 0xc094: "TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc095: "TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc096: "TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc097: "TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc098: "TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc099: "TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc09a: "TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", 0xc09b: "TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", 0xc09c: "TLS_RSA_WITH_AES_128_CCM", 0xc09d: "TLS_RSA_WITH_AES_256_CCM", 0xc09e: "TLS_DHE_RSA_WITH_AES_128_CCM", 0xc09f: "TLS_DHE_RSA_WITH_AES_256_CCM", 0xc0a0: "TLS_RSA_WITH_AES_128_CCM_8", 0xc0a1: "TLS_RSA_WITH_AES_256_CCM_8", 0xc0a2: "TLS_DHE_RSA_WITH_AES_128_CCM_8", 0xc0a3: "TLS_DHE_RSA_WITH_AES_256_CCM_8", 0xc0a4: "TLS_PSK_WITH_AES_128_CCM", 0xc0a5: "TLS_PSK_WITH_AES_256_CCM", 0xc0a6: "TLS_DHE_PSK_WITH_AES_128_CCM", 0xc0a7: "TLS_DHE_PSK_WITH_AES_256_CCM", 0xc0a8: "TLS_PSK_WITH_AES_128_CCM_8", 0xc0a9: "TLS_PSK_WITH_AES_256_CCM_8", 0xc0aa: "TLS_PSK_DHE_WITH_AES_128_CCM_8", 0xc0ab: "TLS_PSK_DHE_WITH_AES_256_CCM_8", 0xc0ac: "TLS_ECDHE_ECDSA_WITH_AES_128_CCM", 0xc0ad: "TLS_ECDHE_ECDSA_WITH_AES_256_CCM", 0xc0ae: "TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8", 0xc0af: "TLS_ECDHE_ECDSA_WITH_AES_256_CCM_8", 0xc0b0: "TLS_ECCPWD_WITH_AES_128_GCM_SHA256", 0xc0b1: "TLS_ECCPWD_WITH_AES_256_GCM_SHA384", 0xc0b2: "TLS_ECCPWD_WITH_AES_128_CCM_SHA256", 0xc0b3: "TLS_ECCPWD_WITH_AES_256_CCM_SHA384", 0xc0b4: "TLS_SHA256_SHA256", 0xc0b5: "TLS_SHA384_SHA384", 0xc100: "TLS_GOSTR341112_256_WITH_KUZNYECHIK_CTR_OMAC", 0xc101: "TLS_GOSTR341112_256_WITH_MAGMA_CTR_OMAC", 0xc102: "TLS_GOSTR341112_256_WITH_28147_CNT_IMIT", 0xc103: "TLS_GOSTR341112_256_WITH_KUZNYECHIK_MGM_L", 0xc104: "TLS_GOSTR341112_256_WITH_MAGMA_MGM_L", 0xc105: "TLS_GOSTR341112_256_WITH_KUZNYECHIK_MGM_S", 0xc106: "TLS_GOSTR341112_256_WITH_MAGMA_MGM_S", 0xcca8: "TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256", 0xcca9: "TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256", 0xccaa: "TLS_DHE_RSA_WITH_CHACHA20_POLY1305_SHA256", 0xccab: "TLS_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xccac: "TLS_ECDHE_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xccad: "TLS_DHE_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xccae: "TLS_RSA_PSK_WITH_CHACHA20_POLY1305_SHA256", 0xd001: "TLS_ECDHE_PSK_WITH_AES_128_GCM_SHA256", 0xd002: "TLS_ECDHE_PSK_WITH_AES_256_GCM_SHA384", 0xd003: "TLS_ECDHE_PSK_WITH_AES_128_CCM_8_SHA256", 0xd005: "TLS_ECDHE_PSK_WITH_AES_128_CCM_SHA256", } chasquid-1.15.0/internal/tlsconst/generate-ciphers.py000077500000000000000000000022451474251645300227040ustar00rootroot00000000000000#!/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 https://www.iana.org/assignments/tls-parameters/tls-parameters.xhtml. URL = "https://www.iana.org/assignments/tls-parameters/tls-parameters-4.csv" def getCiphers(): req = urllib.request.urlopen(URL) data = req.read().decode('utf-8') ciphers = [] reader = csv.DictReader(data.splitlines()) for row in reader: desc = row["Description"] rawval = row["Value"] # Just plain TLS values for now, to keep it simple. if "-" in rawval or not desc.startswith("TLS"): continue rv1, rv2 = rawval.split(",") rv1, rv2 = int(rv1, 16), int(rv2, 16) val = "0x%02x%02x" % (rv1, rv2) ciphers.append((val, desc)) return ciphers ciphers = getCiphers() out = open(sys.argv[1], 'w') out.write("""\ package tlsconst // AUTOGENERATED - DO NOT EDIT // // This file was autogenerated by generate-ciphers.py. var cipherSuiteName = map[uint16]string{ """) for ver, desc in ciphers: out.write('\t%s: "%s",\n' % (ver, desc)) out.write('}\n') chasquid-1.15.0/internal/tlsconst/tlsconst.go000066400000000000000000000014111474251645300212740ustar00rootroot00000000000000// Package tlsconst contains TLS constants for human consumption. package tlsconst // Most of the constants get automatically generated from IANA's assignments. //go:generate ./generate-ciphers.py ciphers.go import "fmt" var versionName = map[uint16]string{ 0x0300: "SSL-3.0", 0x0301: "TLS-1.0", 0x0302: "TLS-1.1", 0x0303: "TLS-1.2", 0x0304: "TLS-1.3", } // VersionName returns a human-readable TLS version name. func VersionName(v uint16) string { name, ok := versionName[v] if !ok { return fmt.Sprintf("TLS-%#04x", v) } return name } // CipherSuiteName returns a human-readable TLS cipher suite name. func CipherSuiteName(s uint16) string { name, ok := cipherSuiteName[s] if !ok { return fmt.Sprintf("TLS_UNKNOWN_CIPHER_SUITE-%#04x", s) } return name } chasquid-1.15.0/internal/tlsconst/tlsconst_test.go000066400000000000000000000013471474251645300223430ustar00rootroot00000000000000package tlsconst import "testing" func TestVersionName(t *testing.T) { cases := []struct { ver uint16 expected string }{ {0x0302, "TLS-1.1"}, {0x1234, "TLS-0x1234"}, } for _, c := range cases { got := VersionName(c.ver) if got != c.expected { t.Errorf("VersionName(%x) = %q, expected %q", c.ver, got, c.expected) } } } func TestCipherSuiteName(t *testing.T) { cases := []struct { suite uint16 expected string }{ {0xc073, "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384"}, {0x1234, "TLS_UNKNOWN_CIPHER_SUITE-0x1234"}, } for _, c := range cases { got := CipherSuiteName(c.suite) if got != c.expected { t.Errorf("CipherSuiteName(%x) = %q, expected %q", c.suite, got, c.expected) } } } chasquid-1.15.0/internal/trace/000077500000000000000000000000001474251645300163245ustar00rootroot00000000000000chasquid-1.15.0/internal/trace/trace.go000066400000000000000000000041301474251645300177470ustar00rootroot00000000000000// Package trace extends nettrace with logging. package trace import ( "fmt" "strconv" "blitiri.com.ar/go/chasquid/internal/nettrace" "blitiri.com.ar/go/log" ) func init() { } // A Trace represents an active request. type Trace struct { family string title string t nettrace.Trace } // New trace. func New(family, title string) *Trace { t := &Trace{family, title, nettrace.New(family, title)} // The default for max events is 10, which is a bit short for a normal // SMTP exchange. Expand it to 100 which should be large enough to keep // most of the traces. t.t.SetMaxEvents(100) return t } // NewChild creates a new child trace. func (t *Trace) NewChild(family, title string) *Trace { n := &Trace{family, title, t.t.NewChild(family, title)} n.t.SetMaxEvents(100) return n } // Printf adds this message to the trace's log. func (t *Trace) Printf(format string, a ...interface{}) { t.t.Printf(format, a...) log.Log(log.Info, 1, "%s %s: %s", t.family, t.title, quote(fmt.Sprintf(format, a...))) } // Debugf adds this message to the trace's log, with a debugging level. func (t *Trace) Debugf(format string, a ...interface{}) { t.t.Printf(format, a...) log.Log(log.Debug, 1, "%s %s: %s", t.family, t.title, quote(fmt.Sprintf(format, a...))) } // Errorf adds this message to the trace's log, with an error level. func (t *Trace) Errorf(format string, a ...interface{}) error { // Note we can't just call t.Error here, as it breaks caller logging. err := fmt.Errorf(format, a...) t.t.SetError() t.t.Printf("error: %v", err) log.Log(log.Info, 1, "%s %s: error: %s", t.family, t.title, quote(err.Error())) return err } // Error marks the trace as having seen an error, and also logs it to the // trace's log. func (t *Trace) Error(err error) error { t.t.SetError() t.t.Printf("error: %v", err) log.Log(log.Info, 1, "%s %s: error: %s", t.family, t.title, quote(err.Error())) return err } // Finish the trace. It should not be changed after this is called. func (t *Trace) Finish() { t.t.Finish() } func quote(s string) string { qs := strconv.Quote(s) return qs[1 : len(qs)-1] } chasquid-1.15.0/internal/userdb/000077500000000000000000000000001474251645300165125ustar00rootroot00000000000000chasquid-1.15.0/internal/userdb/userdb.go000066400000000000000000000150101474251645300203220ustar00rootroot00000000000000// 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=. --go_opt=paths=source_relative userdb.proto import ( "crypto/rand" "crypto/subtle" "errors" "fmt" "os" "sync" "golang.org/x/crypto/scrypt" "blitiri.com.ar/go/chasquid/internal/normalize" "blitiri.com.ar/go/chasquid/internal/protoio" ) // DB represents a single user database. type DB struct { fname string db *ProtoDB // Lock protecting db. mu sync.RWMutex } // New returns a new user database, on the given file name. func New(fname string) *DB { return &DB{ fname: fname, db: &ProtoDB{Users: map[string]*Password{}}, } } // Load the database from the given file. // Return the database, and an error if the database could not be loaded. If // the file does not exist, that is not considered an error. 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{}} } if os.IsNotExist(err) { // If the file does not exist now, it is not an error, as it might // exist later and we want to be able to read it. err = nil } return db, err } // Reload the database, refreshing its contents from the current file on disk. // If there are errors reading from the file, they are returned and the // database is not changed. func (db *DB) Reload() error { newdb, err := Load(db.fname) if err != nil { return err } db.mu.Lock() db.db = newdb.db db.mu.Unlock() return nil } // Write the database to disk. It will do a complete rewrite each time, and is // not safe to call it from different processes in parallel. func (db *DB) Write() error { db.mu.RLock() defer db.mu.RUnlock() return protoio.WriteTextMessage(db.fname, db.db, 0660) } // Authenticate returns true if the password is valid for the user, false // otherwise. func (db *DB) Authenticate(name, plainPassword string) bool { db.mu.RLock() passwd, ok := db.db.Users[name] db.mu.RUnlock() if !ok { return false } return passwd.PasswordMatches(plainPassword) } // PasswordMatches returns true if the given password is a match. func (p *Password) PasswordMatches(plain string) bool { switch s := p.Scheme.(type) { case nil: return false case *Password_Scrypt: return s.Scrypt.PasswordMatches(plain) case *Password_Plain: return s.Plain.PasswordMatches(plain) case *Password_Denied: return false default: return false } } // AddUser to the database. If the user is already present, override it. // Note we enforce that the name has been normalized previously. func (db *DB) AddUser(name, plainPassword string) error { if norm, err := normalize.User(name); err != nil || name != norm { return errors.New("invalid username") } s := &Scrypt{ // Use hard-coded standard parameters for now. // Follow the recommendations from the scrypt paper. LogN: 14, R: 8, P: 1, KeyLen: 32, // 16 bytes of salt (will be filled later). Salt: make([]byte, 16), } n, err := rand.Read(s.Salt) if n != 16 || err != nil { return fmt.Errorf("failed to get salt - %d - %v", n, err) } s.Encrypted, err = scrypt.Key([]byte(plainPassword), s.Salt, 1< userdb.ProtoDB.UsersEntry 2, // 1: userdb.Password.scrypt:type_name -> userdb.Scrypt 3, // 2: userdb.Password.plain:type_name -> userdb.Plain 4, // 3: userdb.Password.denied:type_name -> userdb.Denied 1, // 4: userdb.ProtoDB.UsersEntry.value:type_name -> userdb.Password 5, // [5:5] is the sub-list for method output_type 5, // [5:5] is the sub-list for method input_type 5, // [5:5] is the sub-list for extension type_name 5, // [5:5] is the sub-list for extension extendee 0, // [0:5] is the sub-list for field type_name } func init() { file_userdb_proto_init() } func file_userdb_proto_init() { if File_userdb_proto != nil { return } if !protoimpl.UnsafeEnabled { file_userdb_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*ProtoDB); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_userdb_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Password); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_userdb_proto_msgTypes[2].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Scrypt); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_userdb_proto_msgTypes[3].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Plain); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } file_userdb_proto_msgTypes[4].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*Denied); i { case 0: return &v.state case 1: return &v.sizeCache case 2: return &v.unknownFields default: return nil } } } file_userdb_proto_msgTypes[1].OneofWrappers = []interface{}{ (*Password_Scrypt)(nil), (*Password_Plain)(nil), (*Password_Denied)(nil), } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_userdb_proto_rawDesc, NumEnums: 0, NumMessages: 6, NumExtensions: 0, NumServices: 0, }, GoTypes: file_userdb_proto_goTypes, DependencyIndexes: file_userdb_proto_depIdxs, MessageInfos: file_userdb_proto_msgTypes, }.Build() File_userdb_proto = out.File file_userdb_proto_rawDesc = nil file_userdb_proto_goTypes = nil file_userdb_proto_depIdxs = nil } chasquid-1.15.0/internal/userdb/userdb.proto000066400000000000000000000006741474251645300210720ustar00rootroot00000000000000 syntax = "proto3"; package userdb; option go_package = "blitiri.com.ar/go/chasquid/internal/userdb"; message ProtoDB { map users = 1; } message Password { oneof scheme { Scrypt scrypt = 2; Plain plain = 3; Denied denied = 4; } } 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; } message Denied { } chasquid-1.15.0/internal/userdb/userdb_test.go000066400000000000000000000206431474251645300213710ustar00rootroot00000000000000package userdb import ( "fmt" "os" "reflect" "strings" "testing" ) // Remove the file if the test was successful. Used in defer statements, to // leave files around for inspection when the tests failed. func removeIfSuccessful(t *testing.T, fname string) { // Safeguard, to make sure we only remove test files. // This should help prevent accidental deletions. if !strings.Contains(fname, "userdb_test") { panic("invalid/dangerous directory") } if !t.Failed() { os.Remove(fname) } } // Create a database with the given content on a temporary filename. Return // the filename, or an error if there were errors creating it. func mustCreateDB(t *testing.T, content string) string { f, err := os.CreateTemp("", "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 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.AddDeniedUser("Ãąaca"); err != nil { t.Fatalf("failed to add Ãąaca: %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", "Ãąaca"} { if !db.Exists(name) { t.Errorf("user %q not in database", name) } if db.db.Users[name].GetScheme() == nil { t.Errorf("user %q missing scheme: %#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}, {"Ãąaca", "", false}, {"Ãąaca", "lalala", 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) } err = db.AddDeniedUser(name) if err == nil { t.Errorf("AddDeniedUser(%q) worked, expected it to fail", name) } } } func plainPassword(p string) *Password { return &Password{ Scheme: &Password_Plain{ Plain: &Plain{Password: []byte(p)}, }, } } // Test the plain scheme. Note we don't expect to use it in cases other than // debugging, but it should be functional for that purpose. func TestPlainScheme(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) db.db.Users["user"] = plainPassword("pass word") err := db.Write() if err != nil { t.Errorf("Write failed: %v", err) } db = mustLoad(t, fname) if !db.Authenticate("user", "pass word") { t.Errorf("failed plain authentication") } if db.Authenticate("user", "wrong") { t.Errorf("plain authentication worked but it shouldn't") } } // Test the denied scheme. func TestDeniedScheme(t *testing.T) { fname := mustCreateDB(t, "") defer removeIfSuccessful(t, fname) db := mustLoad(t, fname) db.db.Users["user"] = &Password{Scheme: &Password_Denied{}} err := db.Write() if err != nil { t.Errorf("Write failed: %v", err) } db = mustLoad(t, fname) if db.Authenticate("user", "anything") { t.Errorf("denied 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' >>>" os.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 >" os.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)) } // Delete the file (which is not considered an error). os.Remove(fname) err = db.Reload() if err != nil { t.Errorf("unexpected error: %v", err) } if len(db.db.Users) != 0 { t.Errorf("expected 0 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") } if err := db.AddDeniedUser("denieduser"); err != nil { t.Fatalf("error adding user: %v", err) } if !db.Exists("denieduser") { t.Errorf("known (denied) user does not exist") } } chasquid-1.15.0/monitoring.go000066400000000000000000000136311474251645300161320ustar00rootroot00000000000000package main import ( "context" "expvar" "flag" "fmt" "html/template" "net/http" "os" "runtime" "runtime/debug" "strconv" "time" "blitiri.com.ar/go/chasquid/internal/config" "blitiri.com.ar/go/chasquid/internal/expvarom" "blitiri.com.ar/go/chasquid/internal/nettrace" "blitiri.com.ar/go/log" "google.golang.org/protobuf/encoding/prototext" // To enable live profiling in the monitoring server. _ "net/http/pprof" ) // Build information, overridden at build time using // -ldflags="-X main.version=blah". var ( version = "" sourceDateTs = "" ) var ( versionVar = expvar.NewString("chasquid/version") sourceDate time.Time sourceDateVar = expvar.NewString("chasquid/sourceDateStr") sourceDateTsVar = expvarom.NewInt("chasquid/sourceDateTimestamp", "timestamp when the binary was built, in seconds since epoch") ) func parseVersionInfo() { bi, ok := debug.ReadBuildInfo() if !ok { panic("unable to read build info") } dirty := false gitRev := "" gitTime := "" for _, s := range bi.Settings { switch s.Key { case "vcs.modified": if s.Value == "true" { dirty = true } case "vcs.time": gitTime = s.Value case "vcs.revision": gitRev = s.Value } } if sourceDateTs != "" { sdts, err := strconv.ParseInt(sourceDateTs, 10, 0) if err != nil { panic(err) } sourceDate = time.Unix(sdts, 0) } else { sourceDate, _ = time.Parse(time.RFC3339, gitTime) } sourceDateVar.Set(sourceDate.Format("2006-01-02 15:04:05 -0700")) sourceDateTsVar.Set(sourceDate.Unix()) if version == "" { version = sourceDate.Format("20060102") if gitRev != "" { version += fmt.Sprintf("-%.9s", gitRev) } if dirty { version += "-dirty" } } versionVar.Set(version) } func launchMonitoringServer(conf *config.Config) { log.Infof("Monitoring HTTP server listening on %s", conf.MonitoringAddress) osHostname, _ := os.Hostname() indexData := struct { Version string GoVersion string SourceDate time.Time StartTime time.Time Config *config.Config Hostname string }{ Version: version, GoVersion: runtime.Version(), SourceDate: sourceDate, StartTime: time.Now(), Config: conf, Hostname: osHostname, } 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) } }) srv := &http.Server{Addr: conf.MonitoringAddress} http.HandleFunc("/exit", exitHandler(srv)) http.HandleFunc("/metrics", expvarom.MetricsHandler) http.HandleFunc("/debug/flags", debugFlagsHandler) http.HandleFunc("/debug/config", debugConfigHandler(conf)) http.HandleFunc("/debug/traces", nettrace.RenderTraces) if err := srv.ListenAndServe(); err != http.ErrServerClosed { log.Fatalf("Monitoring server failed: %v", err) } } // Functions available inside the templates. var tmplFuncs = template.FuncMap{ "since": time.Since, "roundDuration": roundDuration, } // Static index for the monitoring website. var monitoringHTMLIndex = template.Must( template.New("index").Funcs(tmplFuncs).Parse( ` {{.Hostname}}: chasquid monitoring

chasquid @{{.Config.Hostname}}

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

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

`)) func exitHandler(srv *http.Server) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if r.Method != "POST" { http.Error(w, "Use POST method for exiting", http.StatusMethodNotAllowed) return } log.Infof("Received /exit") http.Error(w, "OK exiting", http.StatusOK) // Launch srv.Shutdown asynchronously, and then exit. // The http documentation says to wait for Shutdown to return before // exiting, to gracefully close all ongoing requests. go func() { if err := srv.Shutdown(context.Background()); err != nil { log.Fatalf("Monitoring server shutdown failed: %v", err) } os.Exit(0) }() } } func debugFlagsHandler(w http.ResponseWriter, _ *http.Request) { visited := make(map[string]bool) // Print set flags first, then the rest. flag.Visit(func(f *flag.Flag) { fmt.Fprintf(w, "-%s=%s\n", f.Name, f.Value.String()) visited[f.Name] = true }) fmt.Fprintf(w, "\n") flag.VisitAll(func(f *flag.Flag) { if !visited[f.Name] { fmt.Fprintf(w, "-%s=%s\n", f.Name, f.Value.String()) } }) } func debugConfigHandler(conf *config.Config) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(prototext.Format(conf))) } } func roundDuration(d time.Duration) time.Duration { return d.Round(time.Second) } chasquid-1.15.0/test/000077500000000000000000000000001474251645300143715ustar00rootroot00000000000000chasquid-1.15.0/test/.gitignore000066400000000000000000000002121474251645300163540ustar00rootroot00000000000000 # Ignore the user databases - we create them each time. t-*/config/**/users t-*/?/**/users stress-*/config/**/users stress-*/?/**/users chasquid-1.15.0/test/Dockerfile000066400000000000000000000043001474251645300163600ustar00rootroot00000000000000# Docker file for creating a docker container that can run the tests. # # Create the image: # docker build -t chasquid-test -f test/Dockerfile . # # Run the tests: # docker run --rm chasquid-test make test # # Get a shell inside the image (for debugging): # docker run -it --entrypoint=/bin/bash chasquid-test FROM golang:latest WORKDIR /go/src/blitiri.com.ar/go/chasquid # Make debconf/frontend non-interactive, to avoid distracting output about the # lack of $TERM. ENV DEBIAN_FRONTEND noninteractive RUN apt-get update -q # Install the required packages for the integration tests. RUN apt-get install -y -q python3 msmtp # Install the optional packages for the integration tests. RUN apt-get install -y -q \ gettext-base dovecot-imapd \ exim4-daemon-light \ haproxy \ python3-dkim # Install sudo, needed for the docker entrypoint. RUN apt-get install -y -q sudo # Prepare exim. RUN mkdir -p test/t-02-exim/.exim4 \ && ln -s /usr/sbin/exim4 test/t-02-exim/.exim4 # Prepare msmtp: remove setuid, otherwise HOSTALIASES doesn't work. RUN chmod g-s /usr/bin/msmtp # Install binaries for the (optional) DKIM integration test. RUN go install github.com/driusan/dkim/cmd/dkimsign@latest \ && go install github.com/driusan/dkim/cmd/dkimverify@latest \ && go install github.com/driusan/dkim/cmd/dkimkeygen@latest # Copy into the container. Everything below this line will not be cached. COPY . . # Don't run the tests as root: it makes some integration tests more difficult, # as for example Exim has hard-coded protections against running as root. RUN useradd -m chasquid && chown -R chasquid:chasquid . # Update dependencies to the latest versions, and fetch them to the cache. # The fetch is important because once within the entrypoint, we no longer have # network access to the outside, so all modules need to be available. # Do it as chasquid because that is what the tests will run as. USER chasquid ENV GOPATH= RUN go get -v ${GO_GET_ARGS} ./... && go mod download # Build the minidns server, which will be run from within the entrypoint. RUN go build -o /tmp/minidns ./test/util/minidns/minidns.go USER root # Custom entry point, which uses our own DNS server. ENTRYPOINT ["./test/util/docker_entrypoint.sh"] chasquid-1.15.0/test/README.md000066400000000000000000000071451474251645300156570ustar00rootroot00000000000000 # Testing ## Go tests All Go packages have their own test suite, which provides easy and portable tests with decent enough coverage. ## Integration tests In the `test/` directory there is a set of end to end integration tests, written usually in a combination of bash and Python 3. They're not expected to be portable, as that gets impractical very quickly, but should be usable in most Linux environments. They provide critical coverage and integration tests for real life scenarios, as well as interactions with other software (like Exim or Dovecot). ### Dependencies The tests depend on the following things being installed on the system (listed as Debian package, for consistency): - `msmtp` - `util-linux` (for `/usr/bin/setsid`) Some individual tests have additional dependencies, and the tests are skipped if the dependencies are not found: - `t-02-exim` [Exim](https://www.exim.org/) interaction tests: - `gettext-base` (for `/usr/bin/envsubst`) - The `exim` binary available somewhere, but it doesn't have to be installed. There's a script `get-exim4-debian.sh` to get it from the archives. - `t-11-dovecot` [Dovecot](https://www.dovecot.org/) interaction tests: - `dovecot` - `t-15-driusan_dkim` DKIM integration tests: - The `dkimsign dkimverify dkimkeygen` binaries, from [driusan/dkim](https://github.com/driusan/dkim) (no Debian package yet). - `t-18-haproxy` [HAProxy](https://www.haproxy.org/) integration tests: - `haproxy` - `t-19-dkimpy`: [dkimpy](https://pypi.org/project/dkimpy/) integration tests: - `python3-dkim` For some tests, python >= 3.5 is required; they will be skipped if it's not available. Most tests depend on the [`$HOSTALIASES`](https://man7.org/linux/man-pages/man7/hostname.7.html) environment variable being functional, and will be skipped if it isn't. This works by default in most Linux systems, but note that the use of `systemd-resolved` can prevent it from working properly. ## Stress tests Also in the `test/` directory there is a set of stress tests, which generate load against chasquid to measure performance and resource consumption. While they are not exhaustive, they are useful to catch regressions and track improvements on the main code paths. ## Fuzz tests Some Go packages also have instrumentation to run fuzz testing against them, using the [Go native fuzzing support](https://go.dev/security/fuzz/). This is critical for packages that handle sensitive user input, such as authentication encoding, aliases files, or username normalization. ## Command-line tool tests Each command-line tool has their own set of tests, see the `test.sh` file on their corresponding directories. ## Docker The `test/Dockerfile` can be used to set up a suitable isolated environment to run the integration and stress tests. This is very useful for automated tests, or running the integration tests in constrained or non supported environments. ## Automated tests There are two sets of automated tests which are run on every commit to upstream, and also weekly: * [Github Actions](https://github.com/albertito/chasquid/actions), configured in the `.github` directory, runs the Go tests, the integration tests, checks for vulnerabilities, and finally also builds the [public Docker images](docker.md). * [Cirrus CI](https://cirrus-ci.com/github/albertito/chasquid), configured in the `.cirrus.yml` file, runs Go tests on FreeBSD. ## Coverage The `test/cover.sh` script runs the integration tests in coverage mode, and produces a code coverage report in HTML format, for ease of analysis. The target is to keep coverage of the `chasquid` binary above 90%. chasquid-1.15.0/test/cover.sh000077500000000000000000000041451474251645300160520ustar00rootroot00000000000000#!/bin/bash # Runs tests (both go and integration) in coverage-generation mode. # Generates an HTML report with the results. # # The .coverage directory is used to store the data, it will be erased and # recreated on each run. # # This is not very tidy, and relies on some hacky tricks (see # coverage_test.go), but works for now. set -e . "$(dirname "$0")/util/lib.sh" init cd "${TBASE}/.." # Recreate the coverage output directory, to avoid including stale results # from previous runs. rm -rf .coverage mkdir -p .coverage/sh .coverage/go .coverage/all export COVER_DIR="$PWD/.coverage" # Normal go tests. # shellcheck disable=SC2046 go test \ -covermode=count -coverpkg=./... \ $(go list ./... | grep -v -E 'chasquid/cmd/|chasquid/test') \ -args -test.gocoverdir="${COVER_DIR}/go/" # Integration tests. # Will run in coverage mode due to $COVER_DIR being set. GOCOVERDIR="${COVER_DIR}/sh" setsid -w ./test/run.sh # dovecot tests are also coverage-aware. echo "dovecot cli ..." GOCOVERDIR="${COVER_DIR}/sh" setsid -w ./cmd/dovecot-auth-cli/test.sh echo "chasquid-util ..." GOCOVERDIR="${COVER_DIR}/sh" setsid -w ./cmd/chasquid-util/test.sh # Merge all coverage output into a single file. go tool covdata merge -i "${COVER_DIR}/go,${COVER_DIR}/sh" -o "${COVER_DIR}/all" go tool covdata textfmt -i "${COVER_DIR}/all" -o "${COVER_DIR}/merged.out" # Ignore protocol buffer-generated files and test utilities, as they are not # relevant. cat "${COVER_DIR}/merged.out" \ | grep -v ".pb.go:" \ | grep -v "blitiri.com.ar/go/chasquid/test/util/" \ > "${COVER_DIR}/final.out" # Generate reports based on the merged output. go tool cover -func="$COVER_DIR/final.out" | sort -k 3 -n > "$COVER_DIR/func.txt" go tool cover -html="$COVER_DIR/final.out" -o "$COVER_DIR/classic.html" go run "${UTILDIR}/coverhtml/coverhtml.go" \ -input="$COVER_DIR/final.out" -strip=3 \ -output="$COVER_DIR/coverage.html" \ -title="chasquid coverage report" \ -notes="Generated at commit $(git describe --always --dirty --tags) ($(git log -1 --format=%ci))" echo echo echo "Coverage report can be found in:" echo "file://$COVER_DIR/coverage.html" chasquid-1.15.0/test/run.sh000077500000000000000000000002601474251645300155320ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/util/lib.sh" init FAILED=0 for i in t-*; do echo "$i" ... setsid -w "$i/run.sh" FAILED=$(( FAILED + $? )) echo done exit $FAILED chasquid-1.15.0/test/stress-01-load/000077500000000000000000000000001474251645300170475ustar00rootroot00000000000000chasquid-1.15.0/test/stress-01-load/config/000077500000000000000000000000001474251645300203145ustar00rootroot00000000000000chasquid-1.15.0/test/stress-01-load/config/chasquid.conf000066400000000000000000000004711474251645300227660ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.15.0/test/stress-01-load/config/domains/000077500000000000000000000000001474251645300217465ustar00rootroot00000000000000chasquid-1.15.0/test/stress-01-load/config/domains/testserver/000077500000000000000000000000001474251645300241545ustar00rootroot00000000000000chasquid-1.15.0/test/stress-01-load/config/domains/testserver/aliases000066400000000000000000000000351474251645300255160ustar00rootroot00000000000000 null: | true fail: | false chasquid-1.15.0/test/stress-01-load/run.sh000077500000000000000000000012411474251645300202100ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init generate_certs_for testserver add_user user@testserver secretpassword # Note we run the server with minimal logging, to avoid generating very large # log files, which are not very useful anyway. mkdir -p .logs chasquid -v=-1 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 echo "Peak RAM: $(chasquid_ram_peak)" if ! loadgen -logtime -addr=localhost:1025 -run_for=3s -noop; then fail "loadgen -noop error" fi echo "Peak RAM: $(chasquid_ram_peak)" if ! loadgen -logtime -addr=localhost:1025 -run_for=3s; then fail "loadgen error" fi echo "Peak RAM: $(chasquid_ram_peak)" success chasquid-1.15.0/test/stress-02-connections/000077500000000000000000000000001474251645300204535ustar00rootroot00000000000000chasquid-1.15.0/test/stress-02-connections/config/000077500000000000000000000000001474251645300217205ustar00rootroot00000000000000chasquid-1.15.0/test/stress-02-connections/config/chasquid.conf000066400000000000000000000004711474251645300243720ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.15.0/test/stress-02-connections/run.sh000077500000000000000000000016011474251645300216140ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init generate_certs_for testserver add_user user@testserver secretpassword # Note we run the server with minimal logging, to avoid generating very large # log files, which are not very useful anyway. mkdir -p .logs chasquid -v=-1 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 echo "Peak RAM: $(chasquid_ram_peak)" # Set connection count to (max open files) - (leeway). # We set the leeway to account for file descriptors opened by the runtime and # listeners; 20 should be enough for now. # Cap it to 2000, as otherwise it can be problematic due to port availability. COUNT=$(( $(ulimit -n) - 20 )) if [ $COUNT -gt 2000 ]; then COUNT=2000 fi if ! conngen -logtime -addr=localhost:1025 -count=$COUNT; then tail -n 1 .logs/chasquid.log fail "conngen error" fi echo "Peak RAM: $(chasquid_ram_peak)" success chasquid-1.15.0/test/stress.sh000077500000000000000000000002651474251645300162560ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/util/lib.sh" init FAILED=0 for i in stress-*; do echo "$i ..." setsid -w "$i/run.sh" FAILED=$(( FAILED + $? )) echo done exit $FAILED chasquid-1.15.0/test/t-01-simple_local/000077500000000000000000000000001474251645300175135ustar00rootroot00000000000000chasquid-1.15.0/test/t-01-simple_local/config/000077500000000000000000000000001474251645300207605ustar00rootroot00000000000000chasquid-1.15.0/test/t-01-simple_local/config/certs/000077500000000000000000000000001474251645300221005ustar00rootroot00000000000000chasquid-1.15.0/test/t-01-simple_local/config/certs/noprivkey/000077500000000000000000000000001474251645300241265ustar00rootroot00000000000000chasquid-1.15.0/test/t-01-simple_local/config/certs/noprivkey/fullchain.pem000066400000000000000000000021131474251645300265730ustar00rootroot00000000000000-----BEGIN CERTIFICATE----- MIIC/TCCAeWgAwIBAgIQeSGdISDwzlRobkplbaT4uTANBgkqhkiG9w0BAQsFADAS MRAwDgYDVQQKEwdBY21lIENvMB4XDTE4MDYwMzIyNDMwMloXDTE4MDYwMzIzNDMw MlowEjEQMA4GA1UEChMHQWNtZSBDbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC AQoCggEBAKYuJT9DPp7qwDKoNuyhMPgA1456ApSIE+w55N0XyDvIKBTTq0xvRMU/ 1QgL6RvQCOYBh/lf8OF9lSp9IyINFD/H/VRXOOdxLimPOgvu+pZTgOOG9drgivwW 7WdMBIKt+XYhbI0sNgeN2mvkeD1x9Hx0qxRO9n7nurXYYr5ZPCIhlE7NTVbtKxCC qnvJK+nPx/0gMLkhp+38Ishtbr/yUC+KLOtk1Ykt6S8IhiEGbVFSiqZv8KCquTg7 S8e40q9YJkwng6MiHaXoZv4g1QRT1jUZE/8h3VSAfvcRYtWbPQ+R8zIFNfbI8WJA KwrBu34siI5gtzB3GI416DN8YF0l+ncCAwEAAaNPME0wDgYDVR0PAQH/BAQDAgKk MBMGA1UdJQQMMAoGCCsGAQUFBwMBMA8GA1UdEwEB/wQFMAMBAf8wFQYDVR0RBA4w DIIKdGVzdHNlcnZlcjANBgkqhkiG9w0BAQsFAAOCAQEAno2ANy1TPvoqfDebpXJX FqrrF/MmY24PLrnt2VU7dkKatjSaddSL90wTUi/m7gEH8RS3iW6EGRavw30dUrmJ J3lGfDIdm69hemcqcI1jWU0B8HigmOUhKpw/9SnQGV90IBkpv1hNrkdmqhn3a2I0 IPqDshoF1qg3ECmsfnhja5Os5G2Iaxshda5gEk0dZE6epJHwFnJynHw7n3FDTtlQ 1cVvwsamG4mAtey7tPFvG955wZutFgmwoapICvKHKH2ny8dzJCAHkR8RloHLE5ZF HXnhAkgIUX07V314nlUEhrxn28Lhyb92hanc8oExBoJ8OVRtxt2X7y93LY29K0po uQ== -----END CERTIFICATE----- chasquid-1.15.0/test/t-01-simple_local/config/certs/not_a_dir000066400000000000000000000001371474251645300237620ustar00rootroot00000000000000A simple file, to make sure chasquid does not get confused with them in the "certs" directory. chasquid-1.15.0/test/t-01-simple_local/config/certs/symlink000077700000000000000000000000001474251645300257142testserver/ustar00rootroot00000000000000chasquid-1.15.0/test/t-01-simple_local/config/chasquid.conf000066400000000000000000000003621474251645300234310ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-01-simple_local/content000066400000000000000000000001211474251645300211020ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-01-simple_local/hosts000066400000000000000000000000251474251645300205730ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-01-simple_local/msmtprc000066400000000000000000000006551474251645300211310ustar00rootroot00000000000000account default host testserver port 1587 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword account smtpport : default port 1025 account subm_tls : default port 1465 tls_starttls off account baduser : default user unknownuser@testserver password secretpassword account badpasswd : default user user@testserver password badsecretpassword chasquid-1.15.0/test/t-01-simple_local/run.sh000077500000000000000000000024461474251645300206640ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases mkdir -p .logs if ! chasquid --version > /dev/null; then fail "chasquid --version failed" fi generate_certs_for testserver chasquid-util-user-add user@testserver secretpassword chasquid-util-user-add someone@testserver secretpassword chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 run_msmtp someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver # At least for now, we allow AUTH over the SMTP port to avoid unnecessary # complexity, so we expect it to work. if ! run_msmtp -a smtpport someone@testserver < content 2> /dev/null; then fail "failed auth on the SMTP port" fi # Check deliver over the submission-over-TLS port. if ! run_msmtp -a subm_tls someone@testserver < content 2> /dev/null; then fail "failed submission over TLS" fi if run_msmtp nobody@testserver < content 2> /dev/null; then fail "successfully sent an email to a non-existent user" fi if run_msmtp -a baduser someone@testserver < content 2> /dev/null; then fail "successfully sent an email with a bad password" fi if run_msmtp -a badpasswd someone@testserver < content 2> /dev/null; then fail "successfully sent an email with a bad password" fi success chasquid-1.15.0/test/t-02-exim/000077500000000000000000000000001474251645300160135ustar00rootroot00000000000000chasquid-1.15.0/test/t-02-exim/.gitignore000066400000000000000000000000771474251645300200070ustar00rootroot00000000000000 # Packages, so fetches via get-exim4-* don't add cruft. *.deb chasquid-1.15.0/test/t-02-exim/config/000077500000000000000000000000001474251645300172605ustar00rootroot00000000000000chasquid-1.15.0/test/t-02-exim/config/chasquid.conf000066400000000000000000000003621474251645300217310ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-02-exim/config/exim4.in000066400000000000000000000027131474251645300206410ustar00rootroot00000000000000CONFDIR = ${EXIMDIR} spool_directory = CONFDIR/spool exim_path = CONFDIR/exim4 # No need to keep anything on the environment. # This is the default, but exim emits a warning if it's not set # (https://www.exim.org/static/doc/CVE-2016-1531.txt). keep_environment = # Disable TLS for now. tls_advertise_hosts = # Run as the current user. exim_group = ${USER} exim_user = ${USER} # Listen on a non-privileged port. daemon_smtp_port = 2025 # ACLs to let anyone send mail (for testing, obviously). acl_smtp_rcpt = acl_check_rcpt acl_smtp_data = acl_check_data begin acl acl_check_rcpt: accept acl_check_data: accept # Rewrite envelope-from to server@srv-exim. # This is so when we redirect, we don't use user@srv-chasquid in the # envelope-from (we're not authorized to send mail on behalf of # @srv-chasquid). begin rewrite user@srv-chasquid server@srv-exim F # Forward all incoming email to chasquid (running on :1025 in this test). begin routers rewritedst: driver = redirect data = someone@srv-chasquid forwardall: driver = accept transport = tochasquid begin transports tochasquid: driver = smtp # exim4 will by default detect and special-case deliveries to localhost; # this avoids that behaviour and tells it to go ahead anyway. allow_localhost hosts_override # chasquid will be listening on localhost:1025 hosts = localhost port = 1025 # Add headers to help debug failures. delivery_date_add envelope_to_add return_path_add chasquid-1.15.0/test/t-02-exim/content000066400000000000000000000001211474251645300174020ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-02-exim/get-exim4-debian.sh000077500000000000000000000014021474251645300213720ustar00rootroot00000000000000#!/bin/bash # # This script downloads the exim4 binary from Debian's package. # It assumes "apt" is functional, which means it's not very portable, but # given the nature of these tests that's acceptable for now. set -e . "$(dirname "$0")/../util/lib.sh" init # Download and extract the package in .exim-bin apt download exim4-daemon-light dpkg -x exim4-daemon-light_*.deb "$PWD/.exim-bin/" # Create a symlink to .exim4, which is the directory we will use to store # configuration, spool, etc. # The configuration template will look for it here. mkdir -p .exim4 ln -sf "$PWD/.exim-bin/usr/sbin/exim4" .exim4/ # Remove the setuid bit, if there is one - we don't need it and may cause # confusion and/or security troubles. chmod -s .exim-bin/usr/sbin/exim4 success chasquid-1.15.0/test/t-02-exim/hosts000066400000000000000000000000521474251645300170730ustar00rootroot00000000000000srv-chasquid localhost srv-exim localhost chasquid-1.15.0/test/t-02-exim/run.sh000077500000000000000000000041061474251645300171570ustar00rootroot00000000000000#!/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: # smtpc --> chasquid --> exim --> chasquid --> local delivery # # smtpc 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 check_hostaliases # 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 if ! .exim4/exim4 -C "$PWD/.exim4/config" --version > /dev/null; then skip "exim4 binary at .exim4/exim4 is not functional" fi # Build with the DNS override, so we can fake DNS records. export GOTAGS="dnsoverride" # Launch minidns in the background using our configuration. minidns_bg --addr=":9053" -zones=zones >> .minidns.log 2>&1 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__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=2025 & wait_until_ready 1025 wait_until_ready 9053 # Launch exim at port 2025 .exim4/exim4 -bd -d -C "$PWD/.exim4/config" > .exim4/log 2>&1 & wait_until_ready 2025 # smtpc will use chasquid to send an email to someone@srv-exim. smtpc someone@srv-exim < content wait_for_file .mail/someone@srv-chasquid mail_diff content .mail/someone@srv-chasquid success chasquid-1.15.0/test/t-02-exim/smtpc.conf000066400000000000000000000001671474251645300200140ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/srv-chasquid/fullchain.pem user user@srv-chasquid password secretpassword chasquid-1.15.0/test/t-02-exim/zones000066400000000000000000000002641474251645300170760ustar00rootroot00000000000000# srv-chasquid and srv-exim to localhost. # Neither have an MX record, but that's okay. srv-chasquid A 127.0.0.1 srv-chasquid AAAA ::1 srv-exim A 127.0.0.1 srv-exim AAAA ::1 chasquid-1.15.0/test/t-03-queue_persistency/000077500000000000000000000000001474251645300206265ustar00rootroot00000000000000chasquid-1.15.0/test/t-03-queue_persistency/addtoqueue.go000066400000000000000000000022541474251645300233200ustar00rootroot00000000000000// 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. // //go:build ignore // +build ignore package main import ( "flag" "fmt" "io" "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 := io.ReadAll(os.Stdin) if err != nil { fmt.Printf("error reading data: %v\n", err) os.Exit(1) } item := &queue.Item{ Message: queue.Message{ ID: *id, From: *from, To: []string{*rcpt}, Rcpt: []*queue.Recipient{ { Address: *rcpt, Type: queue.Recipient_EMAIL, Status: queue.Recipient_PENDING, }, }, Data: data, }, CreatedAt: time.Now(), } os.MkdirAll(*queueDir, 0700) err = item.WriteTo(*queueDir) if err != nil { fmt.Printf("error writing item: %v\n", err) os.Exit(1) } } chasquid-1.15.0/test/t-03-queue_persistency/config/000077500000000000000000000000001474251645300220735ustar00rootroot00000000000000chasquid-1.15.0/test/t-03-queue_persistency/config/chasquid.conf000066400000000000000000000003621474251645300245440ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-03-queue_persistency/config/domains/000077500000000000000000000000001474251645300235255ustar00rootroot00000000000000chasquid-1.15.0/test/t-03-queue_persistency/config/domains/testserver/000077500000000000000000000000001474251645300257335ustar00rootroot00000000000000chasquid-1.15.0/test/t-03-queue_persistency/config/domains/testserver/.gitignore000066400000000000000000000000001474251645300277110ustar00rootroot00000000000000chasquid-1.15.0/test/t-03-queue_persistency/content000066400000000000000000000001211474251645300222150ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-03-queue_persistency/run.sh000077500000000000000000000007771474251645300220040ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init # Add an item to the queue before starting chasquid. go run addtoqueue.go --queue_dir=.data/queue \ --from someone@testserver \ --rcpt someone@testserver \ < content generate_certs_for testserver mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # Check that the item in the queue was delivered. wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver success chasquid-1.15.0/test/t-04-aliases/000077500000000000000000000000001474251645300164745ustar00rootroot00000000000000chasquid-1.15.0/test/t-04-aliases/alias-resolve-hook000077500000000000000000000004561474251645300221330ustar00rootroot00000000000000#!/bin/bash case "$1" in "vicuÃąa@testserver") # Test one naked, one full. These exist in the static aliases file. echo pepe, joan@testserver ;; "vic.uÃąa+abc@testserver") echo uÃąa ;; "ÃąandÃē@testserver") echo "| writemailto ../.data/pipe_alias_worked" ;; "roto@testserver") exit 1 ;; esac chasquid-1.15.0/test/t-04-aliases/chasquid-util.sh000077500000000000000000000003271474251645300216110ustar00rootroot00000000000000#!/bin/bash # Wrapper so chamuyero scripts can invoke chasquid-util for testing. # Run from the config directory because data_dir is relative. cd config || exit 1 ../../../cmd/chasquid-util/chasquid-util -C=. "$@" chasquid-1.15.0/test/t-04-aliases/config/000077500000000000000000000000001474251645300177415ustar00rootroot00000000000000chasquid-1.15.0/test/t-04-aliases/config/chasquid.conf000066400000000000000000000004411474251645300224100ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.15.0/test/t-04-aliases/config/domains/000077500000000000000000000000001474251645300213735ustar00rootroot00000000000000chasquid-1.15.0/test/t-04-aliases/config/domains/testserver/000077500000000000000000000000001474251645300236015ustar00rootroot00000000000000chasquid-1.15.0/test/t-04-aliases/config/domains/testserver/aliases000066400000000000000000000002441474251645300251450ustar00rootroot00000000000000 # Easy aliases. pepe: jose joan: juan # UTF-8 aliases. pitanga: Ãąangapirí aÃąil: azul, índigo # Pipe aliases. tubo: | writemailto ../.data/pipe_alias_worked chasquid-1.15.0/test/t-04-aliases/content000066400000000000000000000001471474251645300200730ustar00rootroot00000000000000From: user@testserver Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-04-aliases/hosts000066400000000000000000000000251474251645300175540ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-04-aliases/run.sh000077500000000000000000000044401474251645300176410ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases 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() { smtpc "$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 if [ -n "$(ls .mail/)" ]; then fail "unexpected mail was delivered: $(ls .mail/)" fi } # Remove the hooks that could be left over from previous failed tests. rm -f config/hooks/alias-resolve # 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 smtpc tubo@testserver < content wait_for_file .data/pipe_alias_worked mail_diff content .data/pipe_alias_worked # Set up the hooks. mkdir -p config/hooks/ cp alias-resolve-hook config/hooks/alias-resolve # Test email aliases via the hook. send_and_check vicuÃąa juan jose send_and_check vi.cu.Ãąa juan jose send_and_check vi.cu.Ãąa+abc juan jose send_and_check vic.uÃąa+abc uÃąa # Test the pipe alias separately. rm -f .data/pipe_alias_worked smtpc ÃąandÃē@testserver < content wait_for_file .data/pipe_alias_worked mail_diff content .data/pipe_alias_worked # Test when alias-resolve exits with an error if smtpc roto@testserver < content 2> .logs/smtpc.out; then fail "expected delivery to roto@ to fail, but succeeded" fi # Test a non-existent alias. if smtpc nono@testserver < content 2> .logs/smtpc.out; then fail "expected delivery to nono@ to fail, but succeeded" fi # Test chasquid-util's ability to do alias resolution talking to chasquid. # We use chamuyero for convenience, so we can match the output exactly. # We run it once to ensure it gets built. chasquid-util --help > /dev/null for i in *.cmy; do if ! chamuyero "$i" > "$i.log" 2>&1 ; then echo "$i failed, log follows" cat "$i.log" exit 1 fi done # Remove the hooks, leave a clean state. rm -f config/hooks/alias-resolve success chasquid-1.15.0/test/t-04-aliases/smtpc.conf000066400000000000000000000001631474251645300204710ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/testserver/fullchain.pem user user@testserver password secretpassword chasquid-1.15.0/test/t-04-aliases/test_chasquid_util.cmy000066400000000000000000000013601474251645300231030ustar00rootroot00000000000000# Resolve an unknown user. c = ./chasquid-util.sh aliases-resolve anunknownuser@blah c <- (email) anunknownuser@blah c wait 0 # Resolve a known alias. c = ./chasquid-util.sh aliases-resolve a.Ãąi_l-blah@testserver c <- (email) azul@testserver c <- (email) índigo@testserver c wait 0 # Resolve a pipe alias. c = ./chasquid-util.sh aliases-resolve tubo@testserver c <- (pipe) writemailto ../.data/pipe_alias_worked c wait 0 # Resolve aliases that are exposed via the hook. c = ./chasquid-util.sh aliases-resolve vicuÃąa@testserver c <- (email) jose@testserver c <- (email) juan@testserver c wait 0 # The hook for this alias exits with error. c = ./chasquid-util.sh aliases-resolve roto@testserver c <- Error resolving: exit status 1 c wait 1 chasquid-1.15.0/test/t-05-null_address/000077500000000000000000000000001474251645300175335ustar00rootroot00000000000000chasquid-1.15.0/test/t-05-null_address/config/000077500000000000000000000000001474251645300210005ustar00rootroot00000000000000chasquid-1.15.0/test/t-05-null_address/config/chasquid.conf000066400000000000000000000004711474251645300234520ustar00rootroot00000000000000hostname: "testserver" smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" suffix_separators: "+-" drop_characters: "._" chasquid-1.15.0/test/t-05-null_address/config/domains/000077500000000000000000000000001474251645300224325ustar00rootroot00000000000000chasquid-1.15.0/test/t-05-null_address/config/domains/testserver/000077500000000000000000000000001474251645300246405ustar00rootroot00000000000000chasquid-1.15.0/test/t-05-null_address/config/domains/testserver/aliases000066400000000000000000000000201474251645300261740ustar00rootroot00000000000000 fail: | false chasquid-1.15.0/test/t-05-null_address/content000066400000000000000000000001621474251645300211270ustar00rootroot00000000000000From: Mailer daemon Subject: I've come to haunt you Message-ID: ÑaÃąaÃąaÃąaÃąa! chasquid-1.15.0/test/t-05-null_address/expected_dsr000066400000000000000000000025331474251645300221320ustar00rootroot00000000000000From user@testserver From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: * Date: * In-Reply-To: References: X-Failed-Recipients: fail@testserver, Auto-Submitted: auto-replied MIME-Version: 1.0 Content-Type: multipart/report; report-type=delivery-status; boundary="???????????" --??????????? Content-Type: text/plain; charset="utf-8" Content-Disposition: inline Content-Description: Notification Content-Transfer-Encoding: 8bit Delivery of your message to the following recipient(s) failed permanently: - fail@testserver Technical details: - "false" (PIPE) failed permanently with error: exit status 1 --??????????? Content-Type: message/global-delivery-status Content-Description: Delivery Report Content-Transfer-Encoding: 8bit Reporting-MTA: dns; testserver Original-Recipient: utf-8; fail@testserver Final-Recipient: utf-8; false Action: failed Status: 5.0.0 Diagnostic-Code: smtp; exit status 1 --??????????? Content-Type: message/rfc822 Content-Description: Undelivered Message Content-Transfer-Encoding: 8bit Received: from localhost by testserver (chasquid) with ESMTPSA tls * (over * ; * From: Mailer daemon Subject: I've come to haunt you Message-Id: ÑaÃąaÃąaÃąaÃąa! --???????????-- chasquid-1.15.0/test/t-05-null_address/hosts000066400000000000000000000000251474251645300206130ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-05-null_address/run.sh000077500000000000000000000012041474251645300206730ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases 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 smtpc fail@testserver < content wait_for_file .mail/user@testserver mail_diff expected_dsr .mail/user@testserver success chasquid-1.15.0/test/t-05-null_address/sendmail.cmy000066400000000000000000000005331474251645300220420ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> MAIL FROM: <> c <~ 250 c -> RCPT TO: user@testserver c <~ 250 c -> DATA c <~ 354 c -> From: Mailer daemon c -> Subject: I've come to haunt you c -> Message-ID: c -> c -> ÑaÃąaÃąaÃąaÃąa! c -> c -> c -> . c <~ 250 c -> QUIT c <~ 221 chasquid-1.15.0/test/t-05-null_address/smtpc.conf000066400000000000000000000001631474251645300215300ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/testserver/fullchain.pem user user@testserver password secretpassword chasquid-1.15.0/test/t-06-idna/000077500000000000000000000000001474251645300157705ustar00rootroot00000000000000chasquid-1.15.0/test/t-06-idna/.gitignore000066400000000000000000000000711474251645300177560ustar00rootroot00000000000000# Ignore the configuration domain directories. ?/domains chasquid-1.15.0/test/t-06-idna/A/000077500000000000000000000000001474251645300161505ustar00rootroot00000000000000chasquid-1.15.0/test/t-06-idna/A/chasquid.conf000066400000000000000000000003661474251645300206250ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs/mail_log-A" chasquid-1.15.0/test/t-06-idna/B/000077500000000000000000000000001474251645300161515ustar00rootroot00000000000000chasquid-1.15.0/test/t-06-idna/B/chasquid.conf000066400000000000000000000003661474251645300206260ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs/mail_log-B" chasquid-1.15.0/test/t-06-idna/from_A_to_B000066400000000000000000000001421474251645300200560ustar00rootroot00000000000000From: Ãąangapirí@srv-Ãą To: pingÃŧino@srv-Ãŧ Subject: Hola amigo pingÃŧino! Que tal va la vida? chasquid-1.15.0/test/t-06-idna/from_B_to_A000066400000000000000000000001451474251645300200610ustar00rootroot00000000000000From: pingÃŧino@srv-Ãŧ To: Ãąangapirí@srv-Ãą Subject: Feliz primavera! Espero que florezcas feliz! chasquid-1.15.0/test/t-06-idna/run.sh000077500000000000000000000027661474251645300171460ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases rm -rf .data-A .data-B .mail # Build with the DNS override, so we can fake DNS records. export GOTAGS="dnsoverride" # Launch minidns in the background using our configuration. minidns_bg --addr=":9053" -zones=zones >> .minidns.log 2>&1 # Two 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__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1465 wait_until_ready 2465 wait_until_ready 9053 # Send from A to B. smtpc --addr=localhost:1465 --user=nadaA@nadaA --password=nadaA \ --server_cert=A/certs/srv-Ãą/fullchain.pem \ pingÃŧino@srv-Ãŧ < 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 --addr=localhost:2465 --user=nadaB@nadaB --password=nadaB \ --server_cert=B/certs/srv-Ãŧ/fullchain.pem \ Ãąangapirí@srv-Ãą < from_B_to_A wait_for_file .mail/Ãąangapirí@srv-Ãą mail_diff from_B_to_A .mail/Ãąangapirí@srv-Ãą success chasquid-1.15.0/test/t-06-idna/zones000066400000000000000000000001441474251645300170500ustar00rootroot00000000000000xn--srv--3ra A 127.0.0.1 xn--srv--3ra AAAA ::1 xn--srv--jqa A 127.0.0.1 xn--srv--jqa AAAA ::1 chasquid-1.15.0/test/t-07-smtputf8/000077500000000000000000000000001474251645300166505ustar00rootroot00000000000000chasquid-1.15.0/test/t-07-smtputf8/config/000077500000000000000000000000001474251645300201155ustar00rootroot00000000000000chasquid-1.15.0/test/t-07-smtputf8/config/chasquid.conf000066400000000000000000000003621474251645300225660ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-07-smtputf8/content000066400000000000000000000001741474251645300202470ustar00rootroot00000000000000From: ÃąandÃē@ÃąoÑos To: Ñangapirí@ÑoÃąos Subject: AraÃąando el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-07-smtputf8/run.sh000077500000000000000000000017331474251645300200170ustar00rootroot00000000000000#!/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 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 ÃąandÃē@ÃąoÃąOS araÃąo add_user Ãąangapirí@ÃąoÃąOS antaÃąo mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1465 # Use a mix of upper and lower case in the from, to, and username, to check # normalization is well handled end-to-end. smtpc --addr=localhost:1465 \ --server_cert=config/certs/ÃąoÃąos/fullchain.pem \ --user=ÃąanDÚ@ÃąoÃąos --password=araÃąo \ Ñangapirí@ÑoÃąos < content # The MDA should see the normalized users and domains, in lower case. wait_for_file .mail/Ãąangapirí@ÃąoÃąos mail_diff content .mail/Ãąangapirí@ÃąoÃąos success chasquid-1.15.0/test/t-09-loop/000077500000000000000000000000001474251645300160315ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/A/000077500000000000000000000000001474251645300162115ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/A/chasquid.conf000066400000000000000000000003661474251645300206660ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs/mail_log-A" chasquid-1.15.0/test/t-09-loop/A/domains/000077500000000000000000000000001474251645300176435ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/A/domains/srv-A/000077500000000000000000000000001474251645300206335ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/A/domains/srv-A/aliases000066400000000000000000000000261474251645300221750ustar00rootroot00000000000000 aliasA: aliasB@srv-B chasquid-1.15.0/test/t-09-loop/B/000077500000000000000000000000001474251645300162125ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/B/chasquid.conf000066400000000000000000000003661474251645300206670ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs/mail_log-B" chasquid-1.15.0/test/t-09-loop/B/domains/000077500000000000000000000000001474251645300176445ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/B/domains/srv-B/000077500000000000000000000000001474251645300206355ustar00rootroot00000000000000chasquid-1.15.0/test/t-09-loop/B/domains/srv-B/aliases000066400000000000000000000000251474251645300221760ustar00rootroot00000000000000aliasB: aliasA@srv-A chasquid-1.15.0/test/t-09-loop/content000066400000000000000000000003141474251645300174240ustar00rootroot00000000000000From: userA@srv-A To: aliasB@srv-B Subject: Los espejos Yo que sentí el horror de los espejos no sÃŗlo ante el cristal impenetrable donde acaba y empieza, inhabitable, un imposible espacio de reflejos chasquid-1.15.0/test/t-09-loop/hosts000066400000000000000000000000401474251645300171060ustar00rootroot00000000000000srv-A localhost srv-B localhost chasquid-1.15.0/test/t-09-loop/run.sh000077500000000000000000000052521474251645300172000ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases rm -rf .data-A .data-B .mail # Build with the DNS override, so we can fake DNS records. export GOTAGS="dnsoverride" # Launch minidns in the background using our configuration. minidns_bg --addr=":9053" -zones=zones >> .minidns.log 2>&1 # Two 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__dns_addr=127.0.0.1:9053 \ --testing__max_received_headers=5 \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1025 wait_until_ready 2025 wait_until_ready 9053 smtpc aliasB@srv-B < content # Get some of the debugging pages, for troubleshooting, and to make sure they # work reasonably well. function fexp_gt10() { fexp "$1" -save "$2" && \ [ "$( wc -l < "$2" )" -gt 10 ] } fexp_gt10 http://localhost:1099/ .data-A/dbg-root \ || fail "failed to fetch /" fexp_gt10 http://localhost:1099/debug/flags .data-A/dbg-flags \ || fail "failed to fetch /debug/flags" fexp http://localhost:1099/debug/queue -save .data-A/dbg-queue \ || fail "failed to fetch /debug/queue" fexp_gt10 http://localhost:1099/debug/config .data-A/dbg-config \ || fail "failed to fetch /debug/config" fexp http://localhost:1099/404 -status 404 \ || fail "fetch /404 worked, should have failed" fexp_gt10 http://localhost:1099/metrics .data-A/metrics \ || fail "failed to fetch /metrics" # Quick sanity-check of the /metrics page, just in case. grep -q '^chasquid_queue_itemsWritten [0-9]\+$' .data-A/metrics \ || fail "A /metrics is missing the chasquid_queue_itemsWritten counter" # Wait until one of them has noticed and stopped the loop. while sleep 0.1; do fexp http://localhost:1099/debug/vars -save .data-A/vars fexp http://localhost:2099/debug/vars -save .data-B/vars # Allow for up to 2 loops to be detected, because if chasquid is fast # enough the DSN will also loop before this check notices it. if grep -q '"chasquid/smtpIn/loopsDetected": [12],' .data-?/vars; then break fi done # Test that A has outgoing domaininfo for srv-b. # This is unrelated to the loop itself, but serves as an end-to-end # verification that outgoing domaininfo works. if ! grep -q 'outgoing_sec_level:\s*TLS_INSECURE' ".data-A/domaininfo/s:srv-b"; then fail "A is missing the domaininfo for srv-b" fi success chasquid-1.15.0/test/t-09-loop/smtpc.conf000066400000000000000000000001341474251645300200240ustar00rootroot00000000000000addr localhost:1465 server_cert A/certs/srv-A/fullchain.pem user userA@srv-A password userA chasquid-1.15.0/test/t-09-loop/zones000066400000000000000000000001201474251645300171030ustar00rootroot00000000000000srv-a A 127.0.0.1 srv-a AAAA ::1 srv-b A 127.0.0.1 srv-b AAAA ::1 chasquid-1.15.0/test/t-10-hooks/000077500000000000000000000000001474251645300161735ustar00rootroot00000000000000chasquid-1.15.0/test/t-10-hooks/.gitignore000066400000000000000000000000271474251645300201620ustar00rootroot00000000000000config/hooks/post-data chasquid-1.15.0/test/t-10-hooks/config/000077500000000000000000000000001474251645300174405ustar00rootroot00000000000000chasquid-1.15.0/test/t-10-hooks/config/chasquid.conf000066400000000000000000000003621474251645300221110ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-10-hooks/config/hooks/000077500000000000000000000000001474251645300205635ustar00rootroot00000000000000chasquid-1.15.0/test/t-10-hooks/config/hooks/post-data.bad1000077500000000000000000000001131474251645300232060ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo "This is not a header" chasquid-1.15.0/test/t-10-hooks/config/hooks/post-data.bad2000077500000000000000000000001721474251645300232140ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo "X-Post-DATA: This starts like a header" echo echo "But then is not" chasquid-1.15.0/test/t-10-hooks/config/hooks/post-data.bad3000077500000000000000000000001731474251645300232160ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out # Just a newline is quite problematic, as it would break the headers. echo chasquid-1.15.0/test/t-10-hooks/config/hooks/post-data.bad4000077500000000000000000000001561474251645300232200ustar00rootroot00000000000000#!/bin/bash echo $0 > ../.data/post-data.out echo -n "X-Post-DATA: valid header with no newline at the end" chasquid-1.15.0/test/t-10-hooks/config/hooks/post-data.good000077500000000000000000000006141474251645300233350ustar00rootroot00000000000000#!/bin/bash env > ../.data/post-data.out echo >> ../.data/post-data.out cat >> ../.data/post-data.out if [ "$RCPT_TO" == "blockme@testserver" ]; then echo "ÂĄNo pasarÃĄn!" exit 1 fi if [ "$RCPT_TO" == "permanent@testserver" ]; then echo "Nos hacemos la permanente" exit 20 # permanent fi echo "X-Post-Data: success" echo "X-Post-Data-Multiline: multiline" echo " header for testing." chasquid-1.15.0/test/t-10-hooks/content000066400000000000000000000001211474251645300175620ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-10-hooks/hosts000066400000000000000000000000251474251645300172530ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-10-hooks/run.sh000077500000000000000000000037521474251645300173450ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases generate_certs_for testserver add_user user@testserver secretpassword add_user someone@testserver secretpassword add_user blockme@testserver secretpassword add_user permanent@testserver secretpassword mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 cp config/hooks/post-data.good config/hooks/post-data smtpc 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 "EHLO_DOMAIN=localhost" check "EHLO_DOMAIN_RAW=localhost" check "FROM_LOCAL_DOMAIN=1" check "ON_TLS=1" check "AUTH_AS=user@testserver" check "PATH=" check "REMOTE_ADDR=" check "SPF_PASS=0" # Check that failures in the script result in failing delivery. # Transient failure. if smtpc blockme@testserver < content >.logs/smtpc.log 2>&1; then fail "ERROR: hook did not block email as expected" fi if ! grep -q "451 ÂĄNo pasarÃĄn!" .logs/smtpc.log; then cat .logs/smtpc.log fail "ERROR: transient hook error not returned correctly" fi # Permanent failure. if smtpc permanent@testserver < content >.logs/smtpc.log 2>&1; then fail "ERROR: hook did not block email as expected" fi if ! grep -q "554 Nos hacemos la permanente" .logs/smtpc.log; then cat .logs/smtpc.log fail "ERROR: permanent hook error not returned correctly" fi # Check that the bad hooks don't prevent delivery. for i in config/hooks/post-data.bad*; do cp "$i" config/hooks/post-data smtpc someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver done success chasquid-1.15.0/test/t-10-hooks/smtpc.conf000066400000000000000000000001631474251645300201700ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/testserver/fullchain.pem user user@testserver password secretpassword chasquid-1.15.0/test/t-11-dovecot/000077500000000000000000000000001474251645300165145ustar00rootroot00000000000000chasquid-1.15.0/test/t-11-dovecot/config/000077500000000000000000000000001474251645300177615ustar00rootroot00000000000000chasquid-1.15.0/test/t-11-dovecot/config/chasquid.conf000066400000000000000000000006121474251645300224300ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" dovecot_auth: true dovecot_userdb_path: "/tmp/chasquid-dovecot-test/run/auth-userdb" dovecot_client_path: "/tmp/chasquid-dovecot-test/run/auth-client" chasquid-1.15.0/test/t-11-dovecot/config/domains/000077500000000000000000000000001474251645300214135ustar00rootroot00000000000000chasquid-1.15.0/test/t-11-dovecot/config/domains/srv/000077500000000000000000000000001474251645300222255ustar00rootroot00000000000000chasquid-1.15.0/test/t-11-dovecot/config/domains/srv/.keep000066400000000000000000000000001474251645300231400ustar00rootroot00000000000000chasquid-1.15.0/test/t-11-dovecot/config/dovecot.conf.in000066400000000000000000000021371474251645300227030ustar00rootroot00000000000000base_dir = $ROOT/run/ state_dir = $ROOT/lib/ log_path = $ROOT/dovecot.log ssl = no default_internal_user = $USER default_internal_group = $USER default_login_user = $USER # Before auth checks, rename "u@d" to "u-x". This exercises that chasquid # handles well the case where the returned user information does not match the # requested user. # We drop the domain, to exercise "naked" auth handling. auth_username_format = "%n-x" # Disable authentication penalty, since we intentionally make failed requests # and it just slows down tests. auth_failure_delay = 0 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-1.15.0/test/t-11-dovecot/config/passwd000066400000000000000000000001271474251645300212050ustar00rootroot00000000000000user-x:{plain}password:1000:1000::/home/user naked-x:{plain}gun:1001:1001::/home/naked chasquid-1.15.0/test/t-11-dovecot/content000066400000000000000000000001401474251645300201040ustar00rootroot00000000000000From: user@srv Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-11-dovecot/hosts000066400000000000000000000000161474251645300175740ustar00rootroot00000000000000srv localhost chasquid-1.15.0/test/t-11-dovecot/run.sh000077500000000000000000000045261474251645300176660ustar00rootroot00000000000000#!/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 check_hostaliases if ! dovecot --version > /dev/null; then skip "dovecot not installed" fi # Create a temporary directory for dovecot to use, and generate the dovecot # config based on the template. # Note the length 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 $ROOT/lib rm -f $ROOT/dovecot.log GROUP=$(id -g -n) envsubst \ < config/dovecot.conf.in > $ROOT/dovecot.conf cp -f config/passwd $ROOT/passwd dovecot -F -c $ROOT/dovecot.conf & # Early tests: run dovecot-auth-cli for testing purposes. These fail early if # there are obvious problems. OUT=$(dovecot-auth-cli $ROOT/run/auth exists user@srv || true) if [ "$OUT" != "yes" ]; then fail "user does not exist: $OUT" fi OUT=$(dovecot-auth-cli $ROOT/run/auth auth user@srv password || true) if [ "$OUT" != "yes" ]; then fail "auth failed: $OUT" fi # Set up chasquid, using dovecot as authentication backend. generate_certs_for srv mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # Send an email as "user@srv" successfully. smtpc user@srv < content wait_for_file .mail/user@srv mail_diff content .mail/user@srv # Send an email as "naked" successfully. rm .mail/user@srv smtpc --user=naked --password=gun --from=naked@srv user@srv < content wait_for_file .mail/user@srv mail_diff content .mail/user@srv # Send an email to the "naked" user successfully. smtpc naked@srv < content wait_for_file .mail/naked@srv mail_diff content .mail/naked@srv # Fail to send to nobody@srv (user does not exist). if smtpc nobody@srv < content 2> /dev/null; then fail "successfully sent an email to a non-existent user" fi # Fail to send from unknownuser@srv (user does not exist). if smtpc --user=unknownuser@srv 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 smtpc --password=badpasswd user@srv < content 2> /dev/null; then fail "successfully sent an email with a bad password" fi success chasquid-1.15.0/test/t-11-dovecot/smtpc.conf000066400000000000000000000001371474251645300205120ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/srv/fullchain.pem user user@srv password password chasquid-1.15.0/test/t-12-minor_dialogs/000077500000000000000000000000001474251645300177005ustar00rootroot00000000000000chasquid-1.15.0/test/t-12-minor_dialogs/auth_multi_dialog.cmy000066400000000000000000000012661474251645300241110ustar00rootroot00000000000000 c tls_connect localhost:1465 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH SOMETHINGELSE c <~ 534 c -> AUTH PLAIN c <~ 334 c -> dXNlckB0ZXN0c2VydmVyAHlalala== c <~ 501 5.5.2 Error decoding AUTH response # Reconnect to avoid getting rejected due to too many errors. c close c tls_connect localhost:1465 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH PLAIN c <~ 334 c -> dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgB3cm9uZ3Bhc3N3b3Jk c <~ 535 5.7.8 Incorrect user or password c -> AUTH PLAIN c <~ 334 c -> dXNlckB0ZXN0c2VydmVyAHVzZXJAdGVzdHNlcnZlcgBzZWNyZXRwYXNzd29yZA== c <~ 235 2.7.0 Authentication successful c -> AUTH PLAIN c <~ 503 5.5.1 You are already wearing that! chasquid-1.15.0/test/t-12-minor_dialogs/auth_not_tls.cmy000066400000000000000000000002001474251645300231050ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH PLAIN c <- 503 5.7.10 You feel vulnerable chasquid-1.15.0/test/t-12-minor_dialogs/auth_too_many_failures.cmy000066400000000000000000000003221474251645300251470ustar00rootroot00000000000000 c tls_connect localhost:1465 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> AUTH PLAIN something c <~ 501 c -> AUTH PLAIN something c <~ 501 c -> AUTH PLAIN something c <~ 421 4.5.0 Too many errors, bye chasquid-1.15.0/test/t-12-minor_dialogs/bad_data.cmy000066400000000000000000000011361474251645300221320ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> DATA c <- 503 5.5.1 Invisible customers are not welcome! c -> HELO localhost c <~ 250 c -> DATA c <- 503 5.5.1 Sender not yet given # Reconnect to avoid getting rejected due to too many errors. c close c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <~ 250 c -> RCPT TO: user@testserver c <~ 250 c -> DATA c <~ 354 c -> From: Mailer daemon c -> Subject: I've come to haunt you c -> Bad header c -> c -> Muahahahaha c -> c -> c -> . c <~ 554 5.6.0 Error parsing message c -> QUIT c <~ 221 chasquid-1.15.0/test/t-12-minor_dialogs/bad_data_dot.cmy000066400000000000000000000012511474251645300227760ustar00rootroot00000000000000 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 -> # An MTA must not accept isolated line breaks, otherwise it may fall victim to # an SMTP smuggling attack. See readUntilDot for more details. # This test triggers that condition with an invalid dot-ending, so we verify # the server returns an error in this case. c ~> '.\n' c -> That was a bad line ending, this is a good one. c ~> 'xxx\r\n.\r\n' c <- 521 5.5.2 Error reading DATA: invalid line ending chasquid-1.15.0/test/t-12-minor_dialogs/bad_data_dot_2.cmy000066400000000000000000000012531474251645300232210ustar00rootroot00000000000000 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 -> # An MTA must not accept isolated line breaks, otherwise it may fall victim to # an SMTP smuggling attack. See readUntilDot for more details. # This test triggers that condition with an invalid dot-ending, so we verify # the server returns an error in this case. c ~> 'xxx\n.\n' c -> That was a bad line ending, this is a good one. c ~> '\r\n.\r\n' c <- 521 5.5.2 Error reading DATA: invalid line ending chasquid-1.15.0/test/t-12-minor_dialogs/bad_data_dot_on_message_too_big.cmy000066400000000000000000000015171474251645300267050ustar00rootroot00000000000000 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 -> Subject: Message too big c -> # Max message size is 1 MiB. Note this includes line endings but converted to # \n (as per textproto.DotReader), and excluding the final ".". # We already sent (in the header) 26. # Send lines of len 900 to stay under the limit. # (1024 * 1024 - 26) - (900 * 1165) = 50 c ~> ('a' * 899 + '\r\n') * 1165 # We have 50 characters left before the message is too big. c ~> 'b' * 55 + '\r\n' # At this point the message is too big. The remainder data should be # discarded. # We use a "bad ." to try to do an SMTP smuggling attack. c ~> '.\n' c -> HELP c -> HELP # And now the "good .". c -> . c <- 521 5.5.2 Error reading DATA: invalid line ending chasquid-1.15.0/test/t-12-minor_dialogs/bad_mail_from.cmy000066400000000000000000000016061474251645300231700ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL LALA: <> c <- 500 5.5.2 Unknown command c -> MAIL FROM: c <~ 500 # Reconnect to avoid getting rejected due to too many errors. c close c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <~ 501 c -> MAIL FROM: c <- 501 5.1.8 Malformed sender domain (IDNA conversion failed) # Reconnect to avoid getting rejected due to too many errors. c close c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <- 501 5.1.7 Sender address too long chasquid-1.15.0/test/t-12-minor_dialogs/bad_rcpt_to.cmy000066400000000000000000000021261474251645300226730ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <~ 250 c -> RCPT LALA: <> c <- 500 5.5.2 Unknown command c -> RCPT TO: c <~ 500 # Reconnect to avoid getting rejected due to too many errors. c close c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <~ 250 c -> RCPT TO: c <~ 501 c -> RCPT TO: c <- 501 5.1.2 Malformed destination domain (IDNA conversion failed) # Reconnect to avoid getting rejected due to too many errors. c close c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> MAIL FROM: c <~ 250 c -> RCPT TO: c <- 550 5.1.3 Destination address is invalid c -> RCPT TO: c <- 501 5.1.3 Destination address too long chasquid-1.15.0/test/t-12-minor_dialogs/config/000077500000000000000000000000001474251645300211455ustar00rootroot00000000000000chasquid-1.15.0/test/t-12-minor_dialogs/config/chasquid.conf000066400000000000000000000006211474251645300236140ustar00rootroot00000000000000hostname: "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: "._" # Small max data size so we can reach it more easily in the tests. max_data_size_mb: 1 chasquid-1.15.0/test/t-12-minor_dialogs/config/domains/000077500000000000000000000000001474251645300225775ustar00rootroot00000000000000chasquid-1.15.0/test/t-12-minor_dialogs/config/domains/testserver/000077500000000000000000000000001474251645300250055ustar00rootroot00000000000000chasquid-1.15.0/test/t-12-minor_dialogs/config/domains/testserver/aliases000066400000000000000000000000201474251645300263410ustar00rootroot00000000000000 fail: | false chasquid-1.15.0/test/t-12-minor_dialogs/data_dot_stuffing.cmy000066400000000000000000000005611474251645300241000ustar00rootroot00000000000000 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 -> ..x c -> c -> .. c -> c -> .This is stuffy. c -> c -> . c <~ 250 c -> QUIT c <~ 221 chasquid-1.15.0/test/t-12-minor_dialogs/data_dot_stuffing.cmy.verify000066400000000000000000000001601474251645300253760ustar00rootroot00000000000000From: Mailer daemon Subject: I've come to haunt you Muahahahaha .x . This is stuffy. chasquid-1.15.0/test/t-12-minor_dialogs/empty_helo.cmy000066400000000000000000000001541474251645300225570ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO c <~ 501 c -> EHLO c <~ 501 c -> HELO localhost c <~ 250 chasquid-1.15.0/test/t-12-minor_dialogs/helo.cmy000066400000000000000000000001311474251645300213340ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> HELO localhost c <~ 250 c -> QUIT c <~ 221 chasquid-1.15.0/test/t-12-minor_dialogs/line_too_long.cmy000066400000000000000000000020431474251645300232400ustar00rootroot00000000000000c tcp_connect localhost:1025 c <~ 220 c -> HELO aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1aaaaaaaaa1 c <~ 554 chasquid-1.15.0/test/t-12-minor_dialogs/message_too_big.cmy000066400000000000000000000010641474251645300235410ustar00rootroot00000000000000 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 -> Subject: Message too big c -> # Max message size is 1 MiB. Note this includes line endings but converted to # \n (as per textproto.DotReader), and excluding the final ".". # We already sent (in the header) 26. # Send lines of len 900 to stay under the limit. # (1024 * 1024 - 26) - (900 * 1166) = -850 c ~> ('a' * 899 + '\r\n') * 1166 c -> . c <~ 552 5.3.4 Message too big c -> QUIT c <~ 221 chasquid-1.15.0/test/t-12-minor_dialogs/run.sh000077500000000000000000000017031474251645300210440ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init generate_certs_for testserver add_user user@testserver secretpassword mkdir -p .logs .mbox chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 FAILED=0 for i in *.cmy; do if ! chamuyero "$i" > ".logs/$i.log" 2>&1 ; then echo "test $i failed, see .logs/$i.log" echo echo "last lines of the log:" tail -n 10 ".logs/$i.log" | sed 's/^/ /g' echo FAILED=1 continue fi # Some tests do email delivery, this allows us to verify the results. if [ -f "$i.verify" ]; then wait_for_file .mail/user@testserver cp .mail/user@testserver ".mbox/$i.mbox" if ! mail_diff "$i.verify" .mail/user@testserver \ > ".mbox/$i.diff" ; then echo "test $i failed, because it had a mail diff" echo echo "mail diff:" sed 's/^/ /g' ".mbox/$i.diff" echo FAILED=1 fi fi done if [ $FAILED == 1 ]; then fail "got at least one error" fi success chasquid-1.15.0/test/t-12-minor_dialogs/sendmail.cmy000066400000000000000000000004751474251645300222140ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> MAIL FROM: <> c <~ 250 c -> RCPT TO: user@testserver c <~ 250 c -> DATA c <~ 354 c -> From: Mailer daemon c -> Subject: I've come to haunt you c -> c -> Muahahahaha c -> c -> c -> . c <~ 250 c -> QUIT c <~ 221 chasquid-1.15.0/test/t-12-minor_dialogs/sendmail.cmy.verify000066400000000000000000000001311474251645300235040ustar00rootroot00000000000000From: Mailer daemon Subject: I've come to haunt you Muahahahaha chasquid-1.15.0/test/t-12-minor_dialogs/unknown_command.cmy000066400000000000000000000001731474251645300236100ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> EHLO localhost c <... 250 HELP c -> WHATISTHIS c <- 500 5.5.1 Unknown command chasquid-1.15.0/test/t-12-minor_dialogs/wrong_proto.cmy000066400000000000000000000005501474251645300227710ustar00rootroot00000000000000 c tcp_connect localhost:1025 c <~ 220 c -> GET /evil HTTP/1.1 c <- 502 5.7.0 You hear someone cursing shoplifters c tcp_connect localhost:1025 c <~ 220 c -> POST /evil HTTP/1.1 c <- 502 5.7.0 You hear someone cursing shoplifters c tcp_connect localhost:1025 c <~ 220 c -> CONNECT www.evil.com:80 HTTP/1.1 c <- 502 5.7.0 You hear someone cursing shoplifters chasquid-1.15.0/test/t-13-reload/000077500000000000000000000000001474251645300163215ustar00rootroot00000000000000chasquid-1.15.0/test/t-13-reload/.gitignore000066400000000000000000000000421474251645300203050ustar00rootroot00000000000000config/domains/testserver/aliases chasquid-1.15.0/test/t-13-reload/config/000077500000000000000000000000001474251645300175665ustar00rootroot00000000000000chasquid-1.15.0/test/t-13-reload/config/chasquid.conf000066400000000000000000000003621474251645300222370ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-13-reload/content000066400000000000000000000001211474251645300177100ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-13-reload/hosts000066400000000000000000000000251474251645300174010ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-13-reload/run.sh000077500000000000000000000036321474251645300174700ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases generate_certs_for testserver # # Automatic reload. # # Start with the user with the wrong password, and no aliases. chasquid-util-user-add someone@testserver password111 rm -f config/domains/testserver/aliases mkdir -p .logs chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config \ --testing__reload_every=50ms & wait_until_ready 1025 # First, check that delivery fails with the "wrong" password. if smtpc someone@testserver < content 2>/dev/null; then fail "success using the wrong password" fi # Change password, add an alias; then wait a bit more than the reload period # and try again. chasquid-util-user-add someone@testserver password222 echo "analias: someone" > config/domains/testserver/aliases sleep 0.2 smtpc analias@testserver < content wait_for_file .mail/someone@testserver # # Manual log rotation. # # Rotate logs. mv .logs/chasquid.log .logs/chasquid.log-old mv .logs/mail_log .logs/mail_log-old # Send SIGHUP and give it a little for the server to handle it. pkill -HUP -s 0 chasquid sleep 0.2 # Send another mail. rm .mail/someone@testserver smtpc analias@testserver < content wait_for_file .mail/someone@testserver # Check there are new entries. sleep 0.2 if ! grep -q "from=someone@testserver all done" .logs/mail_log; then fail "new mail log did not have the expected entry" fi if ! grep -q -E "Queue.SendLoop .*: someone@testserver sent" .logs/chasquid.log; then fail "new chasquid log did not have the expected entry" fi # Test that we can make the server exit using the /exit endpoint. # First, a GET should fail with status 405. fexp http://localhost:1099/exit -status 405 # A POST should succeed, return an OK body, and the daemon should # eventually exit. CHASQUID_PID=$(pgrep -s 0 chasquid) fexp http://localhost:1099/exit -method POST -bodyre "OK" wait_until ! kill -s 0 "$CHASQUID_PID" 2> /dev/null success chasquid-1.15.0/test/t-13-reload/smtpc.conf000066400000000000000000000001631474251645300203160ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/testserver/fullchain.pem user someone@testserver password password222 chasquid-1.15.0/test/t-14-tls_tracking/000077500000000000000000000000001474251645300175405ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/A/000077500000000000000000000000001474251645300177205ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/A/chasquid.conf000066400000000000000000000003661474251645300223750ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs-A/mail_log" chasquid-1.15.0/test/t-14-tls_tracking/A/domains/000077500000000000000000000000001474251645300213525ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/A/domains/srv-A/000077500000000000000000000000001474251645300223425ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/A/domains/srv-A/.keep000066400000000000000000000000001474251645300232550ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/B/000077500000000000000000000000001474251645300177215ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/B/chasquid.conf000066400000000000000000000003661474251645300223760ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs-B/mail_log" chasquid-1.15.0/test/t-14-tls_tracking/B/domains/000077500000000000000000000000001474251645300213535ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/B/domains/srv-B/000077500000000000000000000000001474251645300223445ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/B/domains/srv-B/.keep000066400000000000000000000000001474251645300232570ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/config/000077500000000000000000000000001474251645300210055ustar00rootroot00000000000000chasquid-1.15.0/test/t-14-tls_tracking/config/chasquid.conf000066400000000000000000000003621474251645300234560ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-14-tls_tracking/content000066400000000000000000000001211474251645300211270ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-14-tls_tracking/hosts000066400000000000000000000000401474251645300206150ustar00rootroot00000000000000srv-A localhost srv-B localhost chasquid-1.15.0/test/t-14-tls_tracking/run.sh000077500000000000000000000042561474251645300207120ustar00rootroot00000000000000#!/bin/bash # Test TLS tracking features, which require faking SPF. set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases # Build with the DNS override, so we can fake DNS records. export GOTAGS="dnsoverride" # Launch minidns in the background using our configuration. minidns_bg --addr=":9053" -zones=zones >> .minidns.log 2>&1 # Two chasquid servers: # A - listens on :1025, hosts srv-A # B - listens on :2025, hosts srv-B CONFDIR=A generate_certs_for srv-A CONFDIR=A add_user usera@srv-A userA CONFDIR=B generate_certs_for srv-B CONFDIR=B add_user userb@srv-B userB rm -rf .data-A .data-B .mail .certs mkdir -p .logs-A .logs-B .mail .certs # Put public certs in .certs, and use it as our trusted cert dir. cp A/certs/srv-A/fullchain.pem .certs/srv-a.pem cp B/certs/srv-B/fullchain.pem .certs/srv-b.pem export SSL_CERT_DIR=$PWD/.certs/ chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \ --testing__dns_addr=127.0.0.1:9053 \ --testing__max_received_headers=5 \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1025 wait_until_ready 2025 wait_until_ready 9053 smtpc userB@srv-B < content wait_for_file .mail/userb@srv-b mail_diff content .mail/userb@srv-b # A should have a secure outgoing connection to srv-b. if ! grep -q 'outgoing_sec_level:\s*TLS_SECURE' ".data-A/domaininfo/s:srv-b"; then fail "A is missing the domaininfo for srv-b" fi # B should have a secure incoming connection from srv-a. if ! grep -q 'incoming_sec_level:\s*TLS_CLIENT' ".data-B/domaininfo/s:srv-a"; then fail "B is missing the domaininfo for srv-a" fi # In A, remove domaininfo data about srv-B. # Check that it was cleared successfully. CONFDIR=A chasquid-util domaininfo-remove srv-b if grep -q 'outgoing_sec_level:' ".data-A/domaininfo/s:srv-b"; then fail "Error clearing A's domaininfo about srv-b" fi # While at it, check that a domaininfo-remove for an unknown domain results in # an error. if CONFDIR=A chasquid-util domaininfo-remove srv-X > .cdu-di-r-x.log 2>&1; then fail "Expected error on chasquid-util domaininfo-remove srv-X" fi success chasquid-1.15.0/test/t-14-tls_tracking/smtpc.conf000066400000000000000000000001341474251645300215330ustar00rootroot00000000000000addr localhost:1465 server_cert A/certs/srv-A/fullchain.pem user userA@srv-A password userA chasquid-1.15.0/test/t-14-tls_tracking/zones000066400000000000000000000002551474251645300206230ustar00rootroot00000000000000# srv-a zone srv-a A 127.0.0.1 srv-a AAAA ::1 srv-a MX srv-a srv-a TXT v=spf1 a # srv-b zone srv-b A 127.0.0.1 srv-b AAAA ::1 srv-b MX srv-b srv-b TXT v=spf1 a chasquid-1.15.0/test/t-16-spf/000077500000000000000000000000001474251645300156465ustar00rootroot00000000000000chasquid-1.15.0/test/t-16-spf/A/000077500000000000000000000000001474251645300160265ustar00rootroot00000000000000chasquid-1.15.0/test/t-16-spf/A/chasquid.conf000066400000000000000000000003661474251645300205030ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs-A/mail_log" chasquid-1.15.0/test/t-16-spf/B/000077500000000000000000000000001474251645300160275ustar00rootroot00000000000000chasquid-1.15.0/test/t-16-spf/B/chasquid.conf000066400000000000000000000003661474251645300205040ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs-B/mail_log" chasquid-1.15.0/test/t-16-spf/config/000077500000000000000000000000001474251645300171135ustar00rootroot00000000000000chasquid-1.15.0/test/t-16-spf/config/chasquid.conf000066400000000000000000000003621474251645300215640ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-16-spf/content000066400000000000000000000001211474251645300172350ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-16-spf/expected_dsn000066400000000000000000000025561474251645300202460ustar00rootroot00000000000000From usera@srv-a From: Mail Delivery System To: Subject: Mail delivery failed: returning message to sender Message-ID: > .minidns.log 2>&1 wait_until_ready 9053 } # T0: Successful. launch_minidns zones.t0 smtpc userB@srv-B < content wait_for_file .mail/userb@srv-b mail_diff content .mail/userb@srv-b # T1: A is not permitted to send to B. # Check that userA got a DSN about it. rm .mail/* launch_minidns zones.t1 smtpc userB@srv-B < content wait_for_file .mail/usera@srv-a mail_diff expected_dsn .mail/usera@srv-a success chasquid-1.15.0/test/t-16-spf/smtpc.conf000066400000000000000000000001341474251645300176410ustar00rootroot00000000000000addr localhost:1465 server_cert A/certs/srv-A/fullchain.pem user userA@srv-A password userA chasquid-1.15.0/test/t-16-spf/zones.t0000066400000000000000000000002551474251645300172530ustar00rootroot00000000000000# srv-a zone srv-a A 127.0.0.1 srv-a AAAA ::1 srv-a MX srv-a srv-a TXT v=spf1 a # srv-b zone srv-b A 127.0.0.1 srv-b AAAA ::1 srv-b MX srv-b srv-b TXT v=spf1 a chasquid-1.15.0/test/t-16-spf/zones.t1000066400000000000000000000003311474251645300172470ustar00rootroot00000000000000# srv-a is forbidden from sending mail. # srv-a zone srv-a A 127.0.0.1 srv-a AAAA ::1 srv-a MX srv-a srv-a TXT v=spf1 -all # srv-b zone srv-b A 127.0.0.1 srv-b AAAA ::1 srv-b MX srv-b srv-b TXT v=spf1 a chasquid-1.15.0/test/t-17-maillog/000077500000000000000000000000001474251645300165035ustar00rootroot00000000000000chasquid-1.15.0/test/t-17-maillog/.gitignore000066400000000000000000000000251474251645300204700ustar00rootroot00000000000000config/chasquid.conf chasquid-1.15.0/test/t-17-maillog/config/000077500000000000000000000000001474251645300177505ustar00rootroot00000000000000chasquid-1.15.0/test/t-17-maillog/config/chasquid.conf.in000066400000000000000000000003571474251645300230320ustar00rootroot00000000000000smtp_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: "$MAIL_LOG_PATH" chasquid-1.15.0/test/t-17-maillog/content000066400000000000000000000001211474251645300200720ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-17-maillog/hosts000066400000000000000000000000251474251645300175630ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-17-maillog/run.sh000077500000000000000000000021071474251645300176460ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases mkdir -p .logs generate_certs_for testserver add_user user@testserver secretpassword add_user someone@testserver secretpassword function send_one() { rm -f .logs/mail_log .logs/stdout .logs/stderr envsubst < config/chasquid.conf.in > config/chasquid.conf chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config \ > .logs/stdout 2> .logs/stderr & wait_until_ready 1025 smtpc someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver pkill -s 0 chasquid sleep 0.2 } export MAIL_LOG_PATH="../.logs/mail_log" send_one if ! grep -q "from=user@testserver all done" .logs/mail_log; then fail "entries not found in .logs/mail_log" fi export MAIL_LOG_PATH="" send_one if ! grep -q "from=user@testserver all done" .logs/stdout; then fail "entries not found in .logs/stdout" fi export MAIL_LOG_PATH="" send_one if ! grep -q "from=user@testserver all done" .logs/stderr; then fail "entries not found in .logs/stderr" fi success chasquid-1.15.0/test/t-17-maillog/smtpc.conf000066400000000000000000000001631474251645300205000ustar00rootroot00000000000000addr localhost:1465 server_cert config/certs/testserver/fullchain.pem user user@testserver password secretpassword chasquid-1.15.0/test/t-18-haproxy/000077500000000000000000000000001474251645300165525ustar00rootroot00000000000000chasquid-1.15.0/test/t-18-haproxy/config/000077500000000000000000000000001474251645300200175ustar00rootroot00000000000000chasquid-1.15.0/test/t-18-haproxy/config/chasquid.conf000066400000000000000000000004121474251645300224640ustar00rootroot00000000000000smtp_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" mail_log_path: "../.logs/mail_log" haproxy_incoming: true chasquid-1.15.0/test/t-18-haproxy/content000066400000000000000000000001211474251645300201410ustar00rootroot00000000000000Subject: Prueba desde el test Crece desde el test el futuro Crece desde el test chasquid-1.15.0/test/t-18-haproxy/haproxy.cfg000066400000000000000000000002121474251645300207200ustar00rootroot00000000000000listen smtp-in mode tcp bind *:1025 server srv1 localhost:2025 send-proxy timeout connect 10s timeout client 10s timeout server 10s chasquid-1.15.0/test/t-18-haproxy/hosts000066400000000000000000000000251474251645300176320ustar00rootroot00000000000000testserver localhost chasquid-1.15.0/test/t-18-haproxy/msmtprc000066400000000000000000000002651474251645300201650ustar00rootroot00000000000000account default host testserver port 1025 tls on tls_trust_file config/certs/testserver/fullchain.pem from user@testserver auth on user user@testserver password secretpassword chasquid-1.15.0/test/t-18-haproxy/run.sh000077500000000000000000000015441474251645300177210ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases mkdir -p .logs if ! haproxy -v > /dev/null; then skip "haproxy binary not found" fi # Set a 2m timeout: if there are issues with haproxy, the wait tends to hang # indefinitely, so an explicit timeout helps with test automation. timeout 2m # Launch haproxy in the background, checking config first to fail fast in that # case. haproxy -f haproxy.cfg -c haproxy -f haproxy.cfg -d > .logs/haproxy.log 2>&1 & generate_certs_for testserver add_user user@testserver secretpassword add_user someone@testserver secretpassword chasquid -v=2 --logfile=.logs/chasquid.log --config_dir=config & wait_until_ready 1025 # haproxy wait_until_ready 2025 # chasquid run_msmtp someone@testserver < content wait_for_file .mail/someone@testserver mail_diff content .mail/someone@testserver success chasquid-1.15.0/test/t-20-bad_configs/000077500000000000000000000000001474251645300173075ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/.gitignore000066400000000000000000000000211474251645300212700ustar00rootroot00000000000000!.expected-error chasquid-1.15.0/test/t-20-bad_configs/c-01-empty/000077500000000000000000000000001474251645300211035ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-01-empty/.expected-error000066400000000000000000000000711474251645300240320ustar00rootroot00000000000000open c-01-empty/chasquid.conf: no such file or directory chasquid-1.15.0/test/t-20-bad_configs/c-02-all_dirs_missing/000077500000000000000000000000001474251645300232705ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-02-all_dirs_missing/.expected-error000066400000000000000000000000471474251645300262220ustar00rootroot00000000000000open certs/: no such file or directory chasquid-1.15.0/test/t-20-bad_configs/c-02-all_dirs_missing/chasquid.conf000066400000000000000000000003261474251645300257410ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/000077500000000000000000000000001474251645300215635ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/.expected-error000066400000000000000000000000511474251645300245100ustar00rootroot00000000000000At least one valid certificate is needed chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/certs/000077500000000000000000000000001474251645300227035ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/certs/testserver/000077500000000000000000000000001474251645300251115ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/certs/testserver/.keep000066400000000000000000000000001474251645300260240ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/chasquid.conf000066400000000000000000000003261474251645300242340ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/domains/000077500000000000000000000000001474251645300232155ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/domains/testserver/000077500000000000000000000000001474251645300254235ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-03-no_certs/domains/testserver/users000066400000000000000000000011011474251645300265000ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-04-no_cert_dirs/000077500000000000000000000000001474251645300224225ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-04-no_cert_dirs/.expected-error000066400000000000000000000000351474251645300253510ustar00rootroot00000000000000No entries found in "certs/" chasquid-1.15.0/test/t-20-bad_configs/c-04-no_cert_dirs/chasquid.conf000066400000000000000000000003261474251645300250730ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-04-no_cert_dirs/domains/000077500000000000000000000000001474251645300240545ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-04-no_cert_dirs/domains/testserver/000077500000000000000000000000001474251645300262625ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-04-no_cert_dirs/domains/testserver/users000066400000000000000000000011011474251645300273370ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-05-no_addrs/000077500000000000000000000000001474251645300215425ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-05-no_addrs/.expected-error000066400000000000000000000000301474251645300244640ustar00rootroot00000000000000No address to listen on chasquid-1.15.0/test/t-20-bad_configs/c-05-no_addrs/chasquid.conf000066400000000000000000000001761474251645300242160ustar00rootroot00000000000000mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-05-no_addrs/domains/000077500000000000000000000000001474251645300231745ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-05-no_addrs/domains/testserver/000077500000000000000000000000001474251645300254025ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-05-no_addrs/domains/testserver/users000066400000000000000000000011011474251645300264570ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-06-bad_maillog/000077500000000000000000000000001474251645300222045ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-06-bad_maillog/.expected-error000066400000000000000000000001161474251645300251330ustar00rootroot00000000000000Error opening mail log: open /sys/bad-dir/mail_log: no such file or directory chasquid-1.15.0/test/t-20-bad_configs/c-06-bad_maillog/chasquid.conf000066400000000000000000000004261474251645300246560ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" # This is expected to be invalid, and impossible to mkdir. mail_log_path: "/sys/bad-dir/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-06-bad_maillog/domains/000077500000000000000000000000001474251645300236365ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-06-bad_maillog/domains/testserver/000077500000000000000000000000001474251645300260445ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-06-bad_maillog/domains/testserver/users000066400000000000000000000011011474251645300271210ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-07-bad_domain_info/000077500000000000000000000000001474251645300230435ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-07-bad_domain_info/.expected-error000066400000000000000000000001431474251645300257720ustar00rootroot00000000000000Error opening domain info database: mkdir ../data-c-07-bad_domain_info/domaininfo: not a directory chasquid-1.15.0/test/t-20-bad_configs/c-07-bad_domain_info/chasquid.conf000066400000000000000000000003521474251645300255130ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../data-c-07-bad_domain_info" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-07-bad_domain_info/domains/000077500000000000000000000000001474251645300244755ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-07-bad_domain_info/domains/testserver/000077500000000000000000000000001474251645300267035ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-07-bad_domain_info/domains/testserver/users000066400000000000000000000011011474251645300277600ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-08-bad_sts_cache/000077500000000000000000000000001474251645300225165ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-08-bad_sts_cache/.expected-error000066400000000000000000000001341474251645300254450ustar00rootroot00000000000000Failed to initialize STS cache: mkdir ../data-c-08-bad_sts_cache/sts-cache: not a directory chasquid-1.15.0/test/t-20-bad_configs/c-08-bad_sts_cache/chasquid.conf000066400000000000000000000003501474251645300251640ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../data-c-08-bad_sts_cache" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-08-bad_sts_cache/domains/000077500000000000000000000000001474251645300241505ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-08-bad_sts_cache/domains/testserver/000077500000000000000000000000001474251645300263565ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-08-bad_sts_cache/domains/testserver/users000066400000000000000000000011011474251645300274330ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-09-bad_queue_dir/000077500000000000000000000000001474251645300225455ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-09-bad_queue_dir/.expected-error000066400000000000000000000001221474251645300254710ustar00rootroot00000000000000Error initializing queue: mkdir ../data-c-09-bad_queue_dir/queue: not a directory chasquid-1.15.0/test/t-20-bad_configs/c-09-bad_queue_dir/chasquid.conf000066400000000000000000000003501474251645300252130ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../data-c-09-bad_queue_dir" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-09-bad_queue_dir/domains/000077500000000000000000000000001474251645300241775ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-09-bad_queue_dir/domains/testserver/000077500000000000000000000000001474251645300264055ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-09-bad_queue_dir/domains/testserver/users000066400000000000000000000011011474251645300274620ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-10-empty_listening_addr/000077500000000000000000000000001474251645300241515ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-10-empty_listening_addr/.expected-error000066400000000000000000000000571474251645300271040ustar00rootroot00000000000000Invalid empty listening address for submission chasquid-1.15.0/test/t-20-bad_configs/c-10-empty_listening_addr/chasquid.conf000066400000000000000000000002251474251645300266200ustar00rootroot00000000000000mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" submission_address: "" chasquid-1.15.0/test/t-20-bad_configs/c-10-empty_listening_addr/domains/000077500000000000000000000000001474251645300256035ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-10-empty_listening_addr/domains/testserver/000077500000000000000000000000001474251645300300115ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-10-empty_listening_addr/domains/testserver/users000066400000000000000000000000001474251645300310630ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-11-bad_dkim_key/000077500000000000000000000000001474251645300223505ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-11-bad_dkim_key/.expected-error000066400000000000000000000000551474251645300253010ustar00rootroot00000000000000DKIM loading error: error decoding PEM block chasquid-1.15.0/test/t-20-bad_configs/c-11-bad_dkim_key/chasquid.conf000066400000000000000000000003261474251645300250210ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-11-bad_dkim_key/domains/000077500000000000000000000000001474251645300240025ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-11-bad_dkim_key/domains/testserver/000077500000000000000000000000001474251645300262105ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-11-bad_dkim_key/domains/testserver/dkim__selector.pem000066400000000000000000000000101474251645300316650ustar00rootroot00000000000000Bad key chasquid-1.15.0/test/t-20-bad_configs/c-12-bad_users/000077500000000000000000000000001474251645300217165ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-12-bad_users/.expected-error000066400000000000000000000001031474251645300246410ustar00rootroot00000000000000users file error: open domains/testserver/users: permission denied chasquid-1.15.0/test/t-20-bad_configs/c-12-bad_users/chasquid.conf000066400000000000000000000003261474251645300243670ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-12-bad_users/domains/000077500000000000000000000000001474251645300233505ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-12-bad_users/domains/testserver/000077500000000000000000000000001474251645300255565ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-12-bad_users/domains/testserver/users000066400000000000000000000011011474251645300266330ustar00rootroot00000000000000users: { key: "someone" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "J\x01\xed7]\x02\n\xe9;z[\x8dËą\x10\xc1" encrypted: "\xa50厴\xcbb\xc1!r]K\xd1yI\xa2\x99\x8d\xdaQx\x8e69\xac\xf4$\x01\x11\x03\x8d\x10" } } } users: { key: "user" value: { scrypt: { logN: 14 r: 8 p: 1 keyLen: 32 salt: "\n\xc6\x1c\x8f\xb2\x0c\x15p\x8d\xa1\xc3\x05U6\xdb\xc4" encrypted: "\xc3\xe6B2\x84W\x1a\nq{\x07\xe0\x9c\x854\n\xac\xbc\xb7\x9c\x86Kyk\x8dj\x16\x1a\x8c$*N" } } } chasquid-1.15.0/test/t-20-bad_configs/c-13-bad_aliases/000077500000000000000000000000001474251645300221775ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-13-bad_aliases/.expected-error000066400000000000000000000001071474251645300251260ustar00rootroot00000000000000aliases file error: open domains/testserver/aliases: permission denied chasquid-1.15.0/test/t-20-bad_configs/c-13-bad_aliases/chasquid.conf000066400000000000000000000003261474251645300246500ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data" mail_log_path: "../.logs/mail_log" chasquid-1.15.0/test/t-20-bad_configs/c-13-bad_aliases/domains/000077500000000000000000000000001474251645300236315ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-13-bad_aliases/domains/testserver/000077500000000000000000000000001474251645300260375ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/c-13-bad_aliases/domains/testserver/aliases000066400000000000000000000000051474251645300273760ustar00rootroot00000000000000a: b chasquid-1.15.0/test/t-20-bad_configs/data-c-07-bad_domain_info/000077500000000000000000000000001474251645300237525ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/data-c-07-bad_domain_info/domaininfo000066400000000000000000000000541474251645300260170ustar00rootroot00000000000000This is a file, not a directory as expected chasquid-1.15.0/test/t-20-bad_configs/data-c-08-bad_sts_cache/000077500000000000000000000000001474251645300234255ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/data-c-08-bad_sts_cache/sts-cache000066400000000000000000000000541474251645300252210ustar00rootroot00000000000000This is a file, not a directory as expected chasquid-1.15.0/test/t-20-bad_configs/data-c-09-bad_queue_dir/000077500000000000000000000000001474251645300234545ustar00rootroot00000000000000chasquid-1.15.0/test/t-20-bad_configs/data-c-09-bad_queue_dir/queue000066400000000000000000000000541474251645300245220ustar00rootroot00000000000000This is a file, not a directory as expected chasquid-1.15.0/test/t-20-bad_configs/run.sh000077500000000000000000000040271474251645300204550ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases mkdir -p .logs if chasquid --config_dir=doesnotexist > .chasquid-doesnotexist.out 2>&1; then fail "chasquid should not start without a config" fi # Create this empty directory. We can't use a .keep file because that defeats # the purpose of the test. mkdir -p c-04-no_cert_dirs/certs/ # Generate certs for the tests that need them. for i in c-05-no_addrs c-06-bad_maillog c-07-bad_domain_info \ c-08-bad_sts_cache c-09-bad_queue_dir c-10-empty_listening_addr \ c-11-bad_dkim_key c-12-bad_users c-13-bad_aliases; do CONFDIR=$i/ generate_certs_for testserver done # Adjust the name of the dkim key file in c-11-bad_dkim_key. # `go get` rejects repos that have files with ':', so as a workaround we store # a compatible file name in the repo, and copy it before testing. cp c-11-bad_dkim_key/domains/testserver/dkim__selector.pem \ c-11-bad_dkim_key/domains/testserver/dkim:selector.pem # For the bad_users and bad_aliases test, make the relevant file unreadable. chmod -rw c-12-bad_users/domains/testserver/users chmod -rw c-13-bad_aliases/domains/testserver/aliases for i in c-*; do if chasquid --config_dir="$i" > ".chasquid-$i.out" 2>&1; then echo "$i failed; output:" echo cat ".chasquid-$i.out" echo fail "$i: chasquid should not start with this invalid config" fi # Test that they failed as expected, and not by chance/unrelated error. # Look in the last 4 lines, because the fatal error may not be in the # very last one due to asynchronous logging. if ! tail -n 4 ".chasquid-$i.out" \ | grep -q -E "$(cat "$i/.expected-error")"; then echo "$i failed" echo "expected last 4 lines to contain:" echo " '$(cat "$i/.expected-error")'" echo "got last 4 lines:" tail -n 4 ".chasquid-$i.out" | sed -e 's/^/ /g' echo fail "$i: chasquid did not fail as expected" fi done # Give permissions back, to avoid annoying git messages. chmod +rw c-12-bad_users/domains/testserver/users chmod +rw c-13-bad_aliases/domains/testserver/aliases success chasquid-1.15.0/test/t-21-dkim/000077500000000000000000000000001474251645300157765ustar00rootroot00000000000000chasquid-1.15.0/test/t-21-dkim/.gitignore000066400000000000000000000000711474251645300177640ustar00rootroot00000000000000# Ignore the configuration domain directories. ?/domains chasquid-1.15.0/test/t-21-dkim/A/000077500000000000000000000000001474251645300161565ustar00rootroot00000000000000chasquid-1.15.0/test/t-21-dkim/A/chasquid.conf000066400000000000000000000003661474251645300206330ustar00rootroot00000000000000smtp_address: ":1025" submission_address: ":1587" submission_over_tls_address: ":1465" monitoring_address: ":1099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-A" mail_log_path: "../.logs-A/mail_log" chasquid-1.15.0/test/t-21-dkim/A/s1._domainkey.srv-a.pem000066400000000000000000000001671474251645300223550ustar00rootroot00000000000000-----BEGIN PRIVATE KEY----- MC4CAQAwBQYDK2VwBCIEID6bjSoiW6g6NJA67RNl0SZ7zpylVOq9w/VGAXF5whnS -----END PRIVATE KEY----- chasquid-1.15.0/test/t-21-dkim/B/000077500000000000000000000000001474251645300161575ustar00rootroot00000000000000chasquid-1.15.0/test/t-21-dkim/B/chasquid.conf000066400000000000000000000003661474251645300206340ustar00rootroot00000000000000smtp_address: ":2025" submission_address: ":2587" submission_over_tls_address: ":2465" monitoring_address: ":2099" mail_delivery_agent_bin: "test-mda" mail_delivery_agent_args: "%to%" data_dir: "../.data-B" mail_log_path: "../.logs-B/mail_log" chasquid-1.15.0/test/t-21-dkim/from_A_to_B000066400000000000000000000005721474251645300200730ustar00rootroot00000000000000DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=srv-a; s=s1; t=1709494311; h=from:subject:to:from:subject:date:to:cc:message-id; bh=0MIF2K4/fGA4bxV9yOwV0PQSZ3Glv67jLvQ8NwgjcKQ=; b=JkROrF9he5gqMhWcU47h6koleiwkz0IWcRV467KuzsMdTeWPMUVB+JDu+6HElBofdzNsz5 Ptug637opt4UaAAg==; From: user-a@srv-a To: user-b@srv-b Subject: Hola amigo pingÃŧino! Que tal va la vida? chasquid-1.15.0/test/t-21-dkim/from_A_to_B.expected000066400000000000000000000007541474251645300216750ustar00rootroot00000000000000Authentication-Results: srv-b ;spf=none (no DNS record found) ;dkim=pass header.b=JkROrF9he5gq header.d=srv-a DKIM-Signature: v=1; a=ed25519-sha256; c=relaxed/relaxed; d=srv-a; s=s1; t=1709494311; h=from:subject:to:from:subject:date:to:cc:message-id; bh=0MIF2K4/fGA4bxV9yOwV0PQSZ3Glv67jLvQ8NwgjcKQ=; b=JkROrF9he5gqMhWcU47h6koleiwkz0IWcRV467KuzsMdTeWPMUVB+JDu+6HElBofdzNsz5 Ptug637opt4UaAAg==; From: user-a@srv-a To: user-b@srv-b Subject: Hola amigo pingÃŧino! Que tal va la vida? chasquid-1.15.0/test/t-21-dkim/from_B_to_A000066400000000000000000000001331474251645300200640ustar00rootroot00000000000000From: user-b@srv-b To: user-a@srv-a Subject: Feliz primavera! Espero que florezcas feliz! chasquid-1.15.0/test/t-21-dkim/from_B_to_A.expected000066400000000000000000000005641474251645300216740ustar00rootroot00000000000000From user-a@srv-a Authentication-Results: srv-a ;spf=none (no DNS record found) ;dkim=pass header.b=* DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=srv-b; s=sel77; * h=from:subject:to:from:subject:date:to:cc:message-id; bh=* b=* * * * * * * * From: user-b@srv-b To: user-a@srv-a Subject: Feliz primavera! Espero que florezcas feliz! chasquid-1.15.0/test/t-21-dkim/run.sh000077500000000000000000000051631474251645300171460ustar00rootroot00000000000000#!/bin/bash set -e . "$(dirname "$0")/../util/lib.sh" init check_hostaliases rm -rf .data-A .data-B .mail # Build with the DNS override, so we can fake DNS records. export GOTAGS="dnsoverride" # srv-A has a pre-generated key, and the mail has a pre-generated header. # Generate a key for srv-B, and append it to our statically configured zones. # Use a fixed selector so we can be more thorough in from_B_to_A.expected. rm -f B/domains/srv-b/*.pem mkdir -p B/domains/srv-b/ CONFDIR=B chasquid-util dkim-keygen srv-b sel77 > /dev/null cp zones .zones CONFDIR=B chasquid-util dkim-dns srv-b | sed 's/"//g' >> .zones # Launch minidns in the background using our configuration. minidns_bg --addr=":9053" -zones=.zones >> .minidns.log 2>&1 # Two servers: # A - listens on :1025, hosts srv-A # B - listens on :2015, hosts srv-B CONFDIR=A generate_certs_for srv-A CONFDIR=A add_user user-a@srv-a nadaA CONFDIR=B generate_certs_for srv-B CONFDIR=B add_user user-b@srv-b nadaB mkdir -p .logs-A .logs-B chasquid -v=2 --logfile=.logs-A/chasquid.log --config_dir=A \ --testing__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=2025 & chasquid -v=2 --logfile=.logs-B/chasquid.log --config_dir=B \ --testing__dns_addr=127.0.0.1:9053 \ --testing__outgoing_smtp_port=1025 & wait_until_ready 1465 wait_until_ready 2465 wait_until_ready 9053 # Send from A to B. smtpc --addr=localhost:1465 \ --server_cert=A/certs/srv-A/fullchain.pem \ --user=user-a@srv-a --password=nadaA \ user-b@srv-b < from_A_to_B wait_for_file .mail/user-b@srv-b mail_diff from_A_to_B.expected .mail/user-b@srv-b # Send from B to A. smtpc --addr=localhost:2465 \ --server_cert=B/certs/srv-B/fullchain.pem \ --user=user-b@srv-b --password=nadaB \ user-a@srv-a < from_B_to_A wait_for_file .mail/user-a@srv-a mail_diff from_B_to_A.expected .mail/user-a@srv-a # Run chasquid-util dkim-verify to double check these are valid. cat .zones | grep _domainkey.srv-b | sed 's/.*TXT//g' > .srv-b.dns.txt CONFDIR=A chasquid-util dkim-verify -v "--txt=$(cat ./.srv-b.dns.txt)" \ < .mail/user-a@srv-a > .chasquid-util-dkim-verify.out 2>&1 if ! grep -q ";dkim=pass" .chasquid-util-dkim-verify.out; then echo "chasquid-util dkim-verify output:" cat .chasquid-util-dkim-verify.out echo fail "Failed chasquid-util dkim-verify" fi # If driusan/dkim's dkimverify is available, use it to check the generated # signature. if dkimverify --help 2>&1 > /dev/null | grep -q -- "-txt string"; then # Verify B's signature only, because dkimverify only supports RSA. dkimverify -txt .srv-b.dns.txt < .mail/user-a@srv-a else echo "skipped driusan's dkimverify cross-check (binary not available)" fi success chasquid-1.15.0/test/t-21-dkim/zones000066400000000000000000000002561474251645300170620ustar00rootroot00000000000000srv-a A 127.0.0.1 srv-a AAAA ::1 srv-b A 127.0.0.1 srv-b AAAA ::1 s1._domainkey.srv-a TXT v=DKIM1; k=ed25519; p=SvoPT692bVrQBT8UNxt6SF538O3snA4fE3/i/glCxwQ= chasquid-1.15.0/test/util/000077500000000000000000000000001474251645300153465ustar00rootroot00000000000000chasquid-1.15.0/test/util/chamuyero000077500000000000000000000202541474251645300172730ustar00rootroot00000000000000#!/usr/bin/env python3 """ chamuyero is a tool to test and validate line-oriented commands and servers. It can launch and communicate with other processes, and follow a script of line-oriented request-response, validating the dialog as it goes along. This can be used to test line-oriented network protocols (such as SMTP) or interactive command-line tools. """ import argparse import os import re import ssl import socket import subprocess import sys import threading import time # Command-line flags. ap = argparse.ArgumentParser() ap.add_argument("script", type=argparse.FileType('r', encoding='utf8')) args = ap.parse_args() # Make sure stdout is open in utf8 mode, as we will print our input, which is # utf8, and want it to work regardless of the environment. sys.stdout = open(sys.stdout.fileno(), mode='w', encoding='utf8', buffering=1) class Process (object): def __init__(self, cmd, **kwargs): self.cmd = subprocess.Popen(cmd, **kwargs) def write(self, s): self.cmd.stdin.write(s) def readline(self): return self.cmd.stdout.readline() def wait(self): return self.cmd.wait() def close(self): return self.cmd.stdin.close() class Sock (object): """A (generic) socket. This class implements the common code for socket support. Subclasses will implement the behaviour specific to different socket types. """ def __init__(self, addr): self.addr = addr self.sock = NotImplemented self.connr = None self.connw = None self.has_conn = threading.Event() def listen(self): self.sock.bind(self.addr) self.sock.listen(1) threading.Thread(target=self._accept).start() def _accept(self): conn, _ = self.sock.accept() self.connr = conn.makefile(mode="r", encoding="utf8") self.connw = conn.makefile(mode="w", encoding="utf8") self.has_conn.set() def write(self, s): self.has_conn.wait() self.connw.write(s) self.connw.flush() def readline(self): self.has_conn.wait() return self.connr.readline() def close(self): self.connr.close() self.connw.close() self.sock.close() class UnixSock (Sock): def __init__(self, addr): Sock.__init__(self, addr) self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) def listen(self): if os.path.exists(self.addr): os.remove(self.addr) Sock.listen(self) class TCPSock (Sock): def __init__(self, addr): host, port = addr.rsplit(":", 1) Sock.__init__(self, (host, int(port))) self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) def connect(self): self.sock = socket.create_connection(self.addr) self.connr = self.sock.makefile(mode="r", encoding="utf8") self.connw = self.sock.makefile(mode="w", encoding="utf8") self.has_conn.set() class TLSSock (Sock): def __init__(self, addr): host, port = addr.rsplit(":", 1) Sock.__init__(self, (host, int(port))) plain_sock = socket.create_connection(self.addr) context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) context.check_hostname = False context.verify_mode = ssl.CERT_NONE self.sock = context.wrap_socket(plain_sock) def connect(self): self.connr = self.sock.makefile(mode="r", encoding="utf8") self.connw = self.sock.makefile(mode="w", encoding="utf8") self.has_conn.set() class Interpreter (object): """Interpreter for chamuyero scripts.""" def __init__(self): # Processes and sockets we have spawn. Indexed by the id provided by # the user. self.procs = {} # Line number we are processing. self.nline = 0 def syntax_error(self, msg): raise SyntaxError("Error in line %d: %s" % (self.nline, msg)) def runtime_error(self, msg): raise RuntimeError("Error in line %d: %s" % (self.nline, msg)) def run(self, fd): """Main processing loop.""" cont_l = "" for l in fd: self.nline += 1 # Remove rightmost \n. l = l[:-1] # Continuations with \. if cont_l: l = cont_l + " " + l.lstrip() if l.endswith("\\"): cont_l = l[:-1] continue else: cont_l = "" # Comments start with a "#". if l.strip().startswith("#") or l.strip() == "": continue print(l) # Everything else is of the form: # [params] sp = l.split(None, 2) if len(sp) == 3: proc, op, params = sp else: proc, op = sp params = "" # = Launch a process. if op == "=": cmd = Process(params, shell=True, universal_newlines=True, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) self.procs[proc] = cmd # |= Launch a process, do not capture stdout. elif op == "|=": cmd = Process(params, shell=True, stdin=subprocess.PIPE) self.procs[proc] = cmd # unix_listen Listen on an UNIX socket. elif op == "unix_listen": sock = UnixSock(params) sock.listen() self.procs[proc] = sock # tcp_listen Listen on a TCP socket. elif op == "tcp_listen": sock = TCPSock(params) sock.listen() self.procs[proc] = sock elif op == "tcp_connect": sock = TCPSock(params) sock.connect() self.procs[proc] = sock elif op == "tls_connect": sock = TLSSock(params) sock.connect() self.procs[proc] = sock # -> Send to a process stdin, with a \r\n at the end. # .> Send to a process stdin, no \r\n at the end. # ~> Send to a process stdin, string is python-evaluated. elif op == "->": self.procs[proc].write(params + "\r\n") elif op == ".>": self.procs[proc].write(params) elif op == "~>": self.procs[proc].write(eval(params)) # <- Read from the process, expect matching input. # <~ Read from the process, match input using regexp. # <... Read many lines until one matches. elif op == "<-": read = self.procs[proc].readline() if read != params + "\n": self.runtime_error("data different that expected:\n" + " expected: %s\n" % repr(params) + " got: %s" % repr(read)) elif op == "<~": read = self.procs[proc].readline() m = re.match(params, read) if m is None: self.runtime_error("data did not match regexp:\n" + " regexp: %s\n" % repr(params) + " got: %s" % repr(read)) elif op == "<...": while True: read = self.procs[proc].readline() m = re.match(params, read) if m: break # sleep Sleep this number of seconds (process-independent). elif op == "sleep": time.sleep(float(params)) # wait Wait for the process to exit (with the given code). elif op == "wait": retcode = self.procs[proc].wait() if params and retcode != int(params): self.runtime_error("return code did not match:\n" + " expected %s, got %d" % (params, retcode)) # close Close the process. elif op == "close": self.procs[proc].close() else: self.syntax_error("unknown syntax") if __name__ == "__main__": i = Interpreter() i.run(args.script) chasquid-1.15.0/test/util/check-hostaliases000077500000000000000000000025221474251645300206670ustar00rootroot00000000000000#!/usr/bin/env python3 import argparse import tempfile import os import socket import subprocess import sys parser = argparse.ArgumentParser( description="Check that $HOSTALIASES is working" ) parser.add_argument( "--child", action="store_true", help="run in child mode, for internal use only", ) parser.add_argument( "-v", action="store_true", help="verbose mode", ) args = parser.parse_args() def dprint(*a): if args.v: print(*a) if args.child: dprint("child mode, getting hosts") lo = socket.gethostbyname("localhost") ts = socket.gethostbyname("testserver") dprint(ts, lo, ts == lo) if ts != lo: sys.exit(1) else: dprint("## parent mode") # Create the hostaliases file. fd = tempfile.NamedTemporaryFile(mode="w+") fd.write("testserver localhost\n") fd.flush() # Re-execute ourselves with --child, to do the check. env = dict(os.environ) env["HOSTALIASES"] = fd.name cargs = [sys.argv[0], "--child"] if args.v: cargs.append("-v") cmd = subprocess.run( cargs, env=env, text=True, encoding="ascii", stdout=subprocess.PIPE, stderr=subprocess.STDOUT, ) dprint("## child output:") dprint(cmd.stdout) dprint("## child returned", cmd.returncode) sys.exit(cmd.returncode) chasquid-1.15.0/test/util/conngen/000077500000000000000000000000001474251645300167755ustar00rootroot00000000000000chasquid-1.15.0/test/util/conngen/conngen.go000066400000000000000000000035411474251645300207560ustar00rootroot00000000000000//go:build !coverage // +build !coverage // SMTP connection generator, for testing purposes. package main import ( "flag" "net" "net/http" "net/smtp" "time" _ "net/http/pprof" "blitiri.com.ar/go/chasquid/internal/nettrace" "blitiri.com.ar/go/log" ) var ( addr = flag.String("addr", "", "server address") httpAddr = flag.String("http_addr", "localhost:8011", "monitoring HTTP server listening address") wait = flag.Bool("wait", false, "don't exit after --run_for has lapsed") count = flag.Int("count", 1000, "how many connections to open") ) var ( host string ) func main() { var err error flag.Parse() log.Init() host, _, err = net.SplitHostPort(*addr) if err != nil { log.Fatalf("failed to split --addr=%q: %v", *addr, err) } if *wait { go http.ListenAndServe(*httpAddr, nil) log.Infof("monitoring address: http://%v/debug/requests?fam=one&b=11", *httpAddr) } log.Infof("creating %d simultaneous connections", *count) conns := []*C{} for i := 0; i < *count; i++ { c, err := newC() if err != nil { log.Fatalf("failed to connect #%d: %v", i, err) } conns = append(conns, c) if i%200 == 0 { log.Infof(" ... %d connections", i) } } log.Infof("done, created %d simultaneous connections", *count) if *wait { for { time.Sleep(24 * time.Hour) } } for _, c := range conns { c.close() } } // C represents a single connection. type C struct { tr nettrace.Trace n net.Conn s *smtp.Client } func newC() (*C, error) { tr := nettrace.New("conn", *addr) conn, err := net.Dial("tcp", *addr) if err != nil { return nil, err } client, err := smtp.NewClient(conn, host) if err != nil { conn.Close() return nil, err } err = client.Hello(host) if err != nil { return nil, err } return &C{tr: tr, n: conn, s: client}, nil } func (c *C) close() { c.tr.Finish() c.s.Close() c.n.Close() } chasquid-1.15.0/test/util/coverhtml/000077500000000000000000000000001474251645300173515ustar00rootroot00000000000000chasquid-1.15.0/test/util/coverhtml/coverhtml.go000066400000000000000000000160531474251645300217100ustar00rootroot00000000000000//go:build !coverage // +build !coverage // Generate an HTML visualization of a Go coverage profile. // Serves a similar purpose to "go tool cover -html", but has a different // visual style. package main import ( "flag" "fmt" "html/template" "math" "os" "strings" "golang.org/x/tools/cover" ) var ( input = flag.String("input", "", "input file") output = flag.String("output", "", "output file") strip = flag.Int("strip", 0, "how many path entries to strip") title = flag.String("title", "Coverage report", "page title") notes = flag.String("notes", "", "notes to add at the beginning (HTML)") ) func errorf(f string, a ...interface{}) { fmt.Printf(f, a...) os.Exit(1) } func main() { flag.Parse() profiles, err := cover.ParseProfiles(*input) if err != nil { errorf("Error parsing input %q: %v\n", *input, err) } totals := &Totals{ totalF: map[string]int{}, coveredF: map[string]int{}, } files := []string{} code := map[string]template.HTML{} for _, p := range profiles { files = append(files, p.FileName) totals.Add(p) fname := strings.Join(strings.Split(p.FileName, "/")[*strip:], "/") src, err := os.ReadFile(fname) if err != nil { errorf("Failed to read %q: %v", fname, err) } code[p.FileName] = genHTML(src, p.Boundaries(src)) } out, err := os.Create(*output) if err != nil { errorf("Failed to open output file %q: %v", *output, err) } data := struct { Title string Notes template.HTML Files []string Code map[string]template.HTML Totals *Totals }{ Title: *title, Notes: template.HTML(*notes), Files: files, Code: code, Totals: totals, } tmpl := template.Must(template.New("html").Parse(htmlTmpl)) err = tmpl.Execute(out, data) if err != nil { errorf("Failed to execute template: %v", err) } for _, f := range files { fmt.Printf("%5.1f%% %v\n", totals.Percent(f), f) } fmt.Printf("\n") fmt.Printf("Total: %.1f\n", totals.TotalPercent()) } // Totals is used to keep track of total counters. type Totals struct { // Total statements. total int // Covered statements. covered int // Total statements per file. totalF map[string]int // Covered statements per file. coveredF map[string]int } // Add the given profile to the total counters. func (t *Totals) Add(p *cover.Profile) { for _, b := range p.Blocks { t.total += b.NumStmt t.totalF[p.FileName] += b.NumStmt if b.Count > 0 { t.covered += b.NumStmt t.coveredF[p.FileName] += b.NumStmt } } } // Percent covered for the given file. func (t *Totals) Percent(f string) float32 { return float32(t.coveredF[f]) / float32(t.totalF[f]) * 100 } // TotalPercent covered, across all files. func (t *Totals) TotalPercent() float32 { return float32(t.covered) / float32(t.total) * 100 } func genHTML(src []byte, boundaries []cover.Boundary) template.HTML { // Position -> []Boundary // The order matters, we expect to receive start-end pairs in order, so // they are properly added. bs := map[int][]cover.Boundary{} for _, b := range boundaries { bs[b.Offset] = append(bs[b.Offset], b) } w := &strings.Builder{} for i := range src { // Emit boundary markers. for _, b := range bs[i] { if b.Start { n := 0 if b.Count > 0 { n = int(math.Floor(b.Norm*4)) + 1 } fmt.Fprintf(w, ``, n, b.Count) } else { w.WriteString("") } } switch b := src[i]; b { case '>': w.WriteString(">") case '<': w.WriteString("<") case '&': w.WriteString("&") case '\t': w.WriteString(" ") default: w.WriteByte(b) } } return template.HTML(w.String()) } const htmlTmpl = ` {{.Title}}

{{.Title}}

{{.Notes}}

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

{{range .Files}} {{end}}
` chasquid-1.15.0/test/util/docker_entrypoint.sh000077500000000000000000000025171474251645300214540ustar00rootroot00000000000000#!/bin/bash # # Script that is used as a Docker entrypoint. # # It starts minidns with a zone resolving "localhost", and overrides # /etc/resolv.conf to use it. Then launches docker CMD. # # This is used for more hermetic Docker test environments. set -e . "$(dirname "$0")/../util/lib.sh" init # Go to the root of the repository. cd ../.. # Undo the EXIT trap, so minidns continues to run in the background. trap - EXIT set -v # The DNS server resolves only "localhost"; tests will rely on this, as we # $HOSTALIASES to point our test hostnames to localhost, so it needs to # resolve. echo " localhost A 127.0.0.1 localhost AAAA ::1 " > /tmp/zones start-stop-daemon --start --background \ --exec /tmp/minidns \ -- --zones=/tmp/zones echo "nameserver 127.0.0.1" > /etc/resolv.conf echo "nameserver ::1" >> /etc/resolv.conf # Wait until the minidns resolver comes up. wait_until_ready 53 # Disable the Go proxy, since now there is no external network access. # Modules should be already be made available in the environment. export GOPROXY=off # Launch arguments, which come from docker CMD, as "chasquid" user. # Running tests as root makes some integration tests more difficult, as for # example Exim has hard-coded protections against running as root. sudo -u "chasquid" -g "chasquid" \ --set-home \ --preserve-env PATH="$PATH" \ -- "$@" chasquid-1.15.0/test/util/exitcode000077500000000000000000000000241474251645300170740ustar00rootroot00000000000000#!/bin/sh exit $1 chasquid-1.15.0/test/util/fexp/000077500000000000000000000000001474251645300163105ustar00rootroot00000000000000chasquid-1.15.0/test/util/fexp/fexp.go000066400000000000000000000101401474251645300175750ustar00rootroot00000000000000//go:build !coverage // +build !coverage // Fetch an URL, and check if the response matches what we expect. // // Useful for testing HTTP(s) servers. package main import ( "crypto/tls" "crypto/x509" "flag" "fmt" "io" "net/http" "os" "regexp" "sort" "strconv" "strings" ) var exitCode int func main() { if len(os.Args) < 2 { fatalf("Usage: fexp \n") } // The first arg is the URL, and then we shift. url := os.Args[1] os.Args = append([]string{os.Args[0]}, os.Args[2:]...) var ( body = flag.String("body", "", "expect body with these exact contents") bodyRE = flag.String("bodyre", "", "expect body matching these contents (regexp match)") bodyNotRE = flag.String("bodynotre", "", "expect body NOT matching these contents (regexp match)") redir = flag.String("redir", "", "expect a redirect to this URL") status = flag.Int("status", 200, "expect this status code") verbose = flag.Bool("v", false, "enable verbose output") save = flag.String("save", "", "save body to this file") method = flag.String("method", "GET", "request method to use") hdrRE = flag.String("hdrre", "", "expect a header matching these contents (regexp match)") caCert = flag.String("cacert", "", "file to read CA cert from") ) flag.Parse() client := &http.Client{ CheckRedirect: noRedirect, Transport: mkTransport(*caCert), } req, err := http.NewRequest(*method, url, nil) if err != nil { fatalf("error building request: %q", err) } resp, err := client.Do(req) if err != nil { fatalf("error getting %q: %v\n", url, err) } defer resp.Body.Close() rbody, err := io.ReadAll(resp.Body) if err != nil { errorf("error reading body: %v\n", err) } if *save != "" { err = os.WriteFile(*save, rbody, 0664) if err != nil { errorf("error writing body to file %q: %v\n", *save, err) } } if *verbose { fmt.Printf("Request: %s\n", url) fmt.Printf("Response:\n") fmt.Printf(" %v %v\n", resp.Proto, resp.Status) ks := []string{} for k := range resp.Header { ks = append(ks, k) } sort.Strings(ks) for _, k := range ks { fmt.Printf(" %v: %s\n", k, strings.Join(resp.Header[k], ", ")) } fmt.Printf("\n") } if resp.StatusCode != *status { errorf("status is not %d: %q\n", *status, resp.Status) } if *body != "" { // Unescape the body to allow control characters more easily. *body, _ = strconv.Unquote("\"" + *body + "\"") if string(rbody) != *body { errorf("unexpected body: %q\n", rbody) } } if *bodyRE != "" { matched, err := regexp.Match(*bodyRE, rbody) if err != nil { errorf("regexp error: %q\n", err) } if !matched { errorf("body did not match regexp: %q\n", rbody) } } if *bodyNotRE != "" { matched, err := regexp.Match(*bodyNotRE, rbody) if err != nil { errorf("regexp error: %q\n", err) } if matched { errorf("body matched regexp: %q\n", rbody) } } if *redir != "" { if loc := resp.Header.Get("Location"); loc != *redir { errorf("unexpected redir location: %q\n", loc) } } if *hdrRE != "" { match := false outer: for k, vs := range resp.Header { for _, v := range vs { hdr := fmt.Sprintf("%s: %s", k, v) matched, err := regexp.MatchString(*hdrRE, hdr) if err != nil { errorf("regexp error: %q\n", err) } if matched { match = true break outer } } } if !match { errorf("header did not match: %v\n", resp.Header) } } os.Exit(exitCode) } func noRedirect(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } func mkTransport(caCert string) http.RoundTripper { if caCert == "" { return nil } certs, err := os.ReadFile(caCert) if err != nil { fatalf("error reading CA file %q: %v\n", caCert, err) } rootCAs := x509.NewCertPool() if ok := rootCAs.AppendCertsFromPEM(certs); !ok { fatalf("error adding certs to root\n") } return &http.Transport{ TLSClientConfig: &tls.Config{ RootCAs: rootCAs, }, } } func fatalf(s string, a ...interface{}) { fmt.Fprintf(os.Stderr, s, a...) os.Exit(1) } func errorf(s string, a ...interface{}) { fmt.Fprintf(os.Stderr, s, a...) exitCode = 1 } chasquid-1.15.0/test/util/generate_cert/000077500000000000000000000000001474251645300201555ustar00rootroot00000000000000chasquid-1.15.0/test/util/generate_cert/generate_cert.go000066400000000000000000000057771474251645300233330ustar00rootroot00000000000000//go:build !coverage // +build !coverage // Utility to generate self-signed certificates. // It generates a self-signed x509 certificate and key pair, and writes them // to "fullchain.pem" and "privkey.pem". // // Intended for use in tests, not for production use. package main import ( crand "crypto/rand" "crypto/rsa" "crypto/x509" "crypto/x509/pkix" "encoding/pem" "flag" "fmt" "math/big" "net" "os" "strings" "time" "golang.org/x/net/idna" ) var ( host = flag.String("host", "", "Hostnames/IPs to generate the certificate for (comma separated)") validFor = flag.Duration("validfor", 4*time.Hour, "How long will the certificate be valid for") isCA = flag.Bool("ca", false, "Should this cert be its own CA?") ) func fatalf(f string, a ...interface{}) { fmt.Printf(f, a...) os.Exit(1) } func main() { flag.Parse() if *host == "" { fatalf("Required flag: --host") } // Build the certificate template. serial, err := crand.Int(crand.Reader, big.NewInt(1<<62)) if err != nil { fatalf("Error generating serial number: %v\n", err) } tmpl := x509.Certificate{ SerialNumber: serial, Subject: pkix.Name{Organization: []string{"Test Cert Org"}}, // Valid from now until `--validfor` in the future. // Extended certs can be useful for manual troubleshooting. NotBefore: time.Now(), NotAfter: time.Now().Add(*validFor), KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, BasicConstraintsValid: true, } if *isCA { tmpl.IsCA = true } hosts := strings.Split(*host, ",") for _, h := range hosts { if ip := net.ParseIP(h); ip != nil { tmpl.IPAddresses = append(tmpl.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 { fatalf("Host %q cannot be IDNA-encoded: %v\n", h, err) } tmpl.DNSNames = append(tmpl.DNSNames, ih) } } // Generate a private key (RSA 2048). privK, err := rsa.GenerateKey(crand.Reader, 2048) if err != nil { fatalf("Error generating key: %v\n", err) } // Write the certificate. { derBytes, err := x509.CreateCertificate( crand.Reader, &tmpl, &tmpl, &privK.PublicKey, privK) if err != nil { fatalf("Failed to create certificate: %v\n", err) } fullchain, err := os.Create("fullchain.pem") if err != nil { fatalf("Failed to open fullchain.pem: %v\n", err) } err = pem.Encode(fullchain, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) if err != nil { fatalf("Error encoding certificate: %v\n", err) } fullchain.Close() } // Write the private key. { privkey, err := os.Create("privkey.pem") if err != nil { fatalf("failed to open privkey.pem: %v\n", err) } block := &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privK)} err = pem.Encode(privkey, block) if err != nil { fatalf("Error encoding private key: %v\n", err) } privkey.Close() } } chasquid-1.15.0/test/util/lib.sh000066400000000000000000000135661474251645300164630ustar00rootroot00000000000000#!/bin/bash # Library to write the shell scripts in the tests. function init() { if [ "$V" == "1" ]; then set -v fi UTILDIR=$(realpath "$(dirname "${BASH_SOURCE[0]}")" ) export UTILDIR TBASE=$(realpath "$(dirname "$0")" ) cd "${TBASE}" || exit 1 if [ "${RACE}" == "1" ]; then GOFLAGS="$GOFLAGS -race" fi if [ "${GOCOVERDIR}" != "" ]; then GOFLAGS="$GOFLAGS -cover -covermode=count" fi # Remove the directory where test-mda will deliver mail, so previous # runs don't interfere with this one. rm -rf .mail # Set traps to kill our subprocesses when we exit (for any reason). trap ":" TERM # Avoid the EXIT handler from killing bash. trap "exit 2" INT # Ctrl-C, make sure we fail in that case. trap "kill 0" EXIT # Kill children on exit. } function chasquid() { go-build-cached "${TBASE}/../../" # HOSTALIASES: so we "fake" hostnames. # PATH: so chasquid can call test-mda without path issues. # MDA_DIR: so our test-mda knows where to deliver emails. HOSTALIASES=${TBASE}/hosts \ PATH=${UTILDIR}:${PATH} \ MDA_DIR=${TBASE}/.mail \ "${TBASE}/../../chasquid" "$@" } function go-build-cached() { ( # This runs "go build" on the given directory, but only once every # 10s, or if the build flags/tags change. # Because in tests we run some of the Go programs often, this speeds # up the tests. cd "$1" || exit 1 touch -d "10 seconds ago" .reference echo "-tags=$GOTAGS : $GOFLAGS" > .flags-new if ! cmp -s .flags-new .flags >/dev/null 2>&1 || [ "$(basename "$PWD")" -ot ".reference" ] ; then # shellcheck disable=SC2086 go build -tags="$GOTAGS" $GOFLAGS # Write to .flags instead of renaming, to prevent races where # was .flags-new is already renamed by the time we get here. # Do this _after_ the build so worst case we build twice, # instead of having the chance to run an old binary. echo "-tags=$GOTAGS : $GOFLAGS" > .flags fi ) } function chasquid-util() { # Run chasquid-util from inside the config dir, since in our tests # data_dir is relative to the config. go-build-cached "${TBASE}/../../cmd/chasquid-util/" CONFDIR="${CONFDIR:-config}" ( cd "$CONFDIR" && \ "${TBASE}/../../cmd/chasquid-util/chasquid-util" \ -C=. \ "$@" \ ) } # Add a user with chasquid-util. Because this is somewhat cryptographically # intensive, it can slow down the tests significantly, so most of the time we # use the simpler add_user (below) for testing purposes. function chasquid-util-user-add() { CONFDIR="${CONFDIR:-config}" DOMAIN=$(echo "$1" | cut -d @ -f 2) mkdir -p "${CONFDIR}/domains/$DOMAIN/" chasquid-util \ user-add "$1" \ --password="$2" \ >> .add_user_logs } function add_user() { CONFDIR="${CONFDIR:-config}" USERNAME=$(echo "$1" | cut -d @ -f 1) DOMAIN=$(echo "$1" | cut -d @ -f 2) USERDB="${CONFDIR}/domains/$DOMAIN/users" mkdir -p "${CONFDIR}/domains/$DOMAIN/" if ! [ -f "${USERDB}" ] || ! grep -E -q "key:.*${USERNAME}" "${USERDB}"; then echo "users:{ key: '${USERNAME}' value:{ plain:{ password: '$2' }}}" \ >> "${USERDB}" fi } function dovecot-auth-cli() { go-build-cached "${TBASE}/../../cmd/dovecot-auth-cli/" "${TBASE}/../../cmd/dovecot-auth-cli/dovecot-auth-cli" "$@" } function run_msmtp() { # msmtp will check that the rc file is only user readable. chmod 600 msmtprc # msmtp binary is often g+s, which causes $HOSTALIASES to not be # honoured, which breaks the tests. Copy the binary to remove the # setgid bit as a workaround. cp -u "$(command -v msmtp)" "${UTILDIR}/.msmtp-bin" HOSTALIASES=${TBASE}/hosts \ "${UTILDIR}/.msmtp-bin" -C msmtprc "$@" } function mail_diff() { "${UTILDIR}/mail_diff" "$@" } function chamuyero() { "${UTILDIR}/chamuyero" "$@" } function generate_cert() { go-build-cached "${UTILDIR}/generate_cert/" "${UTILDIR}/generate_cert/generate_cert" "$@" } function loadgen() { go-build-cached "${UTILDIR}/loadgen/" "${UTILDIR}/loadgen/loadgen" "$@" } function conngen() { go-build-cached "${UTILDIR}/conngen/" "${UTILDIR}/conngen/conngen" "$@" } function minidns_bg() { go-build-cached "${UTILDIR}/minidns/" "${UTILDIR}/minidns/minidns" "$@" & export MINIDNS=$! } function fexp() { go-build-cached "${UTILDIR}/fexp/" "${UTILDIR}/fexp/fexp" "$@" } function smtpc() { go-build-cached "${UTILDIR}/smtpc/" "${UTILDIR}/smtpc/smtpc" "$@" } function timeout() { MYPID=$$ ( sleep "$1" echo "timed out after $1, killing test" kill -9 $MYPID ) & } function success() { echo success } function skip() { echo "skipped: $*" exit 0 } function fail() { echo "FAILED: $*" exit 1 } function check_hostaliases() { if ! "${UTILDIR}/check-hostaliases"; then skip "\$HOSTALIASES not working (probably systemd-resolved)" fi } # 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.01 done } # Wait for the given file to exist. function wait_for_file() { while ! [ -e "$1" ]; do sleep 0.01 done } function wait_until() { while true; do if eval "$*"; then return 0 fi sleep 0.01 done } # Generate certs for the given hostname. function generate_certs_for() { CONFDIR="${CONFDIR:-config}" # Generating certs is takes time and slows the tests down, so we keep # a little cache that is common to all tests. CACHEDIR="${TBASE}/../.generate_certs_cache" mkdir -p "${CACHEDIR}/$1/" touch -d "10 minutes ago" "${CACHEDIR}/.reference" if [ "${CACHEDIR}/$1/privkey.pem" -ot "${CACHEDIR}/.reference" ]; then # Cache miss (either was not there, or was too old). ( cd "${CACHEDIR}/$1/" || exit 1 generate_cert -ca -validfor=1h -host="$1" ) fi mkdir -p "${CONFDIR}/certs/$1/" cp -p "${CACHEDIR}/$1"/* "${CONFDIR}/certs/$1/" } function chasquid_ram_peak() { # Find the pid of the daemon, which we expect is running on the # background somewhere within our current session. SERVER_PID=$(pgrep -s 0 -x chasquid) grep VmHWM "/proc/$SERVER_PID/status" | cut -d ':' -f 2- } chasquid-1.15.0/test/util/loadgen/000077500000000000000000000000001474251645300167575ustar00rootroot00000000000000chasquid-1.15.0/test/util/loadgen/loadgen.go000066400000000000000000000074171474251645300207300ustar00rootroot00000000000000//go:build !coverage // +build !coverage // SMTP load generator, for testing purposes. package main import ( "flag" "net" "net/http" "net/textproto" "runtime" "sync" "time" _ "net/http/pprof" "blitiri.com.ar/go/chasquid/internal/nettrace" "blitiri.com.ar/go/chasquid/internal/smtp" "blitiri.com.ar/go/log" ) var ( addr = flag.String("addr", "", "server address") httpAddr = flag.String("http_addr", "localhost:8011", "monitoring HTTP server listening address") parallel = flag.Int("parallel", 0, "how many sending loops to run in parallel") runFor = flag.Duration("run_for", 0, "how long to run for (0 = forever)") wait = flag.Bool("wait", false, "don't exit after --run_for has lapsed") noop = flag.Bool("noop", false, "don't send an email, just connect and run a NOOP") ) var ( host string exit bool globalCount int64 = 0 globalRuntime time.Duration globalMu = &sync.Mutex{} ) func main() { var err error flag.Parse() log.Init() host, _, err = net.SplitHostPort(*addr) if err != nil { log.Fatalf("failed to split --addr=%q: %v", *addr, err) } if *wait { go http.ListenAndServe(*httpAddr, nil) log.Infof("monitoring address: http://%v/debug/requests?fam=one&b=11", *httpAddr) } if *parallel == 0 { *parallel = runtime.GOMAXPROCS(0) } lt := "full" if *noop { lt = "noop" } log.Infof("launching %d %s sending loops in parallel", *parallel, lt) for i := 0; i < *parallel; i++ { go serial(i) } var totalCount int64 var totalRuntime time.Duration start := time.Now() for range time.Tick(1 * time.Second) { globalMu.Lock() totalCount += globalCount totalRuntime += globalRuntime count := globalCount runtime := globalRuntime globalCount = 0 globalRuntime = 0 globalMu.Unlock() if count == 0 { log.Infof("0 ops") } else { log.Infof("%d ops, %v /op", count, time.Duration(runtime.Nanoseconds()/count).Truncate(time.Microsecond)) } if *runFor > 0 && time.Since(start) > *runFor { exit = true break } } end := time.Now() window := end.Sub(start) log.Infof("total: %d ops, %v wall, %v run", totalCount, window.Truncate(time.Millisecond), totalRuntime.Truncate(time.Millisecond)) avgLat := time.Duration(totalRuntime.Nanoseconds() / totalCount) log.Infof("avg: %v /op, %.0f ops/s", avgLat.Truncate(time.Microsecond), float64(totalCount)/window.Seconds(), ) if *wait { for { time.Sleep(24 * time.Hour) } } } func serial(id int) { var count int64 start := time.Now() for { count++ err := one() if err != nil { log.Fatalf("%v", err) } if count == 5 { globalMu.Lock() globalCount += count globalRuntime += time.Since(start) globalMu.Unlock() count = 0 start = time.Now() if exit { return } } } } func one() error { tr := nettrace.New("one", *addr) defer tr.Finish() conn, err := net.Dial("tcp", *addr) if err != nil { return err } defer conn.Close() client, err := smtp.NewClient(conn, host) if err != nil { return err } defer client.Close() if *noop { err = client.Noop() if err != nil { return err } } else { err = client.MailAndRcpt("test@test", "null@testserver") if err != nil { return err } retry: w, err := client.Data() if err != nil { return err } _, err = w.Write(body) if err != nil { return err } err = w.Close() if err != nil { // If we are sending too fast we might hit chasquid's queue size // limit. In that case, wait and try again. // We detect it with error code 451 which is used for this // situation. if terr, ok := err.(*textproto.Error); ok { if terr.Code == 451 { time.Sleep(10 * time.Millisecond) goto retry } } return err } } return nil } var body = []byte(`Subject: Load test This is the body of the load test email. `) chasquid-1.15.0/test/util/mail_diff000077500000000000000000000046021474251645300172100ustar00rootroot00000000000000#!/usr/bin/env python3 import difflib import email.parser import email.policy import itertools import mailbox import sys def flexible_eq(expected, got): """Compare two strings, supporting wildcards. This functions compares two strings, but supports wildcards on the expected string. The following characters have special meaning: - ? matches any character. - * matches anything until the end of the line. Returns True if equal (considering wildcards), False otherwise. """ posG = 0 for c in expected: if posG >= len(got): return False if c == '?': posG += 1 continue if c == '*': while posG < len(got) and got[posG] != '\t': posG += 1 continue continue if c != got[posG]: return False posG += 1 if posG != len(got): # We got more than we expected. return False return True def msg_equals(expected, msg): """Compare two messages recursively, using flexible_eq().""" diff = False for h, val in expected.items(): if h not in msg: print("Header missing: %r" % h) diff = True continue if expected[h] == '*': continue if not flexible_eq(val, msg[h]): print("Header %r differs:" % h) print("Exp: %r" % val) print("Got: %r" % msg[h]) diff = True if diff: return False if expected.is_multipart() != msg.is_multipart(): print("Multipart differs, expected %s, got %s" % ( expected.is_multipart(), msg.is_multipart())) return False if expected.is_multipart(): for exp, got in itertools.zip_longest(expected.get_payload(), msg.get_payload()): if not msg_equals(exp, got): return False else: if not flexible_eq(expected.get_payload(), msg.get_payload()): exp = expected.get_payload().splitlines() got = msg.get_payload().splitlines() print("Payload differs:") for l in difflib.ndiff(exp, got): print(l) return False return True if __name__ == "__main__": f1, f2 = sys.argv[1:3] # We use a custom strict policy to do more strict content validation. policy = email.policy.EmailPolicy( utf8=True, linesep="\r\n", refold_source='none', raise_on_defect=True) expected = email.parser.Parser(policy=policy).parse(open(f1)) msg = email.parser.Parser(policy=policy).parse(open(f2)) sys.exit(0 if msg_equals(expected, msg) else 1) chasquid-1.15.0/test/util/minidns/000077500000000000000000000000001474251645300170075ustar00rootroot00000000000000chasquid-1.15.0/test/util/minidns/minidns.go000066400000000000000000000154261474251645300210070ustar00rootroot00000000000000//go:build !coverage // +build !coverage // minidns is a trivial DNS server used for testing. // // It takes an "answers" file which contains lines with the following format: // // // // For example: // // blah A 1.2.3.4 // blah MX mx1 // // Supported types: A, AAAA, MX, TXT. // // It's only meant to be used for testing, so it's not robust, performant, or // standards compliant. package main import ( "bufio" "encoding/binary" "flag" "fmt" "net" "os" "regexp" "strings" "sync" "blitiri.com.ar/go/log" "golang.org/x/net/dns/dnsmessage" ) var ( addr = flag.String("addr", ":53", "address to listen to (UDP)") zonesPath = flag.String("zones", "", "file with the zones") ) func main() { flag.Parse() srv := &miniDNS{ answers: map[string][]dnsmessage.Resource{}, } if *zonesPath == "" { log.Fatalf("-zones must be given") } var zonesFile *os.File if *zonesPath == "-" { zonesFile = os.Stdin } else { var err error zonesFile, err = os.Open(*zonesPath) if err != nil { log.Fatalf("error opening %v: %v", *zonesPath, err) } } srv.loadZones(zonesFile) var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() srv.listenAndServeUDP(*addr) }() go func() { defer wg.Done() srv.listenAndServeTCP(*addr) }() wg.Wait() } type miniDNS struct { // Domain -> Answers. // We always respond the same regardless of the query. // Not great, but does the trick. answers map[string][]dnsmessage.Resource } func (m *miniDNS) listenAndServeUDP(addr string) { conn, err := net.ListenPacket("udp", addr) if err != nil { log.Fatalf("error listening UDP %q: %v", addr, err) } log.Infof("listening on %v", conn.LocalAddr()) buf := make([]byte, 64*1024) for { n, addr, err := conn.ReadFrom(buf) if err != nil { log.Infof("error reading from udp: %v", err) continue } msg := &dnsmessage.Message{} err = msg.Unpack(buf[:n]) if err != nil { log.Infof("%v error unpacking message: %v", addr, err) } if lq := len(msg.Questions); lq != 1 { log.Infof("%v/%-5d dropping packet with %d questions", addr, msg.ID, lq) continue } q := msg.Questions[0] log.Infof("%v/%-5d Q: %s %s %s", addr, msg.ID, q.Name, q.Type, q.Class) reply := m.handle(msg) rbuf, err := reply.Pack() if err != nil { log.Fatalf("error packing reply: %v", err) } _, err = conn.WriteTo(rbuf, addr) if err != nil { log.Infof("%v/%-5d error writing: %v", addr, msg.ID, err) } } } func (m *miniDNS) listenAndServeTCP(addr string) { ls, err := net.Listen("tcp", addr) if err != nil { log.Fatalf("error listening TCP %q: %v", addr, err) } log.Infof("listening on %v", addr) for { conn, err := ls.Accept() if err != nil { log.Infof("error accepting: %v", err) continue } msg, err := readTCPMessage(conn) if err != nil { log.Infof("%v error reading message: %v", addr, err) conn.Close() continue } if lq := len(msg.Questions); lq != 1 { log.Infof("%v/%-5d dropping packet with %d questions", addr, msg.ID, lq) conn.Close() continue } q := msg.Questions[0] log.Infof("%v/%-5d Q: %s %s %s", addr, msg.ID, q.Name, q.Type, q.Class) reply := m.handle(msg) err = writeTCPMessage(conn, reply) if err != nil { log.Infof("error writing reply: %v", err) } conn.Close() } } func readTCPMessage(conn net.Conn) (*dnsmessage.Message, error) { // Read the 2-byte length first, then the message. lenHdr := struct{ Len uint16 }{} err := binary.Read(conn, binary.BigEndian, &lenHdr) if err != nil { return nil, err } data := make([]byte, lenHdr.Len) err = binary.Read(conn, binary.BigEndian, &data) if err != nil { return nil, err } msg := &dnsmessage.Message{} err = msg.Unpack(data) if err != nil { return nil, fmt.Errorf("%v error unpacking message: %v", addr, err) } return msg, nil } func writeTCPMessage(conn net.Conn, msg *dnsmessage.Message) error { rbuf, err := msg.Pack() if err != nil { return fmt.Errorf("error packing reply: %v", err) } lenHdr := struct{ Len uint16 }{Len: uint16(len(rbuf))} err = binary.Write(conn, binary.BigEndian, lenHdr) if err != nil { return err } _, err = conn.Write(rbuf) return err } func (m *miniDNS) handle(msg *dnsmessage.Message) *dnsmessage.Message { reply := &dnsmessage.Message{ Header: dnsmessage.Header{ ID: msg.ID, Response: true, RCode: dnsmessage.RCodeSuccess, // We're authoritative for the zones we're serving. // We should either set this, or RecursionAvailable, otherwise // some client libraries will complain. Authoritative: true, }, Questions: msg.Questions, } q := msg.Questions[0] if answers, ok := m.answers[q.Name.String()]; ok { for _, ans := range answers { if q.Type == ans.Header.Type { log.Infof("-> %s %v", q.Type, ans.Body) reply.Answers = append(reply.Answers, ans) } } } else { log.Infof("-> NXERROR") reply.Header.RCode = dnsmessage.RCodeNameError } return reply } func (m *miniDNS) loadZones(f *os.File) { scanner := bufio.NewScanner(f) lineno := 0 for scanner.Scan() { lineno++ line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "#") || line == "" { continue } vs := regexp.MustCompile(`\s+`).Split(line, 3) if len(vs) != 3 { log.Fatalf("line %d: invalid format", lineno) } domain, t, value := vs[0], vs[1], vs[2] if !strings.HasSuffix(domain, ".") { domain += "." } var body dnsmessage.ResourceBody var qType dnsmessage.Type switch strings.ToLower(t) { case "a": qType = dnsmessage.TypeA ip := net.ParseIP(value).To4() if ip == nil { log.Fatalf("line %d: invalid IP %q", lineno, value) } a := &dnsmessage.AResource{} copy(a.A[:], ip[:4]) body = a case "aaaa": qType = dnsmessage.TypeAAAA ip := net.ParseIP(value).To16() if ip == nil { log.Fatalf("line %d: invalid IP %q", lineno, value) } aaaa := &dnsmessage.AAAAResource{} copy(aaaa.AAAA[:], ip[:16]) body = aaaa case "mx": qType = dnsmessage.TypeMX if !strings.HasPrefix(value, ".") { value += "." } body = &dnsmessage.MXResource{ Pref: 10, MX: dnsmessage.MustNewName(value), } case "txt": qType = dnsmessage.TypeTXT // Cut value in chunks of 255 bytes. chunks := []string{} v := value for len(v) > 254 { chunks = append(chunks, v[:254]) v = v[254:] } chunks = append(chunks, v) body = &dnsmessage.TXTResource{ TXT: chunks, } default: log.Fatalf("line %d: unknown type %q", lineno, t) } answer := dnsmessage.Resource{ Header: dnsmessage.ResourceHeader{ Name: dnsmessage.MustNewName(domain), Type: qType, Class: dnsmessage.ClassINET, }, Body: body, } m.answers[domain] = append(m.answers[domain], answer) } if err := scanner.Err(); err != nil { log.Fatalf("error reading zones: %v", err) } } chasquid-1.15.0/test/util/smtpc/000077500000000000000000000000001474251645300164745ustar00rootroot00000000000000chasquid-1.15.0/test/util/smtpc/smtpc.go000066400000000000000000000047461474251645300201640ustar00rootroot00000000000000package main import ( "bytes" "crypto/tls" "crypto/x509" "encoding/pem" "errors" "flag" "io" "net" "net/smtp" "os" "strings" ) var ( addr = flag.String("addr", "", "Address of the SMTP server") user = flag.String("user", "", "Username to use in SMTP AUTH") password = flag.String("password", "", "Password to use in SMTP AUTH") from = flag.String("from", "", "From address to use in the message") serverCert = flag.String("server_cert", "", "Path to the server certificate to expect") confPath = flag.String("c", "smtpc.conf", "Path to the configuration file") ) func main() { flag.Parse() loadConfig() // Read message from stdin. rawMsg, err := io.ReadAll(os.Stdin) notnil(err) // RCPT TO from the command line. tos := make([]string, len(flag.Args())) for i, to := range flag.Args() { tos[i] = to } // Connect to the server. var conn net.Conn if *serverCert != "" { cert := loadCert(*serverCert) rootCAs := x509.NewCertPool() rootCAs.AddCert(cert) tlsConfig := &tls.Config{ ServerName: cert.DNSNames[0], RootCAs: rootCAs, } conn, err = tls.Dial("tcp", *addr, tlsConfig) defer conn.Close() } else { conn, err = net.Dial("tcp", *addr) } notnil(err) // Send the message. client, err := smtp.NewClient(conn, *addr) notnil(err) if *user != "" { auth := smtp.PlainAuth("", *user, *password, *addr) err = client.Auth(auth) notnil(err) } if *from == "" { *from = *user } err = client.Mail(*from) notnil(err) for _, to := range tos { err = client.Rcpt(to) notnil(err) } w, err := client.Data() notnil(err) _, err = io.Copy(w, bytes.NewReader(rawMsg)) notnil(err) err = w.Close() notnil(err) err = client.Quit() notnil(err) } func loadConfig() { data, err := os.ReadFile(*confPath) if errors.Is(err, os.ErrNotExist) { return } notnil(err) for _, line := range strings.Split(string(data), "\n") { k, v, ok := strings.Cut(line, " ") if !ok { continue } k = strings.TrimSpace(k) // Set the flag but only if it wasn't already set. // Command-line flags take precedence. isSet := false flag.Visit(func(f *flag.Flag) { if f.Name == k { isSet = true } }) if !isSet { flag.Lookup(k).Value.Set(strings.TrimSpace(v)) } } } func loadCert(path string) *x509.Certificate { data, err := os.ReadFile(path) notnil(err) block, _ := pem.Decode(data) cert, err := x509.ParseCertificate(block.Bytes) notnil(err) return cert } func notnil(err error) { if err != nil { panic(err) } } chasquid-1.15.0/test/util/test-mda000077500000000000000000000003421474251645300170110ustar00rootroot00000000000000#!/bin/bash set -e mkdir -p ${MDA_DIR} # TODO: use flock to lock the file, to prevent atomic writes. cat >> ${MDA_DIR}/.tmp-${1} X=$? if [ -e ${MDA_DIR}/.tmp-${1} ]; then mv ${MDA_DIR}/.tmp-${1} ${MDA_DIR}/${1} fi exit $X chasquid-1.15.0/test/util/writemailto000077500000000000000000000000751474251645300176360ustar00rootroot00000000000000#!/bin/bash echo "From writemailto" > "$1" exec cat >> "$1"