Repository: emersion/maddy
Branch: master
Commit: 837b1b8a7761
Files: 448
Total size: 1.8 MB
Directory structure:
gitextract_bng2bnky/
├── .dockerignore
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── CODE_OF_CONDUCT.md
│ ├── CONTRIBUTING.md
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug-report.md
│ │ ├── config.yml
│ │ └── feature-request.md
│ ├── SECURITY.md
│ ├── releases.md
│ └── workflows/
│ ├── release.yml
│ └── test.yml
├── .gitignore
├── .golangci.yml
├── .mkdocs.yml
├── .version
├── AGENTS.md
├── COPYING
├── Dockerfile
├── HACKING.md
├── README.md
├── build.sh
├── cmd/
│ ├── README.md
│ ├── maddy/
│ │ └── main.go
│ ├── maddy-pam-helper/
│ │ ├── README.md
│ │ ├── maddy.conf
│ │ ├── main.c
│ │ ├── main.go
│ │ ├── pam.c
│ │ └── pam.h
│ └── maddy-shadow-helper/
│ ├── README.md
│ └── main.go
├── config.go
├── contrib/
│ ├── README.md
│ └── kubernetes/
│ └── chart/
│ ├── .helmignore
│ ├── Chart.yaml
│ ├── README.md
│ ├── files/
│ │ ├── aliases
│ │ └── maddy.conf
│ ├── templates/
│ │ ├── NOTES.txt
│ │ ├── _helpers.tpl
│ │ ├── configmap.yaml
│ │ ├── deployment.yaml
│ │ ├── pvc.yaml
│ │ ├── service.yaml
│ │ ├── serviceaccount.yaml
│ │ └── tests/
│ │ └── test-connection.yaml
│ └── values.yaml
├── directories.go
├── directories_docker.go
├── dist/
│ ├── README.md
│ ├── apparmor/
│ │ └── dev.foxcpp.maddy
│ ├── fail2ban/
│ │ ├── filter.d/
│ │ │ ├── maddy-auth.conf
│ │ │ └── maddy-dictonary-attack.conf
│ │ └── jail.d/
│ │ ├── maddy-auth.conf
│ │ └── maddy-dictonary-attack.conf
│ ├── install.sh
│ ├── logrotate.d/
│ │ └── maddy
│ ├── systemd/
│ │ ├── maddy.service
│ │ └── maddy@.service
│ └── vim/
│ ├── ftdetect/
│ │ └── maddy-conf.vim
│ ├── ftplugin/
│ │ └── maddy-conf.vim
│ └── syntax/
│ └── maddy-conf.vim
├── docs/
│ ├── docker.md
│ ├── faq.md
│ ├── index.md
│ ├── internals/
│ │ ├── quirks.md
│ │ ├── specifications.md
│ │ ├── sqlite.md
│ │ └── unicode.md
│ ├── man/
│ │ ├── .gitignore
│ │ ├── README.md
│ │ ├── maddy.1.scd
│ │ └── prepare_md.py
│ ├── multiple-domains.md
│ ├── reference/
│ │ ├── auth/
│ │ │ ├── dovecot_sasl.md
│ │ │ ├── external.md
│ │ │ ├── ldap.md
│ │ │ ├── netauth.md
│ │ │ ├── pam.md
│ │ │ ├── pass_table.md
│ │ │ ├── plain_separate.md
│ │ │ └── shadow.md
│ │ ├── blob/
│ │ │ ├── fs.md
│ │ │ └── s3.md
│ │ ├── checks/
│ │ │ ├── actions.md
│ │ │ ├── authorize_sender.md
│ │ │ ├── command.md
│ │ │ ├── dkim.md
│ │ │ ├── dnsbl.md
│ │ │ ├── milter.md
│ │ │ ├── misc.md
│ │ │ ├── rspamd.md
│ │ │ └── spf.md
│ │ ├── config-syntax.md
│ │ ├── endpoints/
│ │ │ ├── imap.md
│ │ │ ├── openmetrics.md
│ │ │ └── smtp.md
│ │ ├── global-config.md
│ │ ├── modifiers/
│ │ │ ├── dkim.md
│ │ │ └── envelope.md
│ │ ├── modules.md
│ │ ├── smtp-pipeline.md
│ │ ├── storage/
│ │ │ ├── imap-filters.md
│ │ │ └── imapsql.md
│ │ ├── table/
│ │ │ ├── auth.md
│ │ │ ├── chain.md
│ │ │ ├── email_localpart.md
│ │ │ ├── email_with_domain.md
│ │ │ ├── file.md
│ │ │ ├── regexp.md
│ │ │ ├── sql_query.md
│ │ │ └── static.md
│ │ ├── targets/
│ │ │ ├── queue.md
│ │ │ ├── remote.md
│ │ │ └── smtp.md
│ │ ├── tls-acme.md
│ │ └── tls.md
│ ├── seclevels.md
│ ├── third-party/
│ │ ├── dovecot.md
│ │ ├── mailman3.md
│ │ ├── rspamd.md
│ │ └── smtp-servers.md
│ ├── tutorials/
│ │ ├── alias-to-remote.md
│ │ ├── building-from-source.md
│ │ ├── pam.md
│ │ └── setting-up.md
│ └── upgrading.md
├── framework/
│ ├── address/
│ │ ├── doc.go
│ │ ├── norm.go
│ │ ├── norm_test.go
│ │ ├── rfc6531.go
│ │ ├── rfc6531_test.go
│ │ ├── split.go
│ │ ├── split_test.go
│ │ ├── validation.go
│ │ └── validation_test.go
│ ├── buffer/
│ │ ├── buffer.go
│ │ ├── bytesreader.go
│ │ ├── file.go
│ │ └── memory.go
│ ├── cfgparser/
│ │ ├── env.go
│ │ ├── imports.go
│ │ ├── parse.go
│ │ └── parse_test.go
│ ├── config/
│ │ ├── config.go
│ │ ├── directories.go
│ │ ├── endpoint.go
│ │ ├── endpoint_test.go
│ │ ├── lexer/
│ │ │ ├── LICENSE.APACHE
│ │ │ ├── README.md
│ │ │ ├── dispenser.go
│ │ │ ├── dispenser_test.go
│ │ │ ├── lexer.go
│ │ │ ├── lexer_test.go
│ │ │ └── parse.go
│ │ ├── map.go
│ │ ├── map_test.go
│ │ ├── module/
│ │ │ ├── check_action.go
│ │ │ ├── interfaces.go
│ │ │ └── modconfig.go
│ │ └── tls/
│ │ ├── client.go
│ │ ├── general.go
│ │ └── server.go
│ ├── container/
│ │ ├── container.go
│ │ ├── lifetime.go
│ │ └── registry.go
│ ├── dns/
│ │ ├── debugflags.go
│ │ ├── dnssec.go
│ │ ├── dnssec_test.go
│ │ ├── idna.go
│ │ ├── norm.go
│ │ ├── override.go
│ │ └── resolver.go
│ ├── exterrors/
│ │ ├── dns.go
│ │ ├── exterrors.go
│ │ ├── fields.go
│ │ ├── smtp.go
│ │ └── temporary.go
│ ├── future/
│ │ ├── future.go
│ │ └── future_test.go
│ ├── hooks/
│ │ └── hooks.go
│ ├── log/
│ │ ├── log.go
│ │ ├── orderedjson.go
│ │ ├── output.go
│ │ ├── syslog.go
│ │ ├── syslog_stub.go
│ │ ├── writer.go
│ │ └── zap.go
│ ├── logparser/
│ │ ├── parse.go
│ │ └── parse_test.go
│ ├── module/
│ │ ├── auth.go
│ │ ├── blob_store.go
│ │ ├── check.go
│ │ ├── delivery_target.go
│ │ ├── imap_filter.go
│ │ ├── modifier.go
│ │ ├── module.go
│ │ ├── module_specific_data.go
│ │ ├── modules/
│ │ │ ├── dummy.go
│ │ │ └── modules.go
│ │ ├── msgmetadata.go
│ │ ├── mxauth.go
│ │ ├── partial_delivery.go
│ │ ├── storage.go
│ │ ├── table.go
│ │ └── tls_loader.go
│ └── resource/
│ ├── netresource/
│ │ ├── dup.go
│ │ ├── fd.go
│ │ ├── listen.go
│ │ └── tracker.go
│ ├── resource.go
│ ├── singleton.go
│ └── tracker.go
├── go.mod
├── go.sum
├── internal/
│ ├── README.md
│ ├── auth/
│ │ ├── auth.go
│ │ ├── auth_test.go
│ │ ├── dovecot_sasl/
│ │ │ └── dovecot_sasl.go
│ │ ├── external/
│ │ │ ├── externalauth.go
│ │ │ └── helperauth.go
│ │ ├── ldap/
│ │ │ └── ldap.go
│ │ ├── netauth/
│ │ │ └── netauth.go
│ │ ├── pam/
│ │ │ ├── module.go
│ │ │ ├── pam.c
│ │ │ ├── pam.go
│ │ │ ├── pam.h
│ │ │ └── pam_stub.go
│ │ ├── pass_table/
│ │ │ ├── hash.go
│ │ │ ├── table.go
│ │ │ └── table_test.go
│ │ ├── plain_separate/
│ │ │ ├── plain_separate.go
│ │ │ └── plain_separate_test.go
│ │ ├── sasl.go
│ │ ├── sasl_test.go
│ │ ├── sasllogin/
│ │ │ └── sasllogin.go
│ │ └── shadow/
│ │ ├── module.go
│ │ ├── read.go
│ │ ├── shadow.go
│ │ └── verify.go
│ ├── authz/
│ │ ├── lookup.go
│ │ └── normalization.go
│ ├── check/
│ │ ├── authorize_sender/
│ │ │ └── authorize_sender.go
│ │ ├── command/
│ │ │ └── command.go
│ │ ├── dkim/
│ │ │ ├── dkim.go
│ │ │ └── dkim_test.go
│ │ ├── dns/
│ │ │ ├── dns.go
│ │ │ └── dns_test.go
│ │ ├── dnsbl/
│ │ │ ├── common.go
│ │ │ ├── common_test.go
│ │ │ ├── dnsbl.go
│ │ │ └── dnsbl_test.go
│ │ ├── milter/
│ │ │ ├── milter.go
│ │ │ └── milter_test.go
│ │ ├── requiretls/
│ │ │ └── requiretls.go
│ │ ├── rspamd/
│ │ │ └── rspamd.go
│ │ ├── skeleton.go
│ │ ├── spf/
│ │ │ └── spf.go
│ │ └── stateless_check.go
│ ├── cli/
│ │ ├── app.go
│ │ ├── clitools/
│ │ │ ├── clitools.go
│ │ │ ├── termios.go
│ │ │ └── termios_stub.go
│ │ ├── ctl/
│ │ │ ├── appendlimit.go
│ │ │ ├── hash.go
│ │ │ ├── imap.go
│ │ │ ├── imapacct.go
│ │ │ ├── moduleinit.go
│ │ │ └── users.go
│ │ └── extflag.go
│ ├── dmarc/
│ │ ├── dmarc.go
│ │ ├── evaluate.go
│ │ ├── evaluate_test.go
│ │ ├── verifier.go
│ │ └── verifier_test.go
│ ├── dsn/
│ │ └── dsn.go
│ ├── endpoint/
│ │ ├── dovecot_sasld/
│ │ │ ├── dovecot_sasl.go
│ │ │ └── mech_info.go
│ │ ├── imap/
│ │ │ └── imap.go
│ │ ├── openmetrics/
│ │ │ └── om.go
│ │ └── smtp/
│ │ ├── date.go
│ │ ├── metrics.go
│ │ ├── session.go
│ │ ├── smtp.go
│ │ ├── smtp_test.go
│ │ ├── smtputf8_test.go
│ │ ├── submission.go
│ │ └── submission_test.go
│ ├── imap_filter/
│ │ ├── command/
│ │ │ └── command.go
│ │ └── group.go
│ ├── libdns/
│ │ ├── acmedns.go
│ │ ├── alidns.go
│ │ ├── cloudflare.go
│ │ ├── digitalocean.go
│ │ ├── gandi.go
│ │ ├── gcore.go
│ │ ├── googleclouddns.go
│ │ ├── hetzner.go
│ │ ├── leaseweb.go
│ │ ├── metaname.go
│ │ ├── namecheap.go
│ │ ├── namedotcom.go
│ │ ├── provider_module.go
│ │ ├── rfc2136.go
│ │ ├── route53.go
│ │ └── vultr.go
│ ├── limits/
│ │ ├── limiters/
│ │ │ ├── bucket.go
│ │ │ ├── concurrency.go
│ │ │ ├── limiters.go
│ │ │ ├── multilimit.go
│ │ │ └── rate.go
│ │ └── limits.go
│ ├── modify/
│ │ ├── dkim/
│ │ │ ├── dkim.go
│ │ │ ├── dkim_test.go
│ │ │ ├── keys.go
│ │ │ └── keys_test.go
│ │ ├── group.go
│ │ ├── replace_addr.go
│ │ └── replace_addr_test.go
│ ├── msgpipeline/
│ │ ├── bench_test.go
│ │ ├── bodynonatomic_test.go
│ │ ├── check_group.go
│ │ ├── check_runner.go
│ │ ├── check_test.go
│ │ ├── config.go
│ │ ├── config_test.go
│ │ ├── dmarc_test.go
│ │ ├── metrics.go
│ │ ├── modifier_test.go
│ │ ├── module.go
│ │ ├── msgpipeline.go
│ │ ├── msgpipeline_test.go
│ │ ├── objname.go
│ │ └── regress_test.go
│ ├── proxy_protocol/
│ │ └── proxy_protocol.go
│ ├── smtpconn/
│ │ ├── pool/
│ │ │ └── pool.go
│ │ ├── smtpconn.go
│ │ ├── smtpconn_test.go
│ │ └── smtputf8_test.go
│ ├── sqlite/
│ │ ├── is.go
│ │ ├── modernc_sqlite3.go
│ │ ├── no_sqlite3.go
│ │ └── sqlite3.go
│ ├── storage/
│ │ ├── blob/
│ │ │ ├── fs/
│ │ │ │ ├── fs.go
│ │ │ │ └── fs_test.go
│ │ │ ├── s3/
│ │ │ │ ├── s3.go
│ │ │ │ └── s3_test.go
│ │ │ ├── test_blob.go
│ │ │ └── test_blob_nosqlite.go
│ │ └── imapsql/
│ │ ├── bench_test.go
│ │ ├── delivery.go
│ │ ├── external_blob_store.go
│ │ ├── imapsql.go
│ │ └── maddyctl.go
│ ├── table/
│ │ ├── chain.go
│ │ ├── email_localpart.go
│ │ ├── email_with_domain.go
│ │ ├── file.go
│ │ ├── file_test.go
│ │ ├── identity.go
│ │ ├── regexp.go
│ │ ├── sql_query.go
│ │ ├── sql_query_test.go
│ │ ├── sql_table.go
│ │ └── static.go
│ ├── target/
│ │ ├── delivery.go
│ │ ├── queue/
│ │ │ ├── metrics.go
│ │ │ ├── queue.go
│ │ │ ├── queue_test.go
│ │ │ ├── timewheel.go
│ │ │ └── timewheel_test.go
│ │ ├── received.go
│ │ ├── remote/
│ │ │ ├── connect.go
│ │ │ ├── dane.go
│ │ │ ├── dane_delivery_test.go
│ │ │ ├── dane_test.go
│ │ │ ├── debugflags.go
│ │ │ ├── metrics.go
│ │ │ ├── mxauth_test.go
│ │ │ ├── policy_group.go
│ │ │ ├── remote.go
│ │ │ ├── remote_test.go
│ │ │ └── security.go
│ │ ├── skeleton.go
│ │ └── smtp/
│ │ ├── sasl.go
│ │ ├── sasl_test.go
│ │ ├── smtp_downstream.go
│ │ ├── smtp_downstream_test.go
│ │ └── smtputf8_test.go
│ ├── testutils/
│ │ ├── bench_delivery.go
│ │ ├── buffer.go
│ │ ├── check.go
│ │ ├── filesystem.go
│ │ ├── logger.go
│ │ ├── modifier.go
│ │ ├── multitable.go
│ │ ├── smtp_server.go
│ │ ├── table.go
│ │ └── target.go
│ ├── tls/
│ │ ├── acme/
│ │ │ └── acme.go
│ │ ├── file.go
│ │ └── self_signed.go
│ └── updatepipe/
│ ├── backend.go
│ ├── pubsub/
│ │ ├── pq.go
│ │ └── pubsub.go
│ ├── pubsub_pipe.go
│ ├── serialize.go
│ ├── unix_pipe.go
│ └── update_pipe.go
├── maddy.conf
├── maddy.conf.docker
├── maddy.go
├── maddy_debug.go
├── signal.go
├── signal_nonposix.go
├── systemd.go
├── systemd_nonlinux.go
└── tests/
├── README.md
├── basic_test.go
├── build_cover.sh
├── conn.go
├── cover_test.go
├── dovecot_sasl_test.go
├── dovecot_sasld_test.go
├── ghsa_5835_4gvc_32pc_test.go
├── gocovcat.go
├── golangci-noisy.yml
├── imap_test.go
├── imapsql_test.go
├── issue327_test.go
├── limits_test.go
├── lmtp_test.go
├── modules_test.go
├── mta_test.go
├── multiple_domains_test.go
├── reload_non_unix.go
├── reload_test.go
├── reload_unix.go
├── replace_addr_test.go
├── run.sh
├── smtp_autobuffer_test.go
├── smtp_test.go
├── stress_test.go
├── t.go
└── testdata/
├── check_command.sh
├── testing+addHeader@maddy.test.hdr
└── testing+reject@maddy.test.exit
================================================
FILE CONTENTS
================================================
================================================
FILE: .dockerignore
================================================
testdata/
cmd/maddy/maddy
maddy
tests/maddy.cover
================================================
FILE: .editorconfig
================================================
root = true
[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{scd,go}]
indent_style = tab
indent_size = 4
[*.yml]
indent_style = tab
indent_size = 2
================================================
FILE: .gitattributes
================================================
* text=auto eol=lf
================================================
FILE: .github/CODE_OF_CONDUCT.md
================================================
# Code of Merit
**1.** The project creators, lead developers, core team, constitute the managing
members of the project and have final say in every decision of the project,
technical or otherwise, including overruling previous decisions. There are no
limitations to this decisional power.
**2.** Contributions are an expected result of your membership on the project.
Don’t expect others to do your work or help you with your work forever.
**3.** All members have the same opportunities to seek any challenge they want
within the project.
**4.** Authority or position in the project will be proportional to the accrued
contribution. Seniority must be earned.
**5.** Software is evolutive: the better implementations must supersede lesser
implementations. Technical advantage is the primary evaluation metric.
**6.** This is a space for technical prowess; topics outside of the project will
not be tolerated.
**7.** Non technical conflicts will be discussed in a separate space. Disruption
of the project will not be allowed.
**8.** Individual characteristics, including but not limited to, body, sex,
sexual preference, race, language, religion, nationality, or political
preferences are irrelevant in the scope of the project and will not be taken
into account concerning your value or that of your contribution to the project.
**9.** Discuss or debate the idea, not the person.
**10.** There is no room for ambiguity: Ambiguity will be met with questioning;
further ambiguity will be met with silence. It is the responsibility of the
originator to provide requested context.
**11.** If something is illegal outside the scope of the project, it is illegal
in the scope of the project. This Code of Merit does not take precedence over
governing law.
**12.** This Code of Merit governs the technical procedures of the project not
the activities outside of it.
**13.** Participation on the project equates to agreement of this Code of Merit.
**14.** No objectives beyond the stated objectives of this project are relevant
to the project. Any intent to deviate the project from its original purpose of
existence will constitute grounds for remedial action which may include
expulsion from the project.
This document is the Code of Merit
(`http://code-of-merit.org`), version 1.0.
================================================
FILE: .github/CONTRIBUTING.md
================================================
# Contributing Guidelines
Of course, we love our contributors. Thanks for spending time on making maddy
better.
## Reporting bugs
**Issue tracker is meant to be used only if you have a problem or a feature
request. If you just have some questions about maddy - prefer to use the
[IRC channel](https://webchat.oftc.net/?channels=maddy&uio=MT11bmRlZmluZWQb1).**
- Provide log files, preferably with 'debug' directive set.
- Provide the exact steps to reproduce the issue.
- Provide the example message that causes the error, if applicable.
- "Too much information is better than not enough information".
Issues without enough information will be ignored and possibly closed.
Take some time to be more useful.
See SECURITY.md for information on how to report vulnerabilities.
## Contributing Code
0. Use common sense.
1. Learn Git. Especially, what is `git rebase`. We may ask you to use it if
needed.
2. Tell us that you are willing to work on an issue.
3. Fork the repo. Create a new branch based on `dev`, write your code. Open a
PR.
Ask for advice if you are not sure. We don't bite.
maddy design summary and some recommendations are provided in
[HACKING.md](../HACKING.md) file.
## Commits
1. Prefix commit message with a package path if it affects only a single
package. Omit `internal/` for brevity.
2. Provide reasoning for details in the source code itself (via comments),
provide reasoning for high-level decisions in the commit message.
3. Make sure every commit builds & passes tests. Otherwise `git bisect` becomes
unusable.
## Git workflow
`dev` branch includes the in-development version for the next feature release.
It is based on commit of the latest stable release and is merged into `master`
on release via fast-forward. Unlike `master`, `dev` **is not a protected branch
and may get force-pushes**.
`master` branch contains the latest stable release and is frozen between
releases.
`fix-X.Y` are temporary branches containing backported security fixes.
They are based on the commit of the corresponding stable release and exist
while the corresponding release is maintained. A `fix-*` branch is not created
for the latest release. Changes are added to these branches by cherry-picking
needed commits from the `dev` branch.
================================================
FILE: .github/ISSUE_TEMPLATE/bug-report.md
================================================
---
name: Bug report
about: If you think something is broken
title: Bug report
labels: bug
assignees: ''
---
# Describe the bug
What do you think is wrong?
# Steps to reproduce
# Log files
Use a service like hastebin.com or attach a file if it is big
# Configuration file
Located in /etc/maddy/maddy.conf by default, don't forget to remove DB passwords
and other security-related stuff.
# Environment information
* maddy version: ?
================================================
FILE: .github/ISSUE_TEMPLATE/config.yml
================================================
contact_links:
- name: Questions
url: "https://github.com/foxcpp/maddy/discussions/new?category=q-a"
about: "Use GitHub discussions for any questions"
- name: IRC channel
url: "https://webchat.oftc.net/?channels=maddy&uio=MT11bmRlZmluZWQb1"
about: "... or there is also an IRC channel for any discussions"
================================================
FILE: .github/ISSUE_TEMPLATE/feature-request.md
================================================
---
name: Feature request
about: If you would like to see a new feature in maddy.
title: Feature request
labels: new feature
assignees: ''
---
# Use case
What problem you are trying to solve?
Note alternatives you considered and why they are not useful.
# Your idea for a solution
How your solution would work in general?
Note that some overly complicated solutions may be rejected because maddy is
meant to be simple.
- [ ] I'm willing to help with the implementation
================================================
FILE: .github/SECURITY.md
================================================
# Security Policy
## Supported Versions
Two latest incompatible releases (e.g. 2.0.0 and 1.9.0).
Latest release gets all bug fixes, features, etc. Previous incompatible release
gets security fixes and fixes for problems that render software completely
unusable in certain configurations with no workaround.
## Reporting a Vulnerability
If you believe the vulnerabilitiy does have a big impact on existing
deployments - email `fox.cpp at disroot.org`, put "[maddy security]" in the
Subject.
Otherwise, open a public issue.
================================================
FILE: .github/releases.md
================================================
# Release preparation
1. Run linters, fix all simple warnings. If the behavior is intentional - add
`nolint` comment and explanation. If the warning is non-trviail to fix - open
an issue.
```
golangci-lint run
```
2. Run unit tests suite. Verify that all disabled tests are not related to
serious problems and have corresponding issue open.
```
go test ./...
```
3. Run integration tests suite. Verify that all disabled tests are not related
to serious problems and have corresponding issue open.
```
cd tests/
./run.sh
```
4. Write release notes.
5. Create PGP-signed Git tag and push it to GitHub (do not create a "release"
yet).
5. Use environment configuration from maddy-repro bundle
(https://foxcpp.dev/maddy-repro) to build release artifacts.
6. Create detached PGP signatures for artifacts using key
3197BBD95137E682A59717B434BB2007081396F4.
7. Create sha256sums file for artifacts.
8. Create release on GitHub using the same text for
release notes. Attach signed artifacts and sha256sums file.
9. Build the Docker container and push it to hub.docker.com.
10. Post a message on the sr.ht mailing list.
================================================
FILE: .github/workflows/release.yml
================================================
name: "Prepare release artifacts"
on:
push:
tags: [ "v*" ]
permissions:
id-token: write
contents: read
attestations: write
packages: write
jobs:
artifact-builder-x86:
name: "Prepare release artifacts (x86)"
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
container:
image: "alpine:edge"
steps:
- uses: actions/checkout@v1 # v2 does not work with containers
- name: "Install build dependencies"
run: |
apk add --no-cache gcc go zstd
- name: "Create and package build tree"
run: |
./build.sh --builddir ~/package-output/ --static build
ver=$(cat .version)
if [ "v$ver" != "${{github.ref_name}}" ]; then echo ".version does not match the Git tag"; exit 1; fi
mv ~/package-output/ ~/maddy-$ver-x86_64-linux-musl
cd ~
tar c ./maddy-$ver-x86_64-linux-musl | zstd > ~/maddy-x86_64-linux-musl.tar.zst
cd -
- name: "Save source tree"
run: |
rm -rf .git
ver=$(cat .version)
cp -r . ~/maddy-$ver-src
cd ~
tar c ./maddy-$ver-src | zstd > ~/maddy-src.tar.zst
cd -
- name: "Upload source tree"
uses: actions/upload-artifact@v4
with:
name: maddy-src.tar.zst
path: '~/maddy-src.tar.zst'
if-no-files-found: error
- name: "Upload binary tree"
uses: actions/upload-artifact@v4
with:
name: maddy-binary.tar.zst
path: '~/maddy-x86_64-linux-musl.tar.zst'
if-no-files-found: error
- name: "Generate artifact attestation"
uses: actions/attest-build-provenance@v2
with:
subject-path: '~/maddy-x86_64-linux-musl.tar.zst'
artifact-builder-arm:
name: "Prepare release artifacts (aarch64)"
if: github.ref_type == 'tag'
runs-on: ubuntu-22.04-arm
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Building in a Docker container is a workaround for the issue of
# JavaScript-based GitHub Actions not being supported in Alpine
# containers on the Arm64 platform. Otherwise, we could completely reuse
# artifact-builder-x86 as a matrix job by running it on an Arm runner.
- name: Build in Docker container
run: |
# Create Dockerfile for the build
cat > Dockerfile << 'EOF'
FROM alpine:edge
RUN apk add --no-cache gcc go zstd musl-dev scdoc
WORKDIR /build
COPY . .
RUN ./build.sh --builddir /package-output/ --static build && \
ver=$(cat .version) && \
if [ "v$ver" != "${{github.ref_name}}" ]; then echo ".version does not match the Git tag"; exit 1; fi && \
mv /package-output/ /maddy-$ver-aarch64-linux-musl && \
cd / && \
tar c ./maddy-$ver-aarch64-linux-musl | zstd > /maddy-aarch64-linux-musl.tar.zst
EOF
# Build the image, create a temporary container and copy the artifact.
docker build -t maddy-builder .
container_id=$(docker create maddy-builder)
docker cp $container_id:/maddy-aarch64-linux-musl.tar.zst .
docker rm $container_id
- name: Upload binary tree
uses: actions/upload-artifact@v4
with:
name: maddy-binary-aarch64.tar.zst
path: maddy-aarch64-linux-musl.tar.zst
if-no-files-found: error
- name: "Generate artifact attestation"
uses: actions/attest-build-provenance@v2
with:
subject-path: 'maddy-aarch64-linux-musl.tar.zst'
docker-builder:
name: "Build & push Docker image"
if: github.ref_type == 'tag'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: "Set up QEMU"
uses: docker/setup-qemu-action@v1
with:
platforms: arm64
- name: "Set up Docker Buildx"
id: buildx
uses: docker/setup-buildx-action@v3
- name: "Login to Docker Hub"
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_PASSWORD }}
logout: false
- name: "Login to GitHub Container Registry"
uses: docker/login-action@v3
with:
registry: "ghcr.io"
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
logout: false # https://news.ycombinator.com/item?id=28607735
- name: "Generate container metadata"
uses: docker/metadata-action@v5
id: meta
with:
images: |
foxcpp/maddy
ghcr.io/foxcpp/maddy
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
labels: |
org.opencontainers.image.title=Maddy Mail Server
org.opencontainers.image.documentation=https://maddy.email/docker/
org.opencontainers.image.url=https://maddy.email
- name: "Build and push"
uses: docker/build-push-action@v6
id: docker
with:
context: .
platforms: linux/amd64 #,linux/arm64 Temporary disabled due to SIGSEGV in gcc.
file: Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name: "Generate container attestation"
uses: actions/attest-build-provenance@v2
with:
subject-name: ghcr.io/foxcpp/maddy
subject-digest: ${{ steps.docker.outputs.digest }}
push-to-registry: true
================================================
FILE: .github/workflows/test.yml
================================================
name: "Testing"
on:
push:
branches: [ master, dev ]
tags: [ "v*" ]
pull_request:
branches: [ master, dev ]
permissions:
contents: read
pull-requests: read
checks: write
jobs:
golangci:
name: Lint
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: "Install libpam"
run: |
sudo apt-get update
sudo apt-get install -y libpam-dev
- uses: golangci/golangci-lint-action@v9
with:
version: v2.11
buildsh:
name: "Verify build.sh"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: "Install libpam"
run: |
sudo apt-get update
sudo apt-get install -y libpam-dev
- name: "Verify build.sh"
run: |
./build.sh
./build.sh --destdir destdir/ install
find destdir/
test:
name: "Build and test"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: "Install libpam"
run: |
sudo apt-get update
sudo apt-get install -y libpam-dev
- name: "Unit & module tests"
run: |
go test ./... -coverprofile=coverage.out -covermode=atomic
- name: "Integration tests"
run: |
cd tests/
./run.sh
================================================
FILE: .gitignore
================================================
# gitignore.io
*.o
*.a
*.so
_obj
_test
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.exe~
*.test
*.prof
**/.envrc
**/.DS_Store
# Tests coverage
*.out
# Compiled binaries
cmd/maddy/maddy
cmd/maddy-*-helper/maddy-*-helper
/maddy
# Man pages
docs/man/*.1
docs/man/*.5
# Certificates and private keys.
*.pem
*.crt
*.key
# Some directories that may be created during test-runs
# in repo directory.
cmd/maddy/*mtasts-cache
cmd/maddy/*queue
build/
tests/maddy.cover
tests/maddy
.idea/
================================================
FILE: .golangci.yml
================================================
version: "2"
linters:
enable:
- errcheck
- staticcheck
- ineffassign
- govet
- unused
- prealloc
- unconvert
- misspell
- whitespace
- nakedret
- dogsled
- copyloopvar
- sqlclosecheck
- testifylint
- rowserrcheck
- recvcheck
settings:
errcheck:
disable-default-exclusions: false
formatters:
enable:
- goimports
================================================
FILE: .mkdocs.yml
================================================
site_name: maddy
repo_url: https://github.com/foxcpp/maddy
theme: alb
markdown_extensions:
- codehilite:
guess_lang: false
nav:
- faq.md
- Tutorials:
- tutorials/setting-up.md
- tutorials/building-from-source.md
- tutorials/alias-to-remote.md
- tutorials/pam.md
- Release builds: 'https://maddy.email/builds/'
- multiple-domains.md
- upgrading.md
- seclevels.md
- docker.md
- Reference manual:
- reference/modules.md
- reference/global-config.md
- reference/tls.md
- reference/tls-acme.md
- Endpoints configuration:
- reference/endpoints/imap.md
- reference/endpoints/smtp.md
- reference/endpoints/openmetrics.md
- IMAP storage:
- reference/storage/imap-filters.md
- reference/storage/imapsql.md
- Blob storage:
- reference/blob/fs.md
- reference/blob/s3.md
- reference/smtp-pipeline.md
- SMTP targets:
- reference/targets/queue.md
- reference/targets/remote.md
- reference/targets/smtp.md
- SMTP checks:
- reference/checks/actions.md
- reference/checks/dkim.md
- reference/checks/spf.md
- reference/checks/milter.md
- reference/checks/rspamd.md
- reference/checks/dnsbl.md
- reference/checks/command.md
- reference/checks/authorize_sender.md
- reference/checks/misc.md
- SMTP modifiers:
- reference/modifiers/dkim.md
- reference/modifiers/envelope.md
- Lookup tables (string translation):
- reference/table/static.md
- reference/table/regexp.md
- reference/table/file.md
- reference/table/sql_query.md
- reference/table/chain.md
- reference/table/email_localpart.md
- reference/table/email_with_domain.md
- reference/table/auth.md
- Authentication providers:
- reference/auth/pass_table.md
- reference/auth/pam.md
- reference/auth/shadow.md
- reference/auth/external.md
- reference/auth/ldap.md
- reference/auth/dovecot_sasl.md
- reference/auth/plain_separate.md
- reference/auth/netauth.md
- reference/config-syntax.md
- Integration with software:
- third-party/dovecot.md
- third-party/smtp-servers.md
- third-party/rspamd.md
- third-party/mailman3.md
- Internals:
- internals/specifications.md
- internals/unicode.md
- internals/quirks.md
- internals/sqlite.md
================================================
FILE: .version
================================================
0.9.4
================================================
FILE: AGENTS.md
================================================
# AGENTS.md — Maddy Mail Server
## Architecture
Maddy is a composable all-in-one mail server (MTA/MX/IMAP) written in Go. The core abstraction is the **module system**: every functional component (auth, storage, checks, targets, endpoints) implements `module.Module` from `framework/module/module.go` and registers itself via `module.Register(name, factory)` in an `init()` function.
- **`framework/`** — Stable, reusable packages (config parsing, module interfaces, address handling, error types, logging). Interfaces live here to avoid circular imports.
- **`internal/`** — All module implementations. Subdirectories map to module roles: `endpoint/` (protocol listeners), `target/` (delivery destinations), `auth/`, `check/` (message inspectors), `modify/` (header modifiers), `storage/`, `table/` (string→string lookups).
- **`maddy.go`** — Side-effect imports that pull all `internal/` modules into the binary, plus the `Run`/`moduleConfigure`/`RegisterModules` startup sequence.
- **`cmd/maddy/main.go`** — Thin entrypoint; imports root package for module registration, then calls `maddycli.Run()`.
Modules are wired together at runtime via `maddy.conf` configuration. Top-level blocks are lazily initialized through `module.Registry`. The **message pipeline** (`internal/msgpipeline/`) routes messages from endpoints through checks, modifiers, and to delivery targets based on sender/recipient matching rules.
## Build & Test
```sh
# Build (produces ./build/maddy by default):
./build.sh build
# Build with specific tags (e.g. for Docker):
./build.sh --tags "docker" build
# Unit tests (standard Go):
go test ./...
# Integration tests
cd tests && ./run.sh
```
The build embeds version via `-ldflags -X github.com/foxcpp/maddy.Version=...`. A C compiler is needed for SQLite support (`mattn/go-sqlite3`).
## Adding a New Module
1. Create a package under the appropriate `internal/` subdirectory (e.g. `internal/check/mycheck/`).
2. Implement `module.Module` plus the relevant role interface (`module.Check`, `module.DeliveryTarget`, `module.PlainAuth`, `module.Table`, etc.) from `framework/module/`.
3. Register in `init()`: `module.Register("check.mycheck", NewMyCheck)`. Use naming convention: `check.`, `target.`, `auth.`, `table.`, `modify.` prefixes.
4. Add a blank import `_ "github.com/foxcpp/maddy/internal/check/mycheck"` in `maddy.go`.
5. For checks: use the skeleton at `internal/check/skeleton.go` or `check.RegisterStatelessCheck` (see `internal/check/dns/` for a stateless example).
## Error Handling
Use `framework/exterrors` — not bare `fmt.Errorf`. Errors crossing module boundaries must carry:
- SMTP status info via `exterrors.SMTPError{Code, EnhancedCode, Message, CheckName/TargetName}`
- Temporary flag via `exterrors.WithTemporary`
- Module name field
Keep SMTP error messages generic (no server config details). Use `exterrors.WithFields` for unexpected errors. See `HACKING.md` for full guidelines.
## Key Conventions
- **No shared state between messages** — check/modifier code runs in parallel across messages.
- **Panic recovery** — any goroutine you spawn must recover panics to avoid crashing the server.
- **Address normalization** — domain parts must be U-labels with NFC normalization and case-folding. Use `framework/address.CleanDomain`.
- **Configuration parsing** — modules receive config via `config.Map` in their `Configure` method. See `framework/config/` and existing modules for the pattern.
- **Logging** — use `framework/log.Logger`, not `log` stdlib. Per-delivery loggers via `target.DeliveryLogger(...)`.
================================================
FILE: COPYING
================================================
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc.
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
Copyright (C)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
Copyright (C)
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
.
================================================
FILE: Dockerfile
================================================
FROM golang:1.23-alpine AS build-env
ARG ADDITIONAL_BUILD_TAGS=""
RUN set -ex && \
apk upgrade --no-cache --available && \
apk add --no-cache build-base
WORKDIR /maddy
COPY go.mod go.sum ./
RUN go mod download
COPY . ./
RUN mkdir -p /pkg/data && \
cp maddy.conf.docker /pkg/data/maddy.conf && \
./build.sh --builddir /tmp --destdir /pkg/ --tags "docker ${ADDITIONAL_BUILD_TAGS}" build install
FROM alpine:3.21.2
LABEL maintainer="fox.cpp@disroot.org"
LABEL org.opencontainers.image.source=https://github.com/foxcpp/maddy
RUN set -ex && \
apk upgrade --no-cache --available && \
apk --no-cache add ca-certificates
COPY --from=build-env /pkg/data/maddy.conf /data/maddy.conf
COPY --from=build-env /pkg/usr/local/bin/maddy /bin/
EXPOSE 25 143 993 587 465
VOLUME ["/data"]
ENTRYPOINT ["/bin/maddy", "-config", "/data/maddy.conf"]
CMD ["run"]
================================================
FILE: HACKING.md
================================================
## Design goals
- **Make it easy to deploy.**
Minimal configuration changes should be required to get a typical mail server
running. Though, it is important to avoid making guesses for a
"zero-configuration". A wrong guess is worse than no guess.
- **Provide 80% of needed components.**
E-mail has evolved into a huge mess. With a single package to do one thing, it
quickly turns into a maintenance nightmare. Put all stuff mail server
typically needs into a single package. Though, leave controversial or highly
opinionated stuff out, don't force people to do things our way
(see next point).
- **Interoperate with existing software.**
Implement (de-facto) standard protocols not only for clients but also for
various server-side helper software (content filters, etc).
- **Be secure but interoperable.**
Verify DKIM signatures by default, use DMRAC policies by default, etc. This
makes default setup as secure as possible while maintaining reasonable
interoperability. Though, users can configure maddy to be stricter.
- **Achieve flexibility through composability.**
Allow connecting components in arbitrary ways instead of restricting users to
predefined templates.
- **Use Go concurrency features to the full extent.**
Do as much I/O as possible in parallel to minimize latencies. It is silly to
not do so when it is possible.
## Design summary
Here is a summary of how things are organized in maddy in general. It explains
things from the developer perspective and is meant to be used as an
introduction by the new developers/contributors. It is recommended to read
user documentation to understand how things work from the user perspective as
well.
- User documentation: [maddy.conf(5)](docs/man/maddy.5.scd)
- Design rationale: [Comments on design (Wiki)][1]
There are components called "modules". They are represented by objects
implementing the module.Module interface. Each module gets its unique name.
The function used to create a module instance is saved with this name as a key
into the global map called "modules registry".
Whenever module needs another module for some functionality, it references it
using a configuration directive with a matcher that internally calls
`modconfig.ModuleFromNode`. That function looks up the module "constructor" in
the registry, calls it with corresponding arguments, checks whether the
returned module satisfies the needed interfaces and then initializes it.
Alternatively, if configuration uses &-syntax to reference existing
configuration block, `ModuleFromNode` simply looks it up in the global instances
registry. All modules defined the configuration as a separate top-level blocks
are created before main initialization and are placed in the instances registry
where they can be looked up as mentioned before.
Top-level defined module instances are initialized (`Init` method) lazily as
they are required by other modules. 'smtp' and 'imap' modules follow a special
initialization path, so they are always initialized directly.
## Error handling
Familiarize yourself with the `github.com/foxcpp/maddy/framework/exterrors`
package and make sure you have the following for returned errors:
- SMTP status information (smtp\_code, smtp\_enchcode, smtp\_msg fields)
- SMTP message text should contain a generic description of the error
condition without any details to prevent accidental disclosure of the
server configuration details.
- `Temporary() == true` for temporary errors (see `exterrors.WithTemporary`)
- Field that includes the module name
The easiest way to get all of these is to use `exterrors.SMTPError`.
Put the original error into the `Err` field, so it can be inspected using
`errors.Is`, `errors.Unwrap`, etc. Put the module name into `CheckName` or
`TargetName`. Add any additional context information using the `Misc` field.
Note, the SMTP status code overrides the result of `exterrors.IsTemporary()`
for that error object, so set it using `exterrors.SMTPCode` that uses
`IsTemporary` to select between two codes.
If the error you are wrapping contains details in its structure fields (like
`*net.OpError`) - copy these values into `Misc` map, put the underlying error
object (`net.OpError.Err`, for example) into the `Err` field.
Avoid using `Reason` unless you are sure you can provide the error message
better than the `Err.Error()` or `Err` is `nil`.
Do not attempt to add a SMTP status information for every single possible
error. Use `exterrors.WithFields` with basic information for errors you don't
expect. The SMTP client will get the "Internal server error" message and this
is generally the right thing to do on unexpected errors.
### Goroutines and panics
If you start any goroutines - make sure to catch panics to make sure severe
bugs will not bring the whole server down.
## Adding a check
"Check" is a module that inspects the message and flags it as spam or rejects
it altogether based on some condition.
The skeleton for the stateful check module can be found in
`internal/check/skeleton.go`. Throw it into a file in
`internal/check/check_name` directory and start ~~breaking~~ extending it.
If you don't need any per-message state, you can use `StatelessCheck` wrapper.
See `check/dns` directory for a working example.
Here are some guidelines to make sure your check works well:
- RTFM, docs will tell you about any caveats.
- Don't share any state _between_ messages, your code will be executed in
parallel.
- Use `github.com/foxcpp/maddy/check.FailAction` to select behavior on check
failures. See other checks for examples on how to use it.
- You can assume that order of check functions execution is as follows:
`CheckConnection`, `CheckSender`, `CheckRcpt`, `CheckBody`.
## Adding a modifier
"Modifier" is a module that can modify some parts of the message data.
Note, currently this is not possible to modify the body contents, only header
can be modified.
Structure of the modifier implementation is similar to the structure of check
implementation, check `modify/replace\_addr.go` for a working example.
[1]: https://github.com/foxcpp/maddy/wiki/Dev:-Comments-on-design
================================================
FILE: README.md
================================================
Maddy Mail Server
=====================
> Composable all-in-one mail server.
Maddy Mail Server implements all functionality required to run a e-mail
server. It can send messages via SMTP (works as MTA), accept messages via SMTP
(works as MX) and store messages while providing access to them via IMAP.
In addition to that it implements auxiliary protocols that are mandatory
to keep email reasonably secure (DKIM, SPF, DMARC, DANE, MTA-STS).
It replaces Postfix, Dovecot, OpenDKIM, OpenSPF, OpenDMARC and more with one
daemon with uniform configuration and minimal maintenance cost.
**Note:** IMAP storage is "beta". If you are looking for stable and
feature-packed implementation you may want to use Dovecot instead. maddy still
can handle message delivery business.
[](https://github.com/foxcpp/maddy/actions/workflows/cicd.yml)
[](https://github.com/foxcpp/maddy)
* [Setup tutorial](https://maddy.email/tutorials/setting-up/)
* [Documentation](https://maddy.email/)
* [IRC channel](https://webchat.oftc.net/?channels=maddy&uio=MT11bmRlZmluZWQb1)
* [Mailing list](https://lists.sr.ht/~foxcpp/maddy)
================================================
FILE: build.sh
================================================
#!/bin/sh
destdir=/
builddir="$PWD/build"
prefix=/usr/local
version=
static=0
if [ "${GOFLAGS}" = "" ]; then
GOFLAGS="-trimpath" # set some flags to avoid passing "" to go
fi
print_help() {
cat >&2 < build tags to use
--version version tag to embed into executables (default: auto-detect)
Additional flags for "go build" can be provided using GOFLAGS environment variable.
Options for ./build.sh install:
--prefix installation prefix (default: $prefix)
--destdir system root (default: $destdir)
EOF
}
while :; do
case "$1" in
-h|--help)
print_help
exit
;;
--builddir)
shift
builddir="$1"
;;
--prefix)
shift
prefix="$1"
;;
--destdir)
shift
destdir="$1"
;;
--version)
shift
version="$1"
;;
--static)
static=1
;;
--tags)
shift
tags="$1"
;;
--)
break
shift
;;
-?*)
echo "Unknown option: ${1}. See --help." >&2
exit 2
;;
*)
break
esac
shift
done
configdir="${destdir}etc/maddy"
if [ "$version" = "" ]; then
version=unknown
if [ -e .version ]; then
version="$(cat .version)"
fi
if [ -e .git ] && command -v git 2>/dev/null >/dev/null; then
version="${version}+$(git rev-parse --short HEAD)"
fi
fi
set -e
build_man_pages() {
set +e
if ! command -v scdoc >/dev/null 2>/dev/null; then
echo '-- [!] No scdoc utility found. Skipping man pages building.' >&2
set -e
return
fi
set -e
echo '-- Building man pages...' >&2
mkdir -p "${builddir}/man"
for f in ./docs/man/*.1.scd; do
scdoc < "$f" > "${builddir}/man/$(basename "$f" .scd)"
done
}
build() {
mkdir -p "${builddir}"
echo "-- Version: ${version}" >&2
if [ "$(go env CC)" = "" ]; then
echo '-- [!] No C compiler available. maddy will be built without SQLite3 support and default configuration will be unusable.' >&2
fi
if [ "$static" -eq 1 ]; then
echo "-- Building main server executable..." >&2
# This is literally impossible to specify this line of arguments as part of ${GOFLAGS}
# using only POSIX sh features (and even with Bash extensions I can't figure it out).
go build -trimpath -buildmode pie -tags "$tags osusergo netgo static_build" \
-ldflags "-extldflags '-fno-PIC -static' -X \"github.com/foxcpp/maddy.Version=${version}\"" \
-o "${builddir}/maddy" ${GOFLAGS} ./cmd/maddy
else
echo "-- Building main server executable..." >&2
go build -tags "$tags" -trimpath -ldflags="-X \"github.com/foxcpp/maddy.Version=${version}\"" -o "${builddir}/maddy" ${GOFLAGS} ./cmd/maddy
fi
build_man_pages
echo "-- Copying misc files..." >&2
mkdir -p "${builddir}/systemd"
cp dist/systemd/*.service "${builddir}/systemd/"
cp maddy.conf "${builddir}/maddy.conf"
}
install() {
echo "-- Installing built files..." >&2
command install -m 0755 -d "${destdir}/${prefix}/bin/"
command install -m 0755 "${builddir}/maddy" "${destdir}/${prefix}/bin/"
command ln -sf maddy "${destdir}/${prefix}/bin/maddyctl"
command install -m 0755 -d "${configdir}"
# We do not want to overwrite existing configuration.
# If the file exists, then save it with .default suffix and warn user.
if [ ! -e "${configdir}/maddy.conf" ]; then
command install -m 0644 ./maddy.conf "${configdir}/maddy.conf"
else
echo "-- [!] Configuration file ${configdir}/maddy.conf exists, saving to ${configdir}/maddy.conf.default" >&2
command install -m 0644 ./maddy.conf "${configdir}/maddy.conf.default"
fi
# Attempt to install systemd units only for Linux.
# Check is done using GOOS instead of uname -s to account for possible
# package cross-compilation.
# Though go command might be unavailable if build.sh is run
# with sudo and go installation is user-specific, so fallback
# to using uname -s in the end.
set +e
if command -v go >/dev/null 2>/dev/null; then
set -e
if [ "$(go env GOOS)" = "linux" ]; then
command install -m 0755 -d "${destdir}/${prefix}/lib/systemd/system/"
command install -m 0644 "${builddir}"/systemd/*.service "${destdir}/${prefix}/lib/systemd/system/"
fi
else
set -e
if [ "$(uname -s)" = "Linux" ]; then
command install -m 0755 -d "${destdir}/${prefix}/lib/systemd/system/"
command install -m 0644 "${builddir}"/systemd/*.service "${destdir}/${prefix}/lib/systemd/system/"
fi
fi
if [ -e "${builddir}"/man ]; then
command install -m 0755 -d "${destdir}/${prefix}/share/man/man1/"
for f in "${builddir}"/man/*.1; do
command install -m 0644 "$f" "${destdir}/${prefix}/share/man/man1/"
done
fi
}
# Old build.sh compatibility
install_pkg() {
echo "-- [!] Replace 'install_pkg' with 'install' in build.sh invocation" >&2
install
}
package() {
echo "-- [!] Replace 'package' with 'build' in build.sh invocation" >&2
build
}
if [ $# -eq 0 ]; then
build
else
for arg in "$@"; do
eval "$arg"
done
fi
================================================
FILE: cmd/README.md
================================================
maddy executables
-------------------
### maddy
Main server executable.
### maddy-pam-helper, maddy-shadow-helper
Utilities compatible with the auth.external module that call libpam or read
/etc/shadow on Unix systems.
================================================
FILE: cmd/maddy/main.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package main
import (
_ "github.com/foxcpp/maddy"
maddycli "github.com/foxcpp/maddy/internal/cli"
_ "github.com/foxcpp/maddy/internal/cli/ctl"
)
func main() {
maddycli.Run()
}
================================================
FILE: cmd/maddy-pam-helper/README.md
================================================
## maddy-pam-helper
External setuid binary for interaction with shadow passwords database or other
privileged objects necessary to run PAM authentication.
### Building
It is really easy to build it using any GCC:
```
gcc pam.c main.c -lpam -o maddy-pam-helper
```
Yes, it is not a Go binary.
### Installation
maddy-pam-helper is kinda dangerous binary and should not be allowed to be
executed by everybody but maddy's user. At the same moment it needs to have
access to read-protected files. For this reason installation should be done
very carefully to make sure to not introduce any security "holes".
#### First method
```shell
chown maddy: /usr/bin/maddy-pam-helper
chmod u+x,g-x,o-x /usr/bin/maddy-pam-helper
```
Also maddy-pam-helper needs access to /etc/shadow, one of the ways to provide
it is to set file capability CAP_DAC_READ_SEARCH:
```
setcap cap_dac_read_search+ep /usr/bin/maddy-pam-helper
```
#### Second method
Another, less restrictive is to make it setuid-root (assuming you have both maddy user and group):
```
chown root:maddy /usr/bin/maddy-pam-helper
chmod u+xs,g+x,o-x /usr/bin/maddy-pam-helper
```
#### Third method
The best way actually is to create `shadow` group and grant access to
/etc/shadow to it and then make maddy-pam-helper setgid-shadow:
```
groupadd shadow
chown :shadow /etc/shadow
chmod g+r /etc/shadow
chown maddy:shadow /usr/bin/maddy-pam-helper
chmod u+x,g+xs /usr/bin/maddy-pam-helper
```
Pick what works best for you.
### PAM service
maddy-pam-helper uses custom service instead of pretending to be su or sudo.
Because of this you should configure PAM to accept it.
Minimal example using local passwd/shadow database for authentication can be
found in [maddy.conf][maddy.conf] file.
It should be put into /etc/pam.d/maddy.
================================================
FILE: cmd/maddy-pam-helper/maddy.conf
================================================
#%PAM-1.0
auth required pam_unix.so
account required pam_unix.so
================================================
FILE: cmd/maddy-pam-helper/main.c
================================================
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include "pam.h"
/*
I really doubt it is a good idea to bring Go to the binary whose primary task
is to call libpam using CGo anyway.
*/
int run(void) {
char *username = NULL, *password = NULL;
size_t username_buf_len = 0, password_buf_len = 0;
ssize_t username_len = getline(&username, &username_buf_len, stdin);
if (username_len < 0) {
perror("getline username");
return 2;
}
ssize_t password_len = getline(&password, &password_buf_len, stdin);
if (password_len < 0) {
perror("getline password");
return 2;
}
// Cut trailing \n.
if (username_len > 0) {
username[username_len - 1] = 0;
}
if (password_len > 0) {
password[password_len - 1] = 0;
}
struct error_obj err = run_pam_auth(username, password);
if (err.status != 0) {
if (err.status == 2) {
fprintf(stderr, "%s: %s\n", err.func_name, err.error_msg);
}
return err.status;
}
return 0;
}
#ifndef CGO
int main() {
return run();
}
#endif
================================================
FILE: cmd/maddy-pam-helper/main.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package main
/*
#cgo LDFLAGS: -lpam
#cgo CFLAGS: -DCGO -Wall -Wextra -Werror -Wno-unused-parameter -Wno-error=unused-parameter -Wpedantic -std=c99
extern int run();
*/
import "C"
import "os"
/*
Apparently, some people would not want to build it manually by calling GCC.
Here we do it for them. Not going to tell them that resulting file is 800KiB
bigger than one built using only C compiler.
*/
func main() {
i := int(C.run())
os.Exit(i)
}
================================================
FILE: cmd/maddy-pam-helper/pam.c
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2022 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include "pam.h"
static int conv_func(int num_msg, const struct pam_message **msg, struct pam_response **resp, void *appdata_ptr) {
struct pam_response *reply = malloc(sizeof(struct pam_response));
if (reply == NULL) {
return PAM_CONV_ERR;
}
char* password_cpy = malloc(strlen((char*)appdata_ptr)+1);
if (password_cpy == NULL) {
return PAM_CONV_ERR;
}
memcpy(password_cpy, (char*)appdata_ptr, strlen((char*)appdata_ptr)+1);
reply->resp = password_cpy;
reply->resp_retcode = 0;
// PAM frees pam_response for us.
*resp = reply;
return PAM_SUCCESS;
}
struct error_obj run_pam_auth(const char *username, char *password) {
const struct pam_conv local_conv = { conv_func, password };
pam_handle_t *local_auth = NULL;
int status = pam_start("maddy", username, &local_conv, &local_auth);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
ret_val.status = 2;
ret_val.func_name = "pam_start";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
status = pam_authenticate(local_auth, PAM_SILENT|PAM_DISALLOW_NULL_AUTHTOK);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
if (status == PAM_AUTH_ERR || status == PAM_USER_UNKNOWN) {
ret_val.status = 1;
} else {
ret_val.status = 2;
}
ret_val.func_name = "pam_authenticate";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
status = pam_acct_mgmt(local_auth, PAM_SILENT|PAM_DISALLOW_NULL_AUTHTOK);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
if (status == PAM_AUTH_ERR || status == PAM_USER_UNKNOWN || status == PAM_NEW_AUTHTOK_REQD) {
ret_val.status = 1;
} else {
ret_val.status = 2;
}
ret_val.func_name = "pam_acct_mgmt";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
status = pam_end(local_auth, status);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
ret_val.status = 2;
ret_val.func_name = "pam_end";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
struct error_obj ret_val;
ret_val.status = 0;
ret_val.func_name = NULL;
ret_val.error_msg = NULL;
return ret_val;
}
================================================
FILE: cmd/maddy-pam-helper/pam.h
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
#pragma once
struct error_obj {
int status;
const char* func_name;
const char* error_msg;
};
struct error_obj run_pam_auth(const char *username, char *password);
================================================
FILE: cmd/maddy-shadow-helper/README.md
================================================
## maddy-shadow-helper
External helper binary for interaction with shadow passwords database.
Unlike maddy-pam-helper it supports only local shadow database but it does
not have any C dependencies.
### Installation
maddy-shadow-helper is kinda dangerous binary and should not be allowed to be
executed by everybody but maddy's user. At the same moment it needs to have
access to read-protected files. For this reason installation should be done
very carefully to make sure to not introduce any security "holes".
#### First method
```shell
chown maddy: /usr/bin/maddy-shadow-helper
chmod u+x,g-x,o-x /usr/bin/maddy-shadow-helper
```
Also maddy-shadow-helper needs access to /etc/shadow, one of the ways to provide
it is to set file capability CAP_DAC_READ_SEARCH:
```
setcap cap_dac_read_search+ep /usr/bin/maddy-shadow-helper
```
#### Second method
Another, less restrictive is to make it setuid-root (assuming you have both maddy user and group):
```
chown root:maddy /usr/bin/maddy-shadow-helper
chmod u+xs,g+x,o-x /usr/bin/maddy-shadow-helper
```
#### Third method
The best way actually is to create `shadow` group and grant access to
/etc/shadow to it and then make maddy-shadow-helper setgid-shadow:
```
groupadd shadow
chown :shadow /etc/shadow
chmod g+r /etc/shadow
chown maddy:shadow /usr/bin/maddy-shadow-helper
chmod u+x,g+xs /usr/bin/maddy-shadow-helper
```
Pick what works best for you.
================================================
FILE: cmd/maddy-shadow-helper/main.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package main
import (
"bufio"
"errors"
"fmt"
"os"
"github.com/foxcpp/maddy/internal/auth/shadow"
)
func main() {
scnr := bufio.NewScanner(os.Stdin)
if !scnr.Scan() {
fmt.Fprintln(os.Stderr, scnr.Err())
os.Exit(2)
}
username := scnr.Text()
if !scnr.Scan() {
fmt.Fprintln(os.Stderr, scnr.Err())
os.Exit(2)
}
password := scnr.Text()
ent, err := shadow.Lookup(username)
if err != nil {
if errors.Is(err, shadow.ErrNoSuchUser) {
os.Exit(1)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if !ent.IsAccountValid() {
fmt.Fprintln(os.Stderr, "account is expired")
os.Exit(1)
}
if !ent.IsPasswordValid() {
fmt.Fprintln(os.Stderr, "password is expired")
os.Exit(1)
}
if err := ent.VerifyPassword(password); err != nil {
if errors.Is(err, shadow.ErrWrongPassword) {
os.Exit(1)
}
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
================================================
FILE: config.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package maddy
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/log"
)
/*
Config matchers for module interfaces.
*/
// logOut structure wraps log.Output and preserves
// configuration directive it was constructed from, allowing
// dynamic reinitialization for purposes of log file rotation.
type logOut struct {
args []string
log.Output
}
func logOutput(_ *config.Map, node config.Node) (interface{}, error) {
if len(node.Args) == 0 {
return nil, config.NodeErr(node, "expected at least 1 argument")
}
if len(node.Children) != 0 {
return nil, config.NodeErr(node, "can't declare block here")
}
return LogOutputOption(node.Args)
}
func LogOutputOption(args []string) (log.Output, error) {
outs := make([]log.Output, 0, len(args))
for i, arg := range args {
switch arg {
case "stderr":
outs = append(outs, log.WriterOutput(os.Stderr, false))
case "stderr_ts":
outs = append(outs, log.WriterOutput(os.Stderr, true))
case "syslog":
syslogOut, err := log.SyslogOutput()
if err != nil {
return nil, fmt.Errorf("failed to connect to syslog daemon: %v", err)
}
outs = append(outs, syslogOut)
case "off":
if len(args) != 1 {
return nil, errors.New("'off' can't be combined with other log targets")
}
return log.NopOutput{}, nil
default:
// log file paths are converted to absolute to make sure
// we will be able to recreate them in right location
// after changing working directory to the state dir.
absPath, err := filepath.Abs(arg)
if err != nil {
return nil, err
}
// We change the actual argument, so logOut object will
// keep the absolute path for reinitialization.
args[i] = absPath
w, err := os.OpenFile(absPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0o666)
if err != nil {
return nil, fmt.Errorf("failed to create log file: %v", err)
}
outs = append(outs, log.WriteCloserOutput(w, true))
}
}
if len(outs) == 1 {
return logOut{args, outs[0]}, nil
}
return logOut{args, log.MultiOutput(outs...)}, nil
}
func defaultLogOutput() (interface{}, error) {
return nil, nil
}
func reinitLogging() {
out, ok := log.DefaultLogger.Out.(logOut)
if !ok {
log.Println("Can't reinitialize logger because it was replaced before, this is a bug")
return
}
newOut, err := LogOutputOption(out.args)
if err != nil {
log.Println("Can't reinitialize logger:", err)
return
}
if err := out.Close(); err != nil {
log.Println("Can't close old logger:", err)
}
log.DefaultLogger.Out = newOut
}
================================================
FILE: contrib/README.md
================================================
# Community contributed resources
Disclaimer: Nothing inside subdirectories here is directly supported by Maddy
Mail Server maintainers. Some community members may be able to help you or not.
- Kubernetes helm chart is maintained by @acim.
================================================
FILE: contrib/kubernetes/chart/.helmignore
================================================
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/
================================================
FILE: contrib/kubernetes/chart/Chart.yaml
================================================
apiVersion: v2
name: maddy
description: A Helm chart for Kubernetes
# A chart can be either an 'application' or a 'library' chart.
#
# Application charts are a collection of templates that can be packaged into versioned archives
# to be deployed.
#
# Library charts provide useful utilities or functions for the chart developer. They're included as
# a dependency of application charts to inject those utilities and functions into the rendering
# pipeline. Library charts do not define any templates and therefore cannot be deployed.
type: application
# This is the chart version. This version number should be incremented each time you make changes
# to the chart and its templates, including the app version.
# Versions are expected to follow Semantic Versioning (https://semver.org/)
version: 0.2.6
# This is the version number of the application being deployed. This version number should be
# incremented each time you make changes to the application. Versions are not expected to
# follow Semantic Versioning. They should reflect the version the application is using.
appVersion: 0.4.0
================================================
FILE: contrib/kubernetes/chart/README.md
================================================
# maddy Helm chart for Kubernetes
  
This is just initial effort to run maddy within Kubernetes cluster. We have used Deployment resource which has some downsides
but at least this chart will allow you to install maddy relatively easily on your Kubernetes cluster. We have considered
StatefulSet and DaemonSet but such solutions would require much more configuration and in casae of DaemonSet also a TCP
load balancer in front of the nodes.
## Requirement
In order to run maddy properly, you need to have TLS secret under name maddy present in the cluster. If you have commercial
certificate, you can create it by the following command:
```sh
kubectl create secret tls maddy --cert=fullchain.pem --key=privkey.pem
```
If you use cert-manager, just create the secret under name maddy.
## Replication
Default for this chart is 1 replica of maddy. If you try to increase this, you will probably get an error because of
the busy ports 25, 143, 587, etc. We do not support this feature at the moment, so please use just 1 replica. Like said
at the beginning of this document, multiple replicas would probably require to switch do DaemonSet which would further require
to have TCP load balancer and shared storage between all replicas. This is not supported by this chart, sorry.
This chart is used on one node cluster and then installation is straight forward, like described bellow, but if you have
multiple node cluster, please use taints and tolerations to select the desired node. This chart supports tolerations to
be set.
## Configuration
| Key | Type | Default | Description |
| -------------------------- | ------ | ----------------- | ----------- |
| affinity | object | `{}` | |
| fullnameOverride | string | `""` | |
| image.pullPolicy | string | `"IfNotPresent"` | |
| image.repository | string | `"foxcpp/maddy"` | |
| image.tag | string | `""` | |
| imagePullSecrets | list | `[]` | |
| nameOverride | string | `""` | |
| nodeSelector | object | `{}` | |
| persistence.accessMode | string | `"ReadWriteOnce"` | |
| persistence.annotations | object | `{}` | |
| persistence.enabled | bool | `false` | |
| persistence.path | string | `"/data"` | |
| persistence.size | string | `"128Mi"` | |
| podAnnotations | object | `{}` | |
| podSecurityContext | object | `{}` | |
| replicaCount | int | `1` | |
| resources | object | `{}` | |
| securityContext | object | `{}` | |
| service.type | string | `"NodePort"` | |
| serviceAccount.annotations | object | `{}` | |
| serviceAccount.create | bool | `true` | |
| serviceAccount.name | string | `""` | |
| tolerations | list | `[]` | |
## Installing the chart
```sh
helm upgrade --install maddy ./chart --set service.externapIPs[0]=1.2.3.4
```
1.2.3.4 is your public IP of the node.
## maddy configuration
Feel free to tweak files/maddy.conf and files/aliases according to your needs.
================================================
FILE: contrib/kubernetes/chart/files/aliases
================================================
info@example.org: foxcpp@example.org
================================================
FILE: contrib/kubernetes/chart/files/maddy.conf
================================================
## maddy 0.3 - default configuration file (2020-05-31)
# Suitable for small-scale deployments. Uses its own format for local users DB,
# should be managed via maddy subcommands.
#
# See tutorials at https://foxcpp.dev/maddy for guidance on typical
# configuration changes.
#
# See manual pages (also available at https://foxcpp.dev/maddy) for reference
# documentation.
# ----------------------------------------------------------------------------
# Base variables
$(hostname) = mx1.example.org
$(primary_domain) = example.org
$(local_domains) = $(primary_domain)
tls file /etc/maddy/certs/fullchain.pem /etc/maddy/certs/privkey.pem
# ----------------------------------------------------------------------------
# Local storage & authentication
# pass_table provides local hashed passwords storage for authentication of
# users. It can be configured to use any "table" module, in default
# configuration a table in SQLite DB is used.
# Table can be replaced to use e.g. a file for passwords. Or pass_table module
# can be replaced altogether to use some external source of credentials (e.g.
# PAM, /etc/shadow file).
#
# If table module supports it (sql_table does) - credentials can be managed
# using 'maddy creds' command.
auth.pass_table local_authdb {
table sql_table {
driver sqlite3
dsn credentials.db
table_name passwords
}
}
# imapsql module stores all indexes and metadata necessary for IMAP using a
# relational database. It is used by IMAP endpoint for mailbox access and
# also by SMTP & Submission endpoints for delivery of local messages.
#
# IMAP accounts, mailboxes and all message metadata can be inspected using
# imap-* subcommands of maddy.
storage.imapsql local_mailboxes {
driver sqlite3
dsn imapsql.db
}
# ----------------------------------------------------------------------------
# SMTP endpoints + message routing
hostname $(hostname)
msgpipeline local_routing {
dmarc yes
check {
require_matching_ehlo
require_mx_record
dkim
spf
}
# Insert handling for special-purpose local domains here.
# e.g.
# destination lists.example.org {
# deliver_to lmtp tcp://127.0.0.1:8024
# }
destination postmaster $(local_domains) {
modify {
replace_rcpt regexp "(.+)\+(.+)@(.+)" "$1@$3"
replace_rcpt file /data/aliases
}
deliver_to &local_mailboxes
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
smtp tcp://0.0.0.0:25 {
limits {
# Up to 20 msgs/sec across max. 10 SMTP connections.
all rate 20 1s
all concurrency 10
}
source $(local_domains) {
reject 501 5.1.8 "Use Submission for outgoing SMTP"
}
default_source {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
}
submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
limits {
# Up to 50 msgs/sec across any amount of SMTP connections.
all rate 50 1s
}
auth &local_authdb
source $(local_domains) {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
modify {
dkim $(primary_domain) $(local_domains) default
}
deliver_to &remote_queue
}
}
default_source {
reject 501 5.1.8 "Non-local sender domain"
}
}
target.remote outbound_delivery {
limits {
# Up to 20 msgs/sec across max. 10 SMTP connections
# for each recipient domain.
destination rate 20 1s
destination concurrency 10
}
mx_auth {
dane
mtasts {
cache fs
fs_dir mtasts_cache/
}
local_policy {
min_tls_level encrypted
min_mx_level none
}
}
}
target.queue remote_queue {
target &outbound_delivery
autogenerated_msg_domain $(primary_domain)
bounce {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
}
}
}
# ----------------------------------------------------------------------------
# IMAP endpoints
imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
auth &local_authdb
storage &local_mailboxes
}
================================================
FILE: contrib/kubernetes/chart/templates/NOTES.txt
================================================
================================================
FILE: contrib/kubernetes/chart/templates/_helpers.tpl
================================================
{{/*
Expand the name of the chart.
*/}}
{{- define "maddy.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "maddy.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "maddy.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "maddy.labels" -}}
helm.sh/chart: {{ include "maddy.chart" . }}
{{ include "maddy.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels
*/}}
{{- define "maddy.selectorLabels" -}}
app.kubernetes.io/name: {{ include "maddy.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "maddy.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "maddy.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
================================================
FILE: contrib/kubernetes/chart/templates/configmap.yaml
================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: {{include "maddy.fullname" .}}
labels: {{- include "maddy.labels" . | nindent 4}}
data:
maddy.conf: |
{{ tpl (.Files.Get "files/maddy.conf") . | printf "%s" | indent 4 }}
aliases: |
{{ tpl (.Files.Get "files/aliases") . | printf "%s" | indent 4 }}
================================================
FILE: contrib/kubernetes/chart/templates/deployment.yaml
================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "maddy.fullname" . }}
labels:
{{- include "maddy.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "maddy.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
checksum/config: {{ tpl (.Files.Get "files/maddy.conf") . | printf "%s" | sha256sum }}
checksum/aliases: {{ tpl (.Files.Get "files/aliases") . | printf "%s" | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "maddy.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "maddy.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
initContainers:
- name: init
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: busybox
imagePullPolicy: {{ .Values.image.pullPolicy }}
command:
- sh
- -c
- cp /tmp/maddy/* /data/.
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: {{ .Values.persistence.path }}
{{- if .Values.persistence.subPath }}
subPath: {{ .Values.persistence.subPath }}
{{- end }}
- name: config
mountPath: /tmp/maddy
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: smtp
containerPort: 25
protocol: TCP
- name: imaps
containerPort: 993
protocol: TCP
- name: starttls
containerPort: 587
protocol: TCP
# livenessProbe:
# httpGet:
# path: /
# port: http
# readinessProbe:
# httpGet:
# path: /
# port: http
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: {{ .Values.persistence.path }}
{{- if .Values.persistence.subPath }}
subPath: {{ .Values.persistence.subPath }}
{{- end }}
- name: tls
mountPath: /etc/maddy/certs/fullchain.pem
subPath: tls.crt
- name: tls
mountPath: /etc/maddy/certs/privkey.pem
subPath: tls.key
volumes:
- name: data
{{- if .Values.persistence.enabled }}
persistentVolumeClaim:
claimName: {{ default (include "maddy.fullname" .) .Values.persistence.existingClaim }}
{{- else }}
emptyDir: {}
{{- end }}
- name: config
configMap:
name: {{include "maddy.fullname" .}}
- name: tls
secret:
secretName: {{include "maddy.fullname" .}}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
================================================
FILE: contrib/kubernetes/chart/templates/pvc.yaml
================================================
{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) -}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "maddy.fullname" . }}
annotations:
{{- with .Values.persistence.annotations }}
{{ toYaml . | indent 4 }}
{{- end }}
labels:
{{- include "maddy.labels" . | nindent 4 }}
spec:
accessModes:
- {{ .Values.persistence.accessMode }}
resources:
requests:
storage: {{ .Values.persistence.size }}
{{- if .Values.persistence.storageClass }}
storageClassName: {{ .Values.persistence.storageClass }}
{{- end }}
{{- end -}}
================================================
FILE: contrib/kubernetes/chart/templates/service.yaml
================================================
apiVersion: v1
kind: Service
metadata:
name: {{ include "maddy.fullname" . }}
labels:
{{- include "maddy.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: 25
targetPort: smtp
protocol: TCP
name: smtp
- port: 993
targetPort: imaps
protocol: TCP
name: imaps
- port: 587
targetPort: starttls
protocol: TCP
name: starttls
selector:
{{- include "maddy.selectorLabels" . | nindent 4 }}
{{- with .Values.service.externalIPs }}
externalIPs:
{{- toYaml . | nindent 6 }}
{{- end -}}
================================================
FILE: contrib/kubernetes/chart/templates/serviceaccount.yaml
================================================
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "maddy.serviceAccountName" . }}
labels:
{{- include "maddy.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}
================================================
FILE: contrib/kubernetes/chart/templates/tests/test-connection.yaml
================================================
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "maddy.fullname" . }}-test-connection"
labels:
{{- include "maddy.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test-success
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "maddy.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never
================================================
FILE: contrib/kubernetes/chart/values.yaml
================================================
# Default values for maddy.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1 # Multiple replicas are not supported, please don't change this.
image:
repository: foxcpp/maddy
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: ""
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
serviceAccount:
# Specifies whether a service account should be created
create: true
# Annotations to add to the service account
annotations: {}
# The name of the service account to use.
# If not set and create is true, a name is generated using the fullname template
name: ""
podAnnotations: {}
podSecurityContext:
{}
# fsGroup: 2000
securityContext:
{}
# capabilities:
# drop:
# - ALL
# readOnlyRootFilesystem: true
# runAsNonRoot: true
# runAsUser: 1000
# Set externalPIs to your public IP(s) of the node running maddy. In case of multiple nodes, you need to set tolerations
# and taints in order to run maddy on the exact node.
service:
type: NodePort
# externalIPs:
resources:
{}
# We usually recommend not to specify default resources and to leave this as a conscious
# choice for the user. This also increases chances charts run on environments with little
# resources, such as Minikube. If you do want to specify resources, uncomment the following
# lines, adjust them as necessary, and remove the curly braces after 'resources:'.
# limits:
# cpu: 100m
# memory: 128Mi
# requests:
# cpu: 100m
# memory: 128Mi
persistence:
enabled: false
# existingClaim: ""
accessMode: ReadWriteOnce
size: 128Mi
# storageClass: ""
path: /data
annotations: {}
# subPath: "" # only mount a subpath of the Volume into the pod
nodeSelector: {}
tolerations: []
affinity: {}
================================================
FILE: directories.go
================================================
//go:build !docker
// +build !docker
package maddy
var (
// ConfigDirectory specifies platform-specific value
// that should be used as a location of default configuration
//
// It should not be changed and is defined as a variable
// only for purposes of modification using -X linker flag.
ConfigDirectory = "/etc/maddy"
// DefaultStateDirectory specifies platform-specific
// default for StateDirectory.
//
// Most code should use StateDirectory instead since
// it will contain the effective location of the state
// directory.
//
// It should not be changed and is defined as a variable
// only for purposes of modification using -X linker flag.
DefaultStateDirectory = "/var/lib/maddy"
// DefaultRuntimeDirectory specifies platform-specific
// default for RuntimeDirectory.
//
// Most code should use RuntimeDirectory instead since
// it will contain the effective location of the state
// directory.
//
// It should not be changed and is defined as a variable
// only for purposes of modification using -X linker flag.
DefaultRuntimeDirectory = "/run/maddy"
// DefaultLibexecDirectory specifies platform-specific
// default for LibexecDirectory.
//
// Most code should use LibexecDirectory since it will
// contain the effective location of the libexec
// directory.
//
// It should not be changed and is defined as a variable
// only for purposes of modification using -X linker flag.
DefaultLibexecDirectory = "/usr/lib/maddy"
)
================================================
FILE: directories_docker.go
================================================
//go:build docker
// +build docker
package maddy
var (
ConfigDirectory = "/data"
DefaultStateDirectory = "/data"
DefaultRuntimeDirectory = "/tmp"
DefaultLibexecDirectory = "/usr/lib/maddy"
)
================================================
FILE: dist/README.md
================================================
Distribution files for maddy
------------------------------
**Disclaimer:** Most of the files here are maintained in a "best-effort" way.
That is, they may break or become outdated from time to time. Caveat emptor.
## integration + scripts
These directories provide pre-made configuration snippets suitable for
easy integration with external software.
Usually, this is what you use when you put `import integration/something` in
your config.
## systemd unit
`maddy.service` launches using default config path (/etc/maddy/maddy.conf).
`maddy@.service` launches maddy using custom config path. E.g.
`maddy@foo.service` will use /etc/maddy/foo.conf.
Additionally, unit files apply strict sandboxing, limiting maddy permissions on
the system to a bare minimum. Subset of these options makes it impossible for
privileged authentication helper binaries to gain required permissions, so you
may have to disable it when using system account-based authentication with
maddy running as a unprivileged user.
## fail2ban configuration
Configuration files for use with fail2ban. Assume either `backend = systemd` specified
in system-wide configuration or log file written to /var/log/maddy/maddy.log.
See https://github.com/foxcpp/maddy/wiki/fail2ban-configuration for details.
## logrotate configuration
Meant for logs rotation when logging to file is used.
## vim ftdetect/ftplugin/syntax files
Minimal supplement to make configuration files more readable and help you see
typos in directive names.
================================================
FILE: dist/apparmor/dev.foxcpp.maddy
================================================
# AppArmor profile for maddy daemon.
# vim:syntax=apparmor:ts=2:sw=2:et
#include
profile dev.foxcpp.maddy /usr{/local,}/bin/maddy {
#include
#include
#include
/etc/ca-certificates/** r,
/etc/resolv.conf r,
/proc/sys/net/core/somaxconn r,
/sys/kernel/mm/transparent_hugepage/hpage_pmd_size r,
deny ptrace,
capability net_bind_service,
network tcp,
network unix,
# systemd process management and Type=notify
signal (receive) peer=unconfined,
signal (receive) peer=/usr/bin/systemd,
unix (create, connect, send, setopt) type=dgram addr=@*,
/run/systemd/notify w,
/etc/maddy/** r,
owner /run/maddy/ rw,
owner /run/maddy/** rwkl,
owner /var/lib/maddy/ rw,
owner /var/lib/maddy/** rwk,
owner /var/lib/maddy/**.db-{wal,shm} rmk,
/usr{/local,}/lib/maddy/* PUx,
/usr{/local,}/bin/maddy{,ctl} rmix,
#include if exists
}
================================================
FILE: dist/fail2ban/filter.d/maddy-auth.conf
================================================
[INCLUDES]
before = common.conf
[Definition]
failregex = authentication failed\t\{\"reason\":\".*\",\"src_ip\"\:\":\d+\"\,\"username\"\:\".*\"\}$
journalmatch = _SYSTEMD_UNIT=maddy.service + _COMM=maddy
================================================
FILE: dist/fail2ban/filter.d/maddy-dictonary-attack.conf
================================================
[INCLUDES]
before = common.conf
[Definition]
failregex = smtp\: MAIL FROM error repeated a lot\, possible dictonary attack\t\{\"count\"\:\d+,\"msg_id\":\".+\",\"src_ip\"\:\":\d+\"\}$
smtp\: too many RCPT errors\, possible dictonary attack\t\{\"msg_id\":\".+\","src_ip":":\d+\"\}
journalmatch = _SYSTEMD_UNIT=maddy.service + _COMM=maddy
================================================
FILE: dist/fail2ban/jail.d/maddy-auth.conf
================================================
[maddy-auth]
port = 993,465,25
filter = maddy-auth
bantime = 96h
backend = systemd
================================================
FILE: dist/fail2ban/jail.d/maddy-dictonary-attack.conf
================================================
[maddy-dictonary-attack]
port = 993,465,25
filter = maddy-dictonary-attack
bantime = 72h
maxretry = 3
findtime = 6h
backend = systemd
================================================
FILE: dist/install.sh
================================================
#!/bin/bash
DESTDIR=$DESTDIR
if [ -z "$PREFIX" ]; then
PREFIX=/usr/local
fi
if [ -z "$FAIL2BANDIR" ]; then
FAIL2BANDIR=/etc/fail2ban
fi
if [ -z "$CONFDIR" ]; then
CONFDIR=/etc/maddy
fi
script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && pwd )"
cd $script_dir
install -Dm 0644 -t "$DESTDIR/$PREFIX/share/vim/vimfiles/ftdetect/" vim/ftdetect/maddy-conf.vim
install -Dm 0644 -t "$DESTDIR/$PREFIX/share/vim/vimfiles/ftplugin/" vim/ftplugin/maddy-conf.vim
install -Dm 0644 -t "$DESTDIR/$PREFIX/share/vim/vimfiles/syntax/" vim/syntax/maddy-conf.vim
install -Dm 0644 -t "$DESTDIR/$FAIL2BANDIR/jail.d/" fail2ban/jail.d/*
install -Dm 0644 -t "$DESTDIR/$FAIL2BANDIR/filter.d/" fail2ban/filter.d/*
install -Dm 0644 -t "$DESTDIR/$PREFIX/lib/systemd/system/" systemd/maddy.service systemd/maddy@.service
================================================
FILE: dist/logrotate.d/maddy
================================================
/var/log/maddy/maddy.log {
missingok
su maddy maddy
postrotate
/usr/bin/killall -USR1 maddy
endscript
}
================================================
FILE: dist/systemd/maddy.service
================================================
[Unit]
Description=maddy mail server
Documentation=man:maddy(1)
Documentation=man:maddy.conf(5)
Documentation=https://maddy.email
After=network-online.target
[Service]
Type=notify
NotifyAccess=main
User=maddy
Group=maddy
# cd to state directory to make sure any relative paths
# in config will be relative to it unless handled specially.
WorkingDirectory=/var/lib/maddy
ConfigurationDirectory=maddy
RuntimeDirectory=maddy
StateDirectory=maddy
LogsDirectory=maddy
ReadOnlyPaths=/usr/lib/maddy
ReadWritePaths=/var/lib/maddy
# Strict sandboxing. You have no reason to trust code written by strangers from GitHub.
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectHostname=true
ProtectClock=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
# Additional sandboxing. You need to disable all of these options
# for privileged helper binaries (for system auth) to work correctly.
NoNewPrivileges=true
PrivateDevices=true
DeviceAllow=/dev/syslog
RestrictSUIDSGID=true
ProtectKernelModules=true
MemoryDenyWriteExecute=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
# Graceful shutdown with a reasonable timeout.
TimeoutStopSec=7s
KillMode=mixed
KillSignal=SIGTERM
# Required to bind on ports lower than 1024.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Force all files created by maddy to be only readable by it
# and maddy group.
UMask=0007
# Bump FD limitations. Even idle mail server can have a lot of FDs open (think
# of idle IMAP connections, especially ones abandoned on the other end and
# slowly timing out).
LimitNOFILE=131072
# Limit processes count to something reasonable to
# prevent resources exhausting due to big amounts of helper
# processes launched.
LimitNPROC=512
# Restart server on any problem.
Restart=on-failure
# ... Unless it is a configuration problem.
RestartPreventExitStatus=2
ExecStart=/usr/local/bin/maddy run
ExecReload=/bin/kill -USR2 $MAINPID
[Install]
WantedBy=multi-user.target
================================================
FILE: dist/systemd/maddy@.service
================================================
[Unit]
Description=maddy mail server (using %i.conf)
Documentation=man:maddy(1)
Documentation=man:maddy.conf(5)
Documentation=https://maddy.email
After=network-online.target
[Service]
Type=notify
NotifyAccess=main
User=maddy
Group=maddy
ConfigurationDirectory=maddy
RuntimeDirectory=maddy
StateDirectory=maddy
LogsDirectory=maddy
ReadOnlyPaths=/usr/lib/maddy
ReadWritePaths=/var/lib/maddy
# Strict sandboxing. You have no reason to trust code written by strangers from GitHub.
PrivateTmp=true
PrivateHome=true
ProtectSystem=strict
ProtectKernelTunables=true
ProtectHostname=true
ProtectClock=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
DeviceAllow=/dev/syslog
# Additional sandboxing. You need to disable all of these options
# for privileged helper binaries (for system auth) to work correctly.
NoNewPrivileges=true
PrivateDevices=true
RestrictSUIDSGID=true
ProtectKernelModules=true
MemoryDenyWriteExecute=true
RestrictNamespaces=true
RestrictRealtime=true
LockPersonality=true
# Graceful shutdown with a reasonable timeout.
TimeoutStopSec=7s
KillMode=mixed
KillSignal=SIGTERM
# Required to bind on ports lower than 1024.
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
# Force all files created by maddy to be only readable by it and
# maddy group.
UMask=0007
# Bump FD limitations. Even idle mail server can have a lot of FDs open (think
# of idle IMAP connections, especially ones abandoned on the other end and
# slowly timing out).
LimitNOFILE=131072
# Limit processes count to something reasonable to
# prevent resources exhausting due to big amounts of helper
# processes launched.
LimitNPROC=512
# Restart server on any problem.
Restart=on-failure
# ... Unless it is a configuration problem.
RestartPreventExitStatus=2
ExecStart=/usr/local/bin/maddy --config /etc/maddy/%i.conf run
ExecReload=/bin/kill -USR2 $MAINPID
[Install]
WantedBy=multi-user.target
================================================
FILE: dist/vim/ftdetect/maddy-conf.vim
================================================
au BufNewFile,BufRead /etc/maddy/*,maddy.conf setf maddy-conf
================================================
FILE: dist/vim/ftplugin/maddy-conf.vim
================================================
setlocal commentstring=#\ %s
" That is convention for maddy configs. Period.
" - fox.cpp (maddy developer)
setlocal expandtab
setlocal tabstop=4
setlocal softtabstop=4
setlocal shiftwidth=4
================================================
FILE: dist/vim/syntax/maddy-conf.vim
================================================
" vim: noexpandtab ts=4 sw=4
if exists("b:current_syntax")
finish
endif
" Lexer-defined rules
syn match maddyComment "#.*"
syn region maddyString start=+"+ skip=+\\\\\|\\"+ end=+"+ oneline
syn region maddyBlock start="{" end="}" transparent fold
hi def link maddyComment Comment
hi def link maddyString String
" Parser-defined rules
syn match maddyMacroName "[a-z0-9_]" contained containedin=maddyMacro
syn match maddyMacro "$(.\{-})" contains=maddyMacroName
syn match maddyMacroDefSign "=" contained
syn match maddyMacroDef "\^$([a-z0-9_]\{-})\s=\s.\+" contains=maddyMacro,maddyMacroDefSign
hi def link maddyMacroName Identifier
hi def link maddyMacro Special
hi def link maddyMacroDefSign Special
" config.Map values
syn keyword maddyBool yes no
syn match maddyInt '\<\d\+\>'
syn match maddyInt '\<[-+]\d\+\>'
syn match maddyFloat '\<[-+]\d\+\.\d*\<'
syn match maddyReference /[ \t]&[^ \t]\+/ms=s+1 contains=maddyReferenceSign
syn match maddyReferenceSign /&/ contained
hi def link maddyBool Boolean
hi def link maddyInt Number
hi def link maddyFloat Float
hi def link maddyReferenceSign Special
" Module values
" grep --no-file -E 'Register.*\(".+", ' **.go | sed -E 's/.+Register.*\("([^"]+)", .+/\1/' | sort -u
syn keyword maddyModule
\ checks
\ command
\ dane
\ dkim
\ dnsbl
\ dnssec
\ dummy
\ extauth
\ external
\ file
\ identity
\ imap
\ imap_filters
\ imapsql
\ limits
\ lmtp
\ loader
\ local_policy
\ milter
\ modifiers
\ msgpipeline
\ mtasts
\ mx_auth
\ pam
\ pass_table
\ plain_separate
\ queue
\ regexp
\ remote
\ replace_rcpt
\ replace_sender
\ require_matching_rdns
\ require_mx_record
\ require_tls
\ rspamd
\ shadow
\ smtp
\ sql_query
\ sql_table
\ static
\ submission
syn keyword maddyDispatchDir
\ check
\ modify
\ default_source
\ source
\ default_destination
\ destination
\ reject
\ deliver_to
\ reroute
\ dmarc
" grep --no-file -E 'cfg..+\(".+", ' **.go | sed -E 's/.+cfg..+\("([^"]+)", .+/\1/' | sort -u
syn keyword maddyModDir
\ add
\ add_header_action
\ allow_multiple_from
\ api_path
\ appendlimit
\ attempt_starttls
\ auth
\ autogenerated_msg_domain
\ body_canon
\ bounce
\ broken_sig_action
\ buffer
\ cache
\ case_insensitive
\ certs
\ check_early
\ client_ipv4
\ client_ipv6
\ compression
\ conn_max_idle_count
\ conn_max_idle_time
\ conn_reuse_limit
\ debug
\ defer_sender_reject
\ del
\ domains
\ driver
\ dsn
\ ehlo
\ endpoint
\ enforce_early
\ enforce_testing
\ entry
\ error_resp_action
\ expand_replaceholders
\ fail_action
\ fail_open
\ file
\ flags
\ force_ipv4
\ fs_dir
\ fsstore
\ full_match
\ hash
\ header_canon
\ helper
\ hostname
\ imap_filter
\ init
\ insecure_auth
\ io_debug
\ io_error_action
\ io_errors
\ junk_mailbox
\ key_column
\ key_path
\ keys
\ limits
\ list
\ local_ip
\ location
\ lookup
\ mailfrom
\ max_logged_rcpt_errors
\ max_message_size
\ max_parallelism
\ max_received
\ max_recipients
\ max_tries
\ min_mx_level
\ min_tls_level
\ mx_auth
\ neutral_action
\ newkey_algo
\ none_action
\ no_sig_action
\ oversign_fields
\ pass
\ perdomain
\ permerr_action
\ quarantine_threshold
\ read_timeout
\ reject_threshold
\ reject_action
\ relaxed_requiretls
\ required_fields
\ require_sender_match
\ require_tls
\ requiretls_override
\ responses
\ rewrite_subj_action
\ run_on
\ score
\ selector
\ set
\ settings_id
\ sig_expiry
\ sign_fields
\ sign_subdomains
\ soft_reject_action
\ softfail_action
\ SOME_action
\ source
\ sqlite3_busy_timeout
\ sqlite3_cache_size
\ sqlite3_exclusive_lock
\ storage
\ table
\ table_name
\ tag
\ target
\ targets
\ temperr_action
\ tls
\ tls_client
\ use_helper
\ user
\ value_column
\ write_timeout
hi def link maddyModDir Identifier
hi def link maddyModule Identifier
hi def link maddyDispatchDir Identifier
let b:current_syntax = "maddy"
================================================
FILE: docs/docker.md
================================================
# Docker
Official Docker image is available from Docker Hub.
It expects configuration file to be available at /data/maddy.conf.
If /data is a Docker volume, then default configuration will be placed there
automatically. If it is used, then MADDY_HOSTNAME, MADDY_DOMAIN environment
variables control the host name and primary domain for the server. TLS
certificate should be placed in /data/tls/fullchain.pem, private key in
/data/tls/privkey.pem
DKIM keys are generated in /data/dkim_keys directory.
## Image tags
- `latest` - A latest stable release. May contain breaking changes.
- `X.Y` - A specific feature branch, it is recommended to use these tags to
receive bugfixes without the risk of feature-related regressions or breaking
changes.
- `X.Y.Z` - A specific stable release
## Ports
All standard ports, as described in maddy docs.
- `25` - SMTP inbound port.
- `465`, `587` - SMTP Submission ports
- `993`, `143` - IMAP4 ports
## Volumes
`/data` - maddy state directory. Databases, queues, etc are stored here. You
might want to mount a named volume there. The main configuration file is stored
here too (`/data/maddy.conf`).
## Management utility
To run management commands, create a temporary container with the same
/data directory and put the command after the image name, like this:
```
docker run --rm -it -v maddydata:/data foxcpp/maddy:0.7 creds create foxcpp@maddy.test
docker run --rm -it -v maddydata:/data foxcpp/maddy:0.7 imap-acct create foxcpp@maddy.test
```
Use the same image version as the running server. Things may break badly
otherwise.
Note that, if you modify messages using maddy subcommands while the server is running -
you must ensure that /tmp from the server is accessible for the management
command. One way to it is to run it using `docker exec` instead of `docker run`:
```
docker exec -it container_name_here maddy creds create foxcpp@maddy.test
```
## Build Tags
Some Maddy features (such as automatic certificate management via ACME with [a non-default libdns provider](../reference/tls-acme/#dns-providers)) require build tags to be passed to Maddy's `build.sh`, as this is run in the Dockerfile you must compile your own Docker image. Build tags can be set via the docker build argument `ADDITIONAL_BUILD_TAGS` e.g. `docker build --build-arg ADDITIONAL_BUILD_TAGS="libdns_acmedns libdns_route53" -t yourorgname/maddy:yourtagname .`.
## TL;DR
```
docker volume create maddydata
docker run \
--name maddy \
-e MADDY_HOSTNAME=mx.maddy.test \
-e MADDY_DOMAIN=maddy.test \
-v maddydata:/data \
-p 25:25 \
-p 143:143 \
-p 465:465 \
-p 587:587 \
-p 993:993 \
foxcpp/maddy:0.7
```
It will fail on first startup. Copy TLS certificate to /data/tls/fullchain.pem
and key to /data/tls/privkey.pem. Run the server again. Finish DNS configuration
(DKIM keys, etc) as described in [tutorials/setting-up/](../tutorials/setting-up/).
================================================
FILE: docs/faq.md
================================================
# Frequently Asked Questions
## I configured maddy as recommended and gmail still puts my messages in spam
Unfortunately, GMail policies are opaque so we cannot tell why this happens.
Verify that you have a rDNS record set for the IP used
by sender server. Also some IPs may just happen to
have bad reputation - check it with various DNSBLs. In this
case you do not have much of a choice but to replace it.
Additionally, you may try marking multiple messages sent from
your domain as "not spam" in GMail UI.
## Message sending fails with `dial tcp X.X.X.X:25: connect: connection timed out` in log
Your provider is blocking outbound SMTP traffic on port 25.
You either have to ask them to unblock it or forward
all outbound messages via a "smart-host".
## What is resource usage of maddy?
For a small personal server, you do not need much more than a
single 1 GiB of RAM and disk space.
## How to setup a catchall address?
https://github.com/foxcpp/maddy/issues/243#issuecomment-655694512
## maddy command prints a "permission denied" error
Run maddy command under the same user as maddy itself.
E.g.
```
sudo -u maddy maddy creds ...
```
## How maddy compares to MailCow or Mail-In-The-Box?
MailCow and MIAB are bundles of well-known email-related software configured to
work together. maddy is a single piece of software implementing subset of what
MailCow and MIAB offer.
maddy offers more uniform configuration system, more lightweight implementation
and has no dependency on Docker or similar technologies for deployment.
maddy may have more bugs than 20 years old battle-tested software.
It is easier to get help with MailCow/MITB since underlying implementations
are well-understood and have active community.
maddy has no Web interface for administration, that is currently done via CLI
utility.
## How maddy IMAP server compares to WildDuck?
Both are "more secure by definition": root access is not required,
implementation is in memory-safe language, etc.
Both support message compression.
Both have first-class Unicode/internationalization support.
WildDuck may offer easier scalability options. maddy does not require you to
setup MongoDB and Redis servers, though. In fact, maddy in its default
configuration has no dependencies besides libc.
maddy has less builtin authentication providers. This means no
app-specific passwords and all that WildDuck lists under point 4 on their
features page.
maddy currently has no admin Web interface, all necessary DB changes are
performed via CLI utility.
## How maddy SMTP server compares to ZoneMTA?
maddy SMTP server has a lot of similarities to ZoneMTA.
Both have powerful mechanisms for message routing (although designed
differently).
maddy does not require MongoDB server for deployment.
maddy has no web interface for queue inspection. However, it can
easily inspected by looking at files in /var/lib/maddy.
ZoneMTA has a number of features that may make it easier to integrate
with HTTP-based services. maddy speaks standard email protocols (SMTP,
Submission).
## Is there a webmail?
No, at least currently.
I suggest you to check out [alps](https://git.sr.ht/~migadu/alps) if you
are fine with alpha-quality but extremely easy to deploy webmail.
## Is there a content filter (spam filter)?
No. maddy moves email messages around, it does not classify
them as bad or good with the notable exception of sender policies.
It is possible to integrate rspamd using 'rspamd' module. Just add
`rspamd` line to `checks` in `local_routing`, it should just work
in most cases.
## Is it production-ready?
maddy is considered "beta" quality. Several people use it for personal email.
## Single process makes it unreliable. This is dumb!
This is a compromise between ease of management and reliability. Several
measures are implemented in code base in attempt to reduce possible effect
of bugs in one component.
Besides, you are not required to use a single process, it is easy to launch
maddy with a non-default configuration path and connect multiple instances
together using off-the-shelf protocols.
================================================
FILE: docs/index.md
================================================
> Composable all-in-one mail server.
Maddy Mail Server implements all functionality required to run a e-mail
server. It can send messages via SMTP (works as MTA), accept messages via SMTP
(works as MX) and store messages while providing access to them via IMAP.
In addition to that it implements auxiliary protocols that are mandatory
to keep email reasonably secure (DKIM, SPF, DMARC, DANE, MTA-STS).
It replaces Postfix, Dovecot, OpenDKIM, OpenSPF, OpenDMARC and more with one
daemon with uniform configuration and minimal maintenance cost.
**Note:** IMAP storage is "beta". If you are looking for stable and
feature-packed implementation you may want to use Dovecot instead. maddy still
can handle message delivery business.
[](https://github.com/foxcpp/maddy/actions/workflows/cicd.yml)
[](https://github.com/foxcpp/maddy)
* [Setup tutorial](https://maddy.email/tutorials/setting-up/)
* [Documentation](https://maddy.email/)
* [IRC channel](https://webchat.oftc.net/?channels=maddy&uio=MT11bmRlZmluZWQb1)
* [Mailing list](https://lists.sr.ht/~foxcpp/maddy)
================================================
FILE: docs/internals/quirks.md
================================================
# Implementation quirks
This page documents unusual behavior of the maddy protocols implementations.
Some of these problems break standards, some don't but still can hurt
interoperability.
## SMTP
- `for` field is never included in the `Received` header field.
This is allowed by [RFC 2821].
## IMAP
### `sql`
- `\Recent` flag is not reset in all cases.
This _does not_ break [RFC 3501]. Clients relying on it will work (much) less
efficiently.
[RFC 2821]: https://tools.ietf.org/html/rfc2821
[RFC 3501]: https://tools.ietf.org/html/rfc3501
================================================
FILE: docs/internals/specifications.md
================================================
# Followed specifications
This page lists Internet Standards and other specifications followed by
maddy along with any known deviations.
## Message format
- [RFC 2822] - Internet Message Format
- [RFC 2045] - Multipurpose Internet Mail Extensions (MIME) (part 1)
- [RFC 2046] - Multipurpose Internet Mail Extensions (MIME) (part 2)
- [RFC 2047] - Multipurpose Internet Mail Extensions (MIME) (part 3)
- [RFC 2048] - Multipurpose Internet Mail Extensions (MIME) (part 4)
- [RFC 2049] - Multipurpose Internet Mail Extensions (MIME) (part 5)
- [RFC 6532] - Internationalized Email Headers
- [RFC 2183] - Communicating Presentation Information in Internet Messages: The
Content-Disposition Header Field
## IMAP
- [RFC 3501] - Internet Message Access Protocol - Version 4rev1
* **Partial**: `\Recent` flag is not reset sometimes.
- [RFC 2152] - UTF-7
### Extensions
- [RFC 2595] - Using TLS with IMAP, POP3 and ACAP
- [RFC 7889] - The IMAP APPENDLIMIT Extension
- [RFC 3348] - The Internet Message Action Protocol (IMAP4). Child Mailbox
Extension
- [RFC 6851] - Internet Message Access Protocol (IMAP) - MOVE Extension
- [RFC 6154] - IMAP LIST Extension for Special-Use Mailboxes
* **Partial**: Only SPECIAL-USE capability.
- [RFC 5255] - Internet Message Access Protocol Internationalization
* **Partial**: Only I18NLEVEL=1 capability.
- [RFC 4978] - The IMAP COMPRESS Extension
- [RFC 3691] - Internet Message Access Protocol (IMAP) UNSELECT command
- [RFC 2177] - IMAP4 IDLE command
- [RFC 7888] - IMAP4 Non-Synchronizing Literals
* LITERAL+ capability.
- [RFC 4959] - IMAP Extension for Simple Authentication and Security Layer
(SASL) Initial Client Response
## SMTP
- [RFC 2033] - Local Mail Transfer Protocol
- [RFC 5321] - Simple Mail Transfer Protocol
- [RFC 6409] - Message Submission for Mail
### Extensions
- [RFC 1870] - SMTP Service Extension for Message Size Declaration
- [RFC 2920] - SMTP Service Extension for Command Pipelining
* Server support only, not used by SMTP client
- [RFC 2034] - SMTP Service Extension for Returning Enhanced Error Codes
- [RFC 3207] - SMTP Service Extension for Secure SMTP over Transport Layer
Security
- [RFC 4954] - SMTP Service Extension for Authentication
- [RFC 6152] - SMTP Extension for 8-bit MIME
- [RFC 6531] - SMTP Extension for Internationalized Email
### Misc
- [RFC 6522] - The Multipart/Report Content Type for the Reporting of Mail
System Administrative Messages
- [RFC 3464] - An Extensible Message Format for Delivery Status Notifications
- [RFC 6533] - Internationalized Delivery Status and Disposition Notifications
## Email security
### User authentication
- [RFC 4422] - Simple Authentication and Security Layer (SASL)
- [RFC 4616] - The PLAIN Simple Authentication and Security Layer (SASL)
Mechanism
### Sender authentication
- [RFC 6376] - DomainKeys Identified Mail (DKIM) Signatures
- [RFC 7001] - Message Header Field for Indicating Message Authentication Status
- [RFC 7208] - Sender Policy Framework (SPF) for Authorizing Use of Domains in
Email, Version 1
- [RFC 7372] - Email Authentication Status Codes
- [RFC 7479] - Domain-based Message Authentication, Reporting, and Conformance
(DMARC)
* **Partial**: No report generation.
- [RFC 8301] - Cryptographic Algorithm and Key Usage Update to DomainKeys
Identified Mail (DKIM)
- [RFC 8463] - A New Cryptographic Signature Method for DomainKeys Identified
Mail (DKIM)
- [RFC 8616] - Email Authentication for Internationalized Mail
### Recipient authentication
- [RFC 4033] - DNS Security Introduction and Requirements
- [RFC 6698] - The DNS-Based Authentication of Named Entities (DANE) Transport
Layer Security (TLS) Protocol: TLSA
- [RFC 7672] - SMTP Security via Opportunistic DNS-Based Authentication of
Named Entities (DANE) Transport Layer Security (TLS)
- [RFC 8461] - SMTP MTA Strict Transport Security (MTA-STS)
## Unicode, encodings, internationalization
- [RFC 3492] - Punycode: A Bootstring encoding of Unicode for Internationalized
Domain Names in Applications (IDNA)
- [RFC 3629] - UTF-8, a transformation format of ISO 10646
- [RFC 5890] - Internationalized Domain Names for Applications (IDNA):
Definitions and Document Framework
- [RFC 5891] - Internationalized Domain Names for Applications (IDNA): Protocol
- [RFC 7616] - Preparation, Enforcement, and Comparison of Internationalized
Strings Representing Usernames and Passwords
- [RFC 8264] - PRECIS Framework: Preparation, Enforcement, and Comparison of
Internationalized Strings in Application Protocols
- [Unicode 11.0.0]
- [UAX #15] - Unicode Normalization Forms
There is a huge list of non-Unicode encodings supported by message parser used
for IMAP static cache and search. See [Unicode support](unicode.md) page for
details.
## Misc
- [RFC 5782] - DNS Blacklists and Whitelists
[GH 188]: https://github.com/foxcpp/maddy/issues/188
[RFC 2822]: https://tools.ietf.org/html/rfc2822
[RFC 2045]: https://tools.ietf.org/html/rfc2045
[RFC 2046]: https://tools.ietf.org/html/rfc2046
[RFC 2047]: https://tools.ietf.org/html/rfc2047
[RFC 2048]: https://tools.ietf.org/html/rfc2048
[RFC 2049]: https://tools.ietf.org/html/rfc2049
[RFC 6532]: https://tools.ietf.org/html/rfc6532
[RFC 2183]: https://tools.ietf.org/html/rfc2183
[RFC 3501]: https://tools.ietf.org/html/rfc3501
[RFC 2152]: https://tools.ietf.org/html/rfc2152
[RFC 2595]: https://tools.ietf.org/html/rfc2595
[RFC 7889]: https://tools.ietf.org/html/rfc7889
[RFC 3348]: https://tools.ietf.org/html/rfc3348
[RFC 6851]: https://tools.ietf.org/html/rfc6851
[RFC 6154]: https://tools.ietf.org/html/rfc6154
[RFC 5255]: https://tools.ietf.org/html/rfc5255
[RFC 4978]: https://tools.ietf.org/html/rfc4978
[RFC 3691]: https://tools.ietf.org/html/rfc3691
[RFC 2177]: https://tools.ietf.org/html/rfc2177
[RFC 7888]: https://tools.ietf.org/html/rfc7888
[RFC 4959]: https://tools.ietf.org/html/rfc4959
[RFC 2033]: https://tools.ietf.org/html/rfc2033
[RFC 5321]: https://tools.ietf.org/html/rfc5321
[RFC 6409]: https://tools.ietf.org/html/rfc6409
[RFC 1870]: https://tools.ietf.org/html/rfc1870
[RFC 2920]: https://tools.ietf.org/html/rfc2920
[RFC 2034]: https://tools.ietf.org/html/rfc2034
[RFC 3207]: https://tools.ietf.org/html/rfc3207
[RFC 4954]: https://tools.ietf.org/html/rfc4954
[RFC 6152]: https://tools.ietf.org/html/rfc6152
[RFC 6531]: https://tools.ietf.org/html/rfc6531
[RFC 6522]: https://tools.ietf.org/html/rfc6522
[RFC 3464]: https://tools.ietf.org/html/rfc3464
[RFC 6533]: https://tools.ietf.org/html/rfc6533
[RFC 4422]: https://tools.ietf.org/html/rfc4422
[RFC 4616]: https://tools.ietf.org/html/rfc4616
[RFC 6376]: https://tools.ietf.org/html/rfc6376
[RFC 7001]: https://tools.ietf.org/html/rfc7001
[RFC 7208]: https://tools.ietf.org/html/rfc7208
[RFC 7372]: https://tools.ietf.org/html/rfc7372
[RFC 7479]: https://tools.ietf.org/html/rfc7479
[RFC 8301]: https://tools.ietf.org/html/rfc8301
[RFC 8463]: https://tools.ietf.org/html/rfc8463
[RFC 8616]: https://tools.ietf.org/html/rfc8616
[RFC 4033]: https://tools.ietf.org/html/rfc4033
[RFC 6698]: https://tools.ietf.org/html/rfc6698
[RFC 7672]: https://tools.ietf.org/html/rfc7672
[RFC 8461]: https://tools.ietf.org/html/rfc8461
[RFC 3492]: https://tools.ietf.org/html/rfc3492
[RFC 3629]: https://tools.ietf.org/html/rfc3629
[RFC 5890]: https://tools.ietf.org/html/rfc5890
[RFC 5891]: https://tools.ietf.org/html/rfc5891
[RFC 7616]: https://tools.ietf.org/html/rfc7616
[RFC 8264]: https://tools.ietf.org/html/rfc8264
[RFC 5782]: https://tools.ietf.org/html/rfc5782
[RFC 2822]: https://tools.ietf.org/html/rfc2822
[RFC 2045]: https://tools.ietf.org/html/rfc2045
[RFC 2046]: https://tools.ietf.org/html/rfc2046
[RFC 2047]: https://tools.ietf.org/html/rfc2047
[RFC 2048]: https://tools.ietf.org/html/rfc2048
[RFC 2049]: https://tools.ietf.org/html/rfc2049
[RFC 6532]: https://tools.ietf.org/html/rfc6532
[RFC 3501]: https://tools.ietf.org/html/rfc3501
[RFC 2595]: https://tools.ietf.org/html/rfc2595
[RFC 7889]: https://tools.ietf.org/html/rfc7889
[RFC 3348]: https://tools.ietf.org/html/rfc3348
[RFC 6851]: https://tools.ietf.org/html/rfc6851
[RFC 6154]: https://tools.ietf.org/html/rfc6154
[RFC 5255]: https://tools.ietf.org/html/rfc5255
[RFC 4978]: https://tools.ietf.org/html/rfc4978
[RFC 3691]: https://tools.ietf.org/html/rfc3691
[RFC 2177]: https://tools.ietf.org/html/rfc2177
[RFC 7888]: https://tools.ietf.org/html/rfc7888
[RFC 4959]: https://tools.ietf.org/html/rfc4959
[RFC 2033]: https://tools.ietf.org/html/rfc2033
[RFC 5321]: https://tools.ietf.org/html/rfc5321
[RFC 6409]: https://tools.ietf.org/html/rfc6409
[RFC 1870]: https://tools.ietf.org/html/rfc1870
[RFC 2920]: https://tools.ietf.org/html/rfc2920
[RFC 2034]: https://tools.ietf.org/html/rfc2034
[RFC 3207]: https://tools.ietf.org/html/rfc3207
[RFC 4954]: https://tools.ietf.org/html/rfc4954
[RFC 6152]: https://tools.ietf.org/html/rfc6152
[RFC 6531]: https://tools.ietf.org/html/rfc6531
[RFC 6522]: https://tools.ietf.org/html/rfc6522
[RFC 3464]: https://tools.ietf.org/html/rfc3464
[RFC 6533]: https://tools.ietf.org/html/rfc6533
[RFC 4422]: https://tools.ietf.org/html/rfc4422
[RFC 4616]: https://tools.ietf.org/html/rfc4616
[RFC 6376]: https://tools.ietf.org/html/rfc6376
[RFC 7001]: https://tools.ietf.org/html/rfc7001
[RFC 7208]: https://tools.ietf.org/html/rfc7208
[RFC 7372]: https://tools.ietf.org/html/rfc7372
[RFC 7479]: https://tools.ietf.org/html/rfc7479
[RFC 8301]: https://tools.ietf.org/html/rfc8301
[RFC 8463]: https://tools.ietf.org/html/rfc8463
[RFC 8616]: https://tools.ietf.org/html/rfc8616
[RFC 4033]: https://tools.ietf.org/html/rfc4033
[RFC 6698]: https://tools.ietf.org/html/rfc6698
[RFC 7672]: https://tools.ietf.org/html/rfc7672
[RFC 8461]: https://tools.ietf.org/html/rfc8461
[RFC 3492]: https://tools.ietf.org/html/rfc3492
[RFC 3629]: https://tools.ietf.org/html/rfc3629
[RFC 5890]: https://tools.ietf.org/html/rfc5890
[RFC 5891]: https://tools.ietf.org/html/rfc5891
[RFC 7616]: https://tools.ietf.org/html/rfc7616
[RFC 8264]: https://tools.ietf.org/html/rfc8264
[RFC 5782]: https://tools.ietf.org/html/rfc5782
[RFC 2822]: https://tools.ietf.org/html/rfc2822
[RFC 2045]: https://tools.ietf.org/html/rfc2045
[RFC 2046]: https://tools.ietf.org/html/rfc2046
[RFC 2047]: https://tools.ietf.org/html/rfc2047
[RFC 2048]: https://tools.ietf.org/html/rfc2048
[RFC 2049]: https://tools.ietf.org/html/rfc2049
[RFC 6532]: https://tools.ietf.org/html/rfc6532
[RFC 3501]: https://tools.ietf.org/html/rfc3501
[RFC 2595]: https://tools.ietf.org/html/rfc2595
[RFC 7889]: https://tools.ietf.org/html/rfc7889
[RFC 3348]: https://tools.ietf.org/html/rfc3348
[RFC 6851]: https://tools.ietf.org/html/rfc6851
[RFC 6154]: https://tools.ietf.org/html/rfc6154
[RFC 5255]: https://tools.ietf.org/html/rfc5255
[RFC 4978]: https://tools.ietf.org/html/rfc4978
[RFC 3691]: https://tools.ietf.org/html/rfc3691
[RFC 2177]: https://tools.ietf.org/html/rfc2177
[RFC 7888]: https://tools.ietf.org/html/rfc7888
[RFC 4959]: https://tools.ietf.org/html/rfc4959
[RFC 2033]: https://tools.ietf.org/html/rfc2033
[RFC 5321]: https://tools.ietf.org/html/rfc5321
[RFC 6409]: https://tools.ietf.org/html/rfc6409
[RFC 1870]: https://tools.ietf.org/html/rfc1870
[RFC 2920]: https://tools.ietf.org/html/rfc2920
[RFC 2034]: https://tools.ietf.org/html/rfc2034
[RFC 3207]: https://tools.ietf.org/html/rfc3207
[RFC 4954]: https://tools.ietf.org/html/rfc4954
[RFC 6152]: https://tools.ietf.org/html/rfc6152
[RFC 6531]: https://tools.ietf.org/html/rfc6531
[RFC 6522]: https://tools.ietf.org/html/rfc6522
[RFC 3464]: https://tools.ietf.org/html/rfc3464
[RFC 6533]: https://tools.ietf.org/html/rfc6533
[RFC 4422]: https://tools.ietf.org/html/rfc4422
[RFC 4616]: https://tools.ietf.org/html/rfc4616
[RFC 6376]: https://tools.ietf.org/html/rfc6376
[RFC 8301]: https://tools.ietf.org/html/rfc8301
[RFC 8463]: https://tools.ietf.org/html/rfc8463
[RFC 7208]: https://tools.ietf.org/html/rfc7208
[RFC 7372]: https://tools.ietf.org/html/rfc7372
[RFC 7479]: https://tools.ietf.org/html/rfc7479
[RFC 8616]: https://tools.ietf.org/html/rfc8616
[RFC 4033]: https://tools.ietf.org/html/rfc4033
[RFC 6698]: https://tools.ietf.org/html/rfc6698
[RFC 7672]: https://tools.ietf.org/html/rfc7672
[RFC 8461]: https://tools.ietf.org/html/rfc8461
[RFC 3492]: https://tools.ietf.org/html/rfc3492
[RFC 3629]: https://tools.ietf.org/html/rfc3629
[RFC 5890]: https://tools.ietf.org/html/rfc5890
[RFC 5891]: https://tools.ietf.org/html/rfc5891
[RFC 7616]: https://tools.ietf.org/html/rfc7616
[RFC 8264]: https://tools.ietf.org/html/rfc8264
[RFC 5782]: https://tools.ietf.org/html/rfc5782
[Unicode 11.0.0]: https://www.unicode.org/versions/components-11.0.0.html
[UAX #15]: https://unicode.org/reports/tr15/
================================================
FILE: docs/internals/sqlite.md
================================================
# maddy & SQLite
SQLite is a perfect choice for small deployments because no additional
configuration is required to get started. It is recommended for cases when you
have less than 10 mail accounts.
**Note: SQLite requires DB-wide locking for writing, it means that multiple
messages can't be accepted in parallel. This is not the case for server-based
RDBMS where maddy can accept multiple messages in parallel even for a single
mailbox.**
## WAL mode
maddy forces WAL journal mode for SQLite. This makes things reasonably fast and
reduces locking contention which may be important for a typical mail server.
maddy uses increased WAL autocheckpoint interval. This means that while
maintaining a high write throughput, maddy will have to stop for a bit (0.5-1
second) every time 78 MiB is written to the DB (with default configuration it
is 15 MiB).
Note that when moving the database around you need to move WAL journal (`-wal`)
and shared memory (`-shm`) files as well, otherwise some changes to the DB will
be lost.
## Query planner statistics
maddy updates query planner statistics on shutdown and every 5 hours. It
provides query planner with information to access the database in more
efficient way because go-imap-sql schema does use a few so called "low-quality
indexes".
## Auto-vacuum
maddy turns on SQLite auto-vacuum feature. This means that database file size
will shrink when data is removed (compared to default when it remains unused).
## Manual vacuuming
Auto-vacuuming can lead to database fragmentation and thus reduce the read
performance. To do manual vacuum operation to repack and defragment database
file, install the SQLite3 console utility and run the following commands:
```
sqlite3 -cmd 'vacuum' database_file_path_here.db
sqlite3 -cmd 'pragma wal_checkpoint(truncate)' database_file_path_here.db
```
It will take some time to complete, you can close the utility when the
`sqlite>` prompt appears.
================================================
FILE: docs/internals/unicode.md
================================================
# Unicode support
maddy has the first-class Unicode support in all components (modules). You do
not have to take any actions to make it work with internationalized domains,
mailbox names or non-ASCII message headers.
Internally, all text fields in maddy are represented in UTF-8 and handled using
Unicode-aware operations for comparisons, case-folding and so on.
## Non-ASCII data in message headers and bodies
maddy SMTP implementation does not care about encodings used in MIME headers or
in `Content-Type text/*` charset field.
However, local IMAP storage implementation needs to perform certain operations
on header contents. This is mostly about SEARCH functionality. For IMAP search
to work correctly, the message body and headers should use one of the following
encodings:
- ASCII
- UTF-8
- ISO-8859-1, 2, 3, 4, 9, 10, 13, 14, 15 or 16
- Windows-1250, 1251 or 1252 (aka Code Page 1250 and so on)
- KOI8-R
- ~~HZGB2312~~, GB18030
- GBK (aka Code Page 936)
- Shift JIS (aka Code Page 932 or Windows-31J)
- Big-5 (aka Code Page 950)
- EUC-JP
- ISO-2022-JP
_Support for HZGB2312 is currently disabled due to bugs with security
implications._
If mailbox includes a message with any encoding not listed here, it will not
be returned in search results for any request.
Behavior regarding handling of non-Unicode encodings is not considered stable
and may change between versions (including removal of supported encodings). If
you need your stuff to work correctly - start using UTF-8.
## Configuration files
maddy configuration files are assumed to be encoded in UTF-8. Use of any other
encoding will break stuff, do not do it.
Domain names (e.g. in hostname directive or pipeline rules) can be represented
using the ACE form (aka Punycode). They will be converted to the Unicode form
internally.
## Local credentials
'sql' storage backend and authentication provider enforce a number of additional
constraints on used account names.
PRECIS UsernameCaseMapped profile is enforced for local email addresses.
It limits the use of control and Bidi characters to make sure the used value
can be represented consistently in a variety of contexts. On top of that, the
address is case-folded and normalized to the NFC form for consistent internal
handling.
PRECIS OpaqueString profile is enforced for passwords. Less strict rules are
applied here. Runs of Unicode whitespace characters are replaced with a single
ASCII space. NFC normalization is applied afterwards. If the resulting string
is empty - the password is not accepted.
Both profiles are defined in RFC 8265, consult it for details.
## Protocol support
### SMTPUTF8 extension
maddy SMTP implementation includes support for the SMTPUTF8 extension as
defined in RFC 6531.
This means maddy can handle internationalized mailbox and domain names in MAIL
FROM, RCPT TO commands both for outbound and inbound delivery.
maddy will not accept messages with non-ASCII envelope addresses unless
SMTPUTF8 support is requested. If a message with SMTPUTF8 flag set is forwarded
to a server without SMTPUTF8 support, delivery will fail unless it is possible
to represent envelope addresses in the ASCII form (only domains use Unicode and
they can be converted to Punycode). Contents of message body (and header) are
not considered and always accepted and sent as-is, no automatic downgrading or
reencoding is done.
### IMAP UTF8, I18NLEVEL extensions
Currently, maddy does not include support for UTF8 and I18NLEVEL IMAP
extensions. However, it is not a problem that can prevent it from correctly
handling UTF-8 messages (or even messages in other non-ASCII encodings
mentioned above).
Clients that want to implement proper handling for Unicode strings may assume
maddy does not handle them properly in e.g. SEARCH commands and so such clients
may download messages and process them locally.
================================================
FILE: docs/man/.gitignore
================================================
_generated_*.md
================================================
FILE: docs/man/README.md
================================================
maddy manual pages
-------------------
The reference documentation is maintained in the scdoc format and is compiled
into a set of Unix man pages viewable using the standard `man` utility.
See https://git.sr.ht/~sircmpwn/scdoc for information about the tool used to
build pages.
It can be used as follows:
```
scdoc < maddy-filters.5.scd > maddy-filters.5
man ./maddy-filters.5
```
build.sh script in the repo root compiles and installs man pages if the scdoc
utility is installed in the system.
================================================
FILE: docs/man/maddy.1.scd
================================================
maddy(1) "maddy mail server" "maddy reference documentation"
; TITLE Command line arguments
# Name
maddy - Composable all-in-one mail server.
# Synopsis
*maddy* [options...]
# Description
Maddy is Mail Transfer agent (MTA), Mail Delivery Agent (MDA), Mail Submission
Agent (MSA), IMAP server and a set of other essential protocols/schemes
necessary to run secure email server implemented in one executable.
# Command line arguments
*-h, -help*
Show help message and exit.
*-config* _path_
Path to the configuration file. Default is /etc/maddy/maddy.conf.
*-libexec* _path_
Path to the libexec directory. Helper executables will be searched here.
Default is /usr/lib/maddy.
*-log* _targets..._
Comma-separated list of logging targets. Valid values are the same as the
'log' config directive. Affects logging before configuration parsing
completes and after it, if the different value is not specified in the
configuration.
*-debug*
Enable debug log. You want to use it when reporting bugs.
*-v*
Print version & build metadata.
================================================
FILE: docs/man/prepare_md.py
================================================
#!/usr/bin/python3
"""
This script does all necessary pre-processing to convert scdoc format into
Markdown.
Usage:
prepare_md.py < in > out
prepare_md.py file1 file2 file3
Converts into _generated_file1.md, etc.
"""
import sys
import re
anchor_escape = str.maketrans(r' #()./\+-_', '__________')
def prepare(r, w):
new_lines = list()
title = str()
previous_h1_anchor = ''
inside_literal = False
for line in r:
if not inside_literal:
if line.startswith('; TITLE ') and title == '':
title = line[8:]
if line[0] == ';':
continue
# turn *page*(1) into [**page(1)**](../_generated_page.1)
line = re.sub(r'\*(.+?)\*\(([0-9])\)', r'[*\1(\2)*](../_generated_\1.\2)', line)
# *aaa* => **aaa**
line = re.sub(r'\*(.+?)\*', r'**\1**', line)
# remove ++ from line endings
line = re.sub(r'\+\+$', '
', line)
# turn whatever looks like a link into one
line = re.sub(r'(https://[^ \)\(\\]+[a-z0-9_\-])', r'[\1](\1)', line)
# escape underscores inside words
line = re.sub(r'([^ ])_([^ ])', r'\1\\_\2', line)
if line.startswith('```'):
inside_literal = not inside_literal
new_lines.append(line)
if title != '':
print('#', title, file=w)
print(''.join(new_lines[1:]), file=w)
if len(sys.argv) == 1:
prepare(sys.stdin, sys.stdout)
else:
for f in sys.argv[1:]:
new_name = '_generated_' + f[:-4] + '.md'
prepare(open(f, 'r'), open(new_name, 'w'))
================================================
FILE: docs/multiple-domains.md
================================================
# Multiple domains configuration
By default, maddy uses email addresses as account identifiers for both
authentication and storage purposes. Therefore, account named `user@example.org`
is completely independent from `user@example.com`. They must be created
separately, may have different credentials and have separate IMAP mailboxes.
This makes it extremely easy to setup maddy to manage multiple otherwise
independent domains.
Default configuration file contains two macros - `$(primary_domain)` and
`$(local_domains)`. They are used to used in several places thorough the
file to configure message routing, security checks, etc.
In general, you should just add all domains you want maddy to manage to
`$(local_domains)`, like this:
```
$(primary_domain) = example.org
$(local_domains) = $(primary_domain) example.com
```
Note that you need to pick one domain as a "primary" for use in
auto-generated messages.
With that done, you can create accounts using both domains in the name, send
and receive messages and so on. Do not forget to configure corresponding SPF,
DMARC and MTA-STS records as was recommended in
the [introduction tutorial](tutorials/setting-up.md).
Also note that you do not really need a separate TLS certificate for each
managed domain. You can have one hostname e.g. mail.example.org set as an MX
record for multiple domains.
**If you want multiple domains to share username namespace**, you should change
several more options.
You can make "user@example.org" and "user@example.com" users share the same
credentials of user "user" but have different IMAP mailboxes ("user@example.org"
and "user@example.com" correspondingly). For that, it is enough to set `auth_map`
globally to use `email_localpart` table:
```
auth_map email_localpart
```
This way, when user logs in as "user@example.org", "user" will be passed
to the authentication provider, but "user@example.org" will be passed to the
storage backend. You should create accounts like this:
```
maddy creds create user
maddy imap-acct create user@example.org
maddy imap-acct create user@example.com
```
**If you want accounts to also share the same IMAP storage of account named
"user"**, you can set `storage_map` in IMAP endpoint and `delivery_map` in
storage backend to use `email_locapart`:
```
storage.imapsql local_mailboxes {
...
delivery_map email_localpart # deliver "user@*" to "user"
}
imap tls://0.0.0.0:993 {
...
storage &local_mailboxes
...
storage_map email_localpart # "user@*" accesses "user" mailbox
}
```
You also might want to make it possible to log in without
specifying a domain at all. In this case, use `email_localpart_optional` for
both `auth_map` and `storage_map`.
You also need to make `authorize_sender` check (used in `submission` endpoint)
accept non-email usernames:
```
authorize_sender {
...
user_to_email chain {
step email_localpart_optional # remove domain from username if present
step email_with_domain $(local_domains) # expand username with all allowed domains
}
}
```
## TL;DR
Your options:
**"user@example.org" and "user@example.com" have distinct credentials and
distinct mailboxes.**
```
$(primary_domain) = example.org
$(local_domains) = example.org example.com
```
Create accounts as:
```shell
maddy creds create user@example.org
maddy imap-acct create user@example.org
maddy creds create user@example.com
maddy imap-acct create user@example.com
```
**"user@example.org" and "user@example.com" have same credentials but
distinct mailboxes.**
```
$(primary_domain) = example.org
$(local_domains) = example.org example.com
auth_map email_localpart
```
Create accounts as:
```shell
maddy creds create user
maddy imap-acct create user@example.org
maddy imap-acct create user@example.com
```
**"user@example.org", "user@example.com", "user" have same credentials and same
mailboxes.**
```
$(primary_domain) = example.org
$(local_domains) = example.org example.com
auth_map email_localpart_optional # authenticating as "user@*" checks credentials for "user"
storage.imapsql local_mailboxes {
...
delivery_map email_localpart_optional # deliver "user@*" to "user" mailbox
}
imap tls://0.0.0.0:993 {
...
storage_map email_localpart_optional # authenticating as "user@*" accesses "user" mailboxes
}
submission tls://0.0.0.0:465 {
check {
authorize_sender {
...
user_to_email chain {
step email_localpart_optional # remove domain from username if present
step email_with_domain $(local_domains) # expand username with all allowed domains
}
}
}
...
}
```
Create accounts as:
```shell
maddy creds create user
maddy imap-acct create user
```
================================================
FILE: docs/reference/auth/dovecot_sasl.md
================================================
# Dovecot SASL
The 'auth.dovecot_sasl' module implements the client side of the Dovecot
authentication protocol, allowing maddy to use it as a credentials source.
Currently SASL mechanisms support is limited to mechanisms supported by maddy
so you cannot get e.g. SCRAM-MD5 this way.
```
auth.dovecot_sasl {
endpoint unix://socket_path
}
dovecot_sasl unix://socket_path
```
## Configuration directives
### endpoint _schema://address_
Default: not set
Set the address to use to contact Dovecot SASL server in the standard endpoint
format.
`tcp://10.0.0.1:2222` for TCP, `unix:///var/lib/dovecot/auth.sock` for Unix
domain sockets.
================================================
FILE: docs/reference/auth/external.md
================================================
# System command
auth.external module for authentication using external helper binary. It looks for binary
named `maddy-auth-helper` in $PATH and libexecdir and uses it for authentication
using username/password pair.
The protocol is very simple:
Program is launched for each authentication. Username and password are written
to stdin, adding \n to the end. If binary exits with 0 status code -
authentication is considered successful. If the status code is 1 -
authentication is failed. If the status code is 2 - another unrelated error has
happened. Additional information should be written to stderr.
```
auth.external {
helper /usr/bin/ldap-helper
perdomain no
domains example.org
}
```
## Configuration directives
### helper _file_path_
**Required.**
Location of the helper binary.
---
### perdomain _boolean_
Default: `no`
Don't remove domain part of username when authenticating and require it to be
present. Can be used if you want user@domain1 and user@domain2 to be different
accounts.
---
### domains _domains..._
Default: not specified
Domains that should be allowed in username during authentication.
For example, if 'domains' is set to "domain1 domain2", then
username, username@domain1 and username@domain2 will be accepted as valid login
name in addition to just username.
If used without 'perdomain', domain part will be removed from login before
check with underlying auth. mechanism. If 'perdomain' is set, then
domains must be also set and domain part **will not** be removed before check.
================================================
FILE: docs/reference/auth/ldap.md
================================================
# LDAP BindDN
maddy supports authentication via LDAP using DN binding. Passwords are verified
by the LDAP server.
maddy needs to know the DN to use for binding. It can be obtained either by
directory search or template .
Note that storage backends conventionally use email addresses, if you use
non-email identifiers as usernames then you should map them onto
emails on delivery by using `auth_map` (see documentation page for used storage backend).
auth.ldap also can be a used as a table module. This way you can check
whether the account exists. It works only if DN template is not used.
```
auth.ldap {
urls ldap://maddy.test:389
# Specify initial bind credentials. Not required ('bind off')
# if DN template is used.
bind plain "cn=maddy,ou=people,dc=maddy,dc=test" "123456"
# Specify DN template to skip lookup.
dn_template "cn={username},ou=people,dc=maddy,dc=test"
# Specify base_dn and filter to lookup DN.
base_dn "ou=people,dc=maddy,dc=test"
filter "(&(objectClass=posixAccount)(uid={username}))"
tls_client { ... }
starttls off
debug off
connect_timeout 1m
}
```
```
auth.ldap ldap://maddy.test.389 {
...
}
```
## Configuration directives
### urls _servers..._
**Required.**
URLs of the directory servers to use. First available server
is used - no load-balancing is done.
URLs should use `ldap://`, `ldaps://`, `ldapi://` schemes.
---
### bind `off` | `unauth` | `external` | `plain` _username_ _password_
Default: `off`
Credentials to use for initial binding. Required if DN lookup is used.
`unauth` performs unauthenticated bind. `external` performs external binding
which is useful for Unix socket connections (`ldapi://`) or TLS client certificate
authentication (cert. is set using tls_client directive). `plain` performs a
simple bind using provided credentials.
---
### dn_template _template_
DN template to use for binding. `{username}` is replaced with the
username specified by the user.
---
### base_dn _dn_
Base DN to use for lookup.
---
### filter _str_
DN lookup filter. `{username}` is replaced with the username specified
by the user.
Example:
```
(&(objectClass=posixAccount)(uid={username}))
```
Example (using ActiveDirectory):
```
(&(objectCategory=Person)(memberOf=CN=user-group,OU=example,DC=example,DC=org)(sAMAccountName={username})(!(UserAccountControl:1.2.840.113556.1.4.803:=2)))
```
Example:
```
(&(objectClass=Person)(mail={username}))
```
---
### starttls _bool_
Default: `off`
Whether to upgrade connection to TLS using STARTTLS.
---
### tls_client { ... }
Advanced TLS client configuration. See [TLS configuration / Client](/reference/tls/#client) for details.
---
### connect_timeout _duration_
Default: `1m`
Timeout for initial connection to the directory server.
---
### request_timeout _duration_
Default: `1m`
Timeout for each request (binding, lookup).
================================================
FILE: docs/reference/auth/netauth.md
================================================
# Native NetAuth
maddy supports authentication via NetAuth using direct entity
authentication checks. Passwords are verified by the NetAuth server.
maddy needs to know the Entity ID to use for authentication. It must
match the string the user provides for the Local Atom part of their
mail address.
Note that storage backends conventionally use email addresses. Since NetAuth
recommends *nix compatible usernames. You will need to either map email
identifiers specified by user to NetAuth Entity IDs using `auth_map` in
endpoint.smtp/imap configuration (recommended) or you would need to use
`storage_map` in storage backend configuration to map NetAuth Entity ID
specified by user back to appropriate storage backend account names.
auth.netauth also can be used as a table module. This way you can
check whether the account exists.
Note that the configuration fragment provided below is very sparse.
This is because NetAuth expects to read most of its common
configuration values from the system NetAuth config file located at
`/etc/netauth/config.toml`.
```
auth.netauth {
require_group "maddy-users"
debug off
}
```
```
auth.netauth {}
```
## Configuration directives
### require_group _group_
Optional.
Group that entities must possess to be able to use maddy services.
This can be used to provide email to just a subset of the entities
present in NetAuth.
---
### debug `on` | `off`
Default: `off`
================================================
FILE: docs/reference/auth/pam.md
================================================
# PAM
auth.pam module implements authentication using libpam. Alternatively it can be configured to
use helper binary like auth.external module does.
maddy should be built with libpam build tag to use this module without
'use_helper' directive.
```
go get -tags 'libpam' ...
```
```
auth.pam {
debug no
use_helper no
}
```
## Configuration directives
### debug _boolean_
Default: `no`
Enable verbose logging for all modules. You don't need that unless you are
reporting a bug.
---
### use_helper _boolean_
Default: `no`
Use `LibexecDirectory/maddy-pam-helper` instead of directly calling libpam.
You need to use that if:
1. maddy is not compiled with libpam, but `maddy-pam-helper` is built separately.
2. maddy is running as an unprivileged user and used PAM configuration requires additional privileges (e.g. when using system accounts).
For 2, you need to make `maddy-pam-helper` binary setuid, see
README.md in source tree for details.
TL;DR (assuming you have the maddy group):
```
chown root:maddy /usr/lib/maddy/maddy-pam-helper
chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-pam-helper
```
================================================
FILE: docs/reference/auth/pass_table.md
================================================
# Password table
auth.pass_table module implements username:password authentication by looking up the
password hash using a table module (maddy-tables(5)). It can be used
to load user credentials from text file (via table.file module) or SQL query
(via table.sql_table module).
Definition:
```
auth.pass_table [block name] {
table
}
```
Shortened variant for inline use:
```
pass_table [table arguments] {
[additional table config]
}
```
Example, read username:password pair from the text file:
```
smtp tcp://0.0.0.0:587 {
auth pass_table file /etc/maddy/smtp_passwd
...
}
```
## Password hashes
pass_table expects the used table to contain certain structured values with
hash algorithm name, salt and other necessary parameters.
You should use `maddy hash` command to generate suitable values.
See `maddy hash --help` for details.
## maddy creds
If the underlying table is a "mutable" table (see maddy-tables(5)) then
the `maddy creds` command can be used to modify the underlying tables
via pass_table module. It will act on a "local credentials store" and will write
appropriate hash values to the table.
================================================
FILE: docs/reference/auth/plain_separate.md
================================================
# Separate username and password lookup
auth.plain_separate module implements authentication using username:password pairs but can
use zero or more "table modules" (maddy-tables(5)) and one or more
authentication providers to verify credentials.
```
auth.plain_separate {
user ...
user ...
...
pass ...
pass ...
...
}
```
How it works:
- Initial username input is normalized using PRECIS UsernameCaseMapped profile.
- Each table specified with the 'user' directive looked up using normalized
username. If match is not found in any table, authentication fails.
- Each authentication provider specified with the 'pass' directive is tried.
If authentication with all providers fails - an error is returned.
## Configuration directives
### user _table-module_
Configuration block for any module from maddy-tables(5) can be used here.
Example:
```
user file /etc/maddy/allowed_users
```
---
### pass _auth-provider_
Configuration block for any auth. provider module can be used here, even
'plain_split' itself.
The used auth. provider must provide username:password pair-based
authentication.
================================================
FILE: docs/reference/auth/shadow.md
================================================
# /etc/shadow
auth.shadow module implements authentication by reading /etc/shadow. Alternatively it can be
configured to use helper binary like auth.external does.
```
auth.shadow {
debug no
use_helper no
}
```
## Configuration directives
### debug _boolean_
Default: `no`
Enable verbose logging for all modules. You don't need that unless you are
reporting a bug.
---
### use_helper _boolean_
Default: `no`
Use `LibexecDirectory/maddy-shadow-helper` instead of directly reading `/etc/shadow`.
You need to use that if maddy is running as an unprivileged user
privileges (e.g. when using system accounts).
You need to make `maddy-shadow-helper` binary setuid, see
cmd/maddy-shadow-helper/README.md in source tree for details.
TL;DR (assuming you have maddy group):
```
chown root:maddy /usr/lib/maddy/maddy-shadow-helper
chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-shadow-helper
```
================================================
FILE: docs/reference/blob/fs.md
================================================
# Filesystem
This module stores message bodies in a file system directory.
```
storage.blob.fs {
root
}
```
```
storage.blob.fs
```
## Configuration directives
### root _path_
Default: not set
Path to the FS directory. Must be readable and writable by the server process.
If it does not exist - it will be created (parent directory should be writable
for this). Relative paths are interpreted relatively to server state directory.
================================================
FILE: docs/reference/blob/s3.md
================================================
# Amazon S3
storage.blob.s3 module stores messages bodies in a bucket on S3-compatible storage.
```
storage.blob.s3 {
endpoint play.min.io
secure yes
access_key "Q3AM3UQ867SPQQA43P2F"
secret_key "zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG"
bucket maddy-test
# optional
region eu-central-1
object_prefix maddy/
creds access_key
}
```
Example:
```
storage.imapsql local_mailboxes {
...
msg_store s3 {
endpoint s3.amazonaws.com
access_key "..."
secret_key "..."
bucket maddy-messages
region us-west-2
creds access_key
}
}
```
## Configuration directives
### endpoint _address:port_
**Required**.
Root S3 endpoint. e.g. `s3.amazonaws.com`
---
### secure _boolean_
Default: `yes`
Whether TLS should be used.
---
### access_key _string_
secret_key _string_
**Required**.
Static S3 credentials.
---
### bucket _name_
**Required**.
S3 bucket name. The bucket must exist and
be read-writable.
---
### region _string_
Default: not set
S3 bucket location. May be called "endpoint" in some manuals.
---
### object_prefix _string_
Default: empty string
String to add to all keys stored by maddy.
Can be useful when S3 is used as a file system.
---
### creds `access_key` | `file_minio` | `file_aws` | `iam`
Default: `access_key`
Credentials to use for accessing the S3 Bucket.
Credential Types:
- `access_key`: use AWS access key and secret access key
- `file_minio`: use credentials for Minio present at ~/.mc/config.json
- `file_aws`: use credentials for AWS S3 present at ~/.aws/credentials
- `iam`: use AWS IAM instance profile for credentials.
By default, access_key is used with the access key and secret access key present in the config.
================================================
FILE: docs/reference/checks/actions.md
================================================
# Check actions
When a certain check module thinks the message is "bad", it takes some actions
depending on its configuration. Most checks follow the same configuration
structure and allow following actions to be taken on check failure:
- Do nothing (`action ignore`)
Useful for testing deployment of new checks. Check failures are still logged
but they have no effect on message delivery.
- Reject the message (`action reject`)
Reject the message at connection time. No bounce is generated locally.
- Quarantine the message (`action quarantine`)
Mark message as 'quarantined'. If message is then delivered to the local
storage, the storage backend can place the message in the 'Junk' mailbox.
Another thing to keep in mind that 'target.remote' module
will refuse to send quarantined messages.
================================================
FILE: docs/reference/checks/authorize_sender.md
================================================
# MAIL FROM and From authorization
Module check.authorize_sender verifies that envelope and header sender addresses belong
to the authenticated user. Address ownership is established via table
that maps each user account to a email address it is allowed to use.
There are some special cases, see `user_to_email` description below.
```
check.authorize_sender {
prepare_email identity
user_to_email identity
check_header yes
unauth_action reject
no_match_action reject
malformed_action reject
err_action reject
auth_normalize auto
from_normalize auto
}
```
```
check {
authorize_sender { ... }
}
```
## Configuration directives
### user_to_email _table_
Default: `identity`
Table that maps authorization username to the list of sender emails
the user is allowed to use.
In additional to email addresses, the table can contain domain names or
special string "\*" as a value. If the value is a domain - user
will be allowed to use any mailbox within it as a sender address.
If it is "\*" - user will be allowed to use any address.
By default, table.identity is used, meaning that username should
be equal to the sender email.
Before username is looked up via the table, normalization algorithm
defined by auth_normalize is applied to it.
---
### prepare_email _table_
Default: `identity`
Table that is used to translate email addresses before they
are matched against user_to_email values.
Typically used to allow users to use their aliases as sender
addresses - prepare_email in this case should translate
aliases to "canonical" addresses. This is how it is
done in default configuration.
If table does not contain any mapping for the used sender
address, it will be used as is.
---
### check_header _boolean_
Default: `yes`
Whether to verify header sender in addition to envelope.
Either Sender or From field value should match the
authorization identity.
---
### unauth_action _action_
Default: `reject`
What to do if the user is not authenticated at all.
---
### no_match_action _action_
Default: `reject`
What to do if user is not allowed to use the sender address specified.
---
### malformed_action _action_
Default: `reject`
What to do if From or Sender header fields contain malformed values.
---
### err_action _action_
Default: `reject`
What to do if error happens during prepare_email or user_to_email lookup.
---
### auth_normalize _action_
Default: `auto`
Normalization function to apply to authorization username before
further processing.
Available options:
- `auto` `precis_casefold_email` for valid emails, `precis_casefold` otherwise.
- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain
- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string
- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain
- `precis` PRECIS UsernameCasePreserved profile for the entire string
- `casefold` Convert to lower case
- `noop` Nothing
PRECIS profiles are defined by RFC 8265. In short, they make sure
that Unicode strings that look the same will be compared as if they were
the same. CaseMapped profiles also convert strings to lower case.
---
### from_normalize _action_
Default: `auto`
Normalization function to apply to email addresses before
further processing.
Available options are same as for `auth_normalize`.
================================================
FILE: docs/reference/checks/command.md
================================================
# System command filter
This module executes an arbitrary system command during a specified stage of
checks execution.
```
command executable_name arg0 arg1 ... {
run_on body
code 1 reject
code 2 quarantine
}
```
## Arguments
The module arguments specify the command to run. If the first argument is not
an absolute path, it is looked up in the Libexec Directory (/usr/lib/maddy on
Linux) and in $PATH (in that ordering). Note that no additional handling
of arguments is done, especially, the command is executed directly, not via the
system shell.
There is a set of special strings that are replaced with the corresponding
message-specific values:
- `{source_ip}` – IPv4/IPv6 address of the sending MTA.
- `{source_host}` – Hostname of the sending MTA, from the HELO/EHLO command.
- `{source_rdns}` – PTR record of the sending MTA IP address.
- `{msg_id}` – Internal message identifier. Unique for each delivery.
- `{auth_user}` – Client username, if authenticated using SASL PLAIN
- `{sender}` – Message sender address, as specified in the MAIL FROM SMTP command.
- `{rcpts}` – List of accepted recipient addresses, including the currently handled
one.
- `{address}` – Currently handled address. This is a recipient address if the command
is called during RCPT TO command handling (`run_on rcpt`) or a sender
address if the command is called during MAIL FROM command handling (`run_on
sender`).
If value is undefined (e.g. `{source_ip}` for a message accepted over a Unix
socket) or unavailable (the command is executed too early), the placeholder
is replaced with an empty string. Note that it can not remove the argument.
E.g. `-i {source_ip}` will not become just `-i`, it will be `-i ""`
Undefined placeholders are not replaced.
## Command stdout
The command stdout must be either empty or contain a valid RFC 5322 header.
If it contains a byte stream that does not look a valid header, the message
will be rejected with a temporary error.
The header from stdout will be **prepended** to the message header.
## Configuration directives
### run_on `conn` | `sender` | `rcpt` | `body`
Default: `body`
When to run the command. This directive also affects the information visible
for the message.
- `conn`
Run before the sender address (MAIL FROM) is handled.
**Stdin**: Empty
**Available placeholders**: {source_ip}, {source_host}, {msg_id}, {auth_user}.
- `sender`
Run during sender address (MAIL FROM) handling.
**Stdin**: Empty
**Available placeholders**: conn placeholders + {sender}, {address}.
The {address} placeholder contains the MAIL FROM address.
- `rcpt`
Run during recipient address (RCPT TO) handling. The command is executed
once for each RCPT TO command, even if the same recipient is specified
multiple times.
**Stdin**: Empty
**Available placeholders**: sender placeholders + {rcpts}.
The {address} placeholder contains the recipient address.
- `body`
Run during message body handling.
**Stdin**: The message header + body
**Available placeholders**: all except for {address}.
---
### code _integer_ ignore
code _integer_ quarantine
code _integer_ reject _smtp-code_ _smtp-enhanced-code_ _smtp-message_
This directive specifies the mapping from the command exit code _integer_ to
the message pipeline action.
Two codes are defined implicitly, exit code 1 causes the message to be rejected
with a permanent error, exit code 2 causes the message to be quarantined. Both
actions can be overridden using the 'code' directive.
================================================
FILE: docs/reference/checks/dkim.md
================================================
# DKIM
This is the check module that performs verification of the DKIM signatures
present on the incoming messages.
## Configuration directives
```
check.dkim {
debug no
required_fields From Subject
allow_body_subset no
no_sig_action ignore
broken_sig_action ignore
fail_open no
}
```
### debug _boolean_
Default: global directive value
Log both successful and unsuccessful check executions instead of just
unsuccessful.
---
### required_fields _string..._
Default: `From Subject`
Header fields that should be included in each signature. If signature
lacks any field listed in that directive, it will be considered invalid.
Note that From is always required to be signed, even if it is not included in
this directive.
---
### no_sig_action _action_
Default: `ignore` (recommended by RFC 6376)
Action to take when message without any signature is received.
Note that DMARC policy of the sender domain can request more strict handling of
missing DKIM signatures.
---
### broken_sig_action _action_
Default: `ignore` (recommended by RFC 6376)
Action to take when there are not valid signatures in a message.
Note that DMARC policy of the sender domain can request more strict handling of
broken DKIM signatures.
---
### fail_open _boolean_
Default: `no`
Whether to accept the message if a temporary error occurs during DKIM
verification. Rejecting the message with a 4xx code will require the sender
to resend it later in a hope that the problem will be resolved.
================================================
FILE: docs/reference/checks/dnsbl.md
================================================
# DNSBL lookup
The check.dnsbl module implements checking of source IP and hostnames against a set
of DNS-based Blackhole lists (DNSBLs).
Its configuration consists of module configuration directives and a set
of blocks specifying lists to use and kind of lookups to perform on them.
```
check.dnsbl {
debug no
check_early no
quarantine_threshold 1
reject_threshold 1
# Lists configuration example.
dnsbl.example.org {
client_ipv4 yes
client_ipv6 no
ehlo no
mailfrom no
score 1
}
hsrbl.example.org {
client_ipv4 no
client_ipv6 no
ehlo yes
mailfrom yes
score 1
}
# Example with per-response-code scoring (new in 0.8)
zen.spamhaus.org {
client_ipv4 yes
client_ipv6 yes
# SBL - Spamhaus Block List (known spam sources)
response 127.0.0.2 127.0.0.3 {
score 10
message "Listed in Spamhaus SBL. See https://check.spamhaus.org/"
}
# XBL - Exploits Block List (compromised hosts)
response 127.0.0.4 127.0.0.5 127.0.0.6 127.0.0.7 {
score 10
message "Listed in Spamhaus XBL. See https://check.spamhaus.org/"
}
# PBL - Policy Block List (dynamic IPs)
response 127.0.0.10 127.0.0.11 {
score 5
message "Listed in Spamhaus PBL. See https://check.spamhaus.org/"
}
}
}
```
## Arguments
Arguments specify the list of IP-based BLs to use.
The following configurations are equivalent.
```
check {
dnsbl dnsbl.example.org dnsbl2.example.org
}
```
```
check {
dnsbl {
dnsbl.example.org dnsbl2.example.org {
client_ipv4 yes
client_ipv6 no
ehlo no
mailfrom no
score 1
}
}
}
```
## Configuration directives
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### check_early _boolean_
Default: `no`
Check BLs before mail delivery starts and silently reject blacklisted clients.
For this to work correctly, check should not be used in source/destination
pipeline block.
In particular, this means:
- No logging is done for rejected messages.
- No action is taken if `quarantine_threshold` is hit, only `reject_threshold`
applies.
- `defer_sender_reject` from SMTP configuration takes no effect.
- MAIL FROM is not checked, even if specified.
If you often get hit by spam attacks, it is recommended to enable this
setting to save server resources.
---
### quarantine_threshold _integer_
Default: `1`
DNSBL score needed (equals-or-higher) to quarantine the message.
---
### reject_threshold _integer_
Default: `9999`
DNSBL score needed (equals-or-higher) to reject the message.
## List configuration
```
dnsbl.example.org dnsbl.example.com {
client_ipv4 yes
client_ipv6 no
ehlo no
mailfrom no
responses 127.0.0.1/24
score 1
}
```
Directive name and arguments specify the actual DNS zone to query when checking
the list. Using multiple arguments is equivalent to specifying the same
configuration separately for each list.
### client_ipv4 _boolean_
Default: `yes`
Whether to check address of the IPv4 clients against the list.
---
### client_ipv6 _boolean_
Default: `yes`
Whether to check address of the IPv6 clients against the list.
---
### ehlo _boolean_
Default: `no`
Whether to check hostname specified n the HELO/EHLO command
against the list.
This works correctly only with domain-based DNSBLs.
---
### mailfrom _boolean_
Default: `no`
Whether to check domain part of the MAIL FROM address against the list.
This works correctly only with domain-based DNSBLs.
---
### responses _cidr_ | _ip..._
Default: `127.0.0.1/24`
IP networks (in CIDR notation) or addresses to permit in list lookup results.
Addresses not matching any entry in this directives will be ignored.
---
### score _integer_
Default: `1`
Score value to add for the message if it is listed.
If sum of list scores is equals or higher than `quarantine_threshold`, the
message will be quarantined.
If sum of list scores is equals or higher than `rejected_threshold`, the message
will be rejected.
It is possible to specify a negative value to make list act like a whitelist
and override results of other blocklists.
**Note:** When using `response` blocks (see below), the score from matching response
rules is used instead of this flat score value.
---
### response _ip..._
Defines per-response-code rules for scoring and custom messages. This is useful
for combined DNSBLs like Spamhaus ZEN that return different codes for different
listing types.
This works for both IP-based lookups (client_ipv4, client_ipv6) and domain-based
lookups (ehlo, mailfrom).
Each `response` block takes one or more IP addresses or CIDR ranges as arguments
and contains the following directives:
#### score _integer_
**Required**
Score to add when this response code is returned. If multiple response codes
are returned by the DNSBL, and they match different rules, the scores from
all matched rules are summed together. Each rule is counted only once, even
if multiple returned IPs match networks within that rule.
#### message _string_
**Optional**
Custom rejection or quarantine message to include when this response code
matches. This message is shown to the client or logged when the threshold
is reached.
**Example:**
```
zen.spamhaus.org {
client_ipv4 yes
# High severity - known spam sources
response 127.0.0.2 127.0.0.3 {
score 10
message "Listed in Spamhaus SBL"
}
# Lower severity - dynamic IPs
response 127.0.0.10 127.0.0.11 {
score 5
message "Listed in Spamhaus PBL"
}
}
```
**Scoring behavior:**
- If DNSBL returns `127.0.0.2` only → Score: 10 (matches first rule)
- If DNSBL returns `127.0.0.11` only → Score: 5 (matches second rule)
- If DNSBL returns both `127.0.0.2` and `127.0.0.11` → Score: 15 (both rules match, scores sum)
- If DNSBL returns both `127.0.0.2` and `127.0.0.3` → Score: 10 (same rule matches, counted once)
**Backwards compatibility:** When `response` blocks are not used, the legacy
`responses` and `score` directives work as before.
================================================
FILE: docs/reference/checks/milter.md
================================================
# Milter client
The 'milter' implements subset of Sendmail's milter protocol that can be used
to integrate external software with maddy.
maddy implements version 6 of the protocol, older versions are
not supported.
Notable limitations of protocol implementation in maddy include:
1. Changes of envelope sender address are not supported
2. Removal and addition of envelope recipients is not supported
3. Removal and replacement of header fields is not supported
4. Headers fields can be inserted only on top
5. Milter does not receive some "macros" provided by sendmail.
Restrictions 1 and 2 are inherent to the maddy checks interface and cannot be
removed without major changes to it. Restrictions 3, 4 and 5 are temporary due to
incomplete implementation.
```
check.milter {
endpoint
fail_open false
}
milter
```
## Arguments
When defined inline, the first argument specifies endpoint to access milter
via. See below.
## Configuration directives
### endpoint _scheme://path_
Default: not set
Specifies milter protocol endpoint to use.
The endpoit is specified in standard URL-like format:
`tcp://127.0.0.1:6669` or `unix:///var/lib/milter/filter.sock`
---
### fail_open _boolean_
Default: `false`
Toggles behavior on milter I/O errors. If false ("fail closed") - message is
rejected with temporary error code. If true ("fail open") - check is skipped.
================================================
FILE: docs/reference/checks/misc.md
================================================
# Misc checks
## Configuration directives
Following directives are defined for all modules listed below.
### fail_action `ignore` | `reject` | `quarantine`
Default: `quarantine`
Action to take when check fails. See [Check actions](../actions/) for details.
---
### debug _boolean_
Default: global directive value
Log both successful and unsuccessful check executions instead of just
unsuccessful.
---
### require_mx_record
Check that domain in MAIL FROM command does have a MX record and none of them
are "null" (contain a single dot as the host).
By default, quarantines messages coming from servers missing MX records,
use `fail_action` directive to change that.
---
### require_matching_rdns
Check that source server IP does have a PTR record point to the domain
specified in EHLO/HELO command.
By default, quarantines messages coming from servers with mismatched or missing
PTR record, use `fail_action` directive to change that.
---
### require_tls
Check that the source server is connected via TLS; either directly, or by using
the STARTTLS command.
By default, rejects messages coming from unencrypted servers. Use the
`fail_action` directive to change that.
================================================
FILE: docs/reference/checks/rspamd.md
================================================
# rspamd
The 'rspamd' module implements message filtering by contacting the rspamd
server via HTTP API.
```
check.rspamd {
tls_client { ... }
api_path http://127.0.0.1:11333
settings_id whatever
tag maddy
hostname mx.example.org
io_error_action ignore
error_resp_action ignore
add_header_action quarantine
rewrite_subj_action quarantine
reject_action reject
soft_reject_action reject
flags pass_all
}
rspamd http://127.0.0.1:11333
```
## Configuration directives
### tls_client { ... }
Default: not set
Configure TLS client if HTTPS is used. See [TLS configuration / Client](/reference/tls/#client) for details.
---
### api_path _url_
Default: `http://127.0.0.1:11333`
URL of HTTP API endpoint. Supports both HTTP and HTTPS and can include
path element.
---
### settings_id _string_
Default: not set
Settings ID to pass to the server.
---
### tag _string_
Default: `maddy`
Value to send in MTA-Tag header field.
---
### hostname _string_
Default: value of global directive
Value to send in MTA-Name header field.
---
### io_error_action _action_
Default: `ignore`
Action to take in case of inability to contact the rspamd server.
---
### error_resp_action _action_
Default: `ignore`
Action to take in case of 5xx or 4xx response received from the rspamd server.
---
### add_header_action _action_
Default: `quarantine`
Action to take when rspamd requests to "add header".
X-Spam-Flag and X-Spam-Score are added to the header irregardless of value.
---
### rewrite_subj_action _action_
Default: `quarantine`
Action to take when rspamd requests to "rewrite subject".
X-Spam-Flag and X-Spam-Score are added to the header irregardless of value.
---
### reject_action _action_
Default: `reject`
Action to take when rspamd requests to "reject".
---
### soft_reject_action _action_
Default: `reject`
Action to take when rspamd requests to "soft reject".
---
### flags _string-list..._
Default: `pass_all`
Flags to pass to the rspamd server.
See [https://rspamd.com/doc/architecture/protocol.html](https://rspamd.com/doc/architecture/protocol.html) for details.
================================================
FILE: docs/reference/checks/spf.md
================================================
# SPF
check.spf the check module that verifies whether IP address of the client is
authorized to send messages for domain in MAIL FROM address.
SPF statuses are mapped to maddy check actions in a way
specified by \*_action directives. By default, SPF failure
results in the message being quarantined and errors (both permanent and
temporary) cause message to be rejected.
Authentication-Results field is generated irregardless of status.
## DMARC override
It is recommended by the DMARC standard to don't fail delivery based solely on
SPF policy and always check DMARC policy and take action based on it.
If `enforce_early` is `no`, check.spf module will not take any action on SPF
policy failure if sender domain does have a DMARC record with 'quarantine' or
'reject' policy. Instead it will rely on DMARC support to take necesary
actions using SPF results as an input.
Disabling `enforce_early` without enabling DMARC support will make SPF policies
no-op and is considered insecure.
## Configuration directives
```
check.spf {
debug no
enforce_early no
fail_action quarantine
softfail_action ignore
permerr_action reject
temperr_action reject
}
```
### debug _boolean_
Default: global directive value
Enable verbose logging for check.spf.
---
### enforce_early _boolean_
Default: `no`
Make policy decision on MAIL FROM stage (before the message body is received).
This makes it impossible to apply DMARC override (see above).
---
### none_action `reject` | `quarantine` | `ignore`
Default: `ignore`
Action to take when SPF policy evaluates to a 'none' result.
See [https://tools.ietf.org/html/rfc7208#section-2.6](https://tools.ietf.org/html/rfc7208#section-2.6) for meaning of
SPF results.
---
### neutral_action `reject` | `quarantine` | `ignore`
Default: `ignore`
Action to take when SPF policy evaluates to a 'neutral' result.
See [https://tools.ietf.org/html/rfc7208#section-2.6](https://tools.ietf.org/html/rfc7208#section-2.6) for meaning of
SPF results.
---
### fail_action `reject` | `quarantine` | `ignore`
Default: `quarantine`
Action to take when SPF policy evaluates to a 'fail' result.
---
### softfail_action `reject` | `quarantine` | `ignore`
Default: `ignore`
Action to take when SPF policy evaluates to a 'softfail' result.
---
### permerr_action `reject` | `quarantine` | `ignore`
Default: `reject`
Action to take when SPF policy evaluates to a 'permerror' result.
---
### temperr_action `reject` | `quarantine` | `ignore`
Default: `reject`
Action to take when SPF policy evaluates to a 'temperror' result.
================================================
FILE: docs/reference/config-syntax.md
================================================
# Configuration files syntax
**Note:** This file is a technical document describing how
maddy parses configuration files.
Configuration consists of newline-delimited "directives". Each directive can
have zero or more arguments.
```
directive0
directive1 arg0 arg1
```
Any line starting with # is ignored. Empty lines are ignored too.
## Quoting
Strings with whitespace should be wrapped into double quotes to make sure they
will be interpreted as a single argument.
```
directive0 two arguments
directive1 "one argument"
```
String wrapped in quotes may contain newlines and they will not be interpreted
as a directive separator.
```
directive0 "one long big
argument for directive0"
```
Quotes and only quotes can be escaped inside literals: \\"
Backslash can be used at the end of line to continue the directve on the next
line.
## Blocks
A directive may have several subdirectives. They are written in a {-enclosed
block like this:
```
directive0 arg0 arg1 {
subdirective0 arg0 arg1
subdirective1 etc
}
```
Subdirectives can have blocks too.
```
directive0 {
subdirective0 {
subdirective2 {
a
b
c
}
}
subdirective1 { }
}
```
Level of nesting is limited, but you should never hit the limit with correct
configuration.
In most cases, an empty block is equivalent to no block:
```
directive { }
directive2 # same as above
```
## Environment variables
Environment variables can be referenced in the configuration using either
{env:VARIABLENAME} syntax.
Non-existent variables are expanded to empty strings and not removed from
the arguments list. In the following example, directive0 will have one argument
independently of whether VAR is defined.
```
directive0 {env:VAR}
```
Parse is forgiving and incomplete variable placeholder (e.g. '{env:VAR') will
be left as-is. Variables are expanded inside quotes too.
## Snippets & imports
You can reuse blocks of configuration by defining them as "snippets". Snippet
is just a directive with a block, declared tp top level (not inside any blocks)
and with a directive name wrapped in curly braces.
```
(snippetname) {
a
b
c
}
```
The snippet can then be referenced using 'import' meta-directive.
```
unrelated0
unrelated1
import snippetname
```
The above example will be expanded into the following configuration:
```
unrelated0
unrelated1
a
b
c
```
Import statement also can be used to include content from other files. It works
exactly the same way as with snippets but the file path should be used instead.
The path can be either relative to the location of the currently processed
configuration file or absolute. If there are both snippet and file with the
same name - snippet will be used.
```
# /etc/maddy/tls.conf
tls long_path_to_certificate long_path_to_private_key
# /etc/maddy/maddy.conf
smtp tcp://0.0.0.0:25 {
import tls.conf
}
```
```
# Expanded into:
smtp tcp://0.0.0.0:25 {
tls long_path_to_certificate long_path_to_private_key
}
```
The imported file can introduce new snippets and they can be referenced in any
processed configuration file.
## Duration values
Directives that accept duration use the following format: A sequence of decimal
digits with an optional fraction and unit suffix (zero can be specified without
a suffix). If multiple values are specified, they will be added.
Valid unit suffixes: "h" (hours), "m" (minutes), "s" (seconds), "ms" (milliseconds).
Implementation also accepts us and ns for microseconds and nanoseconds, but these
values are useless in practice.
Examples:
```
1h
1h 5m
1h5m
0
```
## Data size values
Similar to duration values, but fractions are not allowed and suffixes are different.
Valid unit suffixes: "G" (gibibyte, 1024^3 bytes), "M" (mebibyte, 1024^2 bytes),
"K" (kibibyte, 1024 bytes), "B" or "b" (byte).
Examples:
```
32M
3M 5K
5b
```
Also note that the following is not valid, unlike Duration values syntax:
```
32M5K
```
## Address Definitions
Maddy configuration uses URL-like syntax to specify network addresses.
- `unix://file_path` – Unix domain socket. Relative paths are relative to runtime directory (`/run/maddy`).
- `tcp://ADDRESS:PORT` – TCP/IP socket.
- `tls://ADDRESS:PORT` – TCP/IP socket using TLS.
## Dummy Module
No-op module. It doesn't need to be configured explicitly and can be referenced
using "dummy" name. It can act as a delivery target or auth.
provider. In the latter case, it will accept any credentials, allowing any
client to authenticate using any username and password (use with care!).
================================================
FILE: docs/reference/endpoints/imap.md
================================================
# IMAP4rev1 endpoint
Module 'imap' is a listener that implements IMAP4rev1 protocol and provides
access to local messages storage specified by 'storage' directive.
In most cases, local storage modules will auto-create accounts when they are
accessed via IMAP. This relies on authentication provider used by IMAP endpoint
to provide what essentially is access control. There is a caveat, however: this
auto-creation will not happen when delivering incoming messages via SMTP as
there is no authentication to confirm that this account should indeed be
created.
## Configuration directives
```
imap tcp://0.0.0.0:143 tls://0.0.0.0:993 {
tls /etc/ssl/private/cert.pem /etc/ssl/private/pkey.key
io_debug no
debug no
insecure_auth no
sasl_login no
auth pam
storage &local_mailboxes
auth_map identity
auth_map_normalize auto
storage_map identity
storage_map_normalize auto
}
```
### tls _certificate-path_ _key-path_ { ... }
Default: global directive value
TLS certificate & key to use. Fine-tuning of other TLS properties is possible
by specifying a configuration block and options inside it:
```
tls cert.crt key.key {
protocols tls1.2 tls1.3
}
```
See [TLS configuration / Server](/reference/tls/#server-side) for details.
---
### proxy_protocol _trusted ips..._ { ... }
Default: not enabled
Enable use of HAProxy PROXY protocol. Supports both v1 and v2 protocols.
If a list of trusted IP addresses or subnets is provided, only connections
from those will be trusted.
TLS for the channel between the proxies and maddy can be configured
using a 'tls' directive:
```
proxy_protocol {
trust 127.0.0.1 ::1 192.168.0.1/24
tls &proxy_tls
}
```
Note that the top-level 'tls' directive is not inherited here. If you
need TLS on top of the PROXY protocol, securing the protocol header,
you must declare TLS explicitly.
---
### io_debug _boolean_
Default: `no`
Write all commands and responses to stderr.
---
### io_errors _boolean_
Default: `no`
Log I/O errors.
---
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### insecure_auth _boolean_
Default: `no` (`yes` if TLS is disabled)
Allow plain-text authentication over unencrypted connections.
---
### sasl_login _boolean_
Default: `no`
Enable support for SASL LOGIN authentication mechanism used by
some outdated clients.
---
### auth _module-reference_
**Required.**
Use the specified module for authentication.
---
### storage _module-reference_
**Required.**
Use the specified module for message storage.
---
### storage_map _module-reference_
Default: `identity`
Use the specified table to map SASL usernames to storage account names.
Before username is looked up, it is normalized using function defined by
`storage_map_normalize`.
This directive is useful if you want users user@example.org and user@example.com
to share the same storage account named "user". In this case, use
```
storage_map email_localpart
```
Note that `storage_map` does not affect the username passed to the
authentication provider.
It also does not affect how message delivery is handled, you should specify
`delivery_map` in storage module to define how to map email addresses
to storage accounts. E.g.
```
storage.imapsql local_mailboxes {
...
delivery_map email_localpart # deliver "user@*" to mailbox for "user"
}
```
---
### storage_map_normalize _function_
Default: `auto`
Same as `auth_map_normalize` but for `storage_map`.
---
### auth_map_normalize _function_
Default: `auto`
Overrides global `auth_map_normalize` value for this endpoint.
See [Global configuration](/reference/global-config) for details.
================================================
FILE: docs/reference/endpoints/openmetrics.md
================================================
# OpenMetrics/Prometheus telemetry
Various server statistics are provided in OpenMetrics format by the
"openmetrics" module.
To enable it, add the following line to the server config:
```
openmetrics tcp://127.0.0.1:9749 { }
```
Scrape endpoint would be `http://127.0.0.1:9749/metrics`.
## Metrics
```
# AUTH command failures due to invalid credentials.
maddy_smtp_failed_logins{module}
# Failed SMTP transaction commands (MAIL, RCPT, DATA).
maddy_smtp_failed_commands{module, command, smtp_code, smtp_enchcode}
# Messages rejected with 4xx code due to ratelimiting.
maddy_smtp_ratelimit_deferred{module}
# Amount of started SMTP transactions started.
maddy_smtp_started_transactions{module}
# Amount of aborted SMTP transactions started.
maddy_smtp_aborted_transactions{module}
# Amount of completed SMTP transactions.
maddy_smtp_completed_transactions{module}
# Number of times a check returned 'reject' result (may be more than processed
# messages if check does so on per-recipient basis).
maddy_check_reject{check}
# Number of times a check returned 'quarantine' result (may be more than
# processed messages if check does so on per-recipient basis).
maddy_check_quarantined{check}
# Amount of queued messages.
maddy_queue_length{module, location}
# Outbound connections established with specific TLS security level.
maddy_remote_conns_tls_level{module, level}
# Outbound connections established with specific MX security level.
maddy_remote_conns_mx_level{module, level}
```
================================================
FILE: docs/reference/endpoints/smtp.md
================================================
# SMTP/LMTP/Submission endpoint
Module 'smtp' is a listener that implements ESMTP protocol with optional
authentication, LMTP and Submission support. Incoming messages are processed in
accordance with pipeline rules (explained in Message pipeline section below).
```
smtp tcp://0.0.0.0:25 {
hostname example.org
tls /etc/ssl/private/cert.pem /etc/ssl/private/pkey.key
io_debug no
debug no
insecure_auth no
sasl_login no
read_timeout 10m
write_timeout 1m
shutdown_timeout 3m
max_message_size 32M
max_header_size 1M
auth pam
defer_sender_reject yes
dmarc yes
smtp_max_line_length 4000
limits {
endpoint rate 10
endpoint concurrency 500
}
# Example pipeline configuration.
destination example.org {
deliver_to &local_mailboxes
}
default_destination {
reject
}
}
```
## Configuration directives
### hostname _string_
Default: global directive value
Server name to use in SMTP banner.
```
220 example.org ESMTP Service Ready
```
---
### tls _certificate-path_ _key-path_ { ... }
Default: global directive value
TLS certificate & key to use. Fine-tuning of other TLS properties is possible
by specifying a configuration block and options inside it:
```
tls cert.crt key.key {
protocols tls1.2 tls1.3
}
```
See [TLS configuration / Server](/reference/tls/#server-side) for details.
---
### proxy_protocol _trusted ips..._ { ... }
Default: not enabled
Enable use of HAProxy PROXY protocol. Supports both v1 and v2 protocols.
If a list of trusted IP addresses or subnets is provided, only connections
from those will be trusted.
TLS for the channel between the proxies and maddy can be configured
using a 'tls' directive:
```
proxy_protocol {
trust 127.0.0.1 ::1 192.168.0.1/24
tls &proxy_tls
}
```
---
### io_debug _boolean_
Default: `no`
Write all commands and responses to stderr.
---
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### insecure_auth _boolean_
Default: `no` (`yes` if TLS is disabled)
Allow plain-text authentication over unencrypted connections. Not recommended!
---
### sasl_login _boolean_
Default: `no`
Enable support for SASL LOGIN authentication mechanism used by
some outdated clients.
---
### read_timeout _duration_
Default: `10m`
I/O read timeout.
---
### write_timeout _duration_
Default: `1m`
I/O write timeout.
---
### shutdown_timeout _duration_
Default: `3m`
Time to wait until forcibly closing connections on server shutdown
or configuration reload.
---
### max_message_size _size_
Default: `32M`
Limit the size of incoming messages to 'size'.
---
### max_header_size _size_
Default: `1M`
Limit the size of incoming message headers to 'size'.
---
### auth _module-reference_
Default: not specified
Use the specified module for authentication.
---
### defer_sender_reject _boolean_
Default: `yes`
Apply sender-based checks and routing logic when first RCPT TO command
is received. This allows maddy to log recipient address of the rejected
message and also improves interoperability with (improperly implemented)
clients that don't expect an error early in session.
---
### max_logged_rcpt_errors _integer_
Default: `5`
Amount of RCPT-time errors that should be logged. Further errors will be
handled silently. This is to prevent log flooding during email dictionary
attacks (address probing).
---
### max_received _integer_
Default: `50`
Max. amount of Received header fields in the message header. If the incoming
message has more fields than this number, it will be rejected with the permanent error
5.4.6 ("Routing loop detected").
---
### buffer `ram`
buffer `fs` _path_
buffer `auto` _max-size_ _path_
Default: `auto 1M StateDirectory/buffer`
Temporary storage to use for the body of accepted messages.
- `ram` – Store the body in RAM.
- `fs` – Write out the message to the FS and read it back as needed.
_path_ can be omitted and defaults to StateDirectory/buffer.
- `auto` – Store message bodies smaller than `_max_size_` entirely in RAM,
otherwise write them out to the FS. _path_ can be omitted and defaults to `StateDirectory/buffer`.
---
### smtp_max_line_length _integer_
Default: `4000`
The maximum line length allowed in the SMTP input stream. If client sends a
longer line - connection will be closed and message (if any) will be rejected
with a permanent error.
RFC 5321 has the recommended limit of 998 bytes. Servers are not required
to handle longer lines correctly but some senders may produce them.
Unless BDAT extension is used by the sender, this limitation also applies to
the message body.
---
### dmarc _boolean_
Default: `yes`
Enforce sender's DMARC policy. Due to implementation limitations, it is not a
check module.
**Note**: Report generation is not implemented now.
**Note**: DMARC needs SPF and DKIM checks to function correctly.
Without these, DMARC check will not run.
---
## Rate & concurrency limiting
### limits { ... }
Default: no limits
This allows configuring a set of message flow restrictions including
max. concurrency and rate per-endpoint, per-source, per-destination.
Limits are specified as directives inside the block:
```
limits {
all rate 20
destination concurrency 5
}
```
Supported limits:
### _scope_ rate _burst_ _period_
Rate limit. Restrict the amount of messages processed in _period_ to
_burst_ messages. If period is not specified, 1 second is used.
### _scope_ concurrency _max_
Concurrency limit. Restrict the amount of messages processed in parallel
to _max_.
For each supported limitation, _scope_ determines whether it should be applied
for all messages ("all"), per-sender IP ("ip"), per-sender domain ("source") or
per-recipient domain ("destination"). Having a scope other than "all" means
that the restriction will be enforced independently for each group determined
by scope. E.g. "ip rate 20" means that the same IP cannot send more than 20
messages per second. "destination concurrency 5" means that no more than 5
messages can be sent in parallel to a single domain.
**Note**: At the moment, SMTP endpoint on its own does not support per-recipient
limits. They will be no-op. If you want to enforce a per-recipient restriction
on outbound messages, do so using 'limits' directive for the 'table.remote' module
It is possible to share limit counters between multiple endpoints (or any other
modules). To do so define a top-level configuration block for module "limits"
and reference it where needed using standard & syntax. E.g.
```
limits inbound_limits {
all rate 20
}
smtp smtp://0.0.0.0:25 {
limits &inbound_limits
...
}
submission tls://0.0.0.0:465 {
limits &inbound_limits
...
}
```
Using an "all rate" restriction in such way means that no more than 20
messages can enter the server through both endpoints in one second.
# Submission module (submission)
Module 'submission' implements all functionality of the 'smtp' module and adds
certain message preprocessing on top of it, additionally authentication is
always required.
'submission' module checks whether addresses in header fields From, Sender, To,
Cc, Bcc, Reply-To are correct and adds Message-ID and Date if it is missing.
```
submission tcp://0.0.0.0:587 tls://0.0.0.0:465 {
# ... same as smtp ...
}
```
# LMTP module (lmtp)
Module 'lmtp' implements all functionality of the 'smtp' module but uses
LMTP (RFC 2033) protocol.
```
lmtp unix://lmtp.sock {
# ... same as smtp ...
}
```
## Limitations of LMTP implementation
- Can't be used with TCP.
- Delivery to 'sql' module storage is always atomic, either all recipients will
succeed or none of them will.
================================================
FILE: docs/reference/global-config.md
================================================
# Global configuration directives
These directives can be specified outside of any
configuration blocks and they are applied to all modules.
Some directives can be overridden on per-module basis (e.g. hostname).
### state_dir _path_
Default: `/var/lib/maddy`
The path to the state directory. This directory will be used to store all
persistent data and should be writable.
---
### runtime_dir _path_
Default: `/run/maddy`
The path to the runtime directory. Used for Unix sockets and other temporary
objects. Should be writable.
---
### hostname _domain_
Default: not specified
Internet hostname of this mail server. Typicall FQDN is used. It is recommended
to make sure domain specified here resolved to the public IP of the server.
---
### auth_map _module-reference_
Default: `identity`
Use the specified table to translate SASL usernames before passing it to the
authentication provider.
Before username is looked up, it is normalized using function defined by
`auth_map_normalize`.
Note that `auth_map` does not affect the storage account name used. You probably
should also use `storage_map` in IMAP config block to handle this.
This directive is useful if used authentication provider does not support
using emails as usernames but you still want users to have separate mailboxes
on separate domains. In this case, use it with `email_localpart` table:
```
auth_map email_localpart
```
With this configuration, `user@example.org` and `user@example.com` will use
`user` credentials when authenticating, but will access `user@example.org` and
`user@example.com` mailboxes correspondingly. If you want to also accept
`user` as a username, use `auth_map email_localpart_optional`.
If you want `user@example.org` and `user@example.com` to have the same mailbox,
also set `storage_map` in IMAP config block to use `email_localpart`
(or `email_localpart_optional` if you want to also accept just "user"):
```
storage_map email_localpart
```
In this case you will need to create storage accounts without domain part in
the name:
```
maddy imap-acct create user # instead of user@example.org
```
---
### auth_map_normalize _function_
Default: `auto`
Normalization function to apply to SASL usernames before mapping
them to storage accounts.
Available options:
- `auto` `precis_casefold_email` for valid emails, `precis_casefold` otherwise.
- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain
- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string
- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain
- `precis` PRECIS UsernameCasePreserved profile for the entire string
- `casefold` Convert to lower case
- `noop` Nothing
---
### autogenerated_msg_domain _domain_
Default: not specified
Domain that is used in From field for auto-generated messages (such as Delivery
Status Notifications).
---
### tls `file` _cert-file_ _pkey-file_ | _module-reference_ | `off`
Default: not specified
Default TLS certificate to use for all endpoints.
Must be present in either all endpoint modules configuration blocks or as
global directive.
You can also specify other configuration options such as cipher suites and TLS
version. See maddy-tls(5) for details. maddy uses reasonable
cipher suites and TLS versions by default so you generally don't have to worry
about it.
---
### tls_client { ... }
Default: not specified
This is optional block that specifies various TLS-related options to use when
making outbound connections. See TLS client configuration for details on
directives that can be used in it. maddy uses reasonable cipher suites and TLS
versions by default so you generally don't have to worry about it.
---
### log _targets..._ | `off`
Default: `stderr`
Write log to one of more "targets".
The target can be one or the following:
- `stderr` – Write logs to stderr.
- `stderr_ts` – Write logs to stderr with timestamps.
- `syslog` – Send logs to the local syslog daemon.
- _file path_ – Write (append) logs to file.
Example:
```
log syslog /var/log/maddy.log
```
**Note:** Maddy does not perform log files rotation, this is the job of the
logrotate daemon. Send SIGUSR1 to maddy process to make it reopen log files.
---
### debug _boolean_
Default: `no`
Enable verbose logging for all modules. You don't need that unless you are
reporting a bug.
================================================
FILE: docs/reference/modifiers/dkim.md
================================================
# DKIM signing
modify.dkim module is a modifier that signs messages using DKIM
protocol (RFC 6376).
Each configuration block specifies a single selector
and one or more domains.
A key will be generated or read for each domain, the key to use
for each message will be selected based on the SMTP envelope sender. Exception
for that is that for domain-less postmaster address and null address, the
key for the first domain will be used. If domain in envelope sender
does not match any of loaded keys, message will not be signed.
Additionally, for each messages From header is checked to
match MAIL FROM and authorization identity (username sender is logged in as).
This can be controlled using require_sender_match directive.
Generated private keys are stored in unencrypted PKCS#8 format
in state_directory/dkim_keys (`/var/lib/maddy/dkim_keys`).
In the same directory .dns files are generated that contain
public key for each domain formatted in the form of a DNS record.
## Arguments
domains and selector can be specified in arguments, so actual modify.dkim use can
be shortened to the following:
```
modify {
dkim example.org selector
}
```
## Configuration directives
```
modify.dkim {
debug no
domains example.org example.com
selector default
key_path dkim-keys/{domain}-{selector}.key
oversign_fields ...
sign_fields ...
header_canon relaxed
body_canon relaxed
sig_expiry 120h # 5 days
hash sha256
newkey_algo rsa2048
}
```
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### domains _string-list_
**Required**.
Default: not specified
ADministrative Management Domains (ADMDs) taking responsibility for messages.
Should be specified either as a directive or as an argument.
---
### selector _string_
**Required**.
Default: not specified
Identifier of used key within the ADMD.
Should be specified either as a directive or as an argument.
---
### key_path _string_
Default: `dkim_keys/{domain}_{selector}.key`
Path to private key. It should be in PKCS#8 format wrapped in PAM encoding.
If key does not exist, it will be generated using algorithm specified
in newkey_algo.
Placeholders '{domain}' and '{selector}' will be replaced with corresponding
values from domain and selector directives.
Additionally, keys in PKCS#1 ("RSA PRIVATE KEY") and
RFC 5915 ("EC PRIVATE KEY") can be read by modify.dkim. Note, however that
newly generated keys are always in PKCS#8.
---
### oversign_fields _list..._
Default: see below
Header fields that should be signed n+1 times where n is times they are
present in the message. This makes it impossible to replace field
value by prepending another field with the same name to the message.
Fields specified here don't have to be also specified in `sign_fields`.
Default set of oversigned fields:
- Subject
- To
- From
- Date
- MIME-Version
- Content-Type
- Content-Transfer-Encoding
- Reply-To
- Message-Id
- References
- Autocrypt
- Openpgp
---
### sign_fields _list..._
Default: see below
Header fields that should be signed n times where n is times they are
present in the message. For these fields, additional values can be prepended
by intermediate relays, but existing values can't be changed.
Default set of signed fields:
- List-Id
- List-Help
- List-Unsubscribe
- List-Post
- List-Owner
- List-Archive
- Resent-To
- Resent-Sender
- Resent-Message-Id
- Resent-Date
- Resent-From
- Resent-Cc
---
### header_canon `relaxed` | `simple`
Default: `relaxed`
Canonicalization algorithm to use for header fields. With `relaxed`, whitespace within
fields can be modified without breaking the signature, with `simple` no
modifications are allowed.
---
### body_canon `relaxed` | `simple`
Default: `relaxed`
Canonicalization algorithm to use for message body. With `relaxed`, whitespace within
can be modified without breaking the signature, with `simple` no
modifications are allowed.
---
### sig_expiry _duration_
Default: `120h`
Time for which signature should be considered valid. Mainly used to prevent
unauthorized resending of old messages.
---
### hash _hash_
Default: `sha256`
Hash algorithm to use when computing body hash.
sha256 is the only supported algorithm now.
---
### newkey_algo `rsa4096` | `rsa2048` | `ed25519`
Default: `rsa2048`
Algorithm to use when generating a new key.
Currently ed25519 is **not** supported by most platforms.
---
### require_sender_match _ids..._
Default: `envelope auth`
Require specified identifiers to match From header field and key domain,
otherwise - don't sign the message.
If From field contains multiple addresses, message will not be
signed unless `allow_multiple_from` is also specified. In that
case only first address will be compared.
Matching is done in a case-insensitive way.
Valid values:
- `off` – Disable check, always sign.
- `envelope` – Require MAIL FROM address to match From header.
- `auth` – If authorization identity contains @ - then require it to
fully match From header. Otherwise, check only local-part
(username).
---
### allow_multiple_from _boolean_
Default: `no`
Allow multiple addresses in From header field for purposes of
`require_sender_match` checks. Only first address will be checked, however.
---
### sign_subdomains _boolean_
Default: `no`
Sign emails from subdomains using a top domain key.
Allows only one domain to be specified (can be worked around by using `modify.dkim`
multiple times).
================================================
FILE: docs/reference/modifiers/envelope.md
================================================
# Envelope sender / recipient rewriting
`replace_sender` and `replace_rcpt` modules replace SMTP envelope addresses
based on the mapping defined by the table module (maddy-tables(5)). It is possible
to specify 1:N mappings. This allows, for example, implementing mailing lists.
The address is normalized before lookup (Punycode in domain-part is decoded,
Unicode is normalized to NFC, the whole string is case-folded).
First, the whole address is looked up. If there is no replacement, local-part
of the address is looked up separately and is replaced in the address while
keeping the domain part intact. Replacements are not applied recursively, that
is, lookup is not repeated for the replacement.
Recipients are not deduplicated after expansion, so message may be delivered
multiple times to a single recipient. However, used delivery target can apply
such deduplication (imapsql storage does it).
Definition:
```
replace_rcpt [table arguments] {
[extended table config]
}
replace_sender [table arguments] {
[extended table config]
}
```
Use examples:
```
modify {
replace_rcpt file /etc/maddy/aliases
replace_rcpt static {
entry a@example.org b@example.org
entry c@example.org c1@example.org c2@example.org
}
replace_rcpt regexp "(.+)@example.net" "$1@example.org"
replace_rcpt regexp "(.+)@example.net" "$1@example.org" "$1@example.com"
}
```
Possible contents of /etc/maddy/aliases in the example above:
```
# Replace 'cat' with any domain to 'dog'.
# E.g. cat@example.net -> dog@example.net
cat: dog
# Replace cat@example.org with cat@example.com.
# Takes priority over the previous line.
cat@example.org: cat@example.com
# Using aliases in multiple lines
cat2: dog
cat2: mouse
cat2@example.org: cat@example.com
cat2@example.org: cat@example.net
# Comma-separated aliases in multiple lines
cat3: dog , mouse
cat3@example.org: cat@example.com , cat@example.net
```
================================================
FILE: docs/reference/modules.md
================================================
# Modules introduction
maddy is built of many small components called "modules". Each module does one
certain well-defined task. Modules can be connected to each other in arbitrary
ways to achieve wanted functionality. Default configuration file defines
set of modules that together implement typical email server stack.
To specify the module that should be used by another module for something, look
for configuration directives with "module reference" argument. Then
put the module name as an argument for it. Optionally, if referenced module
needs that, put additional arguments after the name. You can also put a
configuration block with additional directives specifing the module
configuration.
Here are some examples:
```
smtp ... {
# Deliver messages to the 'dummy' module with the default configuration.
deliver_to dummy
# Deliver messages to the 'target.smtp' module with
# 'tcp://127.0.0.1:1125' argument as a configuration.
deliver_to smtp tcp://127.0.0.1:1125
# Deliver messages to the 'queue' module with the specified configuration.
deliver_to queue {
target ...
max_tries 10
}
}
```
Additionally, module configuration can be placed in a separate named block
at the top-level and referenced by its name where it is needed.
Here is the example:
```
storage.imapsql local_mailboxes {
driver sqlite3
dsn all.db
}
smtp ... {
deliver_to &local_mailboxes
}
```
It is recommended to use this syntax for modules that are 'expensive' to
initialize such as storage backends and authentication providers.
For top-level configuration block definition, syntax is as follows:
```
namespace.module_name config_block_name... {
module_configuration
}
```
If config\_block\_name is omitted, it will be the same as module\_name. Multiple
names can be specified. All names must be unique.
Note the "storage." prefix. This is the actual module name and includes
"namespace". It is a little cheating to make more concise names and can
be omitted when you reference the module where it is used since it can
be implied (e.g. putting module reference in "check{}" likely means you want
something with "check." prefix)
Usual module arguments can't be specified when using this syntax, however,
modules usually provide explicit directives that allow to specify the needed
values. For example 'sql sqlite3 all.db' is equivalent to
```
storage.imapsql {
driver sqlite3
dsn all.db
}
```
================================================
FILE: docs/reference/smtp-pipeline.md
================================================
# SMTP message routing (pipeline)
# Message pipeline
A message pipeline is a set of module references and associated rules that
describe how to handle messages.
The pipeline is responsible for
- Running message filters (called "checks"), (e.g. DKIM signature verification,
DNSBL lookup, and so on).
- Running message modifiers (e.g. DKIM signature creation).
- Associating each message recipient with one or more delivery targets.
Delivery target is a module that does the final processing (delivery) of the
message.
Message handling flow is as follows:
- Execute checks referenced in top-level `check` blocks (if any)
- Execute modifiers referenced in top-level `modify` blocks (if any)
- If there are `source` blocks - select one that matches the message sender (as
specified in MAIL FROM). If there are no `source` blocks - the entire
configuration is assumed to be the `default_source` block.
- Execute checks referenced in `check` blocks inside the selected `source` block
(if any).
- Execute modifiers referenced in `modify` blocks inside selected `source`
block (if any).
Then, for each recipient:
- Select the `destination` block that matches it. If there are
no `destination` blocks - the entire used `source` block is interpreted as if it
was a `default_destination` block.
- Execute checks referenced in the `check` block inside the selected `destination`
block (if any).
- Execute modifiers referenced in `modify` block inside the selected `destination`
block (if any).
- If the used block contains the `reject` directive - reject the recipient with
the specified SMTP status code.
- If the used block contains the `deliver_to` directive - pass the message to the
specified target module. Only recipients that are handled
by the used block are visible to the target.
Each recipient is handled only by a single `destination` block, in case of
overlapping `destination` - the first one takes priority.
```
destination example.org {
deliver_to targetA
}
destination example.org { # ambiguous and thus not allowed
deliver_to targetB
}
```
Same goes for `source` blocks, each message is handled only by a single block.
Each recipient block should contain at least one `deliver_to` directive or
`reject` directive. If `destination` blocks are used, then
`default_destination` block should also be used to specify behavior for
unmatched recipients. Same goes for source blocks, `default_source` should be
used if `source` is used.
That is, pipeline configuration should explicitly specify behavior for each
possible sender/recipient combination.
Additionally, directives that specify final handling decision (`deliver_to`,
`reject`) can't be used at the same level as source/destination rules.
Consider example:
```
destination example.org {
deliver_to local_mboxes
}
reject
```
It is not obvious whether `reject` applies to all recipients or
just for non-example.org ones, hence this is not allowed.
Complete configuration example using all of the mentioned directives:
```
check {
# Run a check to make sure source SMTP server identification
# is legit.
spf
}
# Messages coming from senders at example.org will be handled in
# accordance with the following configuration block.
source example.org {
# We are example.com, so deliver all messages with recipients
# at example.com to our local mailboxes.
destination example.com {
deliver_to &local_mailboxes
}
# We don't do anything with recipients at different domains
# because we are not an open relay, thus we reject them.
default_destination {
reject 521 5.0.0 "User not local"
}
}
# We do our business only with example.org, so reject all
# other senders.
default_source {
reject
}
```
## Directives
### check _block name_ { ... }
Context: pipeline configuration, source block, destination block
List of the module references for checks that should be executed on
messages handled by block where 'check' is placed in.
Note that message body checks placed in destination block are currently
ignored. Due to the way SMTP protocol is defined, they would cause message to
be rejected for all recipients which is not what you usually want when using
such configurations.
Example:
```
check {
# Reference implicitly defined default configuration for check.
spf
# Inline definition of custom config.
spf {
# Configuration for spf goes here.
permerr_action reject
}
}
```
It is also possible to define the block of checks at the top level
as "checks" module and reference it using & syntax. Example:
```
checks inbound_checks {
spf
dkim
}
# ... somewhere else ...
{
...
check &inbound_checks
}
```
---
### modify { ... }
Default: not specified
Context: pipeline configuration, source block, destination block
List of the module references for modifiers that should be executed on
messages handled by block where 'modify' is placed in.
Message modifiers are similar to checks with the difference in that checks
purpose is to verify whether the message is legitimate and valid per local
policy, while modifier purpose is to post-process message and its metadata
before final delivery.
For example, modifier can replace recipient address to make message delivered
to the different mailbox or it can cryptographically sign outgoing message
(e.g. using DKIM). Some modifier can perform multiple unrelated modifications
on the message.
**Note**: Modifiers that affect source address can be used only globally or on
per-source basis, they will be no-op inside destination blocks. Modifiers that
affect the message header will affect it for all recipients.
It is also possible to define the block of modifiers at the top level
as "modiifers" module and reference it using & syntax. Example:
```
modifiers local_modifiers {
replace_rcpt file /etc/maddy/aliases
}
# ... somewhere else ...
{
...
modify &local_modifiers
}
```
---
### reject _smtp-code_ _smtp-enhanced-code_ _error-description_
reject _smtp-code_ _smtp-enhanced-code_
reject _smtp-code_
reject
Context: destination block
Messages handled by the configuration block with this directive will be
rejected with the specified SMTP error.
If you aren't sure which codes to use, use 541 and 5.4.0 with your message or
just leave all arguments out, the error description will say "message is
rejected due to policy reasons" which is usually what you want to mean.
`reject` can't be used in the same block with `deliver_to` or
`destination`/`source` directives.
Example:
```
reject 541 5.4.0 "We don't like example.org, go away"
```
---
### deliver_to _target-config-block_
Context: pipeline configuration, source block, destination block
Deliver the message to the referenced delivery target. What happens next is
defined solely by used target. If `deliver_to` is used inside `destination`
block, only matching recipients will be passed to the target.
---
### source_in _table-reference_ { ... }
Context: pipeline configuration
Handle messages with envelope senders present in the specified table in
accordance with the specified configuration block.
Takes precedence over all `sender` directives.
Example:
```
source_in file /etc/maddy/banned_addrs {
reject 550 5.7.0 "You are not welcome here"
}
source example.org {
...
}
...
```
See `destination_in` documentation for note about table configuration.
---
### source _rules..._ { ... }
Context: pipeline configuration
Handle messages with MAIL FROM value (sender address) matching any of the rules
in accordance with the specified configuration block.
"Rule" is either a domain or a complete address. In case of overlapping
'rules', first one takes priority. Matching is case-insensitive.
Example:
```
# All messages coming from example.org domain will be delivered
# to local_mailboxes.
source example.org {
deliver_to &local_mailboxes
}
# Messages coming from different domains will be rejected.
default_source {
reject 521 5.0.0 "You were not invited"
}
```
---
### reroute { ... }
Context: pipeline configuration, source block, destination block
This directive allows to make message routing decisions based on the
result of modifiers. The block can contain all pipeline directives and they
will be handled the same with the exception that source and destination rules
will use the final recipient and sender values (e.g. after all modifiers are
applied).
Here is the concrete example how it can be useful:
```
destination example.org {
modify {
replace_rcpt file /etc/maddy/aliases
}
reroute {
destination example.org {
deliver_to &local_mailboxes
}
default_destination {
deliver_to &remote_queue
}
}
}
```
This configuration allows to specify alias local addresses to remote ones
without being an open relay, since remote_queue can be used only if remote
address was introduced as a result of rewrite of local address.
**Warning**: If you have DMARC enabled (default), results generated by SPF
and DKIM checks inside a reroute block **will not** be considered in DMARC
evaluation.
---
### destination_in _table-reference_ { ... }
Context: pipeline configuration, source block
Handle messages with envelope recipients present in the specified table in
accordance with the specified configuration block.
Takes precedence over all 'destination' directives.
Example:
```
destination_in file /etc/maddy/remote_addrs {
deliver_to smtp tcp://10.0.0.7:25
}
destination example.com {
deliver_to &local_mailboxes
}
...
```
Note that due to the syntax restrictions, it is not possible to specify
extended configuration for table module. E.g. this is not valid:
```
destination_in sql_table {
dsn ...
driver ...
} {
deliver_to whatever
}
```
In this case, configuration should be specified separately and be referneced
using '&' syntax:
```
table.sql_table remote_addrs {
dsn ...
driver ...
}
whatever {
destination_in &remote_addrs {
deliver_to whatever
}
}
```
---
### destination _rule..._ { ... }
Context: pipeline configuration, source block
Handle messages with RCPT TO value (recipient address) matching any of the
rules in accordance with the specified configuration block.
"Rule" is either a domain or a complete address. Duplicate rules are not
allowed. Matching is case-insensitive.
Note that messages with multiple recipients are split into multiple messages if
they have recipients matched by multiple blocks. Each block will see the
message only with recipients matched by its rules.
Example:
```
# Messages with recipients at example.com domain will be
# delivered to local_mailboxes target.
destination example.com {
deliver_to &local_mailboxes
}
# Messages with other recipients will be rejected.
default_destination {
rejected 541 5.0.0 "User not local"
}
```
## Reusable pipeline snippets (msgpipeline module)
The message pipeline can be used independently of the SMTP module in other
contexts that require a delivery target via `msgpipeline` module.
Example:
```
msgpipeline local_routing {
destination whatever.com {
deliver_to dummy
}
}
# ... somewhere else ...
deliver_to &local_routing
```
================================================
FILE: docs/reference/storage/imap-filters.md
================================================
# IMAP filters
Most storage backends support application of custom code late in delivery
process. As opposed to using SMTP pipeline modifiers or checks, it allows
modifying IMAP-specific message attributes. In particular, it allows
code to change target folder and add IMAP flags (keywords) to the message.
There is no way to reject message using IMAP filters, this should be done
earlier in SMTP pipeline logic. Quarantined messages are not processed
by IMAP filters and are unconditionally delivered to Junk folder (or other
folder with \Junk special-use attribute).
To use an IMAP filter, specify it in the 'imap\_filter' directive for the
used storage backend, like this:
```
storage.imapsql local_mailboxes {
...
imap_filter {
command /etc/maddy/sieve.sh {account_name}
}
}
```
## System command filter (imap.filter.command)
This filter is similar to check.command module
and runs a system command to obtain necessary information.
Usage:
```
command executable_name args... { }
```
Same as check.command, following placeholders are supported for command
arguments: {source\_ip}, {source\_host}, {source\_rdns}, {msg\_id}, {auth\_user},
{sender}. Note: placeholders
in command name are not processed to avoid possible command injection attacks.
Additionally, for imap.filter.command, {account\_name} placeholder is replaced
with effective IMAP account name, {rcpt_to}, {original_rcpt_to} provide
access to the SMTP envelope recipient (before and after any rewrites),
{subject} is replaced with the Subject header, if it is present.
Note that if you use provided systemd units on Linux, maddy executable is
sandboxed - all commands will be executed with heavily restricted filesystem
access and other privileges. Notably, /tmp is isolated and all directories
except for /var/lib/maddy and /run/maddy are read-only. You will need to modify
systemd unit if your command needs more privileges.
Command output should consist of zero or more lines. First one, if non-empty, overrides
destination folder. All other lines contain additional IMAP flags to add
to the message. If command wants to add flags without changing folder - first
line should be empty.
It is valid for command to not write anything to stdout. In this case its
execution will have no effect on delivery.
Output example:
```
Junk
```
In this case, message will be placed in the Junk folder.
```
$Label1
```
In this case, message will be placed in inbox and will have
'$Label1' added.
================================================
FILE: docs/reference/storage/imapsql.md
================================================
# SQL-indexed storage
The imapsql module implements database for IMAP index and message
metadata using SQL-based relational database.
Message contents are stored in an "blob store" defined by msg_store
directive. By default this is a file system directory under /var/lib/maddy.
Supported RDBMS:
- SQLite 3.25.0
- PostgreSQL 9.6 or newer
- CockroachDB 20.1.5 or newer
Account names are required to have the form of a email address (unless configured otherwise)
and are case-insensitive. UTF-8 names are supported with restrictions defined in the
PRECIS UsernameCaseMapped profile.
```
storage.imapsql {
driver sqlite3
dsn imapsql.db
msg_store fs messages/
}
```
imapsql module also can be used as a lookup table.
It returns empty string values for existing usernames. This might be useful
with `destination_in` directive e.g. to implement catch-all
addresses (this is a bad idea to do so, this is just an example):
```
destination_in &local_mailboxes {
deliver_to &local_mailboxes
}
destination example.org {
modify {
replace_rcpt regexp ".*" "catchall@example.org"
}
deliver_to &local_mailboxes
}
```
## Arguments
Specify the driver and DSN.
## Configuration directives
### driver _string_
**Required.**
Default: not specified
Use a specified driver to communicate with the database. Supported values:
sqlite3, postgres.
Should be specified either via an argument or via this directive.
---
### dsn _string_
**Required.**
Default: not specified
Data Source Name, the driver-specific value that specifies the database to use.
For SQLite3 this is just a file path.
For PostgreSQL: [https://godoc.org/github.com/lib/pq#hdr-Connection\_String\_Parameters](https://godoc.org/github.com/lib/pq#hdr-Connection\_String\_Parameters)
Should be specified either via an argument or via this directive.
---
### msg_store _store_
Default: `fs messages/`
Module to use for message bodies storage.
See "Blob storage" section for what you can use here.
---
### compression `off`
compression _algorithm_
compression _algorithm_ _level_
Default: `off`
Apply compression to message contents.
Supported algorithms: `lz4`, `zstd`.
---
### appendlimit _size_
Default: `32M`
Don't allow users to add new messages larger than 'size'.
This does not affect messages added when using module as a delivery target.
Use `max_message_size` directive in SMTP endpoint module to restrict it too.
---
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### junk_mailbox _name_
Default: `Junk`
The folder to put quarantined messages in. Thishis setting is not used if user
does have a folder with "Junk" special-use attribute.
---
### disable_recent _boolean_
Default: `true`
Disable RFC 3501-conforming handling of \Recent flag.
This significantly improves storage performance when SQLite3 or CockroackDB is
used at the cost of confusing clients that use this flag.
---
### sqlite_cache_size _integer_
Default: defined by SQLite
SQLite page cache size. If positive - specifies amount of pages (1 page - 4
KiB) to keep in cache. If negative - specifies approximate upper bound
of cache size in KiB.
---
### sqlite_busy_timeout _integer_
Default: `5000000`
SQLite-specific performance tuning option. Amount of milliseconds to wait
before giving up on DB lock.
---
### imap_filter { ... }
Default: not set
Specifies IMAP filters to apply for messages delivered from SMTP pipeline.
Ex.
```
imap_filter {
command /etc/maddy/sieve.sh {account_name}
}
```
---
### delivery_map _table_
Default: `identity`
Use specified table module to map recipient
addresses from incoming messages to mailbox names.
Normalization algorithm specified in `delivery_normalize` is appied before
`delivery_map`.
---
### delivery_normalize _name_
Default: `precis_casefold_email`
Normalization function to apply to email addresses before mapping them
to mailboxes.
See `auth_normalize`.
---
### auth_map _table_
**Deprecated:** Use `storage_map` in imap config instead.
Default: `identity`
Use specified table module to map authentication
usernames to mailbox names.
Normalization algorithm specified in auth_normalize is applied before
auth_map.
---
### auth_normalize _name_
**Deprecated:** Use `storage_map_normalize` in imap config instead.
**Default**: `precis_casefold_email`
Normalization function to apply to authentication usernames before mapping
them to mailboxes.
Available options:
- `precis_casefold_email` PRECIS UsernameCaseMapped profile + U-labels form for domain
- `precis_casefold` PRECIS UsernameCaseMapped profile for the entire string
- `precis_email` PRECIS UsernameCasePreserved profile + U-labels form for domain
- `precis` PRECIS UsernameCasePreserved profile for the entire string
- `casefold` Convert to lower case
- `noop` Nothing
Note: On message delivery, recipient address is unconditionally normalized
using `precis_casefold_email` function.
================================================
FILE: docs/reference/table/auth.md
================================================
# Authentication providers
Most authentication providers are also usable as a table
that contains all usernames known to the module. Exceptions are auth.external and
pam as underlying interfaces do not define a way to check credentials
existence.
================================================
FILE: docs/reference/table/chain.md
================================================
# Table chaining
The table.chain module allows chaining together multiple table modules
by using value returned by a previous table as an input for the second
table.
Example:
```
table.chain {
step regexp "(.+)(\\+[^+"@]+)?@example.org" "$1@example.org"
step file /etc/maddy/emails
}
```
This will strip +prefix from mailbox before looking it up
in /etc/maddy/emails list.
## Configuration directives
### step _table_
Adds a table module to the chain. If input value is not in the table
(e.g. file) - return "not exists" error.
---
### optional_step _table_
Same as step but if input value is not in the table - it is passed to the
next step without changes.
Example:
Something like this can be used to map emails to usernames
after translating them via aliases map:
```
table.chain {
optional_step file /etc/maddy/aliases
step regexp "(.+)@(.+)" "$1"
}
```
================================================
FILE: docs/reference/table/email_localpart.md
================================================
# Email local part
The module `table.email_localpart` extracts and unescapes local ("username") part
of the email address.
E.g.
* `test@example.org` => `test`
* `"test @ a"@example.org` => `test @ a`
Mappings for invalid emails are not defined (will be treated as non-existing
values).
```
table.email_localpart { }
```
`table.email_localpart_optional` works the same, but returns non-email strings
as is. This can be used if you want to accept both `user@example.org` and
`user` somewhere and treat it the same.
================================================
FILE: docs/reference/table/email_with_domain.md
================================================
# Email with domain
The table module `table.email_with_domain` appends one or more
domains (allowing 1:N expansion) to the specified value.
```
table.email_with_domain DOMAIN DOMAIN... { }
```
It can be used to implement domain-level expansion for aliases if used together
with `table.chain`. Example:
```
modify {
replace_rcpt chain {
step email_local_part
step email_with_domain example.org example.com
}
}
```
This configuration will alias `anything@anydomain` to `anything@example.org`
and `anything@example.com`.
It is also useful with `authorize_sender` to authorize sending using multiple
addresses under different domains if non-email usernames are used for
authentication:
```
check.authorize_sender {
...
user_to_email email_with_domain example.org example.com
}
```
This way, user authenticated as `user` will be allowed to use
`user@example.org` or `user@example.com` as a sender address.
================================================
FILE: docs/reference/table/file.md
================================================
# File
table.file module builds string-string mapping from a text file.
File is reloaded every 15 seconds if there are any changes (detected using
modification time). No changes are applied if file contains syntax errors.
Definition:
```
file
```
or
```
file {
file
}
```
Usage example:
```
# Resolve SMTP address aliases using text file mapping.
modify {
replace_rcpt file /etc/maddy/aliases
}
```
## Syntax
Better demonstrated by examples:
```
# Lines starting with # are ignored.
# And so are lines only with whitespace.
# Whenever 'aaa' is looked up, return 'bbb'
aaa: bbb
# Trailing and leading whitespace is ignored.
ccc: ddd
# If there is no colon, the string is translated into ""
# That is, the following line is equivalent to
# aaa:
aaa
# If the same key is used multiple times - table.file will return
# multiple values when queries.
ddd: firstvalue
ddd: secondvalue
# Alternatively, multiple values can be specified
# using a comma. There is no support for escaping
# so you would have to use a different format if you require
# comma-separated values.
ddd: firstvalue, secondvalue
```
================================================
FILE: docs/reference/table/regexp.md
================================================
# Regexp rewrite table
The 'regexp' module implements table lookups by applying a regular expression
to the key value. If it matches - 'replacement' value is returned with $N
placeholders being replaced with corresponding capture groups from the match.
Otherwise, no value is returned.
The regular expression syntax is the subset of PCRE. See
[https://golang.org/pkg/regexp/syntax](https://golang.org/pkg/regexp/syntax)/ for details.
```
table.regexp [replacement] {
full_match yes
case_insensitive yes
expand_placeholders yes
}
```
Note that [replacement] is optional. If it is not included - table.regexp
will return the original string, therefore acting as a regexp match check.
This can be useful in combination in `destination_in` for
advanced matching:
```
destination_in regexp ".*-bounce+.*@example.com" {
...
}
```
## Configuration directives
### full_match _boolean_
Default: `yes`
Whether to implicitly add start/end anchors to the regular expression.
That is, if `full_match` is `yes`, then the provided regular expression should
match the whole string. With `no` - partial match is enough.
---
### case_insensitive _boolean_
Default: `yes`
Whether to make matching case-insensitive.
---
### expand_placeholders _boolean_
Default: `yes`
Replace '$name' and '${name}' in the replacement string with contents of
corresponding capture groups from the match.
To insert a literal $ in the output, use $$ in the template.
## Identity table (table.identity)
The module 'identity' is a table module that just returns the key looked up.
```
table.identity { }
```
================================================
FILE: docs/reference/table/sql_query.md
================================================
# SQL query mapping
The table.sql_query module implements table interface using SQL queries.
Definition:
```
table.sql_query {
driver
dsn
lookup
# Optional:
init
list
add
del
set
}
```
Usage example:
```
# Resolve SMTP address aliases using PostgreSQL DB.
modify {
replace_rcpt sql_query {
driver postgres
dsn "dbname=maddy user=maddy"
lookup "SELECT alias FROM aliases WHERE address = $1"
}
}
```
## Configuration directives
### driver _driver name_
**Required.**
Driver to use to access the database.
Supported drivers: `postgres`, `sqlite3` (if compiled with C support)
---
### dsn _data source name_
**Required.**
Data Source Name to pass to the driver. For SQLite3 this is just a path to DB
file. For Postgres, see
[https://pkg.go.dev/github.com/lib/pq?tab=doc#hdr-Connection\_String\_Parameters](https://pkg.go.dev/github.com/lib/pq?tab=doc#hdr-Connection\_String\_Parameters)
---
### lookup _query_
**Required.**
SQL query to use to obtain the lookup result.
It will get one named argument containing the lookup key. Use :key
placeholder to access it in SQL. The result row set should contain one row, one
column with the string that will be used as a lookup result. If there are more
rows, they will be ignored. If there are more columns, lookup will fail. If
there are no rows, lookup returns "no results". If there are any error - lookup
will fail.
---
### init _queries..._
Default: empty
List of queries to execute on initialization. Can be used to configure RDBMS.
Example, to improve SQLite3 performance:
```
table.sql_query {
driver sqlite3
dsn whatever.db
init "PRAGMA journal_mode=WAL" \
"PRAGMA synchronous=NORMAL"
lookup "SELECT alias FROM aliases WHERE address = $1"
}
```
---
### named_args _boolean_
Default: `yes`
Whether to use named parameters binding when executing SQL queries
or not.
Note that maddy's PostgreSQL driver does not support named parameters and
SQLite3 driver has issues handling numbered parameters:
[https://github.com/mattn/go-sqlite3/issues/472](https://github.com/mattn/go-sqlite3/issues/472)
---
### add _query_
list _query_
set _query_
del _query_
Default: none
If queries are set to implement corresponding table operations - table becomes
"mutable" and can be used in contexts that require writable key-value store.
'add' query gets :key, :value named arguments - key and value strings to store.
They should be added to the store. The query **should** not add multiple values
for the same key and **should** fail if the key already exists.
'list' query gets no arguments and should return a column with all keys in
the store.
'set' query gets :key, :value named arguments - key and value and should replace the existing
entry in the database.
'del' query gets :key argument - key and should remove it from the database.
If `named_args` is set to `no` - key is passed as the first numbered parameter
($1), value is passed as the second numbered parameter ($2).
================================================
FILE: docs/reference/table/static.md
================================================
# Static table
The 'static' module implements table lookups using key-value pairs in its
configuration.
```
table.static {
entry KEY1 VALUE1
entry KEY2 VALUE2
...
}
```
## Configuration directives
### entry _key_ _value_
Add an entry to the table.
If the same key is used multiple times, the last one takes effect.
================================================
FILE: docs/reference/targets/queue.md
================================================
# Local queue
Queue module buffers messages on disk and retries delivery multiple times to
another target to ensure reliable delivery.
It is also responsible for generation of DSN messages
in case of delivery failures.
## Arguments
First argument specifies directory to use for storage.
Relative paths are relative to the StateDirectory.
## Configuration directives
```
target.queue {
target remote
location ...
max_parallelism 16
max_tries 4
bounce {
destination example.org {
deliver_to &local_mailboxes
}
default_destination {
reject
}
}
autogenerated_msg_domain example.org
debug no
}
```
### target _block_name_
**Required.**
Default: not specified
Delivery target to use for final delivery.
---
### location _directory_
Default: `StateDirectory/configuration_block_name`
File system directory to use to store queued messages.
Relative paths are relative to the StateDirectory.
---
### max_parallelism _integer_
Default: `16`
Start up to _integer_ goroutines for message processing. Basically, this option
limits amount of messages tried to be delivered concurrently.
---
### max_tries _integer_
Default: `20`
Attempt delivery up to _integer_ times. Note that no more attempts will be done
is permanent error occurred during previous attempt.
Delay before the next attempt will be increased exponentially using the
following formula: 15mins * 1.2 ^ (n - 1) where n is the attempt number.
This gives you approximately the following sequence of delays:
18mins, 21mins, 25mins, 31mins, 37mins, 44mins, 53mins, 64mins, ...
---
### bounce { ... }
Default: not specified
This configuration contains pipeline configuration to be used for generated DSN
(Delivery Status Notification) messages.
If this is block is not present in configuration, DSNs will not be generated.
Note, however, this is not what you want most of the time.
---
### autogenerated_msg_domain _domain_
Default: global directive value
Domain to use in sender address for DSNs. Should be specified too if 'bounce'
block is specified.
---
### debug _boolean_
Default: `no`
Enable verbose logging.
================================================
FILE: docs/reference/targets/remote.md
================================================
# Remote MX delivery
Module that implements message delivery to remote MTAs discovered via DNS MX
records. You probably want to use it with queue module for reliability.
If a message check marks a message as 'quarantined', remote module
will refuse to deliver it.
## Configuration directives
```
target.remote {
hostname mx.example.org
debug no
}
```
### hostname _domain_
Default: global directive value
Hostname to use client greeting (EHLO/HELO command). Some servers require it to
be FQDN, SPF-capable servers check whether it corresponds to the server IP
address, so it is better to set it to a domain that resolves to the server IP.
---
### limits { ... }
Default: no limits
See ['limits' directive for SMTP endpoint](/reference/endpoints/smtp/#rate-concurrency-limiting).
It works the same except for address domains used for
per-source/per-destination are as observed when message exits the server.
---
### local_ip _ip-address_
Default: empty
Choose the local IP to bind for outbound SMTP connections.
---
### force_ipv4 _boolean_
Default: `false`
Force resolving outbound SMTP domains to IPv4 addresses. Some server providers
do not offer a way to properly set reverse PTR domains for IPv6 addresses; this
option makes maddy only connect to IPv4 addresses so that its public IPv4 address
is used to connect to that server, and thus reverse PTR checks are made against
its IPv4 address.
Warning: this may break sending outgoing mail to IPv6-only SMTP servers.
---
### connect_timeout _duration_
Default: `5m`
Timeout for TCP connection establishment.
RFC 5321 recommends 5 minutes for "initial greeting" that includes TCP
handshake. maddy uses two separate timers - one for "dialing" (DNS A/AAAA
lookup + TCP handshake) and another for "initial greeting". This directive
configures the former. The latter is not configurable and is hardcoded to be
5 minutes.
---
### command_timeout _duration_
Default: `5m`
Timeout for any SMTP command (EHLO, MAIL, RCPT, DATA, etc).
If STARTTLS is used this timeout also applies to TLS handshake.
RFC 5321 recommends 5 minutes for MAIL/RCPT and 3 minutes for
DATA.
---
### submission_timeout _duration_
Default: `12m`
Time to wait after the entire message is sent (after "final dot").
RFC 5321 recommends 10 minutes.
---
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### requiretls_override _boolean_
Default: `true`
Allow local security policy to be disabled using 'TLS-Required' header field in
sent messages. Note that the field has no effect if transparent forwarding is
used, message body should be processed before outbound delivery starts for it
to take effect (e.g. message should be queued using 'queue' module).
---
### relaxed_requiretls _boolean_
Default: `true`
This option disables strict conformance with REQUIRETLS specification and
allows forwarding of messages 'tagged' with REQUIRETLS to MXes that are not
advertising REQUIRETLS support. It is meant to allow REQUIRETLS use without the
need to have support from all servers. It is based on the assumption that
server referenced by MX record is likely the final destination and therefore
there is only need to secure communication towards it and not beyond.
---
### conn_reuse_limit _integer_
Default: `10`
Amount of times the same SMTP connection can be used.
Connections are never reused if the previous DATA command failed.
---
### conn_max_idle_count _integer_
Default: `10`
Max. amount of idle connections per recipient domains to keep in cache.
---
### conn_max_idle_time _integer_
Default: `150` (2.5 min)
Amount of time the idle connection is still considered potentially usable.
---
## Security policies
### mx_auth { ... }
Default: no policies
'remote' module implements a number of of schemes and protocols necessary to
ensure security of message delivery. Most of these schemes are concerned with
authentication of recipient server and TLS enforcement.
To enable mechanism, specify its name in the `mx_auth` directive block:
```
mx_auth {
dane
mtasts
}
```
Additional configuration is possible if supported by the mechanism by
specifying additional options as a block for the corresponding mechanism.
E.g.
```
mtasts {
cache ram
}
```
If the `mx_auth` directive is not specified, no mechanisms are enabled. Note
that, however, this makes outbound SMTP vulnerable to a numerous downgrade
attacks and hence not recommended.
It is possible to share the same set of policies for multiple 'remote' module
instances by defining it at the top-level using `mx_auth` module and then
referencing it using standard & syntax:
```
mx_auth outbound_policy {
dane
mtasts {
cache ram
}
}
# ... somewhere else ...
deliver_to remote {
mx_auth &outbound_policy
}
# ... somewhere else ...
deliver_to remote {
mx_auth &outbound_policy
tls_client { ... }
}
```
---
### MTA-STS
Checks MTA-STS policy of the recipient domain. Provides proper authentication
and TLS enforcement for delivery, but partially vulnerable to persistent active
attacks.
Sets MX level to "mtasts" if the used MX matches MTA-STS policy even if it is
not set to "enforce" mode.
```
mtasts {
cache fs
fs_dir StateDirectory/mtasts_cache
}
```
### cache `fs` | `ram`
Default: `fs`
Storage to use for MTA-STS cache. 'fs' is to use a filesystem directory, 'ram'
to store the cache in memory.
It is recommended to use 'fs' since that will not discard the cache (and thus
cause MTA-STS security to disappear) on server restart. However, using the RAM
cache can make sense for high-load configurations with good uptime.
### fs_dir _directory_
Default: `StateDirectory/mtasts_cache`
Filesystem directory to use for policies caching if 'cache' is set to 'fs'.
---
### DNSSEC
Checks whether MX records are signed. Sets MX level to "dnssec" is they are.
maddy does not validate DNSSEC signatures on its own. Instead it relies on
the upstream resolver to do so by causing lookup to fail when verification
fails and setting the AD flag for signed and verified zones. As a safety
measure, if the resolver is not 127.0.0.1 or ::1, the AD flag is ignored.
DNSSEC is currently not supported on Windows and other platforms that do not
have the /etc/resolv.conf file in the standard format.
```
dnssec { }
```
---
### DANE
Checks TLSA records for the recipient MX. Provides downgrade-resistant TLS
enforcement.
Sets TLS level to "authenticated" if a valid and matching TLSA record uses
DANE-EE or DANE-TA usage type.
See above for notes on DNSSEC. DNSSEC support is required for DANE to work.
```
dane { }
```
---
### Local policy
Checks effective TLS and MX levels (as set by other policies) against local
configuration.
```
local_policy {
min_tls_level none
min_mx_level none
}
```
Using `local_policy off` is equivalent to setting both directives to `none`.
### min_tls_level `none` | `encrypted` | `authenticated`
Default: `encrypted`
Set the minimal TLS security level required for all outbound messages.
See [Security levels](/seclevels) page for details.
### min_mx_level `none` | `mtasts` | `dnssec`
Default: `none`
Set the minimal MX security level required for all outbound messages.
See [Security levels](/seclevels) page for details.
================================================
FILE: docs/reference/targets/smtp.md
================================================
# SMTP & LMTP transparent forwarding
Module that implements transparent forwarding of messages over SMTP.
Use in pipeline configuration:
```
deliver_to smtp tcp://127.0.0.1:5353
# or
deliver_to smtp tcp://127.0.0.1:5353 {
# Other settings, see below.
}
```
target.lmtp can be used instead of target.smtp to
use LMTP protocol.
Endpoint addresses use format described in [Configuration files syntax / Address definitions](/reference/config-syntax/#address-definitions).
## Configuration directives
```
target.smtp {
debug no
tls_client {
...
}
attempt_starttls yes
require_tls no
auth off
targets tcp://127.0.0.1:2525
connect_timeout 5m
command_timeout 5m
submission_timeout 12m
}
```
### debug _boolean_
Default: global directive value
Enable verbose logging.
---
### tls_client { ... }
Default: not specified
Advanced TLS client configuration options. See [TLS configuration / Client](/reference/tls/#client) for details.
---
### starttls _boolean_
Default: `yes` (`no` for `target.lmtp`)
Use STARTTLS to enable TLS encryption. If STARTTLS is not supported
by the remote server - connection will fail.
maddy will use `localhost` as HELO hostname before STARTTLS
and will only send its actual hostname after STARTTLS.
### attempt_starttls _boolean_
Default: `yes` (`no` for `target.lmtp`)
DEPRECATED: Equivalent to `starttls`. Plaintext fallback is no longer
supported.
---
### require_tls _boolean_
Default: `no`
DEPRECATED: Ignored. Set `starttls yes` to use STARTLS.
---
### auth `off` | `plain` _username_ _password_ | `forward` | `external`
Default: `off`
Specify the way to authenticate to the remote server.
Valid values:
- `off` – No authentication.
- `plain` – Authenticate using specified username-password pair.
**Don't use** this without enforced TLS (`require_tls`).
- `forward` – Forward credentials specified by the client.
**Don't use** this without enforced TLS (`require_tls`).
- `external` – Request "external" SASL authentication. This is usually used for
authentication using TLS client certificates. See [TLS configuration / Client](/reference/tls/#client) for details.
---
### targets _endpoints..._
**Required.**
Default: not specified
List of remote server addresses to use. See [Address definitions](/reference/config-syntax/#address-definitions)
for syntax to use. Basically, it is `tcp://ADDRESS:PORT`
for plain SMTP and `tls://ADDRESS:PORT` for SMTPS (aka SMTP with Implicit
TLS).
Multiple addresses can be specified, they will be tried in order until connection to
one succeeds (including TLS handshake if TLS is required).
---
### connect_timeout _duration_
Default: `5m`
Same as for target.remote.
---
### command_timeout _duration_
Default: `5m`
Same as for target.remote.
---
### submission_timeout _duration_
Default: `12m`
Same as for target.remote.
================================================
FILE: docs/reference/tls-acme.md
================================================
# Automatic certificate management via ACME
Maddy supports obtaining certificates using ACME protocol.
To use it, create a configuration name for `tls.loader.acme`
and reference it from endpoints that should use automatically
configured certificates:
```
tls.loader.acme local_tls {
email put-your-email-here@example.org
agreed # indicate your agreement with Let's Encrypt ToS
challenge dns-01
}
smtp tcp://127.0.0.1:25 {
tls &local_tls
...
}
```
You can also use a global `tls` directive to use automatically
obtained certificates for all endpoints:
```
tls {
loader acme {
email maddy-acme@example.org
agreed
challenge dns-01
}
}
```
Note: `tls &local_tls` as a global directive won't work because
global directives are initialized before other configuration blocks.
Currently the only supported challenge is `dns-01` one therefore
you also need to configure the DNS provider:
```
tls.loader.acme local_tls {
email maddy-acme@example.org
agreed
challenge dns-01
dns PROVIDER_NAME {
...
}
}
```
See below for supported providers and necessary configuration
for each.
## Configuration directives
```
tls.loader.acme {
debug off
hostname example.maddy.invalid
store_path /var/lib/maddy/acme
ca https://acme-v02.api.letsencrypt.org/directory
test_ca https://acme-staging-v02.api.letsencrypt.org/directory
email test@maddy.invalid
agreed off
challenge dns-01
dns ...
}
```
### debug _boolean_
Default: global directive value
Enable debug logging.
---
### hostname _str_
**Required.**
Default: global directive value
Domain name to issue certificate for.
---
### store_path _path_
Default: `state_dir/acme`
Where to store issued certificates and associated metadata.
Currently only filesystem-based store is supported.
---
### ca _url_
Default: Let's Encrypt production CA
URL of ACME directory to use.
---
### test_ca _url_
Default: Let's Encrypt staging CA
URL of ACME directory to use for retries should
primary CA fail.
maddy will keep attempting to issues certificates
using `test_ca` until it succeeds then it will switch
back to the one configured via 'ca' option.
This avoids rate limit issues with production CA.
---
### override_domain _domain_
Default: not set
Override the domain to set the TXT record on for DNS-01 challenge.
This is to delegate the challenge to a different domain.
See https://www.eff.org/deeplinks/2018/02/technical-deep-dive-securing-automation-acme-dns-challenge-validation
for explanation why this might be useful.
---
### email _str_
Default: not set
Email to pass while registering an ACME account.
---
### agreed _boolean_
Default: false
Whether you agreed to ToS of the CA service you are using.
---
### challenge `dns-01`
Default: not set
Challenge(s) to use while performing domain verification.
## DNS providers
Support for some providers is not provided by standard builds.
To be able to use these, you need to compile maddy
with "libdns_PROVIDER" build tag.
E.g.
```
./build.sh --tags 'libdns_googleclouddns'
```
- gandi
```
dns gandi {
api_token "token"
}
```
- digitalocean
```
dns digitalocean {
api_token "..."
}
```
- cloudflare
See [https://github.com/libdns/cloudflare#authenticating](https://github.com/libdns/cloudflare#authenticating)
```
dns cloudflare {
api_token "..."
}
```
- vultr
```
dns vultr {
api_token "..."
}
```
- hetzner
```
dns hetzner {
api_token "..."
}
```
- namecheap
```
dns namecheap {
api_key "..."
api_username "..."
# optional: API endpoint, production one is used if not set.
endpoint "https://api.namecheap.com/xml.response"
# optional: your public IP, discovered using icanhazip.com if not set
client_ip 1.2.3.4
}
```
- googleclouddns (non-default)
```
dns googleclouddns {
project "project_id"
service_account_json "path"
}
```
- route53 (non-default)
```
dns route53 {
secret_access_key "..."
access_key_id "..."
# or use environment variables: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
}
```
- leaseweb (non-default)
```
dns leaseweb {
api_key "key"
}
```
- metaname (non-default)
```
dns metaname {
api_key "key"
account_ref "reference"
}
```
- alidns (non-default)
```
dns alidns {
key_id "..."
key_secret "..."
}
```
- namedotcom (non-default)
```
dns namedotcom {
user "..."
token "..."
}
```
- rfc2136 (non-default)
```
dns rfc2136 {
key_name "..."
# Secret
key "..."
# HMAC algorithm used to generate the key, lowercase, e.g. hmac-sha512
key_alg "..."
# server to which the dynamic update will be sent, e.g. 127.0.0.1
# you can also specify the port: 127.0.0.1:53
server "..."
}
```
- acmedns (non-default)
```
dns acmedns {
username "..."
password "..."
subdomain "..."
server_url "..."
}
```
================================================
FILE: docs/reference/tls.md
================================================
# TLS configuration
## Server-side
TLS certificates are obtained by modules called "certificate loaders". 'tls' directive
arguments specify name of loader to use and arguments. Due to syntax limitations
advanced configuration for loader should be specified using 'loader' directive, see
below.
```
tls file cert.pem key.pem {
protocols tls1.2 tls1.3
curves X25519
ciphers ...
}
tls {
loader file cert.pem key.pem {
# Options for loader go here.
}
protocols tls1.2 tls1.3
curves X25519
ciphers ...
}
```
### Available certificate loaders
- `file` – Accepts argument pairs specifying certificate and then key.
E.g. `tls file certA.pem keyA.pem certB.pem keyB.pem`.
If multiple certificates are listed, SNI will be used.
- `acme` – Automatically obtains a certificate using ACME protocol (Let's Encrypt)
- `off` – Not really a loader but a special value for tls directive,
explicitly disables TLS for endpoint(s).
## Advanced TLS configuration
**Note: maddy uses secure defaults and TLS handshake is resistant to active downgrade attacks. There is no need to change anything in most cases.**
---
### protocols _min-version_ _max-version_ | _version_
Default: `tls1.0 tls1.3`
Minimum/maximum accepted TLS version. If only one value is specified, it will
be the only one usable version.
Valid values are: `tls1.0`, `tls1.1`, `tls1.2`, `tls1.3`
---
### ciphers _ciphers..._
Default: Go version-defined set of 'secure ciphers', ordered by hardware
performance
List of supported cipher suites, in preference order. Not used with TLS 1.3.
Valid values:
- `RSA-WITH-RC4128-SHA`
- `RSA-WITH-3DES-EDE-CBC-SHA`
- `RSA-WITH-AES128-CBC-SHA`
- `RSA-WITH-AES256-CBC-SHA`
- `RSA-WITH-AES128-CBC-SHA256`
- `RSA-WITH-AES128-GCM-SHA256`
- `RSA-WITH-AES256-GCM-SHA384`
- `ECDHE-ECDSA-WITH-RC4128-SHA`
- `ECDHE-ECDSA-WITH-AES128-CBC-SHA`
- `ECDHE-ECDSA-WITH-AES256-CBC-SHA`
- `ECDHE-RSA-WITH-RC4128-SHA`
- `ECDHE-RSA-WITH-3DES-EDE-CBC-SHA`
- `ECDHE-RSA-WITH-AES128-CBC-SHA`
- `ECDHE-RSA-WITH-AES256-CBC-SHA`
- `ECDHE-ECDSA-WITH-AES128-CBC-SHA256`
- `ECDHE-RSA-WITH-AES128-CBC-SHA256`
- `ECDHE-RSA-WITH-AES128-GCM-SHA256`
- `ECDHE-ECDSA-WITH-AES128-GCM-SHA256`
- `ECDHE-RSA-WITH-AES256-GCM-SHA384`
- `ECDHE-ECDSA-WITH-AES256-GCM-SHA384`
- `ECDHE-RSA-WITH-CHACHA20-POLY1305`
- `ECDHE-ECDSA-WITH-CHACHA20-POLY1305`
---
### curves _curves..._
Default: defined by Go version
The elliptic curves that will be used in an ECDHE handshake, in preference
order.
Valid values: `p256`, `p384`, `p521`, `X25519`.
## Client
`tls_client` directive allows to customize behavior of TLS client implementation,
notably adjusting minimal and maximal TLS versions and allowed cipher suites,
enabling TLS client authentication.
```
tls_client {
protocols tls1.2 tls1.3
ciphers ...
curves X25519
root_ca /etc/ssl/cert.pem
cert /etc/ssl/private/maddy-client.pem
key /etc/ssl/private/maddy-client.pem
}
```
---
### protocols _min-version_ _max-version_ | _version_
Default: `tls1.0 tls1.3`
Minimum/maximum accepted TLS version. If only one value is specified, it will
be the only one usable version.
Valid values are: `tls1.0`, `tls1.1`, `tls1.2`, `tls1.3`
---
### ciphers _ciphers..._
Default: Go version-defined set of 'secure ciphers', ordered by hardware
performance
List of supported cipher suites, in preference order. Not used with TLS 1.3.
See TLS server configuration for list of supported values.
---
### curves _curves..._
Default: defined by Go version
The elliptic curves that will be used in an ECDHE handshake, in preference
order.
Valid values: `p256`, `p384`, `p521`, `X25519`.
---
### root_ca _paths..._
Default: system CA pool
List of files with PEM-encoded CA certificates to use when verifying
server certificates.
---
### cert _cert-path_
key _key-path_
Default: not specified
Present the specified certificate when server requests a client certificate.
Files should use PEM format. Both directives should be specified.
================================================
FILE: docs/seclevels.md
================================================
# Outbound delivery security
maddy implements a number of schemes and protocols for discovery and
enforcement of security features supported by the recipient MTA.
## Introduction to the problems of secure SMTP
Outbound delivery security involves two independent problems:
- MX record authentication
- TLS enforcement
### MX record authentication
When MTA wants to deliver a message to a mailbox at remote domain, it needs to
discover the server to use for it. It is done through the lookup of DNS MX
records for the recipient.
Problem arises from the fact that DNS does not have any cryptographic
protection and so any malicious actor can technically modify the response to
contain any server. And MTA would use that server!
There are two protocols that solve this problem: MTA-STS and DNSSEC.
Former requires the MTA to verify used records against a list of rules published
via HTTPS. Later cryptographically signs the records themselves.
### TLS enforcement
By default, server-server SMTP is unencrypted. If remote server supports TLS,
it is advertised via the ESMTP extension named STARTTLS, but malicious actor
controlling communication channel can hide the support for STARTTLS and sender
MTA will have to use plaintext. There needs to be a out-of-band authenticated
channel to indicate TLS support (and to require its use).
MTA-STS and DANE solve this problem. In the first case, if policy is in
"enforce" mode then MTA is required to use TLS when delivering messages to a
remote server. DANE does pretty much the same thing, but using DNSSEC-signed
TLSA records.
## maddy policy details
maddy defines two values indicating how "secure" delivery of message will be:
- MX security level
- TLS security level
These values correspond to the problems described above. On delivery, the
established connection to the remote server is "ranked" using these values and
then they are compared against a number of policies (including local
configuration). If the effective value is lower than the required one, the
connection is closed and next candidate server is used. If all connections fail
this way - the delivery is failed (or deferred if there was a temporary error
when checking policies).
Below is the table summarizing the security level values defined in maddy and
protection they offer.
| MX/TLS level | None | Encrypted | Authenticated |
| ------------- | ---- | --------- | -------------------- |
| None | - | P | P |
| MTA-STS | - | P | PA (see note 1) |
| DNSSEC | - | P | PA |
Legend: P - protects against passive attacks; A - protects against active
attacks
- MX level: None. MX candidate was returned as a result of DNS lookup for the
recipient domain, no additional checks done.
- MX level: MTA-STS. Used MX matches the MTA-STS policy published by the
recipient domain (even one in testing mode).
- MX level: DNSSEC. MX record is signed.
- TLS level: None. Plaintext connection was established, TLS is not available
or failed.
- TLS level: Encrypted. TLS connection was established, the server certificate
failed X.509 and DANE verification.
- TLS level: Authenticated. TLS connection was established, the server
certificate passes X.509 **or** DANE verification.
**Note 1:** Persistent attacker able to control network connection can
interfere with policy refresh, downgrading protection to be secure only against
passive attacks.
## maddy security policies
See [Remote MX delivery](/reference/targets/remote/) for description of configuration options available for each policy mechanism
supported by maddy.
[RFC 8461 Section 10.2]: https://www.rfc-editor.org/rfc/rfc8461.html#section-10.2 (SMTP MTA Strict Transport Security - 10.2. Preventing Policy Discovery)
================================================
FILE: docs/third-party/dovecot.md
================================================
# Dovecot
Builtin maddy IMAP server may not match your requirements in terms of
performance, reliability or anything. For this reason it is possible to
integrate it with any external IMAP server that implements necessary
protocols. Here is how to do it for Dovecot.
1. Get rid of `imap` endpoint and existing `local_authdb` and `local_mailboxes`
blocks.
2. Setup Dovecot to provide LMTP endpoint
Here is an example configuration snippet:
```
# /etc/dovecot/dovecot.conf
protocols = imap lmtp
# /etc/dovecot/conf.d/10-master.conf
service lmtp {
unix_listener lmtp-maddy {
mode = 0600
user = maddy
}
}
```
Add `local_mailboxes` block to maddy config using `target.lmtp` module:
```
target.lmtp local_mailboxes {
targets unix:///var/run/dovecot/lmtp-maddy
}
```
### Authentication
In addition to MTA service, maddy also provides Submission service, but it
needs authentication provider data to work correctly, maddy can use Dovecot
SASL authentication protocol for it.
You need the following in Dovecot's `10-master.conf`:
```
service auth {
unix_listener auth-maddy-client {
mode = 0660
user = maddy
}
}
```
Then just configure `dovecot_sasl` module for `submission`:
```
submission ... {
auth dovecot_sasl unix:///var/run/dovecot/auth-maddy-client
... other configuration ...
}
```
## Other IMAP servers
Integration with other IMAP servers might be more problematic because there is
no standard protocol for authentication delegation. You might need to configure
the IMAP server to implement MSA functionality by forwarding messages to maddy
for outbound delivery. This might require more configuration changes on maddy
side since by default it will not allow relay on port 25 even for localhost
addresses. The easiest way is to create another SMTP endpoint on some port
(probably Submission port):
```
smtp tcp://127.0.0.1:587 {
deliver_to &remote_queue
}
```
And configure IMAP server's Submission service to forward outbound messages
there.
Depending on how Submission service is implemented you may also need to route
messages for local domains back to it via LMTP:
```
smtp tcp://127.0.0.1:587 {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
deliver_to &remote_queue
}
}
```
================================================
FILE: docs/third-party/mailman3.md
================================================
# Mailman 3
Setting up Mailman 3 with maddy involves some additional work as compared to
other MTAs as there is no Python package in Mailman suite that can generate
address lists in format supported by maddy.
We assume you are already familiar with Mailman configuration guidelines and
how stuff works in general/for other MTAs.
## Accepting messages
First of all, you need to use NullMTA package for mta.incoming so Mailman will
not try to generate any configs. LMTP listener is configured as usual.
```
[mta]
incoming: mailman.mta.null.NullMTA
lmtp_host: 127.0.0.1
lmtp_port: 8024
```
After that, you will need to configure maddy to send messages to Mailman.
The preferable way of doing so is destination_in and table.regexp:
```
msgpipeline local_routing {
destination_in regexp "first-mailinglist(-(bounces\+.*|confirm\+.*|join|leave|owner|request|subscribe|unsubscribe))?@lists.example.org" {
deliver_to lmtp tcp://127.0.0.1:8024
}
destination_in regexp "second-mailinglist(-(bounces\+.*|confirm\+.*|join|leave|owner|request|subscribe|unsubscribe))?@lists.example.org" {
deliver_to lmtp tcp://127.0.0.1:8024
}
...
}
```
A more simple option is also meaningful (provided you have a separate domain
for lists):
```
msgpipeline local_routing {
destination lists.example.org {
deliver_to lmtp tcp://127.0.0.1:8024
}
...
}
```
But this variant will lead to inefficient handling of non-existing subaddresses.
See [Mailman Core issue 14](https://gitlab.com/mailman/mailman/-/issues/14) for
details. (5 year old issue, sigh...)
## Sending messages
It is recommended to configure Mailman to send messages using Submission port
with authentication and TLS as maddy does not allow relay on port 25 for local
clients as some MTAs do:
```
[mta]
# ... incoming configuration here ...
outgoing: mailman.mta.deliver.deliver
smtp_host: mx.example.org
smtp_port: 465
smtp_user: mailman@example.org
smtp_pass: something-very-secret
smtp_secure_mode: smtps
```
If you do not want to use TLS and/or authentication you can create a separate
endpoint and just point Mailman to it. E.g.
```
smtp tcp://127.0.0.1:2525 {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
deliver_to &remote_queue
}
}
```
Note that if you use a separate domain for lists, it need to be included in
local_domains macro in default config. This will ensure maddy signs messages
using DKIM for outbound messages. It is also highly recommended to configure
ARC in Mailman 3.
================================================
FILE: docs/third-party/rspamd.md
================================================
# rspamd
maddy has direct support for rspamd HTTP protocol. There is no need to use
milter proxy.
If rspamd is running locally, it is enough to just add `rspamd` check
with default configuration into appropriate check block (probably in
local_routing):
```
check {
...
rspamd
}
```
You might want to disable builtin SPF, DKIM and DMARC for performance
reasons but note that at the moment, maddy will not generate
Authentication-Results field with rspamd results.
If rspamd is not running on a local machine, change api_path to point
to the "normal" worker socket:
```
check {
...
rspamd {
api_path http://spam-check.example.org:11333
}
}
```
Default mapping of rspamd action -> maddy action is as follows:
- "add header" => Quarantine
- "rewrite subject" => Quarantine
- "soft reject" => Reject with temporary error
- "reject" => Reject with permanent error
- "greylist" => Ignored
================================================
FILE: docs/third-party/smtp-servers.md
================================================
# External SMTP server
It is possible to use maddy as an IMAP server only and have it interface with
external SMTP server using standard protocols.
Here is the minimal configuration that creates a local IMAP index, credentials
database and IMAP endpoint:
```
# Credentials DB.
table.pass_table local_authdb {
table sql_table {
driver sqlite3
dsn credentials.db
table_name passwords
}
}
# IMAP storage/index.
storage.imapsql local_mailboxes {
driver sqlite3
dsn imapsql.db
}
# IMAP endpoint using these above.
imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
auth &local_authdb
storage &local_mailboxes
}
```
To accept local messages from an external SMTP server
it is possible to create an LMTP endpoint:
```
# LMTP endpoint on Unix socket delivering to IMAP storage
# in previous config snippet.
lmtp unix:/run/maddy/lmtp.sock {
hostname mx.maddy.test
deliver_to &local_mailboxes
}
```
Look up documentation for your SMTP server on how to make it
send messages using LMTP to /run/maddy/lmtp.sock.
To handle authentication for Submission (client-server SMTP) SMTP server
needs to access credentials database used by maddy. maddy implements
server side of Dovecot authentication protocol so you can use
it if SMTP server implements "Dovecot SASL" client.
To create a Dovecot-compatible sasld endpoint, add the following configuration
block:
```
# Dovecot-compatible sasld endpoint using data from local_authdb.
dovecot_sasld unix:/run/maddy/auth-client.sock {
auth &local_authdb
}
```
================================================
FILE: docs/tutorials/alias-to-remote.md
================================================
# Forward messages to a remote MX
Default maddy configuration is done in a way that does not result in any
outbound messages being sent as a result of port 25 traffic.
In particular, this means that if you handle messages for example.org but not
example.com and have the following in your aliases file (e.g. /etc/maddy/aliases):
```
foxcpp@example.org: foxcpp@example.com
```
You will get "User does not exist" error when attempting to send a message to
foxcpp@example.org because foxcpp@example.com does not exist on as a local
user.
Some users may want to make it work, but it is important to understand the
consequences of such configuration:
- Flooding your server will also flood the remote server.
- If your spam filtering is not good enough, you will send spam to the remote
server.
In both cases, you might harm the reputation of your server (e.g. get your IP
listed in a DNSBL).
**So, this is a bad practice. Do so only if you clearly understand the
consequences (including the Bounce handling section below).**
If you want to do it anyway, here is the part of the configuration that needs
tweaking:
```
msgpipeline local_routing {
destination postmaster $(local_domains) {
modify {
replace_rcpt regexp "(.+)\+(.+)@(.+)" "$1@$3"
replace_rcpt file /etc/maddy/aliases
}
deliver_to &local_mailboxes
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
```
In default configuration, `local_routing` block is responsible for handling
messages that are received via SMTP or Submission and have the initial
destination address at a local domain.
Note the `modify { }` block being nested inside `destination` and then followed
by unconditional `deliver_to &local_mailboxes`. This means: if address is
on `$(local_domains)`, apply aliases and deliver to mailboxes from
`&local_mailboxes`.
The problem here is that recipients are matched before aliases are resolved so
in the end, maddy attempts to look up foxcpp@example.com locally. The solution
is to insert another step into the pipeline configuration to rerun matching
*after* aliases are resolved. This can be done using the 'reroute' directive:
```
msgpipeline local_routing {
destination postmaster $(local_domains) {
modify {
replace_rcpt file /etc/maddy/aliases
...
}
reroute {
destination postmaster $(local_domains) {
deliver_to &local_mailboxes
}
default_destination {
deliver_to &remote_queue
}
}
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
```
## Bounce handling
Once the message is delivered to `remote_queue`, it will follow the usual path
for outbound delivery, including queuing and multiple attempts. This also
means bounce messages will be generated on failures. When accepting messages
from arbitrary senders via the 25 port, the DSN recipient will be whatever
sender specifies in the MAIL FROM command. This is prone to [collateral spam]
when an automatically generated bounce message gets sent to a spoofed address.
However, the default maddy configuration ensures that in this case, the NDN
will be delivered only if the original sender is a local user. Backscatter can
not happen if the sender spoofed a local address since such messages will not
be accepted in the first place.
You can also configure maddy to send bounce messages to remote
addresses, but in this case, you should configure a really strict local policy
to make sure the sender address is not spoofed. There is no detailed
explanation of how to do this since this is a terrible idea in general.
[collateral spam]: https://en.wikipedia.org/wiki/Backscatter_(e-mail)
## Transparent forwarding
As an alternative to silently dropping messages on remote delivery failures,
you might want to use transparent forwarding and reject the message without
accepting it first ("connection-stage rejection").
To do so, simply do not use the queue, replace
```
deliver_to &remote_queue
```
with
```
deliver_to &outbound_delivery
```
(assuming outbound_delivery refers to target.remote block)
================================================
FILE: docs/tutorials/building-from-source.md
================================================
# Building from source
## System dependencies
You need C toolchain, Go toolchain and Make:
On Debian-based system this should work:
```
apt-get install golang-1.23 gcc libc6-dev make
```
Additionally, if you want manual pages, you should also have scdoc installed.
Figuring out the appropriate way to get scdoc is left as an exercise for
reader (for Ubuntu 22.04 LTS it is in repositories).
## Recent Go toolchain
maddy depends on a rather recent Go toolchain version that may not be
available in some distributions (*cough* Debian *cough*).
`go` command in Go 1.21 or newer will automatically download up-to-date
toolchain to build maddy. It is necessary to run commands below only
if you have `go` command version older than 1.21.
```
wget "https://go.dev/dl/go1.23.5.linux-amd64.tar.gz"
tar xf "go1.23.5.linux-amd64.tar.gz"
export GOROOT="$PWD/go"
export PATH="$PWD/go/bin:$PATH"
```
## Step-by-step
1. Clone repository
```
$ git clone https://github.com/foxcpp/maddy.git
$ cd maddy
```
2. Select the appropriate version to build:
```
$ git checkout v0.8.0 # a specific release
$ git checkout master # next bugfix release
$ git checkout dev # next feature release
```
3. Build & install it
```
$ ./build.sh
$ sudo ./build.sh install
```
4. Finish setup as described in [Setting up](../setting-up) (starting from System configuration).
================================================
FILE: docs/tutorials/pam.md
================================================
# Using PAM authentication
maddy supports user authentication using PAM infrastructure via `auth.pam`
module.
In order to use it, however, either maddy itself should be compiled
with libpam support or a helper executable should be built and
installed into an appropriate directory.
It is recommended to use builtin libpam support if you are using
PAM as an intermediate for authentication provider not directly
supported by maddy.
If PAM authentication requires privileged access on the host system
(e.g. pam_unix.so aka /etc/shadow) then it is recommended to use
a privileged helper executable since maddy process itself won't
have access to it.
## Built-in PAM support
Binary artifacts provided for releases do not come with
libpam support. You should build maddy from source.
See [here](../building-from-source) for detailed instructions.
You should have libpam development files installed (`libpam-dev`
package on Ubuntu/Debian).
Then add `--tags 'libpam'` to the build command:
```
./build.sh --tags 'libpam'
```
Then you should be able to replace `local_authdb` implementation
in default configuration with `auth.pam`:
```
auth.pam local_authdb {
use_helper no
}
```
## Helper executable
TL;DR
```
git clone https://github.com/foxcpp/maddy
cd maddy/cmd/maddy-pam-helper
gcc pam.c main.c -lpam -o maddy-pam-helper
```
Copy the resulting executable into /usr/lib/maddy/ and make
it setuid-root so it can read /etc/shadow (if that's necessary):
```
chown root:maddy /usr/lib/maddy/maddy-pam-helper
chmod u+xs,g+x,o-x /usr/lib/maddy/maddy-pam-helper
```
Then you should be able to replace `local_authdb` implementation
in default configuration with `auth.pam`:
```
auth.pam local_authdb {
use_helper yes
}
```
## Account names
Since PAM does not use emails for authentication you should configure
maddy to either strip domain part when checking credentials or do not
use email when authenticating.
See [Multiple domains configuration](/multiple-domains) for how to configure
authentication.
## PAM service
You should create a PAM configuration file for maddy to use.
Place it into /etc/pam.d/maddy.
Here is the minimal example using pam_unix (shadow database).
```
#%PAM-1.0
auth required pam_unix.so
account required pam_unix.so
```
Here is the configuration example you could use on Ubuntu
to use the authentication config system itself uses:
```
#%PAM-1.0
@include common-auth
@include common-account
@include common-session
```
================================================
FILE: docs/tutorials/setting-up.md
================================================
# Installation & initial configuration
This is the practical guide on how to set up a mail server using maddy for
personal use. It omits most of the technical details for brevity and just gives
you the minimal list of things you need to be aware of and what to do to make
stuff work.
For purposes of clarity, these values are used in this tutorial as examples,
wherever you see them, you need to replace them with your actual values:
- Domain: example.org
- MX domain (hostname): mx1.example.org
- IPv4 address: 10.2.3.4
- IPv6 address: 2001:beef::1
## Getting a server
Where to get a server to run maddy on is out of the scope of this article. Any
VPS (virtual private server) will work fine for small configurations. However,
there are a few things to keep in mind:
- Make sure your provider does not block SMTP traffic (25 TCP port). Most VPS
providers don't do it, but some "cloud" providers (such as Google Cloud) do
it, so you can't host your mail there.
- It is recommended to run your own DNS resolver with DNSSEC verification
enabled.
## Installing maddy
Your options are:
* Pre-built tarball (Linux, amd64)
Available on [GitHub](https://github.com/foxcpp/maddy/releases) or
[maddy.email/builds](https://maddy.email/builds/).
The tarball includes maddy executable you can
copy into /usr/local/bin as well as systemd unit file you can
use on systemd-based distributions for automatic startup and service
supervision. You should also create "maddy" user and group.
See below for more detailed instructions.
* Docker image (Linux, amd64)
```
docker pull foxcpp/maddy:0.6
```
See [here](../../docker) for Docker-specific instructions.
* Building from source
See [here](../building-from-source) for instructions.
* Arch Linux packages
For Arch Linux users, `maddy` and `maddy-git` PKGBUILDs are available
in AUR. Additionally, binary packages are available in 3rd-party
repository at [https://maddy.email/archlinux/](https://maddy.email/archlinux/)
## System configuration (systemd-based distribution)
If you built maddy from source and used `./build.sh install` then
systemd unit files should be already installed. If you used
a pre-built tarball - copy `systemd/*.service` to `/etc/systemd/system`
manually.
You need to reload service manager configuration to make service available:
```
systemctl daemon-reload
```
Additionally, you should create maddy user and group. Unlike most other
Linux mail servers, maddy never runs as root.
```
useradd -mrU -s /sbin/nologin -d /var/lib/maddy -c "maddy mail server" maddy
```
## Host name + domain
Open /etc/maddy/maddy.conf with vim^W your favorite editor and change
the following lines to match your server name and domain you want to handle
mail for.
If you setup a very small mail server you can use example.org in both fields.
However, to easier a future migration of service, it's recommended to use a
separate DNS entry for that purpose. It's usually mx1.example.org, mx2, etc.
You can of course use another subdomain, for instance: smtp1.example.org.
An email failover server will become possible if you forward mx2.example.org
to another server (as long as you configure it to handle your domain).
```
$(hostname) = mx1.example.org
$(primary_domain) = example.org
```
If you want to handle multiple domains, you still need to designate
one as "primary". Add all other domains to the `local_domains` line:
```
$(local_domains) = $(primary_domain) example.com other.example.com
```
Do not forget to set a suitable rDNS (PTR) record for your server's IP address
to reduce the chances of outgoing mails getting marked as spam or being
downright rejected. Ideally, the PTR record should match whatever you specified
in `$(hostname)`.
## TLS certificates
One thing that can't be automatically configured is TLS certs. If you already
have them somewhere - use them, open /etc/maddy/maddy.conf and put the right
paths in. You need to make sure maddy can read them while running as
unprivileged user (maddy never runs as root, even during start-up), one way to
do so is to use ACLs (replace with your actual paths):
```
$ sudo setfacl -R -m u:maddy:rX /etc/ssl/mx1.example.org.crt /etc/ssl/mx1.example.org.key
```
maddy reloads TLS certificates from disk once in a minute so it will notice
renewal. It is possible to force reload via `systemctl reload maddy` (or just
`killall -USR2 maddy`).
### Let's Encrypt and certbot
If you use certbot to manage your certificates, you can simply symlink
/etc/maddy/certs into /etc/letsencrypt/live. maddy will pick the right
certificate depending on the domain you specified during installation.
You still need to make keys readable for maddy, though:
```
$ sudo setfacl -R -m u:maddy:rX /etc/letsencrypt/{live,archive}
```
### ACME.sh
If you use acme.sh to manage your certificates, you could simply run:
```
mkdir -p /etc/maddy/certs/mx1.example.org
acme.sh --force --install-cert -d mx1.example.org \
--key-file /etc/maddy/certs/mx1.example.org/privkey.pem \
--fullchain-file /etc/maddy/certs/mx1.example.org/fullchain.pem
```
## First run
```
systemctl start maddy
```
The daemon should be running now, except that it is useless because we haven't
configured DNS records.
## DNS records
How it is configured depends on your DNS provider (or server, if you run your
own). Here is how your DNS zone should look like:
```
; Basic domain->IP records, you probably already have them.
example.org. A 10.2.3.4
example.org. AAAA 2001:beef::1
; It says that "server mx1.example.org is handling messages for example.org".
example.org. MX 10 mx1.example.org.
; Of course, mx1 should have A/AAAA entry as well:
mx1.example.org. A 10.2.3.4
mx1.example.org. AAAA 2001:beef::1
; Use SPF to say that the servers in "MX" above are allowed to send email
; for this domain, and nobody else.
example.org. TXT "v=spf1 mx ~all"
; It is recommended to server SPF record for both domain and MX hostname
mx1.example.org. TXT "v=spf1 a ~all"
; Opt-in into DMARC with permissive policy and request reports about broken
; messages.
_dmarc.example.org. TXT "v=DMARC1; p=quarantine; ruf=mailto:postmaster@example.org"
; Mark domain as MTA-STS compatible (see the next section)
; and request reports about failures to be sent to postmaster@example.org
_mta-sts.example.org. TXT "v=STSv1; id=1"
_smtp._tls.example.org. TXT "v=TLSRPTv1;rua=mailto:postmaster@example.org"
```
And the last one, DKIM key, is a bit tricky. maddy generated a key for you on
the first start-up. You can find it in
/var/lib/maddy/dkim_keys/example.org_default.dns. You need to put it in a TXT
record for `default._domainkey.example.org.` domain, like that:
```
default._domainkey.example.org. TXT "v=DKIM1; k=ed25519; p=nAcUUozPlhc4VPhp7hZl+owES7j7OlEv0laaDEDBAqg="
```
## MTA-STS and DANE
By default SMTP is not protected against active attacks. MTA-STS policy tells
compatible senders to always use properly authenticated TLS when talking to
your server, offering a simple-to-deploy way to protect your server against
MitM attacks on port 25.
Basically, you to create a file with following contents and make it available
at https://mta-sts.example.org/.well-known/mta-sts.txt:
```
version: STSv1
mode: enforce
max_age: 604800
mx: mx1.example.org
```
**Note**: mx1.example.org in the file is your MX hostname, In a simple configuration,
it will be the same as your hostname example.org.
In a more complex setups, you would have multiple MX servers - add them all once
per line, like that:
```
mx: mx1.example.org
mx: mx2.example.org
```
It is also recommended to set a TLSA (DANE) record.
Use https://www.huque.com/bin/gen_tlsa to generate one.
Set port to 25, Transport Protocol to "tcp" and Domain Name to **the MX hostname**.
Example of a valid record:
```
_25._tcp.mx1.example.org. TLSA 3 1 1 7f59d873a70e224b184c95a4eb54caa9621e47d48b4a25d312d83d96e3498238
```
## User accounts and maddy command
A mail server is useless without mailboxes, right? Unlike software like postfix
and dovecot, maddy uses "virtual users" by default, meaning it does not care or
know about system users.
IMAP mailboxes ("accounts") and authentication credentials are kept separate.
To register user credentials, use `maddy creds create` command.
Like that:
```
$ maddy creds create postmaster@example.org
```
Note the username is a e-mail address. This is required as username is used to
authorize IMAP and SMTP access (unless you configure custom mappings, not
described here).
After registering the user credentials, you also need to create a local
storage account:
```
$ maddy imap-acct create postmaster@example.org
```
Note: to run `maddy` CLI commands, your user should be in the `maddy`
group. Alternatively, just use `sudo -u maddy`.
That is it. Now you have your first e-mail address. when authenticating using
your e-mail client, do not forget the username is "postmaster@example.org", not
just "postmaster".
You may find running `maddy creds --help` and `maddy imap-acct --help`
useful to learn about other commands. Note that IMAP accounts and credentials
are managed separately yet usernames should match by default for things to
work.
================================================
FILE: docs/upgrading.md
================================================
# Upgrading from older maddy versions
It is generally possible to just install latest version (e.g. using build.sh
script) over the existing installation.
It is recommended to backup state directory (usually /var/lib/maddy for Linux)
before doing so. The new server version may automatically convert DB files in a
way that will make them unreadable by older versions.
Specific instructions for upgrading between versions with incompatible changes
are documented on this page below.
## Incompatible version migration
## 0.2 -> 0.3
0.3 includes a significant change to the authentication code that makes it
completely independent of IMAP index. This means 0.2 "unified" database cannot
be used in 0.3 and auto-migration is not possible. Additionally, the way
passwords are hashed is changed, meaning that after migration passwords will
need to be reset.
**Migration utility is SQLite-specific, if you need one that works for
Postgres - reach out at the IRC channel.**
1. Make sure the server is not running.
```
systemctl stop maddy
```
2. Take a backup of `imapsql.db*` files in state directory (/var/lib/maddy).
```
mkdir backup
cp /var/lib/maddy/imapsql.db* backup/
```
3. Compile migration utility:
```
git clone https://github.com/foxcpp/maddy.git
cd maddy/
git checkout v0.3.0
cd cmd/migrate-db-0.2
go build
```
4. Run compiled binary:
```
./migrate-db-0.2 /var/lib/maddy/imapsql.db
```
5. Open maddy.conf and make following changes:
Remove `local_authdb` name from imapsql configuration block:
```
imapsql local_mailboxes {
driver sqlite3
dsn imapsql.db
}
```
Add `local_authdb` configuration block using `pass_table` module:
```
pass_table local_authdb {
table sql_table {
driver sqlite3
dsn credentials.db
table_name passwords
}
}
```
6. Use `maddy creds create ACCOUNT_NAME` to add credentials to `pass_table`
store.
7. Start the server back.
```
systemctl start maddy
```
## 0.1 -> 0.2
0.2 requires several changes in configuration file.
Change
```
sql local_mailboxes local_authdb {
```
to
```
imapsql local_mailboxes local_authdb {
```
Replace
```
replace_rcpt postmaster postmaster@$(primary_domain)
```
with
```
replace_rcpt static {
entry postmaster postmaster@$(primary_domain)
}
```
and
```
replace_rcpt "(.+)\+(.+)@(.+)" "$1@$3"
```
with
```
replace_rcpt regexp "(.+)\+(.+)@(.+)" "$1@$3"
```
================================================
FILE: framework/address/doc.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package address provides utilities for parsing
// and validation of RFC 2821 addresses.
package address
================================================
FILE: framework/address/norm.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/foxcpp/maddy/framework/dns"
"golang.org/x/net/idna"
"golang.org/x/text/secure/precis"
"golang.org/x/text/unicode/norm"
)
// ForLookup transforms the local-part of the address into a canonical form
// usable for map lookups or direct comparisons.
//
// If Equal(addr1, addr2) == true, then ForLookup(addr1) == ForLookup(addr2).
//
// On error, case-folded addr is also returned.
func ForLookup(addr string) (string, error) {
if addr == "" { // Null return-path case.
return "", nil
}
mbox, domain, err := Split(addr)
if err != nil {
return strings.ToLower(addr), err
}
if domain != "" {
domain, err = dns.ForLookup(domain)
if err != nil {
return strings.ToLower(addr), err
}
}
mbox = strings.ToLower(norm.NFC.String(mbox))
if domain == "" {
return mbox, nil
}
return mbox + "@" + domain, nil
}
// CleanDomain returns the address with the domain part converted into its canonical form.
//
// More specifically, converts the domain part of the address to U-labels,
// normalizes it to NFC and then case-folds it.
//
// Original value is also returned on the error.
func CleanDomain(addr string) (string, error) {
if addr == "" { // Null return-path
return "", nil
}
mbox, domain, err := Split(addr)
if err != nil {
return addr, err
}
uDomain, err := idna.ToUnicode(domain)
if err != nil {
return addr, err
}
uDomain = strings.ToLower(norm.NFC.String(uDomain))
if domain == "" {
return mbox, nil
}
return mbox + "@" + uDomain, nil
}
// Equal reports whether addr1 and addr2 are considered to be
// case-insensitively equivalent.
//
// The equivalence is defined to be the conjunction of IDN label equivalence
// for the domain part and canonical equivalence* of the local-part converted
// to lower case.
//
// * IDN label equivalence is defined by RFC 5890 Section 2.3.2.4.
// ** Canonical equivalence is defined by UAX #15.
//
// Equivalence for malformed addresses is defined using regular byte-string
// comparison with case-folding applied.
func Equal(addr1, addr2 string) bool {
// Short circuit. If they are bit-equivalent, then they are also canonically
// equivalent.
if addr1 == addr2 {
return true
}
uAddr1, _ := ForLookup(addr1)
uAddr2, _ := ForLookup(addr2)
return uAddr1 == uAddr2
}
func IsASCII(s string) bool {
for _, ch := range s {
if ch > utf8.RuneSelf {
return false
}
}
return true
}
func FQDNDomain(addr string) string {
if strings.HasSuffix(addr, ".") {
return addr
}
return addr + "."
}
// PRECISFold applies UsernameCaseMapped to the local part and dns.ForLookup
// to domain part of the address.
func PRECISFold(addr string) (string, error) {
return precisEmail(addr, precis.UsernameCaseMapped)
}
// PRECIS applies UsernameCasePreserved to the local part and dns.ForLookup
// to domain part of the address.
func PRECIS(addr string) (string, error) {
return precisEmail(addr, precis.UsernameCasePreserved)
}
func precisEmail(addr string, profile *precis.Profile) (string, error) {
mbox, domain, err := Split(addr)
if err != nil {
return "", fmt.Errorf("address: precis: %w", err)
}
// PRECISFold is not included in the regular address.ForLookup since it reduces
// the range of valid addresses to a subset of actually valid values.
// PRECISFold is a matter of our own local policy, not a general rule for all
// email addresses.
// Side note: For used profiles, there is no practical difference between
// CompareKey and String.
mbox, err = profile.CompareKey(mbox)
if err != nil {
return "", fmt.Errorf("address: precis: %w", err)
}
domain, err = dns.ForLookup(domain)
if err != nil {
return "", fmt.Errorf("address: precis: %w", err)
}
return mbox + "@" + domain, nil
}
================================================
FILE: framework/address/norm_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"testing"
)
func addrFuncTest(t *testing.T, f func(string) (string, error)) func(in, wantOut string, fail bool) {
return func(in, wantOut string, fail bool) {
t.Helper()
out, err := f(in)
if err != nil {
if !fail {
t.Errorf("Expected failure, got none")
}
}
if out != wantOut {
t.Errorf("Wrong result: want '%s', got '%s'", wantOut, out)
}
}
}
func TestForLookup(t *testing.T) {
test := addrFuncTest(t, ForLookup)
test("test@example.org", "test@example.org", false)
test("E\u0301@example.org", "\u00E9@example.org", false)
test("test@EXAMPLE.org", "test@example.org", false)
test("test@xn--e1aybc.example.org", "test@тест.example.org", false)
test("TEST@xn--99999999999.example.org", "test@xn--99999999999.example.org", true)
test("tESt@", "test@", true)
test("postmaster", "postmaster", false)
}
func TestCleanDomain(t *testing.T) {
test := addrFuncTest(t, CleanDomain)
test("test@example.org", "test@example.org", false)
test("whateveR@example.org", "whateveR@example.org", false)
test("E\u0301@example.org", "E\u0301@example.org", false)
test("test@EXAMPLE.org", "test@example.org", false)
test("test@xn--e1aybc.example.org", "test@тест.example.org", false)
test("TEST@xn--99999999999.example.org", "TEST@xn--99999999999.example.org", true)
test("tESt@", "tESt@", true)
test("postmaster", "postmaster", false)
}
func TestEqual(t *testing.T) {
test := func(in1, in2 string, wantEq bool) {
eq := Equal(in1, in2)
if eq != wantEq {
t.Errorf("Want Equal(%s, %s) == %v, got %v", in1, in2, wantEq, eq)
}
}
test("test@example.org", "test@example.org", true)
test("test2@example.org", "test@example.org", false)
test("TEST2@example.org", "TesT2@example.org", true)
test("E\u0301@example.org", "\u00E9@example.org", true)
test("test@тест.example.org", "test@xn--e1aybc.example.org", true)
test("test@xn--999999999999999.example.org", "test@xn--999999999999999.example.org", true)
test("test@xn--999999999999.example.org", "test@xn--999999999999999.example.org", false)
}
func TestIsASCII(t *testing.T) {
if !IsASCII("hello") {
t.Errorf("'hello' is ASCII")
}
if IsASCII("тест") {
t.Errorf("'тест' is non-ASCII")
}
}
================================================
FILE: framework/address/rfc6531.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"errors"
"golang.org/x/net/idna"
"golang.org/x/text/unicode/norm"
)
var ErrUnicodeMailbox = errors.New("address: cannot convert the Unicode local-part to the ACE form")
// ToASCII converts the domain part of the email address to the A-label form and
// fails with ErrUnicodeMailbox if the local-part contains non-ASCII characters.
func ToASCII(addr string) (string, error) {
mbox, domain, err := Split(addr)
if err != nil {
return addr, err
}
for _, ch := range mbox {
if ch > 128 {
return addr, ErrUnicodeMailbox
}
}
if domain == "" {
return mbox, nil
}
aDomain, err := idna.ToASCII(domain)
if err != nil {
return addr, err
}
return mbox + "@" + aDomain, nil
}
// ToUnicode converts the domain part of the email address to the U-label form.
func ToUnicode(addr string) (string, error) {
mbox, domain, err := Split(addr)
if err != nil {
return norm.NFC.String(addr), err
}
if domain == "" {
return mbox, nil
}
uDomain, err := idna.ToUnicode(domain)
if err != nil {
return norm.NFC.String(addr), err
}
return mbox + "@" + norm.NFC.String(uDomain), nil
}
// SelectIDNA is a convenience function for conversion of domains in the email
// addresses to/from the Punycode form.
//
// ulabel=true => ToUnicode is used.
// ulabel=false => ToASCII is used.
func SelectIDNA(ulabel bool, addr string) (string, error) {
if ulabel {
return ToUnicode(addr)
}
return ToASCII(addr)
}
================================================
FILE: framework/address/rfc6531_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"strings"
"testing"
)
func TestToASCII(t *testing.T) {
test := addrFuncTest(t, ToASCII)
test("test@тест.example.org", "test@xn--e1aybc.example.org", false)
test("test@org."+strings.Repeat("x", 65535)+"\uFF00", "test@org."+strings.Repeat("x", 65535)+"\uFF00", true)
test("тест@example.org", "тест@example.org", true)
test("postmaster", "postmaster", false)
test("postmaster@", "postmaster@", true)
}
func TestToUnicode(t *testing.T) {
test := addrFuncTest(t, ToUnicode)
test("test@xn--e1aybc.example.org", "test@тест.example.org", false)
test("test@xn--9999999999999999999a.org", "test@xn--9999999999999999999a.org", true)
test("postmaster", "postmaster", false)
test("postmaster@", "postmaster@", true)
}
================================================
FILE: framework/address/split.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"errors"
"strings"
)
// Split splits a email address (as defined by RFC 5321 as a forward-path
// token) into local part (mailbox) and domain.
//
// Note that definition of the forward-path token includes the special
// postmaster address without the domain part. Split will return domain == ""
// in this case.
//
// Split does almost no sanity checks on the input and is intentionally naive.
// If this is a concern, ValidMailbox and ValidDomain should be used on the
// output.
func Split(addr string) (mailbox, domain string, err error) {
if strings.EqualFold(addr, "postmaster") {
return addr, "", nil
}
indx := strings.LastIndexByte(addr, '@')
if indx == -1 {
return "", "", errors.New("address: missing at-sign")
}
mailbox = addr[:indx]
domain = addr[indx+1:]
if mailbox == "" {
return "", "", errors.New("address: empty local-part")
}
if domain == "" {
return "", "", errors.New("address: empty domain")
}
return
}
// UnquoteMbox undoes escaping and quoting of the local-part. That is, for
// local-part `"test\" @ test"` it will return `test" @test`.
func UnquoteMbox(mbox string) (string, error) {
var (
quoted bool
escaped bool
terminatedQuote bool
mailboxB strings.Builder
)
for _, ch := range mbox {
if terminatedQuote {
return "", errors.New("address: closing quote should be right before at-sign")
}
switch ch {
case '"':
if !escaped {
quoted = !quoted
if !quoted {
terminatedQuote = true
}
continue
}
case '\\':
if !escaped {
if !quoted {
return "", errors.New("address: escapes are allowed only in quoted strings")
}
escaped = true
continue
}
case '@':
if !quoted {
return "", errors.New("address: extra at-sign in non-quoted local-part")
}
}
escaped = false
mailboxB.WriteRune(ch)
}
if mailboxB.Len() == 0 {
return "", errors.New("address: empty local part")
}
return mailboxB.String(), nil
}
// "specials" from RFC5322 grammar with dot removed (it is defined in grammar separately, for some reason)
var mboxSpecial = map[rune]struct{}{
'(': {}, ')': {}, '<': {}, '>': {},
'[': {}, ']': {}, ':': {}, ';': {},
'@': {}, '\\': {}, ',': {},
'"': {}, ' ': {},
}
func QuoteMbox(mbox string) string {
var mailboxEsc strings.Builder
mailboxEsc.Grow(len(mbox))
quoted := false
for _, ch := range mbox {
if _, ok := mboxSpecial[ch]; ok {
if ch == '\\' || ch == '"' {
mailboxEsc.WriteRune('\\')
}
mailboxEsc.WriteRune(ch)
quoted = true
} else {
mailboxEsc.WriteRune(ch)
}
}
if quoted {
return `"` + mailboxEsc.String() + `"`
}
return mbox
}
================================================
FILE: framework/address/split_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"testing"
)
func TestSplit(t *testing.T) {
test := func(addr, mbox, domain string, fail bool) {
t.Helper()
actualMbox, actualDomain, err := Split(addr)
if err != nil && !fail {
t.Errorf("%s: unexpected error: %v", addr, err)
return
}
if err == nil && fail {
t.Errorf("%s: expected error, got %s, %s", addr, actualMbox, actualDomain)
return
}
if actualMbox != mbox {
t.Errorf("%s: wrong local part, want %s, got %s", addr, mbox, actualMbox)
}
if actualDomain != domain {
t.Errorf("%s: wrong domain part, want %s, got %s", addr, domain, actualDomain)
}
}
test("simple@example.org", "simple", "example.org", false)
test("simple@[1.2.3.4]", "simple", "[1.2.3.4]", false)
test("simple@[IPv6:beef::1]", "simple", "[IPv6:beef::1]", false)
test("@example.org", "", "", true)
test("@", "", "", true)
test("no-domain@", "", "", true)
test("@no-local-part", "", "", true)
// Not a valid address, but a special value for SMTP
// should be handled separately where necessary.
test("", "", "", true)
// A special SMTP value too, but permitted now.
test("postmaster", "postmaster", "", false)
}
func TestUnquoteMbox(t *testing.T) {
test := func(inputMbox, expectedMbox string, fail bool) {
t.Helper()
actualMbox, err := UnquoteMbox(inputMbox)
if err != nil && !fail {
t.Errorf("unexpected error: %v", err)
return
}
if err == nil && fail {
t.Errorf("expected error, got %s", actualMbox)
return
}
if actualMbox != expectedMbox {
t.Errorf("wrong local part, want %s, got %s", actualMbox, actualMbox)
}
}
test(`no\@no`, "", true)
test("no@no", "", true)
test(`no\"no`, "", true)
test(`"no\"no"`, `no"no`, false)
test(`"no@no"`, `no@no`, false)
test(`"no no"`, `no no`, false)
test(`"no\\no"`, `no\no`, false)
test(`"no"no`, "", true)
test(`postmaster`, "postmaster", false)
test(`foo`, "foo", false)
}
func TestQuoteMbox(t *testing.T) {
test := func(inputMbox, expectedMbox string) {
t.Helper()
actualMbox := QuoteMbox(inputMbox)
if actualMbox != expectedMbox {
t.Errorf("wrong local part, want %s, got %s", actualMbox, actualMbox)
}
}
test(`no"no`, `"no\"no"`)
test(`no@no`, `"no@no"`)
test(`no no`, `"no no"`)
test(`no\no`, `"no\\no"`)
test("postmaster", `postmaster`)
test("foo", `foo`)
}
================================================
FILE: framework/address/validation.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package address
import (
"strings"
"golang.org/x/net/idna"
)
/*
Rules for validation are subset of rules listed here:
https://emailregex.com/email-validation-summary/
*/
// Valid checks whether ths string is valid as a email address as defined by
// RFC 5321.
func Valid(addr string) bool {
if len(addr) > 320 { // RFC 3696 says it's 320, not 255.
return false
}
mbox, domain, err := Split(addr)
if err != nil {
return false
}
// The only case where this can be true is "postmaster".
// So allow it.
if domain == "" {
return true
}
return ValidMailboxName(mbox) && ValidDomain(domain)
}
var validGraphic = map[rune]bool{
'!': true, '#': true,
'$': true, '%': true,
'&': true, '\'': true,
'*': true, '+': true,
'-': true, '/': true,
'=': true, '?': true,
'^': true, '_': true,
'`': true, '{': true,
'|': true, '}': true,
'~': true, '.': true,
}
// ValidMailboxName checks whether the specified string is a valid mailbox-name
// element of e-mail address (left part of it, before at-sign).
func ValidMailboxName(mbox string) bool {
if strings.HasPrefix(mbox, `"`) {
raw, err := UnquoteMbox(mbox)
if err != nil {
return false
}
// Inside quotes, any ASCII graphic and space is allowed.
// Additionally, RFC 6531 extends that to allow any Unicode (UTF-8).
for _, ch := range raw {
if ch < ' ' || ch == 0x7F /* DEL */ {
// ASCII control characters.
return false
}
}
return true
}
// Without quotes, limited set of ASCII graphics is allowed + ASCII
// alphanumeric characters.
// RFC 6531 extends that to allow any Unicode (UTF-8).
for _, ch := range mbox {
if validGraphic[ch] {
continue
}
if ch >= '0' && ch <= '9' {
continue
}
if ch >= 'A' && ch <= 'Z' {
continue
}
if ch >= 'a' && ch <= 'z' {
continue
}
if ch > 0x7F { // Unicode
continue
}
return false
}
return true
}
// ValidDomain checks whether the specified string is a valid DNS domain.
func ValidDomain(domain string) bool {
if len(domain) > 255 || len(domain) == 0 {
return false
}
if strings.HasPrefix(domain, ".") {
return false
}
if strings.Contains(domain, "..") {
return false
}
// Length checks are to be applied to A-labels form.
// maddy uses U-labels representation across the code (for lookups, etc).
domainASCII, err := idna.ToASCII(domain)
if err != nil {
return false
}
labels := strings.Split(domainASCII, ".")
for _, label := range labels {
if len(label) > 64 {
return false
}
}
return true
}
================================================
FILE: framework/address/validation_test.go
================================================
package address_test
import (
"strings"
"testing"
"github.com/foxcpp/maddy/framework/address"
)
func TestValidMailboxName(t *testing.T) {
if !address.ValidMailboxName("caddy.bug") {
t.Error("caddy.bug should be valid mailbox name")
}
}
func TestValidDomain(t *testing.T) {
for _, c := range []struct {
Domain string
Valid bool
}{
{Domain: "maddy.email", Valid: true},
{Domain: "", Valid: false},
{Domain: "maddy.email.", Valid: true},
{Domain: "..", Valid: false},
{Domain: strings.Repeat("a", 256), Valid: false},
{Domain: "äõäoaõoäaõaäõaoäaoaäõoaäooaoaoiuaiauäõiuüõaõäiauõaaa.tld", Valid: true}, // https://github.com/foxcpp/maddy/issues/554
{Domain: "xn--oaoaaaoaoaoaooaoaoiuaiauiuaiauaaa-f1cadccdcmd01eddchqcbe07a.tld", Valid: true}, // https://github.com/foxcpp/maddy/issues/554
} {
if actual := address.ValidDomain(c.Domain); actual != c.Valid {
t.Errorf("expected domain %v to be valid=%v, but got %v", c.Domain, c.Valid, actual)
}
}
}
================================================
FILE: framework/buffer/buffer.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// The buffer package provides utilities for temporary storage (buffering)
// of large blobs.
package buffer
import (
"io"
)
// Buffer interface represents abstract temporary storage for blobs.
//
// The Buffer storage is assumed to be immutable. If any modifications
// are made - new storage location should be used for them.
// This is important to ensure goroutine-safety.
//
// Since Buffer objects require a careful management of lifetimes, here
// is the convention: Its always creator responsibility to call Remove after
// Buffer is no longer used. If Buffer object is passed to a function - it is not
// guaranteed to be valid after this function returns. If function needs to preserve
// the storage contents, it should "re-buffer" it either by reading entire blob
// and storing it somewhere or applying implementation-specific methods (for example,
// the FileBuffer storage may be "re-buffered" by hard-linking the underlying file).
type Buffer interface {
// Open creates new Reader reading from the underlying storage.
Open() (io.ReadCloser, error)
// Len reports the length of the stored blob.
//
// Notably, it indicates the amount of bytes that can be read from the
// newly created Reader without hiting io.EOF.
Len() int
// Remove discards buffered body and releases all associated resources.
//
// Multiple Buffer objects may refer to the same underlying storage.
// In this case, care should be taken to ensure that Remove is called
// only once since it will discard the shared storage and invalidate
// all Buffer objects using it.
//
// Readers previously created using Open can still be used, but
// new ones can't be created.
Remove() error
}
================================================
FILE: framework/buffer/bytesreader.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package buffer
import (
"bytes"
)
// BytesReader is a wrapper for bytes.Reader that stores the original []byte
// value and allows to retrieve it.
//
// It is meant for passing to libraries that expect a io.Reader
// but apply certain optimizations when the Reader implements
// Bytes() interface.
type BytesReader struct {
*bytes.Reader
value []byte
}
// Bytes returns the unread portion of underlying slice used to construct
// BytesReader.
func (br BytesReader) Bytes() []byte {
return br.value[int(br.Size())-br.Len():]
}
// Copy returns the BytesReader reading from the same slice as br at the same
// position.
func (br BytesReader) Copy() BytesReader {
return NewBytesReader(br.Bytes())
}
// Close is a dummy method for implementation of io.Closer so BytesReader can
// be used in MemoryBuffer directly.
func (br BytesReader) Close() error {
return nil
}
func NewBytesReader(b []byte) BytesReader {
// BytesReader and not *BytesReader because BytesReader already wraps two
// pointers and double indirection would be pointless.
return BytesReader{
Reader: bytes.NewReader(b),
value: b,
}
}
================================================
FILE: framework/buffer/file.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package buffer
import (
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
// FileBuffer implements Buffer interface using file system.
type FileBuffer struct {
Path string
// LenHint is the size of the stored blob. It can
// be set to avoid the need to call os.Stat in the
// Len() method.
LenHint int
}
func (fb FileBuffer) Open() (io.ReadCloser, error) {
return os.Open(fb.Path)
}
func (fb FileBuffer) Len() int {
if fb.LenHint != 0 {
return fb.LenHint
}
info, err := os.Stat(fb.Path)
if err != nil {
// Any access to the file will probably fail too. So we can't return a
// sensible value.
return 0
}
return int(info.Size())
}
func (fb FileBuffer) Remove() error {
return os.Remove(fb.Path)
}
// BufferInFile is a convenience function which creates FileBuffer with underlying
// file created in the specified directory with the random name.
func BufferInFile(r io.Reader, dir string) (Buffer, error) {
// It is assumed that PRNG is initialized somewhere during program startup.
nameBytes := make([]byte, 32)
_, err := rand.Read(nameBytes)
if err != nil {
return nil, fmt.Errorf("buffer: failed to generate randomness for file name: %v", err)
}
path := filepath.Join(dir, hex.EncodeToString(nameBytes))
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("buffer: failed to create file: %v", err)
}
if _, err = io.Copy(f, r); err != nil {
return nil, fmt.Errorf("buffer: failed to write file: %v", err)
}
if err := f.Close(); err != nil {
return nil, fmt.Errorf("buffer: failed to close file: %v", err)
}
return FileBuffer{Path: path}, nil
}
================================================
FILE: framework/buffer/memory.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package buffer
import (
"io"
)
// MemoryBuffer implements Buffer interface using byte slice.
type MemoryBuffer struct {
Slice []byte
}
func (mb MemoryBuffer) Open() (io.ReadCloser, error) {
return NewBytesReader(mb.Slice), nil
}
func (mb MemoryBuffer) Len() int {
return len(mb.Slice)
}
func (mb MemoryBuffer) Remove() error {
return nil
}
// BufferInMemory is a convenience function which creates MemoryBuffer with
// contents of the passed io.Reader.
func BufferInMemory(r io.Reader) (Buffer, error) {
blob, err := io.ReadAll(r)
if err != nil {
return nil, err
}
return MemoryBuffer{Slice: blob}, nil
}
================================================
FILE: framework/cfgparser/env.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package parser
import (
"os"
"regexp"
"strings"
)
func expandEnvironment(nodes []Node) []Node {
// If nodes is nil - don't replace with empty slice, as nil indicates "no
// block".
if nodes == nil {
return nil
}
replacer := buildEnvReplacer()
newNodes := make([]Node, 0, len(nodes))
for _, node := range nodes {
node.Name = removeUnexpandedEnvvars(replacer.Replace(node.Name))
newArgs := make([]string, 0, len(node.Args))
for _, arg := range node.Args {
newArgs = append(newArgs, removeUnexpandedEnvvars(replacer.Replace(arg)))
}
node.Args = newArgs
node.Children = expandEnvironment(node.Children)
newNodes = append(newNodes, node)
}
return newNodes
}
var unixEnvvarRe = regexp.MustCompile(`{env:([^\$]+)}`)
func removeUnexpandedEnvvars(s string) string {
s = unixEnvvarRe.ReplaceAllString(s, "")
return s
}
func buildEnvReplacer() *strings.Replacer {
env := os.Environ()
pairs := make([]string, 0, len(env)*4)
for _, entry := range env {
parts := strings.SplitN(entry, "=", 2)
key := parts[0]
value := parts[1]
pairs = append(pairs, "{env:"+key+"}", value)
}
return strings.NewReplacer(pairs...)
}
================================================
FILE: framework/cfgparser/imports.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package parser
import (
"os"
"path/filepath"
"regexp"
"strings"
)
func (ctx *parseContext) expandImports(node Node, expansionDepth int) (Node, error) {
// Leave nil value as is because it is used as non-existent block indicator
// (vs empty slice - empty block).
if node.Children == nil {
return node, nil
}
newChildrens := make([]Node, 0, len(node.Children))
containsImports := false
for _, child := range node.Children {
child, err := ctx.expandImports(child, expansionDepth+1)
if err != nil {
return node, err
}
if child.Name == "import" {
// We check it here instead of function start so we can
// use line information from import directive that is likely
// caused this error.
if expansionDepth > 255 {
return node, NodeErr(child, "hit import expansion limit")
}
containsImports = true
if len(child.Args) != 1 {
return node, ctx.Err("import directive requires exactly 1 argument")
}
subtree, err := ctx.resolveImport(child, child.Args[0], expansionDepth)
if err != nil {
return node, err
}
newChildrens = append(newChildrens, subtree...)
} else {
newChildrens = append(newChildrens, child)
}
}
node.Children = newChildrens
// We need to do another pass to expand any imports added by snippets we
// just expanded.
if containsImports {
return ctx.expandImports(node, expansionDepth+1)
}
return node, nil
}
func (ctx *parseContext) resolveImport(node Node, name string, expansionDepth int) ([]Node, error) {
if subtree, ok := ctx.snippets[name]; ok {
return subtree, nil
}
file := name
if !filepath.IsAbs(name) {
file = filepath.Join(filepath.Dir(ctx.fileLocation), name)
}
src, err := os.Open(file)
if err != nil {
if os.IsNotExist(err) {
src, err = os.Open(file + ".conf")
if err != nil {
if os.IsNotExist(err) {
return nil, NodeErr(node, "unknown import: %s", name)
}
return nil, err
}
} else {
return nil, err
}
}
nodes, snips, macros, err := readTree(src, file, expansionDepth+1)
if err != nil {
return nodes, err
}
for k, v := range snips {
ctx.snippets[k] = v
}
for k, v := range macros {
ctx.macros[k] = v
}
return nodes, nil
}
func (ctx *parseContext) expandMacros(node *Node) error {
if strings.HasPrefix(node.Name, "$(") && strings.HasSuffix(node.Name, ")") {
return ctx.Err("can't use macro argument as directive name")
}
newArgs := make([]string, 0, len(node.Args))
for _, arg := range node.Args {
if !strings.HasPrefix(arg, "$(") || !strings.HasSuffix(arg, ")") {
if strings.Contains(arg, "$(") && strings.Contains(arg, ")") {
var err error
arg, err = ctx.expandSingleValueMacro(arg)
if err != nil {
return err
}
}
newArgs = append(newArgs, arg)
continue
}
macroName := arg[2 : len(arg)-1]
replacement, ok := ctx.macros[macroName]
if !ok {
// Undefined macros are expanded to zero arguments.
continue
}
newArgs = append(newArgs, replacement...)
}
node.Args = newArgs
if node.Children != nil {
for i := range node.Children {
if err := ctx.expandMacros(&node.Children[i]); err != nil {
return err
}
}
}
return nil
}
var macroRe = regexp.MustCompile(`\$\(([^\$]+)\)`)
func (ctx *parseContext) expandSingleValueMacro(arg string) (string, error) {
matches := macroRe.FindAllStringSubmatch(arg, -1)
for _, match := range matches {
macroName := match[1]
if len(ctx.macros[macroName]) > 1 {
return "", ctx.Err("can't expand macro with multiple arguments inside a string")
}
var value string
if ctx.macros[macroName] != nil {
// Macros have at least one argument.
value = ctx.macros[macroName][0]
}
arg = strings.ReplaceAll(arg, "$("+macroName+")", value)
}
return arg, nil
}
================================================
FILE: framework/cfgparser/parse.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package config provides set of utilities for configuration parsing.
package parser
import (
"errors"
"fmt"
"io"
"strings"
"unicode"
"github.com/foxcpp/maddy/framework/config/lexer"
)
// Node struct describes a parsed configurtion block or a simple directive.
//
// name arg0 arg1 {
// children0
// children1
// }
type Node struct {
// Name is the first string at node's line.
Name string
// Args are any strings placed after the node name.
Args []string
// Children slice contains all children blocks if node is a block. Can be nil.
Children []Node
// Snippet indicates whether current parsed node is a snippet. Always false
// for all nodes returned from Read because snippets are expanded before it
// returns.
Snippet bool
// Macro indicates whether current parsed node is a macro. Always false
// for all nodes returned from Read because macros are expanded before it
// returns.
Macro bool
// File is the name of node's source file.
File string
// Line is the line number where the directive is located in the source file. For
// blocks this is the line where "block header" (name + args) resides.
Line int
}
type parseContext struct {
lexer.Dispenser
nesting int
snippets map[string][]Node
macros map[string][]string
fileLocation string
}
func validateNodeName(s string) error {
if len(s) == 0 {
return errors.New("empty directive name")
}
if unicode.IsDigit([]rune(s)[0]) {
return errors.New("directive name cannot start with a digit")
}
allowedPunct := map[rune]bool{'.': true, '-': true, '_': true}
for _, ch := range s {
if !unicode.IsLetter(ch) &&
!unicode.IsDigit(ch) &&
!allowedPunct[ch] {
return errors.New("character not allowed in directive name: " + string(ch))
}
}
return nil
}
// readNode reads node starting at current token pointed by the lexer's
// cursor (it should point to node name).
//
// After readNode returns, the lexer's cursor will point to the last token of the parsed
// Node. This ensures predictable cursor location independently of the EOF state.
// Thus code reading multiple nodes should call readNode then manually
// advance lexer cursor (ctx.Next) and either call readNode again or stop
// because cursor hit EOF.
//
// readNode calls readNodes if currently parsed node is a block.
func (ctx *parseContext) readNode() (Node, error) {
node := Node{}
node.File = ctx.File()
node.Line = ctx.Line()
if ctx.Val() == "{" {
return node, ctx.SyntaxErr("block header")
}
node.Name = ctx.Val()
if ok, name := ctx.isSnippet(node.Name); ok {
node.Name = name
node.Snippet = true
}
var continueOnLF bool
for {
for ctx.NextArg() || (continueOnLF && ctx.NextLine()) {
continueOnLF = false
// name arg0 arg1 {
// # ^ called when we hit this token
// c0
// c1
// }
if ctx.Val() == "{" {
var err error
node.Children, err = ctx.readNodes()
if err != nil {
return node, err
}
break
}
node.Args = append(node.Args, ctx.Val())
}
// Continue reading the same Node if the \ was used to escape the newline.
// E.g.
// name arg0 arg1 \
// arg2 arg3
if len(node.Args) != 0 && node.Args[len(node.Args)-1] == `\` {
last := len(node.Args) - 1
node.Args[last] = node.Args[last][:len(node.Args[last])-1]
if len(node.Args[last]) == 0 {
node.Args = node.Args[:last]
}
continueOnLF = true
continue
}
break
}
macroName, macroArgs, err := ctx.parseAsMacro(&node)
if err != nil {
return node, err
}
if macroName != "" {
node.Name = macroName
node.Args = macroArgs
node.Macro = true
}
if !node.Macro && !node.Snippet {
if err := validateNodeName(node.Name); err != nil {
return node, err
}
}
return node, nil
}
func NodeErr(node Node, f string, args ...interface{}) error {
if node.File == "" {
return fmt.Errorf(f, args...)
}
return fmt.Errorf("%s:%d: %s", node.File, node.Line, fmt.Sprintf(f, args...))
}
func (ctx *parseContext) isSnippet(name string) (bool, string) {
if strings.HasPrefix(name, "(") && strings.HasSuffix(name, ")") {
return true, name[1 : len(name)-1]
}
return false, ""
}
func (ctx *parseContext) parseAsMacro(node *Node) (macroName string, args []string, err error) {
if !strings.HasPrefix(node.Name, "$(") {
return "", nil, nil
}
if !strings.HasSuffix(node.Name, ")") {
return "", nil, ctx.Err("macro name must end with )")
}
macroName = node.Name[2 : len(node.Name)-1]
if len(node.Args) < 2 {
return macroName, nil, ctx.Err("at least 2 arguments are required")
}
if node.Args[0] != "=" {
return macroName, nil, ctx.Err("missing = in macro declaration")
}
return macroName, node.Args[1:], nil
}
// readNodes reads nodes from the currently parsed block.
//
// The lexer's cursor should point to the opening brace
// name arg0 arg1 { #< this one
//
// c0
// c1
// }
//
// To stay consistent with readNode after this function returns the lexer's cursor points
// to the last token of the black (closing brace).
func (ctx *parseContext) readNodes() ([]Node, error) {
// It is not 'var res []Node' because we want empty
// but non-nil Children slice for empty braces.
res := []Node{}
if ctx.nesting > 255 {
return res, ctx.Err("nesting limit reached")
}
ctx.nesting++
var requireNewLine bool
// This loop iterates over logical lines.
// Here are some examples, '#' is placed before token where cursor is when
// another iteration of this loop starts.
//
// #a
// #a b
// #a b {
// #ac aa
// #}
// #aa bbb bbb \
// ccc ccc
// #a b { #ac aa }
//
// As can be seen by the latest example, sometimes such logical line might
// not be terminated by an actual LF character and so this needs to be
// handled carefully.
//
// Note that if the '}' is on the same physical line, it is currently
// included as the part of the logical line, that is:
// #a b { #ac aa }
// ^------- that's the logical line
// #c d
// ^--- that's the next logical line
// This is handled by the "edge case" branch inside the loop.
for {
if requireNewLine {
if !ctx.NextLine() {
// If we can't advance cursor even without Line constraint -
// that's EOF.
if !ctx.Next() {
return res, nil
}
return res, ctx.Err("newline is required after closing brace")
}
} else if !ctx.Next() {
break
}
// name arg0 arg1 {
// c0
// c1
// }
// ^ called when we hit } on separate line,
// This means block we hit end of our block.
if ctx.Val() == "}" {
ctx.nesting--
// name arg0 arg1 { #<1
// } }
// ^2 ^3
//
// After #1 ctx.nesting is incremented by ctx.nesting++ before this loop.
// Then we advance cursor and hit }, we exit loop, ctx.nesting now becomes 0.
// But then the parent block reader does the same when it hits #3 -
// ctx.nesting becomes -1 and it fails.
if ctx.nesting < 0 {
return res, ctx.Err("unexpected }")
}
break
}
node, err := ctx.readNode()
if err != nil {
return res, err
}
requireNewLine = true
shouldStop := false
// name arg0 arg1 {
// c1 c2 }
// ^
// Edge case, here we check if the last argument of the last node is a }
// If it is - we stop as we hit the end of our block.
if len(node.Args) != 0 && node.Args[len(node.Args)-1] == "}" {
ctx.nesting--
if ctx.nesting < 0 {
return res, ctx.Err("unexpected }")
}
node.Args = node.Args[:len(node.Args)-1]
shouldStop = true
}
if node.Macro {
if ctx.nesting != 0 {
return res, ctx.Err("macro declarations are only allowed at top-level")
}
// Macro declaration itself can contain macro references.
if err := ctx.expandMacros(&node); err != nil {
return res, err
}
// = sign is removed by parseAsMacro.
// It also cuts $( and ) from name.
ctx.macros[node.Name] = node.Args
continue
}
if node.Snippet {
if ctx.nesting != 0 {
return res, ctx.Err("snippet declarations are only allowed at top-level")
}
if len(node.Args) != 0 {
return res, ctx.Err("snippet declarations can't have arguments")
}
ctx.snippets[node.Name] = node.Children
continue
}
if err := ctx.expandMacros(&node); err != nil {
return res, err
}
res = append(res, node)
if shouldStop {
break
}
}
return res, nil
}
func readTree(r io.Reader, location string, expansionDepth int) (nodes []Node, snips map[string][]Node, macros map[string][]string, err error) {
ctx := parseContext{
Dispenser: lexer.NewDispenser(location, r),
snippets: make(map[string][]Node),
macros: map[string][]string{},
nesting: -1,
fileLocation: location,
}
root := Node{}
root.File = location
root.Line = 1
// Before parsing starts the lexer's cursor points to the non-existent
// token before the first one. From readNodes viewpoint this is opening
// brace so we don't break any requirements here.
//
// For the same reason we use -1 as a starting nesting. So readNodes
// will see this as it is reading block at nesting level 0.
root.Children, err = ctx.readNodes()
if err != nil {
return root.Children, ctx.snippets, ctx.macros, err
}
// There is no need to check ctx.nesting < 0 because it is checked by readNodes.
if ctx.nesting > 0 {
return root.Children, ctx.snippets, ctx.macros, ctx.Err("unexpected EOF when looking for }")
}
root, err = ctx.expandImports(root, expansionDepth)
if err != nil {
return root.Children, ctx.snippets, ctx.macros, err
}
return root.Children, ctx.snippets, ctx.macros, nil
}
func Read(r io.Reader, location string) (nodes []Node, err error) {
nodes, _, _, err = readTree(r, location, 0)
nodes = expandEnvironment(nodes)
return
}
================================================
FILE: framework/cfgparser/parse_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package parser
import (
"os"
"reflect"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
var cases = []struct {
name string
cfg string
tree []Node
fail bool
}{
{
"single directive without args",
`a`,
[]Node{
{
Name: "a",
Args: []string{},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"single directive with args",
`a a1 a2`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2"},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"single directive with empty braces",
`a { }`,
[]Node{
{
Name: "a",
Args: []string{},
Children: []Node{},
File: "test",
Line: 1,
},
},
false,
},
{
"single directive with arguments and empty braces",
`a a1 a2 { }`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2"},
Children: []Node{},
File: "test",
Line: 1,
},
},
false,
},
{
"single directive with a block",
`a a1 a2 {
a_child1 c1arg1 c1arg2
a_child2 c2arg1 c2arg2
}`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2"},
Children: []Node{
{
Name: "a_child1",
Args: []string{"c1arg1", "c1arg2"},
Children: nil,
File: "test",
Line: 2,
},
{
Name: "a_child2",
Args: []string{"c2arg1", "c2arg2"},
Children: nil,
File: "test",
Line: 3,
},
},
File: "test",
Line: 1,
},
},
false,
},
{
"single directive with missing closing brace",
`a {`,
nil,
true,
},
{
"single directive with missing opening brace",
`a }`,
nil,
true,
},
{
"two directives",
`a
b`,
[]Node{
{
Name: "a",
Args: []string{},
Children: nil,
File: "test",
Line: 1,
},
{
Name: "b",
Args: []string{},
Children: nil,
File: "test",
Line: 2,
},
},
false,
},
{
"two directives with arguments",
`a a1 a2
b b1 b2`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2"},
Children: nil,
File: "test",
Line: 1,
},
{
Name: "b",
Args: []string{"b1", "b2"},
Children: nil,
File: "test",
Line: 2,
},
},
false,
},
{
"backslash on the end of line",
`a a1 a2 \
a3 a4`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2", "a3", "a4"},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"directive with missing closing brace on different line",
`a a1 a2 {
a_child1 c1arg1 c1arg2
`,
nil,
true,
},
{
"single directive with closing brace on children's line",
`a a1 a2 {
a_child1 c1arg1 c1arg2
a_child2 c2arg1 c2arg2 }
b`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2"},
Children: []Node{
{
Name: "a_child1",
Args: []string{"c1arg1", "c1arg2"},
Children: nil,
File: "test",
Line: 2,
},
{
Name: "a_child2",
Args: []string{"c2arg1", "c2arg2"},
Children: nil,
File: "test",
Line: 3,
},
},
File: "test",
Line: 1,
},
{
Name: "b",
Args: []string{},
Children: nil,
File: "test",
Line: 4,
},
},
false,
},
{
"single directive with childrens on the same line",
`a a1 a2 { a_child1 c1arg1 c1arg2 }`,
[]Node{
{
Name: "a",
Args: []string{"a1", "a2"},
Children: []Node{
{
Name: "a_child1",
Args: []string{"c1arg1", "c1arg2"},
Children: nil,
File: "test",
Line: 1,
},
},
File: "test",
Line: 1,
},
},
false,
},
{
"invalid directive name",
`a-a4@%8 whatever`,
nil,
true,
},
{
"directive name starts with a digit",
`1w whatever`,
nil,
true,
},
{
"missing block header",
`{ a_child1 c1arg1 c1arg2 }`,
nil,
true,
},
{
"extra closing brace",
`a {
child1
} }
`,
nil,
true,
},
{
"extra opening brace",
`a { {
}`,
nil,
true,
},
{
"closing brace in next block header",
`a {
} b b1`,
nil,
true,
},
{
"environment variable expansion",
`a {env:TESTING_VARIABLE}`,
[]Node{
{
Name: "a",
Args: []string{"ABCDEF"},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"missing environment variable expansion (unix-like syntax)",
`a {env:TESTING_VARIABLE3}`,
[]Node{
{
Name: "a",
Args: []string{""},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"incomplete environment variable syntax",
`a {env:TESTING_VARIABLE`,
[]Node{
{
Name: "a",
Args: []string{"{env:TESTING_VARIABLE"},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"snippet expansion",
`(foo) { a }
import foo`,
[]Node{
{
Name: "a",
Args: []string{},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"snippet expansion inside a block",
`(foo) { a }
foo {
boo
import foo
}`,
[]Node{
{
Name: "foo",
Args: []string{},
Children: []Node{
{
Name: "boo",
Args: []string{},
File: "test",
Line: 3,
},
{
Name: "a",
Args: []string{},
File: "test",
Line: 1,
},
},
File: "test",
Line: 2,
},
},
false,
},
{
"missing snippet",
`import foo`,
nil,
true,
},
{
"unlimited recursive snippet expansion",
`(foo) { import foo }
import foo`,
nil,
true,
},
{
"snippet declaration with args",
`(foo) a b c { }`,
nil,
true,
},
{
"snippet declaration inside block",
`abc {
(foo) { }
}`,
nil,
true,
},
{
"block nesting limit",
`a ` + strings.Repeat("a { ", 1000) + strings.Repeat(" }", 1000),
nil,
true,
},
{
"macro expansion, single argument",
`$(foo) = bar
dir $(foo)`,
[]Node{
{
Name: "dir",
Args: []string{"bar"},
Children: nil,
File: "test",
Line: 2,
},
},
false,
},
{
"macro expansion, inside argument",
`$(foo) = bar
dir aaa/$(foo)/bbb`,
[]Node{
{
Name: "dir",
Args: []string{"aaa/bar/bbb"},
Children: nil,
File: "test",
Line: 2,
},
},
false,
},
{
"macro expansion, inside argument, multi-value",
`$(foo) = bar baz
dir aaa/$(foo)/bbb`,
nil,
true,
},
{
"macro expansion, multiple arguments",
`$(foo) = bar baz
dir $(foo)`,
[]Node{
{
Name: "dir",
Args: []string{"bar", "baz"},
Children: nil,
File: "test",
Line: 2,
},
},
false,
},
{
"macro expansion, undefined",
`dir $(foo)`,
[]Node{
{
Name: "dir",
Args: []string{},
Children: nil,
File: "test",
Line: 1,
},
},
false,
},
{
"macro expansion, empty",
`$(foo) =`,
nil,
true,
},
{
"macro expansion, name replacement",
`$(foo) = a b
$(foo) 1`,
nil,
true,
},
{
"macro expansion, missing =",
`$(foo) a b
$(foo) 1`,
nil,
true,
},
{
"macro expansion, not on top level",
`a {
$(foo) = a b
}
$(foo) 1`,
nil,
true,
},
{
"macro expansion, nested",
`$(foo) = a
$(bar) = $(foo) b
dir $(bar)`,
[]Node{
{
Name: "dir",
Args: []string{"a", "b"},
Children: nil,
File: "test",
Line: 3,
},
},
false,
},
{
"macro expansion, used inside snippet",
`$(foo) = a
(bar) {
dir $(foo)
}
import bar`,
[]Node{
{
Name: "dir",
Args: []string{"a"},
Children: nil,
File: "test",
Line: 3,
},
},
false,
},
{
"macro expansion, used inside snippet, defined after",
`
(bar) {
dir $(foo)
}
$(foo) = a
import bar`,
[]Node{
{
Name: "dir",
Args: []string{},
Children: nil,
File: "test",
Line: 3,
},
},
false,
},
}
func printTree(t *testing.T, root Node, indent int) {
t.Log(strings.Repeat(" ", indent)+root.Name, root.Args)
for _, child := range root.Children {
t.Log(child, indent+1)
}
}
func TestRead(t *testing.T) {
require.NoError(t, os.Setenv("TESTING_VARIABLE", "ABCDEF"))
require.NoError(t, os.Setenv("TESTING_VARIABLE2", "ABC2 DEF2"))
for _, case_ := range cases {
t.Run(case_.name, func(t *testing.T) {
tree, err := Read(strings.NewReader(case_.cfg), "test")
if !case_.fail && err != nil {
t.Error("unexpected failure:", err)
return
}
if case_.fail {
if err == nil {
t.Log("expected failure but Read succeeded")
t.Log("got tree:")
t.Logf("%+v", tree)
for _, node := range tree {
printTree(t, node, 0)
}
t.Fail()
return
}
return
}
if !reflect.DeepEqual(case_.tree, tree) {
t.Log("parse result mismatch")
t.Log("expected:")
t.Logf("%+#v", case_.tree)
for _, node := range case_.tree {
printTree(t, node, 0)
}
t.Log("actual:")
t.Logf("%+#v", tree)
for _, node := range tree {
printTree(t, node, 0)
}
t.Fail()
}
})
}
}
================================================
FILE: framework/config/config.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package config
import (
"fmt"
parser "github.com/foxcpp/maddy/framework/cfgparser"
)
type (
Node = parser.Node
)
func NodeErr(node Node, f string, args ...interface{}) error {
if node.File == "" {
return fmt.Errorf(f, args...)
}
return fmt.Errorf("%s:%d: %s", node.File, node.Line, fmt.Sprintf(f, args...))
}
================================================
FILE: framework/config/directories.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package config
var (
// StateDirectory contains the path to the directory that
// should be used to store any data that should be
// preserved between sessions.
//
// Value of this variable must not change after initialization
// in cmd/maddy/main.go.
StateDirectory string
// RuntimeDirectory contains the path to the directory that
// should be used to store any temporary data.
//
// It should be preferred over os.TempDir, which is
// global and world-readable on most systems, while
// RuntimeDirectory can be dedicated for maddy.
//
// Value of this variable must not change after initialization
// in cmd/maddy/main.go.
RuntimeDirectory string
// LibexecDirectory contains the path to the directory
// where helper binaries should be searched.
//
// Value of this variable must not change after initialization
// in cmd/maddy/main.go.
LibexecDirectory string
)
================================================
FILE: framework/config/endpoint.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package config
import (
"fmt"
"net"
"net/url"
"path/filepath"
"strings"
)
// Endpoint represents a site address. It contains the original input value,
// and the component parts of an address. The component parts may be updated to
// the correct values as setup proceeds, but the original value should never be
// changed.
type Endpoint struct {
Original, Scheme, Host, Port, Path string
}
// String returns a human-friendly print of the address.
func (e Endpoint) String() string {
if e.Original != "" {
return e.Original
}
if e.Scheme == "unix" {
return "unix://" + e.Path
}
if e.Host == "" && e.Port == "" {
return ""
}
s := e.Scheme
if s != "" {
s += "://"
}
host := e.Host
if strings.Contains(host, ":") {
host = "[" + host + "]"
}
s += host
if e.Port != "" {
s += ":" + e.Port
}
if e.Path != "" {
s += e.Path
}
return s
}
func (e Endpoint) Network() string {
if e.Scheme == "unix" {
return "unix"
}
return "tcp"
}
func (e Endpoint) Address() string {
if e.Scheme == "unix" {
return e.Path
}
return net.JoinHostPort(e.Host, e.Port)
}
func (e Endpoint) IsTLS() bool {
return e.Scheme == "tls"
}
// ParseEndpoint parses an endpoint string into a structured format with separate
// scheme, host, port, and path portions, as well as the original input string.
func ParseEndpoint(str string) (Endpoint, error) {
input := str
u, err := url.Parse(str)
if err != nil {
return Endpoint{}, err
}
switch u.Scheme {
case "tcp", "tls":
// ALL GREEN
// scheme:OPAQUE URL syntax
if u.Host == "" && u.Opaque != "" {
u.Host = u.Opaque
}
case "unix":
// scheme:OPAQUE URL syntax
if u.Path == "" && u.Opaque != "" {
u.Path = u.Opaque
}
var actualPath string
if u.Host != "" {
actualPath += u.Host
}
if u.Path != "" {
actualPath += u.Path
}
if !filepath.IsAbs(actualPath) {
actualPath = filepath.Join(RuntimeDirectory, actualPath)
}
return Endpoint{Original: input, Scheme: u.Scheme, Path: actualPath}, err
default:
return Endpoint{}, fmt.Errorf("unsupported scheme: %s (%+v)", input, u)
}
// separate host and port
host, port, err := net.SplitHostPort(u.Host)
if err != nil {
host, port, err = net.SplitHostPort(u.Host + ":")
if err != nil {
host = u.Host
}
}
if port == "" {
return Endpoint{}, fmt.Errorf("port is required")
}
return Endpoint{Original: input, Scheme: u.Scheme, Host: host, Port: port, Path: u.Path}, err
}
================================================
FILE: framework/config/endpoint_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package config
import (
"reflect"
"testing"
)
func TestStandardizeAddress(t *testing.T) {
for _, expected := range []Endpoint{
{Original: "tcp://0.0.0.0:10025", Scheme: "tcp", Host: "0.0.0.0", Port: "10025"},
{Original: "tcp://[::]:10025", Scheme: "tcp", Host: "::", Port: "10025"},
{Original: "tcp:127.0.0.1:10025", Scheme: "tcp", Host: "127.0.0.1", Port: "10025"},
{Original: "unix://path", Scheme: "unix", Host: "", Path: "path", Port: ""},
{Original: "unix:path", Scheme: "unix", Host: "", Path: "path", Port: ""},
{Original: "unix:/path", Scheme: "unix", Host: "", Path: "/path", Port: ""},
{Original: "unix:///path", Scheme: "unix", Host: "", Path: "/path", Port: ""},
{Original: "unix://also/path", Scheme: "unix", Host: "", Path: "also/path", Port: ""},
{Original: "unix:///also/path", Scheme: "unix", Host: "", Path: "/also/path", Port: ""},
{Original: "tls://0.0.0.0:10025", Scheme: "tls", Host: "0.0.0.0", Port: "10025"},
{Original: "tls:0.0.0.0:10025", Scheme: "tls", Host: "0.0.0.0", Port: "10025"},
} {
actual, err := ParseEndpoint(expected.Original)
if err != nil {
t.Errorf("Unexpected failure for %s: %v", expected.Original, err)
return
}
if !reflect.DeepEqual(expected, actual) {
t.Errorf("Didn't parse URL %q correctly\ngot %#v\nwant %#v", expected.Original, actual, expected)
continue
}
if actual.String() != expected.Original {
t.Errorf("actual.String() = %s, want %s", actual.String(), expected.Original)
}
}
}
================================================
FILE: framework/config/lexer/LICENSE.APACHE
================================================
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.
================================================
FILE: framework/config/lexer/README.md
================================================
caddyfile lexer copied from [caddy](https://github.com/caddyserver/caddy) project.
Taken from the following commit:
```
commit ed4c2775e46b924d4851e04cc281633b1b2c15af
Author: Alexander Danilov
Date: Wed Aug 21 20:13:34 2019 +0300
main: log caddy version on start (#2717)
```
License of the original code is included in LICENSE.APACHE file in this
directory.
No signficant changes was made to the code (e.g. it is safe to update it from
caddy repo).
The code is copied because caddy brings quite a lot of dependencies we don't
use and this slows down many tools.
================================================
FILE: framework/config/lexer/dispenser.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Copyright 2015 Light Code Labs, LLC
//
// 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.
package lexer
import (
"errors"
"fmt"
"io"
"strings"
)
// Dispenser is a type that dispenses tokens, similarly to a lexer,
// except that it can do so with some notion of structure and has
// some really convenient methods.
type Dispenser struct {
filename string
tokens []Token
cursor int
nesting int
}
// NewDispenser returns a Dispenser, ready to use for parsing the given input.
func NewDispenser(filename string, input io.Reader) Dispenser {
tokens, _ := allTokens(input) // ignoring error because nothing to do with it
return Dispenser{
filename: filename,
tokens: tokens,
cursor: -1,
}
}
// NewDispenserTokens returns a Dispenser filled with the given tokens.
func NewDispenserTokens(filename string, tokens []Token) Dispenser {
return Dispenser{
filename: filename,
tokens: tokens,
cursor: -1,
}
}
// Next loads the next token. Returns true if a token
// was loaded; false otherwise. If false, all tokens
// have been consumed.
func (d *Dispenser) Next() bool {
if d.cursor < len(d.tokens)-1 {
d.cursor++
return true
}
return false
}
// NextArg loads the next token if it is on the same
// line. Returns true if a token was loaded; false
// otherwise. If false, all tokens on the line have
// been consumed. It handles imported tokens correctly.
func (d *Dispenser) NextArg() bool {
if d.cursor < 0 {
d.cursor++
return true
}
if d.cursor >= len(d.tokens) {
return false
}
if d.cursor < len(d.tokens)-1 &&
d.tokens[d.cursor].File == d.tokens[d.cursor+1].File &&
d.tokens[d.cursor].Line+d.numLineBreaks(d.cursor) == d.tokens[d.cursor+1].Line {
d.cursor++
return true
}
return false
}
// NextLine loads the next token only if it is not on the same
// line as the current token, and returns true if a token was
// loaded; false otherwise. If false, there is not another token
// or it is on the same line. It handles imported tokens correctly.
func (d *Dispenser) NextLine() bool {
if d.cursor < 0 {
d.cursor++
return true
}
if d.cursor >= len(d.tokens) {
return false
}
if d.cursor < len(d.tokens)-1 &&
(d.tokens[d.cursor].File != d.tokens[d.cursor+1].File ||
d.tokens[d.cursor].Line+d.numLineBreaks(d.cursor) < d.tokens[d.cursor+1].Line) {
d.cursor++
return true
}
return false
}
// NextBlock can be used as the condition of a for loop
// to load the next token as long as it opens a block or
// is already in a block. It returns true if a token was
// loaded, or false when the block's closing curly brace
// was loaded and thus the block ended. Nested blocks are
// not supported.
func (d *Dispenser) NextBlock() bool {
if d.nesting > 0 {
d.Next()
if d.Val() == "}" {
d.nesting--
return false
}
return true
}
if !d.NextArg() { // block must open on same line
return false
}
if d.Val() != "{" {
d.cursor-- // roll back if not opening brace
return false
}
d.Next()
if d.Val() == "}" {
// Open and then closed right away
return false
}
d.nesting++
return true
}
// Val gets the text of the current token. If there is no token
// loaded, it returns empty string.
func (d *Dispenser) Val() string {
if d.cursor < 0 || d.cursor >= len(d.tokens) {
return ""
}
return d.tokens[d.cursor].Text
}
// Line gets the line number of the current token. If there is no token
// loaded, it returns 0.
func (d *Dispenser) Line() int {
if d.cursor < 0 || d.cursor >= len(d.tokens) {
return 0
}
return d.tokens[d.cursor].Line
}
// File gets the filename of the current token. If there is no token loaded,
// it returns the filename originally given when parsing started.
func (d *Dispenser) File() string {
if d.cursor < 0 || d.cursor >= len(d.tokens) {
return d.filename
}
if tokenFilename := d.tokens[d.cursor].File; tokenFilename != "" {
return tokenFilename
}
return d.filename
}
// Args is a convenience function that loads the next arguments
// (tokens on the same line) into an arbitrary number of strings
// pointed to in targets. If there are fewer tokens available
// than string pointers, the remaining strings will not be changed
// and false will be returned. If there were enough tokens available
// to fill the arguments, then true will be returned.
func (d *Dispenser) Args(targets ...*string) bool {
enough := true
for i := 0; i < len(targets); i++ {
if !d.NextArg() {
enough = false
break
}
*targets[i] = d.Val()
}
return enough
}
// RemainingArgs loads any more arguments (tokens on the same line)
// into a slice and returns them. Open curly brace tokens also indicate
// the end of arguments, and the curly brace is not included in
// the return value nor is it loaded.
func (d *Dispenser) RemainingArgs() []string {
var args []string
for d.NextArg() {
if d.Val() == "{" {
d.cursor--
break
}
args = append(args, d.Val())
}
return args
}
// ArgErr returns an argument error, meaning that another
// argument was expected but not found. In other words,
// a line break or open curly brace was encountered instead of
// an argument.
func (d *Dispenser) ArgErr() error {
if d.Val() == "{" {
return d.Err("Unexpected token '{', expecting argument")
}
return d.Errf("Wrong argument count or unexpected line ending after '%s'", d.Val())
}
// SyntaxErr creates a generic syntax error which explains what was
// found and what was expected.
func (d *Dispenser) SyntaxErr(expected string) error {
msg := fmt.Sprintf("%s:%d - Syntax error: Unexpected token '%s', expecting '%s'", d.File(), d.Line(), d.Val(), expected)
return errors.New(msg)
}
// EOFErr returns an error indicating that the dispenser reached
// the end of the input when searching for the next token.
func (d *Dispenser) EOFErr() error {
return d.Errf("Unexpected EOF")
}
// Err generates a custom parse-time error with a message of msg.
func (d *Dispenser) Err(msg string) error {
msg = fmt.Sprintf("%s:%d - Error during parsing: %s", d.File(), d.Line(), msg)
return errors.New(msg)
}
// Errf is like Err, but for formatted error messages
func (d *Dispenser) Errf(format string, args ...interface{}) error {
return d.Err(fmt.Sprintf(format, args...))
}
// numLineBreaks counts how many line breaks are in the token
// value given by the token index tknIdx. It returns 0 if the
// token does not exist or there are no line breaks.
func (d *Dispenser) numLineBreaks(tknIdx int) int {
if tknIdx < 0 || tknIdx >= len(d.tokens) {
return 0
}
return strings.Count(d.tokens[tknIdx].Text, "\n")
}
================================================
FILE: framework/config/lexer/dispenser_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Copyright 2015 Light Code Labs, LLC
//
// 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.
package lexer
import (
"reflect"
"strings"
"testing"
)
func TestDispenser_Val_Next(t *testing.T) {
input := `host:port
dir1 arg1
dir2 arg2 arg3
dir3`
d := NewDispenser("Testfile", strings.NewReader(input))
if val := d.Val(); val != "" {
t.Fatalf("Val(): Should return empty string when no token loaded; got '%s'", val)
}
assertNext := func(shouldLoad bool, expectedCursor int, expectedVal string) {
if loaded := d.Next(); loaded != shouldLoad {
t.Errorf("Next(): Expected %v but got %v instead (val '%s')", shouldLoad, loaded, d.Val())
}
if d.cursor != expectedCursor {
t.Errorf("Expected cursor to be %d, but was %d", expectedCursor, d.cursor)
}
if d.nesting != 0 {
t.Errorf("Nesting should be 0, was %d instead", d.nesting)
}
if val := d.Val(); val != expectedVal {
t.Errorf("Val(): Expected '%s' but got '%s'", expectedVal, val)
}
}
assertNext(true, 0, "host:port")
assertNext(true, 1, "dir1")
assertNext(true, 2, "arg1")
assertNext(true, 3, "dir2")
assertNext(true, 4, "arg2")
assertNext(true, 5, "arg3")
assertNext(true, 6, "dir3")
// Note: This next test simply asserts existing behavior.
// If desired, we may wish to empty the token value after
// reading past the EOF. Open an issue if you want this change.
assertNext(false, 6, "dir3")
}
func TestDispenser_NextArg(t *testing.T) {
input := `dir1 arg1
dir2 arg2 arg3
dir3`
d := NewDispenser("Testfile", strings.NewReader(input))
assertNext := func(shouldLoad bool, expectedVal string, expectedCursor int) {
if d.Next() != shouldLoad {
t.Errorf("Next(): Should load token but got false instead (val: '%s')", d.Val())
}
if d.cursor != expectedCursor {
t.Errorf("Next(): Expected cursor to be at %d, but it was %d", expectedCursor, d.cursor)
}
if val := d.Val(); val != expectedVal {
t.Errorf("Val(): Expected '%s' but got '%s'", expectedVal, val)
}
}
assertNextArg := func(expectedVal string, loadAnother bool, expectedCursor int) {
if !d.NextArg() {
t.Error("NextArg(): Should load next argument but got false instead")
}
if d.cursor != expectedCursor {
t.Errorf("NextArg(): Expected cursor to be at %d, but it was %d", expectedCursor, d.cursor)
}
if val := d.Val(); val != expectedVal {
t.Errorf("Val(): Expected '%s' but got '%s'", expectedVal, val)
}
if !loadAnother {
if d.NextArg() {
t.Fatalf("NextArg(): Should NOT load another argument, but got true instead (val: '%s')", d.Val())
}
if d.cursor != expectedCursor {
t.Errorf("NextArg(): Expected cursor to remain at %d, but it was %d", expectedCursor, d.cursor)
}
}
}
assertNext(true, "dir1", 0)
assertNextArg("arg1", false, 1)
assertNext(true, "dir2", 2)
assertNextArg("arg2", true, 3)
assertNextArg("arg3", false, 4)
assertNext(true, "dir3", 5)
assertNext(false, "dir3", 5)
}
func TestDispenser_NextLine(t *testing.T) {
input := `host:port
dir1 arg1
dir2 arg2 arg3`
d := NewDispenser("Testfile", strings.NewReader(input))
assertNextLine := func(shouldLoad bool, expectedVal string, expectedCursor int) {
if d.NextLine() != shouldLoad {
t.Errorf("NextLine(): Should load token but got false instead (val: '%s')", d.Val())
}
if d.cursor != expectedCursor {
t.Errorf("NextLine(): Expected cursor to be %d, instead was %d", expectedCursor, d.cursor)
}
if val := d.Val(); val != expectedVal {
t.Errorf("Val(): Expected '%s' but got '%s'", expectedVal, val)
}
}
assertNextLine(true, "host:port", 0)
assertNextLine(true, "dir1", 1)
assertNextLine(false, "dir1", 1)
d.Next() // arg1
assertNextLine(true, "dir2", 3)
assertNextLine(false, "dir2", 3)
d.Next() // arg2
assertNextLine(false, "arg2", 4)
d.Next() // arg3
assertNextLine(false, "arg3", 5)
}
func TestDispenser_NextBlock(t *testing.T) {
input := `foobar1 {
sub1 arg1
sub2
}
foobar2 {
}`
d := NewDispenser("Testfile", strings.NewReader(input))
assertNextBlock := func(shouldLoad bool, expectedCursor, expectedNesting int) {
if loaded := d.NextBlock(); loaded != shouldLoad {
t.Errorf("NextBlock(): Should return %v but got %v", shouldLoad, loaded)
}
if d.cursor != expectedCursor {
t.Errorf("NextBlock(): Expected cursor to be %d, was %d", expectedCursor, d.cursor)
}
if d.nesting != expectedNesting {
t.Errorf("NextBlock(): Nesting should be %d, not %d", expectedNesting, d.nesting)
}
}
assertNextBlock(false, -1, 0)
d.Next() // foobar1
assertNextBlock(true, 2, 1)
assertNextBlock(true, 3, 1)
assertNextBlock(true, 4, 1)
assertNextBlock(false, 5, 0)
d.Next() // foobar2
assertNextBlock(false, 8, 0) // empty block is as if it didn't exist
}
func TestDispenser_Args(t *testing.T) {
var s1, s2, s3 string
input := `dir1 arg1 arg2 arg3
dir2 arg4 arg5
dir3 arg6 arg7
dir4`
d := NewDispenser("Testfile", strings.NewReader(input))
d.Next() // dir1
// As many strings as arguments
if all := d.Args(&s1, &s2, &s3); !all {
t.Error("Args(): Expected true, got false")
}
if s1 != "arg1" {
t.Errorf("Args(): Expected s1 to be 'arg1', got '%s'", s1)
}
if s2 != "arg2" {
t.Errorf("Args(): Expected s2 to be 'arg2', got '%s'", s2)
}
if s3 != "arg3" {
t.Errorf("Args(): Expected s3 to be 'arg3', got '%s'", s3)
}
d.Next() // dir2
// More strings than arguments
if all := d.Args(&s1, &s2, &s3); all {
t.Error("Args(): Expected false, got true")
}
if s1 != "arg4" {
t.Errorf("Args(): Expected s1 to be 'arg4', got '%s'", s1)
}
if s2 != "arg5" {
t.Errorf("Args(): Expected s2 to be 'arg5', got '%s'", s2)
}
if s3 != "arg3" {
t.Errorf("Args(): Expected s3 to be unchanged ('arg3'), instead got '%s'", s3)
}
// (quick cursor check just for kicks and giggles)
if d.cursor != 6 {
t.Errorf("Cursor should be 6, but is %d", d.cursor)
}
d.Next() // dir3
// More arguments than strings
if all := d.Args(&s1); !all {
t.Error("Args(): Expected true, got false")
}
if s1 != "arg6" {
t.Errorf("Args(): Expected s1 to be 'arg6', got '%s'", s1)
}
d.Next() // dir4
// No arguments or strings
if all := d.Args(); !all {
t.Error("Args(): Expected true, got false")
}
// No arguments but at least one string
if all := d.Args(&s1); all {
t.Error("Args(): Expected false, got true")
}
}
func TestDispenser_RemainingArgs(t *testing.T) {
input := `dir1 arg1 arg2 arg3
dir2 arg4 arg5
dir3 arg6 { arg7
dir4`
d := NewDispenser("Testfile", strings.NewReader(input))
d.Next() // dir1
args := d.RemainingArgs()
if expected := []string{"arg1", "arg2", "arg3"}; !reflect.DeepEqual(args, expected) {
t.Errorf("RemainingArgs(): Expected %v, got %v", expected, args)
}
d.Next() // dir2
args = d.RemainingArgs()
if expected := []string{"arg4", "arg5"}; !reflect.DeepEqual(args, expected) {
t.Errorf("RemainingArgs(): Expected %v, got %v", expected, args)
}
d.Next() // dir3
args = d.RemainingArgs()
if expected := []string{"arg6"}; !reflect.DeepEqual(args, expected) {
t.Errorf("RemainingArgs(): Expected %v, got %v", expected, args)
}
d.Next() // {
d.Next() // arg7
d.Next() // dir4
args = d.RemainingArgs()
if len(args) != 0 {
t.Errorf("RemainingArgs(): Expected %v, got %v", []string{}, args)
}
}
func TestDispenser_ArgErr_Err(t *testing.T) {
input := `dir1 {
}
dir2 arg1 arg2`
d := NewDispenser("Testfile", strings.NewReader(input))
d.cursor = 1 // {
if err := d.ArgErr(); err == nil || !strings.Contains(err.Error(), "{") {
t.Errorf("ArgErr(): Expected an error message with { in it, but got '%v'", err)
}
d.cursor = 5 // arg2
if err := d.ArgErr(); err == nil || !strings.Contains(err.Error(), "arg2") {
t.Errorf("ArgErr(): Expected an error message with 'arg2' in it; got '%v'", err)
}
err := d.Err("foobar")
if err == nil {
t.Fatalf("Err(): Expected an error, got nil")
}
if !strings.Contains(err.Error(), "Testfile:3") {
t.Errorf("Expected error message with filename:line in it; got '%v'", err)
}
if !strings.Contains(err.Error(), "foobar") {
t.Errorf("Expected error message with custom message in it ('foobar'); got '%v'", err)
}
}
================================================
FILE: framework/config/lexer/lexer.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Copyright 2015 Light Code Labs, LLC
//
// 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.
package lexer
import (
"bufio"
"io"
"unicode"
)
type (
// lexer is a utility which can get values, token by
// token, from a Reader. A token is a word, and tokens
// are separated by whitespace. A word can be enclosed
// in quotes if it contains whitespace.
lexer struct {
reader *bufio.Reader
token Token
line int
lastErr error
}
// Token represents a single parsable unit.
Token struct {
File string
Line int
Text string
}
)
// load prepares the lexer to scan an input for tokens.
// It discards any leading byte order mark.
func (l *lexer) load(input io.Reader) error {
l.reader = bufio.NewReader(input)
l.line = 1
// discard byte order mark, if present
firstCh, _, err := l.reader.ReadRune()
if err != nil {
return err
}
if firstCh != 0xFEFF {
err := l.reader.UnreadRune()
if err != nil {
return err
}
}
return nil
}
func (l *lexer) err() error {
return l.lastErr
}
// next loads the next token into the lexer.
//
// A token is delimited by whitespace, unless the token starts with a quotes
// character (") in which case the token goes until the closing quotes (the
// enclosing quotes are not included). Inside quoted strings, quotes may be
// escaped with a preceding \ character. No other chars may be escaped. Curly
// braces ('{', '}') are emitted as a separate tokens.
//
// The rest of the line is skipped if a "#" character is read in.
//
// Returns true if a token was loaded; false otherwise. If read from
// underlying Reader fails, next() returns false and err() will return the
// error occurred.
func (l *lexer) next() bool {
var val []rune
var comment, quoted, escaped bool
makeToken := func() bool {
l.token.Text = string(val)
l.lastErr = nil
return true
}
for {
ch, _, err := l.reader.ReadRune()
if err != nil {
if len(val) > 0 {
return makeToken()
}
if err == io.EOF {
return false
}
l.lastErr = err
return false
}
if quoted {
if !escaped {
if ch == '\\' {
escaped = true
continue
} else if ch == '"' {
return makeToken()
}
}
if ch == '\n' {
l.line++
}
if escaped {
// only escape quotes
if ch != '"' {
val = append(val, '\\')
}
}
val = append(val, ch)
escaped = false
continue
}
if unicode.IsSpace(ch) {
if ch == '\r' {
continue
}
if ch == '\n' {
l.line++
comment = false
}
if len(val) > 0 {
return makeToken()
}
continue
}
if ch == '#' {
comment = true
}
if comment {
continue
}
if len(val) == 0 {
l.token = Token{Line: l.line}
if ch == '"' {
quoted = true
continue
}
}
val = append(val, ch)
}
}
================================================
FILE: framework/config/lexer/lexer_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Copyright 2015 Light Code Labs, LLC
//
// 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.
package lexer
import (
"log"
"strings"
"testing"
)
type lexerTestCase struct {
input string
expected []Token
}
func TestLexer(t *testing.T) {
testCases := []lexerTestCase{
{
input: `host:123`,
expected: []Token{
{Line: 1, Text: "host:123"},
},
},
{
input: `host:123
directive`,
expected: []Token{
{Line: 1, Text: "host:123"},
{Line: 3, Text: "directive"},
},
},
{
input: `host:123 {
directive
}`,
expected: []Token{
{Line: 1, Text: "host:123"},
{Line: 1, Text: "{"},
{Line: 2, Text: "directive"},
{Line: 3, Text: "}"},
},
},
{
input: `host:123 { directive }`,
expected: []Token{
{Line: 1, Text: "host:123"},
{Line: 1, Text: "{"},
{Line: 1, Text: "directive"},
{Line: 1, Text: "}"},
},
},
{
input: `host:123 {
#comment
directive
# comment
foobar # another comment
}`,
expected: []Token{
{Line: 1, Text: "host:123"},
{Line: 1, Text: "{"},
{Line: 3, Text: "directive"},
{Line: 5, Text: "foobar"},
{Line: 6, Text: "}"},
},
},
{
input: `a "quoted value" b
foobar`,
expected: []Token{
{Line: 1, Text: "a"},
{Line: 1, Text: "quoted value"},
{Line: 1, Text: "b"},
{Line: 2, Text: "foobar"},
},
},
{
input: `A "quoted \"value\" inside" B`,
expected: []Token{
{Line: 1, Text: "A"},
{Line: 1, Text: `quoted "value" inside`},
{Line: 1, Text: "B"},
},
},
{
input: `"don't\escape"`,
expected: []Token{
{Line: 1, Text: `don't\escape`},
},
},
{
input: `"don't\\escape"`,
expected: []Token{
{Line: 1, Text: `don't\\escape`},
},
},
{
input: `A "quoted value with line
break inside" {
foobar
}`,
expected: []Token{
{Line: 1, Text: "A"},
{Line: 1, Text: "quoted value with line\n\t\t\t\t\tbreak inside"},
{Line: 2, Text: "{"},
{Line: 3, Text: "foobar"},
{Line: 4, Text: "}"},
},
},
{
input: `"C:\php\php-cgi.exe"`,
expected: []Token{
{Line: 1, Text: `C:\php\php-cgi.exe`},
},
},
{
input: `empty "" string`,
expected: []Token{
{Line: 1, Text: `empty`},
{Line: 1, Text: ``},
{Line: 1, Text: `string`},
},
},
{
input: "skip those\r\nCR characters",
expected: []Token{
{Line: 1, Text: "skip"},
{Line: 1, Text: "those"},
{Line: 2, Text: "CR"},
{Line: 2, Text: "characters"},
},
},
{
input: "\xEF\xBB\xBF:8080", // test with leading byte order mark
expected: []Token{
{Line: 1, Text: ":8080"},
},
},
}
for i, testCase := range testCases {
actual := tokenize(testCase.input)
lexerCompare(t, i, testCase.expected, actual)
}
}
func tokenize(input string) (tokens []Token) {
l := lexer{}
if err := l.load(strings.NewReader(input)); err != nil {
log.Printf("[ERROR] load failed: %v", err)
}
for l.next() {
tokens = append(tokens, l.token)
}
return
}
func lexerCompare(t *testing.T, n int, expected, actual []Token) {
if len(expected) != len(actual) {
t.Errorf("Test case %d: expected %d token(s) but got %d", n, len(expected), len(actual))
}
for i := 0; i < len(actual) && i < len(expected); i++ {
if actual[i].Line != expected[i].Line {
t.Errorf("Test case %d token %d ('%s'): expected line %d but was line %d",
n, i, expected[i].Text, expected[i].Line, actual[i].Line)
break
}
if actual[i].Text != expected[i].Text {
t.Errorf("Test case %d token %d: expected text '%s' but was '%s'",
n, i, expected[i].Text, actual[i].Text)
break
}
}
}
================================================
FILE: framework/config/lexer/parse.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package lexer
import (
"io"
)
// allTokens lexes the entire input, but does not parse it.
// It returns all the tokens from the input, unstructured
// and in order.
func allTokens(input io.Reader) ([]Token, error) {
l := new(lexer)
err := l.load(input)
if err != nil {
return nil, err
}
var tokens []Token
for l.next() {
tokens = append(tokens, l.token)
}
if err := l.err(); err != nil {
return nil, err
}
return tokens, nil
}
================================================
FILE: framework/config/map.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package config
import (
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"time"
"unicode"
)
type matcher struct {
name string
required bool
inheritGlobal bool
defaultVal func() (interface{}, error)
mapper func(*Map, Node) (interface{}, error)
store *reflect.Value
customCallback func(*Map, Node) error
}
func (m *matcher) assign(val interface{}) {
valRefl := reflect.ValueOf(val)
// Convert untyped nil into typed nil. Otherwise it will panic.
if !valRefl.IsValid() {
valRefl = reflect.Zero(m.store.Type())
}
m.store.Set(valRefl)
}
// Map structure implements reflection-based conversion between configuration
// directives and Go variables.
type Map struct {
allowUnknown bool
// All values saved by Map during processing.
Values map[string]interface{}
entries map[string]matcher
// Values used by Process as default values if inheritGlobal is true.
Globals map[string]interface{}
// Config block used by Process.
Block Node
}
func NewMap(globals map[string]interface{}, block Node) *Map {
return &Map{Globals: globals, Block: block}
}
// AllowUnknown makes config.Map skip unknown configuration directives instead
// of failing.
func (m *Map) AllowUnknown() {
m.allowUnknown = true
}
// EnumList maps a configuration directive to a []string variable.
//
// Directive must be in form 'name string1 string2' where each string should be from *allowed*
// slice. At least one argument should be present.
//
// See Map.Custom for description of inheritGlobal and required.
func (m *Map) EnumList(name string, inheritGlobal, required bool, allowed, defaultVal []string, store *[]string) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare a block here")
}
if len(node.Args) == 0 {
return nil, NodeErr(node, "expected at least one argument")
}
for _, arg := range node.Args {
isAllowed := false
for _, str := range allowed {
if str == arg {
isAllowed = true
}
}
if !isAllowed {
return nil, NodeErr(node, "invalid argument, valid values are: %v", allowed)
}
}
return node.Args, nil
}, store)
}
// Enum maps a configuration directive to a string variable.
//
// Directive must be in form 'name string' where string should be from *allowed*
// slice. That string argument will be stored in store variable.
//
// See Map.Custom for description of inheritGlobal and required.
func (m *Map) Enum(name string, inheritGlobal, required bool, allowed []string, defaultVal string, store *string) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare a block here")
}
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected exactly one argument")
}
for _, str := range allowed {
if str == node.Args[0] {
return node.Args[0], nil
}
}
return nil, NodeErr(node, "invalid argument, valid values are: %v", allowed)
}, store)
}
// EnumMapped is similar to Map.Enum but maps a stirng to a custom type.
func EnumMapped[V any](m *Map, name string, inheritGlobal, required bool, mapped map[string]V, defaultVal V, store *V) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare a block here")
}
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected exactly one argument")
}
val, ok := mapped[node.Args[0]]
if !ok {
validValues := make([]string, 0, len(mapped))
for k := range mapped {
validValues = append(validValues, k)
}
return nil, NodeErr(node, "invalid argument, valid values are: %v", validValues)
}
return val, nil
}, store)
}
// EnumListMapped is similar to Map.EnumList but maps a stirng to a custom type.
func EnumListMapped[V any](m *Map, name string, inheritGlobal, required bool, mapped map[string]V, defaultVal []V, store *[]V) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare a block here")
}
if len(node.Args) == 0 {
return nil, NodeErr(node, "expected at least one argument")
}
values := make([]V, 0, len(node.Args))
for _, arg := range node.Args {
val, ok := mapped[arg]
if !ok {
validValues := make([]string, 0, len(mapped))
for k := range mapped {
validValues = append(validValues, k)
}
return nil, NodeErr(node, "invalid argument, valid values are: %v", validValues)
}
values = append(values, val)
}
return values, nil
}, store)
}
// Duration maps configuration directive to a time.Duration variable.
//
// Directive must be in form 'name duration' where duration is any string accepted by
// time.ParseDuration. As an additional requirement, result of time.ParseDuration must not
// be negative.
//
// Note that for convenience, if directive does have multiple arguments, they will be joined
// without separators. E.g. 'name 1h 2m' will become 'name 1h2m' and so '1h2m' will be passed
// to time.ParseDuration.
//
// See Map.Custom for description of arguments.
func (m *Map) Duration(name string, inheritGlobal, required bool, defaultVal time.Duration, store *time.Duration) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
if len(node.Args) == 0 {
return nil, NodeErr(node, "at least one argument is required")
}
durationStr := strings.Join(node.Args, "")
dur, err := time.ParseDuration(durationStr)
if err != nil {
return nil, NodeErr(node, "%v", err)
}
if dur < 0 {
return nil, NodeErr(node, "duration must not be negative")
}
return dur, nil
}, store)
}
func ParseDataSize(s string) (int, error) {
if len(s) == 0 {
return 0, errors.New("missing a number")
}
// ' ' terminates the number+suffix pair.
s = s + " "
var total int
currentDigit := ""
suffix := ""
for _, ch := range s {
if unicode.IsDigit(ch) {
if suffix != "" {
return 0, errors.New("unexpected digit after a suffix")
}
currentDigit += string(ch)
continue
}
if ch != ' ' {
suffix += string(ch)
continue
}
num, err := strconv.Atoi(currentDigit)
if err != nil {
return 0, err
}
if num < 0 {
return 0, errors.New("value must not be negative")
}
switch suffix {
case "G":
total += num * 1024 * 1024 * 1024
case "M":
total += num * 1024 * 1024
case "K":
total += num * 1024
case "B", "b":
total += num
default:
if num != 0 {
return 0, errors.New("unknown unit suffix: " + suffix)
}
}
suffix = ""
currentDigit = ""
}
return total, nil
}
// DataSize maps configuration directive to a int variable, representing data size.
//
// Syntax requires unit suffix to be added to the end of string to specify
// data unit and allows multiple arguments (they will be added together).
//
// See Map.Custom for description of arguments.
func (m *Map) DataSize(name string, inheritGlobal, required bool, defaultVal int64, store *int64) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
if len(node.Args) == 0 {
return nil, NodeErr(node, "at least one argument is required")
}
durationStr := strings.Join(node.Args, " ")
dur, err := ParseDataSize(durationStr)
if err != nil {
return nil, NodeErr(node, "%v", err)
}
return int64(dur), nil
}, store)
}
func ParseBool(s string) (bool, error) {
switch strings.ToLower(s) {
case "1", "true", "on", "yes":
return true, nil
case "0", "false", "off", "no":
return false, nil
}
return false, fmt.Errorf("bool argument should be 'yes' or 'no'")
}
// Bool maps presence of some configuration directive to a boolean variable.
// Additionally, 'name yes' and 'name no' are mapped to true and false
// correspondingly.
//
// I.e. if directive 'io_debug' exists in processed configuration block or in
// the global configuration (if inheritGlobal is true) then Process will store
// true in target variable.
func (m *Map) Bool(name string, inheritGlobal, defaultVal bool, store *bool) {
m.Custom(name, inheritGlobal, false, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
if len(node.Args) == 0 {
return true, nil
}
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected exactly 1 argument")
}
b, err := ParseBool(node.Args[0])
if err != nil {
return nil, NodeErr(node, "bool argument should be 'yes' or 'no'")
}
return b, nil
}, store)
}
// StringList maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name arbitrary_string arbitrary_string ...'
// Where at least one argument must be present.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) StringList(name string, inheritGlobal, required bool, defaultVal []string, store *[]string) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) == 0 {
return nil, NodeErr(node, "expected at least one argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
return node.Args, nil
}, store)
}
// String maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name arbitrary_string'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) String(name string, inheritGlobal, required bool, defaultVal string, store *string) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
return node.Args[0], nil
}, store)
}
// Int maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) Int(name string, inheritGlobal, required bool, defaultVal int, store *int) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
i, err := strconv.Atoi(node.Args[0])
if err != nil {
return nil, NodeErr(node, "invalid integer: %s", node.Args[0])
}
return i, nil
}, store)
}
// UInt maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) UInt(name string, inheritGlobal, required bool, defaultVal uint, store *uint) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
i, err := strconv.ParseUint(node.Args[0], 10, 32)
if err != nil {
return nil, NodeErr(node, "invalid integer: %s", node.Args[0])
}
return uint(i), nil
}, store)
}
// Int32 maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) Int32(name string, inheritGlobal, required bool, defaultVal int32, store *int32) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
i, err := strconv.ParseInt(node.Args[0], 10, 32)
if err != nil {
return nil, NodeErr(node, "invalid integer: %s", node.Args[0])
}
return int32(i), nil
}, store)
}
// UInt32 maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) UInt32(name string, inheritGlobal, required bool, defaultVal uint32, store *uint32) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
i, err := strconv.ParseUint(node.Args[0], 10, 32)
if err != nil {
return nil, NodeErr(node, "invalid integer: %s", node.Args[0])
}
return uint32(i), nil
}, store)
}
// Int64 maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) Int64(name string, inheritGlobal, required bool, defaultVal int64, store *int64) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
i, err := strconv.ParseInt(node.Args[0], 10, 64)
if err != nil {
return nil, NodeErr(node, "invalid integer: %s", node.Args[0])
}
return i, nil
}, store)
}
// UInt64 maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) UInt64(name string, inheritGlobal, required bool, defaultVal uint64, store *uint64) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
if len(node.Children) != 0 {
return nil, NodeErr(node, "can't declare block here")
}
i, err := strconv.ParseUint(node.Args[0], 10, 64)
if err != nil {
return nil, NodeErr(node, "invalid integer: %s", node.Args[0])
}
return i, nil
}, store)
}
// Float maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// Configuration directive must be in form 'name 123.55'.
//
// See Custom function for details about inheritGlobal, required and
// defaultVal.
func (m *Map) Float(name string, inheritGlobal, required bool, defaultVal float64, store *float64) {
m.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, func(_ *Map, node Node) (interface{}, error) {
if len(node.Args) != 1 {
return nil, NodeErr(node, "expected 1 argument")
}
f, err := strconv.ParseFloat(node.Args[0], 64)
if err != nil {
return nil, NodeErr(node, "invalid float: %s", node.Args[0])
}
return f, nil
}, store)
}
// Custom maps configuration directive with the specified name to variable
// referenced by 'store' pointer.
//
// If inheritGlobal is true - Map will try to use a value from globalCfg if
// none is set in a processed configuration block.
//
// If required is true - Map will fail if no value is set in the configuration,
// both global (if inheritGlobal is true) and in the processed block.
//
// defaultVal is a factory function that should return the default value for
// the variable. It will be used if no value is set in the config. It can be
// nil if required is true.
// Note that if inheritGlobal is true, defaultVal of the global directive
// will be used instead.
//
// mapper is a function that should convert configuration directive arguments
// into variable value. Both functions may fail with errors, configuration
// processing will stop immediately then.
// Note: mapper function should not modify passed values.
//
// store is where the value returned by mapper should be stored. Can be nil
// (value will be saved only in Map.Values).
func (m *Map) Custom(name string, inheritGlobal, required bool, defaultVal func() (interface{}, error), mapper func(*Map, Node) (interface{}, error), store interface{}) {
if m.entries == nil {
m.entries = make(map[string]matcher)
}
if _, ok := m.entries[name]; ok {
panic("Map.Custom: duplicate matcher")
}
var target *reflect.Value
ptr := reflect.ValueOf(store)
if ptr.IsValid() && !ptr.IsNil() {
val := ptr.Elem()
if !val.CanSet() {
panic("Map.Custom: store argument must be settable (a pointer)")
}
target = &val
}
m.entries[name] = matcher{
name: name,
inheritGlobal: inheritGlobal,
required: required,
defaultVal: defaultVal,
mapper: mapper,
store: target,
}
}
// Callback creates mapping that will call mapper() function for each
// directive with the specified name. No further processing is done.
//
// Directives with the specified name will not be returned by Process if
// AllowUnknown is used.
//
// It is intended to permit multiple independent values of directive with
// implementation-defined handling.
func (m *Map) Callback(name string, mapper func(*Map, Node) error) {
if m.entries == nil {
m.entries = make(map[string]matcher)
}
if _, ok := m.entries[name]; ok {
panic("Map.Custom: duplicate matcher")
}
m.entries[name] = matcher{
name: name,
customCallback: mapper,
}
}
// Process maps variables from global configuration and block passed in NewMap.
//
// If Map instance was not created using NewMap - Process panics.
func (m *Map) Process() (unknown []Node, err error) {
return m.ProcessWith(m.Globals, m.Block)
}
// Process maps variables from global configuration and block passed in arguments.
func (m *Map) ProcessWith(globalCfg map[string]interface{}, block Node) (unknown []Node, err error) {
unknown = make([]Node, 0, len(block.Children))
matched := make(map[string]bool)
m.Values = make(map[string]interface{})
for _, subnode := range block.Children {
matcher, ok := m.entries[subnode.Name]
if !ok {
if !m.allowUnknown {
return nil, NodeErr(subnode, "unexpected directive: %s", subnode.Name)
}
unknown = append(unknown, subnode)
continue
}
if matcher.customCallback != nil {
if err := matcher.customCallback(m, subnode); err != nil {
return nil, err
}
matched[subnode.Name] = true
continue
}
if matched[subnode.Name] {
return nil, NodeErr(subnode, "duplicate directive: %s", subnode.Name)
}
matched[subnode.Name] = true
val, err := matcher.mapper(m, subnode)
if err != nil {
return nil, err
}
m.Values[matcher.name] = val
if matcher.store != nil {
matcher.assign(val)
}
}
for _, matcher := range m.entries {
if matched[matcher.name] {
continue
}
if matcher.mapper == nil {
continue
}
var val interface{}
globalVal, ok := globalCfg[matcher.name]
if matcher.inheritGlobal && ok {
val = globalVal
} else if !matcher.required {
if matcher.defaultVal == nil {
continue
}
val, err = matcher.defaultVal()
if err != nil {
return nil, err
}
} else {
return nil, NodeErr(block, "missing required directive: %s", matcher.name)
}
// If we put zero values into map then code that checks globalCfg
// above will inherit them for required fields instead of failing.
//
// This is important for fields that are required to be specified
// either globally or on per-block basis (e.g. tls, hostname).
// For these directives, global Map does have required = false
// so global values are default which is usually zero value.
//
// This is a temporary solutions, of course, in the long-term
// the way global values and "inheritance" is handled should be
// revised.
store := false
valT := reflect.TypeOf(val)
if valT != nil {
zero := reflect.Zero(valT)
store = !reflect.DeepEqual(val, zero.Interface())
}
if store {
m.Values[matcher.name] = val
}
if matcher.store != nil {
matcher.assign(val)
}
}
return unknown, nil
}
================================================
FILE: framework/config/map_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package config
import (
"testing"
)
func TestMapProcess(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"bar"},
},
},
}
m := NewMap(nil, cfg)
foo := ""
m.Custom("foo", false, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != "bar" {
t.Errorf("Incorrect value stored in variable, want 'bar', got '%s'", foo)
}
}
func TestMapProcess_MissingRequired(t *testing.T) {
cfg := Node{
Children: []Node{},
}
m := NewMap(nil, cfg)
foo := ""
m.Custom("foo", false, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err == nil {
t.Errorf("Expected failure")
}
}
func TestMapProcess_InheritGlobal(t *testing.T) {
cfg := Node{
Children: []Node{},
}
m := NewMap(map[string]interface{}{"foo": "bar"}, cfg)
foo := ""
m.Custom("foo", true, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != "bar" {
t.Errorf("Incorrect value stored in variable, want 'bar', got '%s'", foo)
}
}
func TestMapProcess_InheritGlobal_MissingRequired(t *testing.T) {
cfg := Node{
Children: []Node{},
}
m := NewMap(map[string]interface{}{}, cfg)
foo := ""
m.Custom("foo", false, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err == nil {
t.Errorf("Expected failure")
}
}
func TestMapProcess_InheritGlobal_Override(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"bar"},
},
},
}
m := NewMap(map[string]interface{}{}, cfg)
foo := ""
m.Custom("foo", false, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != "bar" {
t.Errorf("Incorrect value stored in variable, want 'bar', got '%s'", foo)
}
}
func TestMapProcess_DefaultValue(t *testing.T) {
cfg := Node{
Children: []Node{},
}
m := NewMap(nil, cfg)
foo := ""
m.Custom("foo", false, false, func() (interface{}, error) {
return "bar", nil
}, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != "bar" {
t.Errorf("Incorrect value stored in variable, want 'bar', got '%s'", foo)
}
}
func TestMapProcess_InheritGlobal_DefaultValue(t *testing.T) {
cfg := Node{
Children: []Node{},
}
m := NewMap(map[string]interface{}{"foo": "baz"}, cfg)
foo := ""
m.Custom("foo", true, false, func() (interface{}, error) {
return "bar", nil
}, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != "baz" {
t.Errorf("Incorrect value stored in variable, want 'baz', got '%s'", foo)
}
t.Run("no global", func(t *testing.T) {
_, err := m.ProcessWith(map[string]interface{}{}, cfg)
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != "bar" {
t.Errorf("Incorrect value stored in variable, want 'bar', got '%s'", foo)
}
})
}
func TestMapProcess_Duplicate(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"bar"},
},
{
Name: "foo",
Args: []string{"bar"},
},
},
}
m := NewMap(nil, cfg)
foo := ""
m.Custom("foo", false, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err == nil {
t.Errorf("Expected failure")
}
}
func TestMapProcess_Unexpected(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"baz"},
},
{
Name: "bar",
Args: []string{"baz"},
},
},
}
m := NewMap(nil, cfg)
foo := ""
m.Custom("bar", false, true, nil, func(_ *Map, n Node) (interface{}, error) {
return n.Args[0], nil
}, &foo)
_, err := m.Process()
if err == nil {
t.Errorf("Expected failure")
}
m.AllowUnknown()
unknown, err := m.Process()
if err != nil {
t.Errorf("Unexpected failure: %v", err)
}
if len(unknown) != 1 {
t.Fatalf("Wrong amount of unknown nodes: %v", len(unknown))
}
if unknown[0].Name != "foo" {
t.Fatalf("Wrong node in unknown: %v", unknown[0].Name)
}
}
func TestMapInt(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"1"},
},
},
}
m := NewMap(nil, cfg)
foo := 0
m.Int("foo", false, true, 0, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != 1 {
t.Errorf("Incorrect value stored in variable, want 1, got %d", foo)
}
}
func TestMapInt_Invalid(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"AAAA"},
},
},
}
m := NewMap(nil, cfg)
foo := 0
m.Int("foo", false, true, 0, &foo)
_, err := m.Process()
if err == nil {
t.Errorf("Expected failure")
}
}
func TestMapFloat(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"1"},
},
},
}
m := NewMap(nil, cfg)
foo := 0.0
m.Float("foo", false, true, 0, &foo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if foo != 1.0 {
t.Errorf("Incorrect value stored in variable, want 1, got %v", foo)
}
}
func TestMapFloat_Invalid(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
Args: []string{"AAAA"},
},
},
}
m := NewMap(nil, cfg)
foo := 0.0
m.Float("foo", false, true, 0, &foo)
_, err := m.Process()
if err == nil {
t.Errorf("Expected failure")
}
}
func TestMapBool(t *testing.T) {
cfg := Node{
Children: []Node{
{
Name: "foo",
},
{
Name: "bar",
Args: []string{"yes"},
},
{
Name: "baz",
Args: []string{"no"},
},
},
}
m := NewMap(nil, cfg)
foo, bar, baz, boo := false, false, false, false
m.Bool("foo", false, false, &foo)
m.Bool("bar", false, false, &bar)
m.Bool("baz", false, false, &baz)
m.Bool("boo", false, false, &boo)
_, err := m.Process()
if err != nil {
t.Fatalf("Unexpected failure: %v", err)
}
if !foo {
t.Errorf("Incorrect value stored in variable foo, want true, got false")
}
if !bar {
t.Errorf("Incorrect value stored in variable bar, want true, got false")
}
if baz {
t.Errorf("Incorrect value stored in variable baz, want false, got true")
}
if boo {
t.Errorf("Incorrect value stored in variable boo, want false, got true")
}
}
func TestParseDataSize(t *testing.T) {
check := func(s string, ok bool, expected int) {
val, err := ParseDataSize(s)
if err != nil && ok {
t.Errorf("unexpected parseDataSize('%s') fail: %v", s, err)
return
}
if err == nil && !ok {
t.Errorf("unexpected parseDataSize('%s') success, got %d", s, val)
return
}
if val != expected {
t.Errorf("parseDataSize('%s') != %d", s, expected)
return
}
}
check("1M", true, 1024*1024)
check("1K", true, 1024)
check("1b", true, 1)
check("1M 5b", true, 1024*1024+5)
check("1M 5K 5b", true, 1024*1024+5*1024+5)
check("0", true, 0)
check("1", false, 0)
check("1d", false, 0)
check("d", false, 0)
check("unrelated", false, 0)
check("1M5b", false, 0)
check("", false, 0)
check("-5M", false, 0)
}
func TestMap_Callback(t *testing.T) {
called := map[string]int{}
cfg := Node{
Children: []Node{
{
Name: "test2",
Args: []string{"a"},
},
{
Name: "test3",
Args: []string{"b"},
},
{
Name: "test3",
Args: []string{"b"},
},
{
Name: "unrelated",
Args: []string{"b"},
},
},
}
m := NewMap(nil, cfg)
m.Callback("test1", func(*Map, Node) error {
called["test1"]++
return nil
})
m.Callback("test2", func(_ *Map, n Node) error {
called["test2"]++
if n.Args[0] != "a" {
t.Fatal("Wrong n.Args[0] for test2:", n.Args[0])
}
return nil
})
m.Callback("test3", func(_ *Map, n Node) error {
called["test3"]++
if n.Args[0] != "b" {
t.Fatal("Wrong n.Args[0] for test2:", n.Args[0])
}
return nil
})
m.AllowUnknown()
others, err := m.Process()
if err != nil {
t.Fatal("Unexpected error:", err)
}
if called["test1"] != 0 {
t.Error("test1 CB was called when it should not")
}
if called["test2"] != 1 {
t.Error("test2 CB was not called when it should")
}
if called["test3"] != 2 {
t.Error("test3 CB was not called when it should")
}
if len(others) != 1 {
t.Error("Wrong amount of unmatched directives")
}
if others[0].Name != "unrelated" {
t.Error("Wrong directive returned in unmatched slice:", others[0].Name)
}
}
================================================
FILE: framework/config/module/check_action.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package modconfig
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
)
// FailAction specifies actions that messages pipeline should take based on the
// result of the check.
//
// Its check module responsibility to apply FailAction on the CheckResult it
// returns. It is intended to be used as follows:
//
// Add the configuration directive to allow user to specify the action:
//
// cfg.Custom("SOME_action", false, false,
// func() (interface{}, error) {
// return modconfig.FailAction{Quarantine: true}, nil
// }, modconfig.FailActionDirective, &yourModule.SOMEAction)
//
// return in func literal is the default value, you might want to adjust it.
//
// Call yourModule.SOMEAction.Apply on CheckResult containing only the
// Reason field:
//
// func (yourModule YourModule) CheckConnection() module.CheckResult {
// return yourModule.SOMEAction.Apply(module.CheckResult{
// Reason: ...,
// })
// }
type FailAction struct {
Quarantine bool
Reject bool
ReasonOverride *exterrors.SMTPError
}
func FailActionDirective(_ *config.Map, node config.Node) (interface{}, error) {
if len(node.Children) != 0 {
return nil, config.NodeErr(node, "can't declare block here")
}
val, err := ParseActionDirective(node.Args)
if err != nil {
return nil, config.NodeErr(node, "%v", err)
}
return val, nil
}
func ParseActionDirective(args []string) (FailAction, error) {
if len(args) == 0 {
return FailAction{}, errors.New("expected at least 1 argument")
}
res := FailAction{}
switch args[0] {
case "reject", "quarantine":
if len(args) > 1 {
var err error
res.ReasonOverride, err = ParseRejectDirective(args[1:])
if err != nil {
return FailAction{}, err
}
}
case "ignore":
default:
return FailAction{}, errors.New("invalid action")
}
res.Reject = args[0] == "reject"
res.Quarantine = args[0] == "quarantine"
return res, nil
}
// Apply merges the result of check execution with action configuration specified
// in the check configuration.
func (cfa FailAction) Apply(originalRes module.CheckResult) module.CheckResult {
if originalRes.Reason == nil {
return originalRes
}
if cfa.ReasonOverride != nil {
// Wrap instead of replace to preserve other fields.
originalRes.Reason = &exterrors.SMTPError{
Code: cfa.ReasonOverride.Code,
EnhancedCode: cfa.ReasonOverride.EnhancedCode,
Message: cfa.ReasonOverride.Message,
Err: originalRes.Reason,
}
}
originalRes.Quarantine = cfa.Quarantine || originalRes.Quarantine
originalRes.Reject = cfa.Reject || originalRes.Reject
return originalRes
}
func ParseRejectDirective(args []string) (*exterrors.SMTPError, error) {
code := 554
enchCode := exterrors.EnhancedCode{0, 7, 0}
msg := "Message rejected due to a local policy"
var err error
switch len(args) {
case 3:
msg = args[2]
if msg == "" {
return nil, fmt.Errorf("message can't be empty")
}
fallthrough
case 2:
enchCode, err = parseEnhancedCode(args[1])
if err != nil {
return nil, err
}
if enchCode[0] != 4 && enchCode[0] != 5 {
return nil, fmt.Errorf("enhanced code should use either 4 or 5 as a first number")
}
fallthrough
case 1:
code, err = strconv.Atoi(args[0])
if err != nil {
return nil, fmt.Errorf("invalid error code integer: %v", err)
}
if (code/100) != 4 && (code/100) != 5 {
return nil, fmt.Errorf("error code should start with either 4 or 5")
}
// If enchanced code is not set - set first digit based on provided "basic" code.
if enchCode[0] == 0 {
enchCode[0] = code / 100
}
case 0:
// If no codes provided at all - use 5.7.0 and 554.
enchCode[0] = 5
default:
return nil, fmt.Errorf("invalid count of arguments")
}
return &exterrors.SMTPError{
Code: code,
EnhancedCode: enchCode,
Message: msg,
Reason: "reject directive used",
}, nil
}
func parseEnhancedCode(s string) (exterrors.EnhancedCode, error) {
parts := strings.Split(s, ".")
if len(parts) != 3 {
return exterrors.EnhancedCode{}, fmt.Errorf("wrong amount of enhanced code parts")
}
code := exterrors.EnhancedCode{}
for i, part := range parts {
num, err := strconv.Atoi(part)
if err != nil {
return code, err
}
code[i] = num
}
return code, nil
}
================================================
FILE: framework/config/module/interfaces.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package modconfig
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/module"
)
func MessageCheck(globals map[string]interface{}, args []string, block config.Node) (module.Check, error) {
var check module.Check
if err := ModuleFromNode("check", args, block, globals, &check); err != nil {
return nil, err
}
return check, nil
}
// DeliveryDirective is a callback for use in config.Map.Custom.
//
// It does all work necessary to create a module instance from the config
// directive with the following structure:
//
// directive_name mod_name [inst_name] [{
// inline_mod_config
// }]
//
// Note that if used configuration structure lacks directive_name before mod_name - this function
// should not be used (call DeliveryTarget directly).
func DeliveryDirective(m *config.Map, node config.Node) (interface{}, error) {
return DeliveryTarget(m.Globals, node.Args, node)
}
func DeliveryTarget(globals map[string]interface{}, args []string, block config.Node) (module.DeliveryTarget, error) {
var target module.DeliveryTarget
if err := ModuleFromNode("target", args, block, globals, &target); err != nil {
return nil, err
}
return target, nil
}
func MsgModifier(globals map[string]interface{}, args []string, block config.Node) (module.Modifier, error) {
var check module.Modifier
if err := ModuleFromNode("modify", args, block, globals, &check); err != nil {
return nil, err
}
return check, nil
}
func IMAPFilter(globals map[string]interface{}, args []string, block config.Node) (module.IMAPFilter, error) {
var filter module.IMAPFilter
if err := ModuleFromNode("imap.filter", args, block, globals, &filter); err != nil {
return nil, err
}
return filter, nil
}
func StorageDirective(m *config.Map, node config.Node) (interface{}, error) {
var backend module.Storage
if err := ModuleFromNode("storage", node.Args, node, m.Globals, &backend); err != nil {
return nil, err
}
return backend, nil
}
// Table is a convenience wrapper for TableDirective.
//
// cfg.Bool(...)
// modconfig.Table(cfg, "auth_map", false, false, nil, &mod.authMap)
// cfg.Process()
func Table(cfg *config.Map, name string, inheritGlobal, required bool, defaultVal module.Table, store *module.Table) {
cfg.Custom(name, inheritGlobal, required, func() (interface{}, error) {
return defaultVal, nil
}, TableDirective, store)
}
func TableDirective(m *config.Map, node config.Node) (interface{}, error) {
var tbl module.Table
if err := ModuleFromNode("table", node.Args, node, m.Globals, &tbl); err != nil {
return nil, err
}
return tbl, nil
}
================================================
FILE: framework/config/module/modconfig.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package modconfig provides matchers for config.Map that query
// modules registry and parse inline module definitions.
//
// They should be used instead of manual querying when there is need to
// reference a module instance in the configuration.
//
// See ModuleFromNode documentation for explanation of what is 'args'
// for some functions (DeliveryTarget).
package modconfig
import (
"fmt"
"reflect"
"strings"
parser "github.com/foxcpp/maddy/framework/cfgparser"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
)
// createInlineModule is a helper function for config matchers that can create inline modules.
func createInlineModule(c *container.C, preferredNamespace, modName string) (module.Module, error) {
var newMod modules.FuncNewModule
originalModName := modName
// First try to extend the name with preferred namespace unless the name
// already contains it.
if !strings.Contains(modName, ".") && preferredNamespace != "" {
modName = preferredNamespace + "." + modName
newMod = modules.Get(modName)
}
// Then try global namespace for compatibility and complex modules.
if newMod == nil {
newMod = modules.Get(originalModName)
}
// Bail if both failed.
if newMod == nil {
return nil, fmt.Errorf("unknown module: %s (namespace: %s)", originalModName, preferredNamespace)
}
return newMod(c, modName, "")
}
// configureInlineModule constructs "faked" config tree and passes it to module
// Init function to make it look like it is defined at top-level.
//
// args must contain at least one argument, otherwise configureInlineModule panics.
func configureInlineModule(modObj module.Module, args []string, globals map[string]interface{}, block config.Node) error {
err := modObj.Configure(args, config.NewMap(globals, block))
if err != nil {
return err
}
if li, ok := modObj.(container.LifetimeModule); ok {
container.Global.Lifetime.Add(li)
}
return nil
}
// ModuleFromNode does all work to create or get existing module object with a certain type.
// It is not used by top-level module definitions, only for references from other
// modules configuration blocks.
//
// inlineCfg should contain configuration directives for inline declarations.
// args should contain values that are used to create module.
// It should be either module name + instance name or just module name. Further extensions
// may add other string arguments (currently, they can be accessed by module instances
// as inlineArgs argument to constructor).
//
// It checks using reflection whether it is possible to store a module object into modObj
// pointer (e.g. it implements all necessary interfaces) and stores it if everything is fine.
// If module object doesn't implement necessary module interfaces - error is returned.
// If modObj is not a pointer, ModuleFromNode panics.
//
// preferredNamespace is used as an implicit prefix for module name lookups.
// Module with name preferredNamespace + "." + args[0] will be preferred over just args[0].
// It can be omitted.
func ModuleFromNode(preferredNamespace string, args []string, inlineCfg config.Node, globals map[string]interface{}, moduleIface interface{}) error {
if len(args) == 0 {
return parser.NodeErr(inlineCfg, "at least one argument is required")
}
referenceExisting := strings.HasPrefix(args[0], "&")
var modObj module.Module
var err error
if referenceExisting {
if len(args) != 1 || inlineCfg.Children != nil {
return parser.NodeErr(inlineCfg, "exactly one argument is required to use existing config block")
}
modObj, err = container.Global.Modules.Get(args[0][1:])
log.Debugf("%s:%d: reference %s", inlineCfg.File, inlineCfg.Line, args[0])
} else {
log.Debugf("%s:%d: new module %s %v", inlineCfg.File, inlineCfg.Line, args[0], args[1:])
modObj, err = createInlineModule(container.Global, preferredNamespace, args[0])
}
if err != nil {
return err
}
// NOTE: This will panic if moduleIface is not a pointer.
modIfaceType := reflect.TypeOf(moduleIface).Elem()
modObjType := reflect.TypeOf(modObj)
if modIfaceType.Kind() == reflect.Interface {
// Case for assignment to module interface type.
if !modObjType.Implements(modIfaceType) && !modObjType.AssignableTo(modIfaceType) {
return parser.NodeErr(inlineCfg, "module %s (%s) doesn't implement %v interface", modObj.Name(), modObj.InstanceName(), modIfaceType)
}
} else if !modObjType.AssignableTo(modIfaceType) {
// Case for assignment to concrete module type. Used in "module groups".
return parser.NodeErr(inlineCfg, "module %s (%s) is not %v", modObj.Name(), modObj.InstanceName(), modIfaceType)
}
reflect.ValueOf(moduleIface).Elem().Set(reflect.ValueOf(modObj))
if !referenceExisting {
if err := configureInlineModule(modObj, args[1:], globals, inlineCfg); err != nil {
return err
}
}
return nil
}
// GroupFromNode provides a special kind of ModuleFromNode syntax that allows
// to omit the module name when defining inine configuration. If it is not
// present, name in defaultModule is used.
func GroupFromNode(defaultModule string, args []string, inlineCfg config.Node, globals map[string]interface{}, moduleIface interface{}) error {
if len(args) == 0 {
args = append(args, defaultModule)
}
return ModuleFromNode("", args, inlineCfg, globals, moduleIface)
}
================================================
FILE: framework/config/tls/client.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package tls
import (
"crypto/tls"
"crypto/x509"
"fmt"
"os"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/log"
)
func TLSClientBlock(_ *config.Map, node config.Node) (interface{}, error) {
cfg := tls.Config{}
childM := config.NewMap(nil, node)
var (
tlsVersions [2]uint16
rootCAPaths []string
certPath, keyPath string
)
childM.StringList("root_ca", false, false, nil, &rootCAPaths)
childM.String("cert", false, false, "", &certPath)
childM.String("key", false, false, "", &keyPath)
childM.Custom("protocols", false, false, func() (interface{}, error) {
return [2]uint16{0, 0}, nil
}, TLSVersionsDirective, &tlsVersions)
childM.Custom("ciphers", false, false, func() (interface{}, error) {
return nil, nil
}, TLSCiphersDirective, &cfg.CipherSuites)
childM.Custom("curves", false, false, func() (interface{}, error) {
return nil, nil
}, TLSCurvesDirective, &cfg.CurvePreferences)
if _, err := childM.Process(); err != nil {
return nil, err
}
if len(rootCAPaths) != 0 {
pool := x509.NewCertPool()
for _, path := range rootCAPaths {
blob, err := os.ReadFile(path)
if err != nil {
return nil, err
}
if !pool.AppendCertsFromPEM(blob) {
return nil, fmt.Errorf("no certificates was loaded from %s", path)
}
}
cfg.RootCAs = pool
}
if certPath != "" && keyPath != "" {
keypair, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, err
}
log.Debugf("using client keypair %s/%s", certPath, keyPath)
cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) {
return &keypair, nil
}
}
cfg.MinVersion = tlsVersions[0]
cfg.MaxVersion = tlsVersions[1]
log.Debugf("tls: min version: %x, max version: %x", tlsVersions[0], tlsVersions[1])
return &cfg, nil
}
================================================
FILE: framework/config/tls/general.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package tls
import (
"crypto/tls"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/log"
)
var strVersionsMap = map[string]uint16{
"tls1.0": tls.VersionTLS10,
"tls1.1": tls.VersionTLS11,
"tls1.2": tls.VersionTLS12,
"tls1.3": tls.VersionTLS13,
"": 0, // use crypto/tls defaults if value is not specified
}
var strCiphersMap = map[string]uint16{
// TLS 1.0 - 1.2 cipher suites.
"RSA-WITH-RC4128-SHA": tls.TLS_RSA_WITH_RC4_128_SHA,
"RSA-WITH-3DES-EDE-CBC-SHA": tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
"RSA-WITH-AES128-CBC-SHA": tls.TLS_RSA_WITH_AES_128_CBC_SHA,
"RSA-WITH-AES256-CBC-SHA": tls.TLS_RSA_WITH_AES_256_CBC_SHA,
"RSA-WITH-AES128-CBC-SHA256": tls.TLS_RSA_WITH_AES_128_CBC_SHA256,
"RSA-WITH-AES128-GCM-SHA256": tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
"RSA-WITH-AES256-GCM-SHA384": tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
"ECDHE-ECDSA-WITH-RC4128-SHA": tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,
"ECDHE-ECDSA-WITH-AES128-CBC-SHA": tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
"ECDHE-ECDSA-WITH-AES256-CBC-SHA": tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
"ECDHE-RSA-WITH-RC4128-SHA": tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA,
"ECDHE-RSA-WITH-3DES-EDE-CBC-SHA": tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
"ECDHE-RSA-WITH-AES128-CBC-SHA": tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
"ECDHE-RSA-WITH-AES256-CBC-SHA": tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
"ECDHE-ECDSA-WITH-AES128-CBC-SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
"ECDHE-RSA-WITH-AES128-CBC-SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
"ECDHE-RSA-WITH-AES128-GCM-SHA256": tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
"ECDHE-ECDSA-WITH-AES128-GCM-SHA256": tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
"ECDHE-RSA-WITH-AES256-GCM-SHA384": tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
"ECDHE-ECDSA-WITH-AES256-GCM-SHA384": tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
"ECDHE-RSA-WITH-CHACHA20-POLY1305": tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
"ECDHE-ECDSA-WITH-CHACHA20-POLY1305": tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
}
var strCurvesMap = map[string]tls.CurveID{
"p256": tls.CurveP256,
"p384": tls.CurveP384,
"p521": tls.CurveP521,
"X25519": tls.X25519,
}
// TLSversionsDirective parses directive with arguments that specify
// minimum and maximum supported TLS versions.
//
// It returns [2]uint16 value for use in corresponding fields from tls.Config.
func TLSVersionsDirective(_ *config.Map, node config.Node) (interface{}, error) {
switch len(node.Args) {
case 1:
value, ok := strVersionsMap[node.Args[0]]
if !ok {
return nil, config.NodeErr(node, "invalid TLS version value: %s", node.Args[0])
}
return [2]uint16{value, value}, nil
case 2:
minValue, ok := strVersionsMap[node.Args[0]]
if !ok {
return nil, config.NodeErr(node, "invalid TLS version value: %s", node.Args[0])
}
maxValue, ok := strVersionsMap[node.Args[1]]
if !ok {
return nil, config.NodeErr(node, "invalid TLS version value: %s", node.Args[1])
}
return [2]uint16{minValue, maxValue}, nil
default:
return nil, config.NodeErr(node, "expected 1 or 2 arguments")
}
}
// TLSCiphersDirective parses directive with arguments that specify
// list of ciphers to offer to clients (or to use for outgoing connections).
//
// It returns list of []uint16 with corresponding cipher IDs.
func TLSCiphersDirective(_ *config.Map, node config.Node) (interface{}, error) {
if len(node.Args) == 0 {
return nil, config.NodeErr(node, "expected at least 1 argument, got 0")
}
res := make([]uint16, 0, len(node.Args))
for _, arg := range node.Args {
cipherId, ok := strCiphersMap[arg]
if !ok {
return nil, config.NodeErr(node, "unknown cipher: %s", arg)
}
res = append(res, cipherId)
}
log.Debugln("tls: using non-default cipherset:", node.Args)
return res, nil
}
// TLSCurvesDirective parses directive with arguments that specify
// elliptic curves to use during TLS key exchange.
//
// It returns []tls.CurveID.
func TLSCurvesDirective(_ *config.Map, node config.Node) (interface{}, error) {
if len(node.Args) == 0 {
return nil, config.NodeErr(node, "expected at least 1 argument, got 0")
}
res := make([]tls.CurveID, 0, len(node.Args))
for _, arg := range node.Args {
curveId, ok := strCurvesMap[arg]
if !ok {
return nil, config.NodeErr(node, "unknown curve: %s", arg)
}
res = append(res, curveId)
}
log.Debugln("tls: using non-default curve preferences:", node.Args)
return res, nil
}
================================================
FILE: framework/config/tls/server.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package tls
import (
"crypto/tls"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
)
type TLSConfig struct {
loader module.TLSLoader
baseCfg *tls.Config
}
func (cfg *TLSConfig) Get() (*tls.Config, error) {
if cfg.loader == nil {
return nil, nil
}
tlsCfg := cfg.baseCfg.Clone()
err := cfg.loader.ConfigureTLS(tlsCfg)
if err != nil {
return nil, err
}
return tlsCfg, nil
}
// TLSDirective reads the TLS configuration and adds the reload handler to
// reread certificates on SIGUSR2.
//
// The returned value is *tls.Config with GetConfigForClient set.
// If the 'tls off' is used, returned value is nil.
func TLSDirective(m *config.Map, node config.Node) (interface{}, error) {
cfg, err := readTLSBlock(m.Globals, node)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, nil
}
return &tls.Config{
GetConfigForClient: func(hello *tls.ClientHelloInfo) (*tls.Config, error) {
return cfg.Get()
},
}, nil
}
func readTLSBlock(globals map[string]interface{}, blockNode config.Node) (*TLSConfig, error) {
baseCfg := tls.Config{
// Workaround for issue https://github.com/foxcpp/maddy/issues/730
SessionTicketsDisabled: true,
}
var loader module.TLSLoader
if len(blockNode.Args) > 0 {
if blockNode.Args[0] == "off" {
return nil, nil
}
err := modconfig.ModuleFromNode("tls.loader", blockNode.Args, config.Node{}, globals, &loader)
if err != nil {
return nil, err
}
}
childM := config.NewMap(globals, blockNode)
var tlsVersions [2]uint16
childM.Custom("loader", false, false, func() (interface{}, error) {
return loader, nil
}, func(_ *config.Map, node config.Node) (interface{}, error) {
var l module.TLSLoader
err := modconfig.ModuleFromNode("tls.loader", node.Args, node, globals, &l)
return l, err
}, &loader)
childM.Custom("protocols", false, false, func() (interface{}, error) {
return [2]uint16{tls.VersionTLS10, 0}, nil
}, TLSVersionsDirective, &tlsVersions)
childM.Custom("ciphers", false, false, func() (interface{}, error) {
return nil, nil
}, TLSCiphersDirective, &baseCfg.CipherSuites)
childM.Custom("curves", false, false, func() (interface{}, error) {
return nil, nil
}, TLSCurvesDirective, &baseCfg.CurvePreferences)
if _, err := childM.Process(); err != nil {
return nil, err
}
baseCfg.MinVersion = tlsVersions[0]
baseCfg.MaxVersion = tlsVersions[1]
log.Debugf("tls: min version: %x, max version: %x", tlsVersions[0], tlsVersions[1])
return &TLSConfig{
loader: loader,
baseCfg: &baseCfg,
}, nil
}
================================================
FILE: framework/container/container.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package container
import (
"github.com/foxcpp/maddy/framework/log"
)
type GlobalConfig struct {
// StateDirectory contains the path to the directory that
// should be used to store any data that should be
// preserved between sessions.
//
// Value of this variable must not change after initialization
// in cmd/maddy/main.go.
StateDirectory string
// RuntimeDirectory contains the path to the directory that
// should be used to store any temporary data.
//
// It should be preferred over os.TempDir, which is
// global and world-readable on most systems, while
// RuntimeDirectory can be dedicated for maddy.
//
// Value of this variable must not change after initialization
// in cmd/maddy/main.go.
RuntimeDirectory string
// LibexecDirectory contains the path to the directory
// where helper binaries should be searched.
//
// Value of this variable must not change after initialization
// in cmd/maddy/main.go.
LibexecDirectory string
}
type C struct {
Config GlobalConfig
DefaultLogger *log.Logger
Modules *Registry
Lifetime *LifetimeTracker
}
func New() *C {
rootLog := log.DefaultLogger.Sublogger("")
return &C{
DefaultLogger: rootLog,
Modules: NewRegistry(rootLog.Sublogger("registry")),
Lifetime: NewLifetime(rootLog.Sublogger("lifetime")),
}
}
// Global is the default instance while refactoring is in progress.
var Global *C
================================================
FILE: framework/container/lifetime.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2025 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package container
import (
"fmt"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
)
// LifetimeModule is a stateful module that needs to have post-configuration
// startup and graceful shutdown functionality.
type LifetimeModule interface {
module.Module
Start() error
Stop() error
}
type ReloadModule interface {
module.Module
Reload() error
}
// EarlyStopModule is a LifetimeModule that needs to do some bookkeeping
// before new server instance starts during reload.
type EarlyStopModule interface {
LifetimeModule
EarlyStop() error
}
type LifetimeTracker struct {
logger *log.Logger
instances []*struct {
mod LifetimeModule
started bool
earlyStopped bool
}
}
func (lt *LifetimeTracker) Add(mod LifetimeModule) {
lt.instances = append(lt.instances, &struct {
mod LifetimeModule
started bool
earlyStopped bool
}{mod: mod, started: false})
}
// StartAll calls Start for all registered LifetimeModule instances.
func (lt *LifetimeTracker) StartAll() error {
for _, entry := range lt.instances {
if entry.started {
continue
}
lt.logger.DebugMsg("starting module",
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
if err := entry.mod.Start(); err != nil {
if err := lt.StopAll(); err != nil {
lt.logger.Error("StopAll failed after Start fail", err)
}
return fmt.Errorf("failed to start module %v: %w",
entry.mod.InstanceName(), err)
}
lt.logger.DebugMsg("module started",
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
entry.started = true
}
return nil
}
func (lt *LifetimeTracker) ReloadAll() error {
for _, entry := range lt.instances {
if !entry.started {
continue
}
rm, ok := entry.mod.(ReloadModule)
if !ok {
continue
}
if err := rm.Reload(); err != nil {
lt.logger.Error("module reload failed", err,
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
continue
}
lt.logger.DebugMsg("module reloaded",
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
}
return nil
}
func (lt *LifetimeTracker) EarlyStopAll() error {
for i := len(lt.instances) - 1; i >= 0; i-- {
entry := lt.instances[i]
if !entry.started {
continue
}
rsm, ok := entry.mod.(EarlyStopModule)
if !ok {
continue
}
if err := rsm.EarlyStop(); err != nil {
lt.logger.Error("module early stop failed", err,
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
continue
}
lt.logger.DebugMsg("module early stopped",
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
entry.earlyStopped = true
}
return nil
}
// StopAll calls Stop for all registered LifetimeModule instances.
func (lt *LifetimeTracker) StopAll() error {
for i := len(lt.instances) - 1; i >= 0; i-- {
entry := lt.instances[i]
if !entry.started {
continue
}
if err := entry.mod.Stop(); err != nil {
lt.logger.Error("module stop failed", err,
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
continue
}
lt.logger.DebugMsg("module stopped",
"mod_name", entry.mod.Name(), "inst_name", entry.mod.InstanceName())
entry.started = false
}
return nil
}
func NewLifetime(log *log.Logger) *LifetimeTracker {
return &LifetimeTracker{
logger: log,
}
}
================================================
FILE: framework/container/registry.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package container
import (
"errors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
)
var (
ErrInstanceNameDuplicate = errors.New("instance name already registered")
ErrInstanceUnknown = errors.New("no such instance registered")
)
type registryEntry struct {
Mod module.Module
LazyInit func() error
}
type Registry struct {
logger *log.Logger
instances map[string]registryEntry
initialized map[string]struct{}
started map[string]struct{}
aliases map[string]string
}
func NewRegistry(log *log.Logger) *Registry {
return &Registry{
logger: log,
instances: make(map[string]registryEntry),
initialized: make(map[string]struct{}),
started: make(map[string]struct{}),
aliases: make(map[string]string),
}
}
// Register adds not-initialized (configured) module into registry.
//
// lazyInit function will be called on first request to get the module from
// registry.
func (r *Registry) Register(mod module.Module, lazyInit func() error) error {
instName := mod.InstanceName()
if instName == "" {
panic("module with empty instance name cannot be added to the registry")
}
_, ok := r.instances[instName]
if ok {
return ErrInstanceNameDuplicate
}
r.instances[instName] = registryEntry{
Mod: mod,
LazyInit: lazyInit,
}
return nil
}
func (r *Registry) AddAlias(instanceName string, alias string) error {
if instanceName == "" {
panic("cannot add an alias for empty instance name")
}
if alias == "" {
panic("cannot add an empty alias")
}
_, ok := r.aliases[alias]
if ok {
return ErrInstanceNameDuplicate
}
_, ok = r.instances[instanceName]
if ok {
return ErrInstanceNameDuplicate
}
r.aliases[alias] = instanceName
return nil
}
func (r *Registry) ensureInitialized(name string, entry *registryEntry) error {
_, ok := r.initialized[name]
if ok {
return nil
}
if entry.LazyInit == nil {
return nil
}
r.logger.DebugMsg("module configure",
"mod_name", entry.Mod.Name(), "inst_name", entry.Mod.InstanceName())
r.initialized[name] = struct{}{}
err := entry.LazyInit()
if err != nil {
return err
}
return nil
}
func (r *Registry) Get(name string) (module.Module, error) {
if name == "" {
panic("cannot get module with empty name")
}
aliasedName := r.aliases[name]
if aliasedName != "" {
name = aliasedName
}
mod, ok := r.instances[name]
if !ok {
return nil, ErrInstanceUnknown
}
if err := r.ensureInitialized(name, &mod); err != nil {
return nil, err
}
return mod.Mod, nil
}
func (r *Registry) NotInitialized() []module.Module {
notinit := make([]module.Module, 0, len(r.instances)-len(r.initialized))
for name, mod := range r.instances {
if _, ok := r.initialized[name]; ok {
continue
}
notinit = append(notinit, mod.Mod)
}
return notinit
}
================================================
FILE: framework/dns/debugflags.go
================================================
//go:build debugflags
// +build debugflags
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
maddycli "github.com/foxcpp/maddy/internal/cli"
"github.com/urfave/cli/v2"
)
func init() {
maddycli.AddGlobalFlag(&cli.StringFlag{
Name: "debug.dnsoverride",
Usage: "replace the DNS resolver address",
Value: "system-default",
Destination: &overrideServ,
Action: func(context *cli.Context, s string) error {
if s != "" && s != "system-default" {
override(s)
}
overrideServ = s
return nil
},
})
}
================================================
FILE: framework/dns/dnssec.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
"context"
"net"
"strconv"
"strings"
"time"
"github.com/foxcpp/maddy/framework/log"
"github.com/miekg/dns"
)
type TLSA = dns.TLSA
// ExtResolver is a convenience wrapper for miekg/dns library that provides
// access to certain low-level functionality (notably, AD flag in responses,
// indicating whether DNSSEC verification was performed by the server).
type ExtResolver struct {
cl *dns.Client
Cfg *dns.ClientConfig
}
// RCodeError is returned by ExtResolver when the RCODE in response is not
// NOERROR.
type RCodeError struct {
Name string
Code int
}
func (err RCodeError) Temporary() bool {
return err.Code == dns.RcodeServerFailure
}
func (err RCodeError) Error() string {
switch err.Code {
case dns.RcodeFormatError:
return "dns: rcode FORMERR when looking up " + err.Name
case dns.RcodeServerFailure:
return "dns: rcode SERVFAIL when looking up " + err.Name
case dns.RcodeNameError:
return "dns: rcode NXDOMAIN when looking up " + err.Name
case dns.RcodeNotImplemented:
return "dns: rcode NOTIMP when looking up " + err.Name
case dns.RcodeRefused:
return "dns: rcode REFUSED when looking up " + err.Name
}
return "dns: non-success rcode: " + strconv.Itoa(err.Code) + " when looking up " + err.Name
}
func IsNotFound(err error) bool {
if dnsErr, ok := err.(*net.DNSError); ok {
return dnsErr.IsNotFound
}
if rcodeErr, ok := err.(RCodeError); ok {
return rcodeErr.Code == dns.RcodeNameError
}
return false
}
func isLoopback(addr string) bool {
ip := net.ParseIP(addr)
if ip == nil {
return false
}
return ip.IsLoopback()
}
func (e ExtResolver) exchange(ctx context.Context, msg *dns.Msg) (*dns.Msg, error) {
var resp *dns.Msg
var lastErr error
for _, srv := range e.Cfg.Servers {
resp, _, lastErr = e.cl.ExchangeContext(ctx, msg, net.JoinHostPort(srv, e.Cfg.Port))
if lastErr != nil {
continue
}
if resp.Rcode != dns.RcodeSuccess {
lastErr = RCodeError{msg.Question[0].Name, resp.Rcode}
continue
}
// Diregard AD flags from non-local resolvers, likely they are
// communicated with using an insecure channel and so flags can be
// tampered with.
if !isLoopback(srv) {
resp.AuthenticatedData = false
}
break
}
return resp, lastErr
}
func (e ExtResolver) AuthLookupAddr(ctx context.Context, addr string) (ad bool, names []string, err error) {
revAddr, err := dns.ReverseAddr(addr)
if err != nil {
return false, nil, err
}
msg := new(dns.Msg)
msg.SetQuestion(revAddr, dns.TypePTR)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err != nil {
return false, nil, err
}
ad = resp.AuthenticatedData
names = make([]string, 0, len(resp.Answer))
for _, rr := range resp.Answer {
ptrRR, ok := rr.(*dns.PTR)
if !ok {
continue
}
names = append(names, ptrRR.Ptr)
}
return
}
func (e ExtResolver) AuthLookupHost(ctx context.Context, host string) (ad bool, addrs []string, err error) {
ad, addrParsed, err := e.AuthLookupIPAddr(ctx, host)
if err != nil {
return false, nil, err
}
addrs = make([]string, 0, len(addrParsed))
for _, addr := range addrParsed {
addrs = append(addrs, addr.String())
}
return ad, addrs, nil
}
func (e ExtResolver) AuthLookupMX(ctx context.Context, name string) (ad bool, mxs []*net.MX, err error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), dns.TypeMX)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err != nil {
return false, nil, err
}
ad = resp.AuthenticatedData
mxs = make([]*net.MX, 0, len(resp.Answer))
for _, rr := range resp.Answer {
mxRR, ok := rr.(*dns.MX)
if !ok {
continue
}
mxs = append(mxs, &net.MX{
Host: mxRR.Mx,
Pref: mxRR.Preference,
})
}
return
}
func (e ExtResolver) AuthLookupTXT(ctx context.Context, name string) (ad bool, recs []string, err error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), dns.TypeTXT)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err != nil {
return false, nil, err
}
ad = resp.AuthenticatedData
recs = make([]string, 0, len(resp.Answer))
for _, rr := range resp.Answer {
txtRR, ok := rr.(*dns.TXT)
if !ok {
continue
}
recs = append(recs, strings.Join(txtRR.Txt, ""))
}
return
}
// CheckCNAMEAD is a special function for use in DANE lookups. It attempts to determine final
// (canonical) name of the host and also reports whether the whole chain of CNAME's and final zone
// are "secure".
//
// If there are no A or AAAA records for host, rname = "" is returned.
func (e ExtResolver) CheckCNAMEAD(ctx context.Context, host string) (ad bool, rname string, err error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(host), dns.TypeA)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err != nil {
return false, "", err
}
for _, r := range resp.Answer {
switch r := r.(type) {
case *dns.A:
rname = r.Hdr.Name
ad = resp.AuthenticatedData // Use AD flag from response we used to determine rname
}
}
if rname == "" {
// IPv6-only host? Try to find out rname using AAAA lookup.
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(host), dns.TypeAAAA)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err == nil {
for _, r := range resp.Answer {
switch r := r.(type) {
case *dns.AAAA:
rname = r.Hdr.Name
ad = resp.AuthenticatedData
}
}
}
}
return ad, rname, nil
}
func (e ExtResolver) AuthLookupCNAME(ctx context.Context, host string) (ad bool, cname string, err error) {
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(host), dns.TypeCNAME)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err != nil {
return false, "", err
}
for _, r := range resp.Answer {
cnameR, ok := r.(*dns.CNAME)
if !ok {
continue
}
return resp.AuthenticatedData, cnameR.Target, nil
}
return resp.AuthenticatedData, "", nil
}
func (e ExtResolver) AuthLookupIPAddr(ctx context.Context, host string) (ad bool, addrs []net.IPAddr, err error) {
// First, query IPv6.
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(host), dns.TypeAAAA)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
aaaaFailed := false
var (
v6ad bool
v6addrs []net.IPAddr
)
if err != nil {
// Disregard the error for AAAA lookups.
aaaaFailed = true
log.DefaultLogger.Error("Network I/O error during AAAA lookup", err, "host", host)
} else {
v6addrs = make([]net.IPAddr, 0, len(resp.Answer))
v6ad = resp.AuthenticatedData
for _, rr := range resp.Answer {
aaaaRR, ok := rr.(*dns.AAAA)
if !ok {
continue
}
v6addrs = append(v6addrs, net.IPAddr{IP: aaaaRR.AAAA})
}
}
// Then repeat query with IPv4.
msg = new(dns.Msg)
msg.SetQuestion(dns.Fqdn(host), dns.TypeA)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err = e.exchange(ctx, msg)
var (
v4ad bool
v4addrs []net.IPAddr
)
if err != nil {
if aaaaFailed {
return false, nil, err
}
// Disregard A lookup error if AAAA succeeded.
log.DefaultLogger.Error("Network I/O error during A lookup, using AAAA records", err, "host", host)
} else {
v4ad = resp.AuthenticatedData
v4addrs = make([]net.IPAddr, 0, len(resp.Answer))
for _, rr := range resp.Answer {
aRR, ok := rr.(*dns.A)
if !ok {
continue
}
v4addrs = append(v4addrs, net.IPAddr{IP: aRR.A})
}
}
// A little bit of careful handling is required if AD is inconsistent
// for A and AAAA queries. This unfortunatenly happens in practice. For
// purposes of DANE handling (A/AAAA check) we disregard AAAA records
// if they are not authenctiated and return only A records with AD=true.
addrs = make([]net.IPAddr, 0, len(v4addrs)+len(v6addrs))
if !v6ad && !v4ad {
addrs = append(addrs, v6addrs...)
addrs = append(addrs, v4addrs...)
} else {
if v6ad {
addrs = append(addrs, v6addrs...)
}
addrs = append(addrs, v4addrs...)
}
return v4ad, addrs, nil
}
func (e ExtResolver) AuthLookupTLSA(ctx context.Context, service, network, domain string) (ad bool, recs []TLSA, err error) {
name, err := dns.TLSAName(domain, service, network)
if err != nil {
return false, nil, err
}
msg := new(dns.Msg)
msg.SetQuestion(dns.Fqdn(name), dns.TypeTLSA)
msg.SetEdns0(4096, false)
msg.AuthenticatedData = true
resp, err := e.exchange(ctx, msg)
if err != nil {
return false, nil, err
}
ad = resp.AuthenticatedData
recs = make([]dns.TLSA, 0, len(resp.Answer))
for _, rr := range resp.Answer {
rr, ok := rr.(*dns.TLSA)
if !ok {
continue
}
recs = append(recs, *rr)
}
return
}
func NewExtResolver() (*ExtResolver, error) {
cfg, err := dns.ClientConfigFromFile("/etc/resolv.conf")
if err != nil {
return nil, err
}
if overrideServ != "" && overrideServ != "system-default" {
host, port, err := net.SplitHostPort(overrideServ)
if err != nil {
panic(err)
}
cfg.Servers = []string{host}
cfg.Port = port
}
if len(cfg.Servers) == 0 {
cfg.Servers = []string{"127.0.0.1"}
}
cl := new(dns.Client)
cl.Dialer = &net.Dialer{
Timeout: time.Duration(cfg.Timeout) * time.Second,
}
return &ExtResolver{
cl: cl,
Cfg: cfg,
}, nil
}
================================================
FILE: framework/dns/dnssec_test.go
================================================
package dns
import (
"context"
"fmt"
"net"
"reflect"
"strconv"
"testing"
"time"
"github.com/foxcpp/maddy/framework/log"
"github.com/miekg/dns"
"github.com/stretchr/testify/require"
)
type TestSrvAction int
const (
TestSrvTimeout TestSrvAction = iota
TestSrvServfail
TestSrvNoAddr
TestSrvOk
)
func (a TestSrvAction) String() string {
switch a {
case TestSrvTimeout:
return "SrvTimeout"
case TestSrvServfail:
return "SrvServfail"
case TestSrvNoAddr:
return "SrvNoAddr"
case TestSrvOk:
return "SrvOk"
default:
panic("wtf action")
}
}
type IPAddrTestServer struct {
udpServ dns.Server
aAction TestSrvAction
aAD bool
aaaaAction TestSrvAction
aaaaAD bool
}
func (s *IPAddrTestServer) Run() {
pconn, err := net.ListenPacket("udp4", "127.0.0.1:0")
if err != nil {
panic(err)
}
s.udpServ.PacketConn = pconn
s.udpServ.Handler = s
go s.udpServ.ActivateAndServe() //nolint:errcheck
}
func (s *IPAddrTestServer) Close() error {
return s.udpServ.PacketConn.Close()
}
func (s *IPAddrTestServer) Addr() *net.UDPAddr {
return s.udpServ.PacketConn.LocalAddr().(*net.UDPAddr)
}
func (s *IPAddrTestServer) ServeDNS(w dns.ResponseWriter, m *dns.Msg) {
q := m.Question[0]
var (
act TestSrvAction
ad bool
)
switch q.Qtype {
case dns.TypeA:
act = s.aAction
ad = s.aAD
case dns.TypeAAAA:
act = s.aaaaAction
ad = s.aaaaAD
default:
panic("wtf qtype")
}
reply := new(dns.Msg)
reply.SetReply(m)
reply.RecursionAvailable = true
reply.AuthenticatedData = ad
switch act {
case TestSrvTimeout:
return // no nobody heard from him since...
case TestSrvServfail:
reply.Rcode = dns.RcodeServerFailure
case TestSrvNoAddr:
case TestSrvOk:
switch q.Qtype {
case dns.TypeA:
reply.Answer = append(reply.Answer, &dns.A{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 9999,
},
A: net.ParseIP("127.0.0.1"),
})
case dns.TypeAAAA:
reply.Answer = append(reply.Answer, &dns.AAAA{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: 9999,
},
AAAA: net.ParseIP("::1"),
})
}
}
if err := w.WriteMsg(reply); err != nil {
panic(err)
}
}
func TestExtResolver_AuthLookupIPAddr(t *testing.T) {
// AuthLookupIPAddr has a rather convoluted logic for combined A/AAAA
// lookups that return the best-effort result and also has some nuanced in
// AD flag handling for use in DANE algorithms.
// Silence log messages about disregarded I/O errors.
oldLog := log.DefaultLogger
log.DefaultLogger = log.NopLogger
t.Cleanup(func() {
log.DefaultLogger = oldLog
})
test := func(aAct, aaaaAct TestSrvAction, aAD, aaaaAD, ad bool, addrs []net.IP, err bool) {
t.Helper()
t.Run(fmt.Sprintln(aAct, aaaaAct, aAD, aaaaAD), func(t *testing.T) {
t.Helper()
s := IPAddrTestServer{}
s.aAction = aAct
s.aaaaAction = aaaaAct
s.aAD = aAD
s.aaaaAD = aaaaAD
s.Run()
defer func() {
require.NoError(t, s.Close())
}()
res := ExtResolver{
cl: new(dns.Client),
Cfg: &dns.ClientConfig{
Servers: []string{"127.0.0.1"},
Port: strconv.Itoa(s.Addr().Port),
Timeout: 1,
},
}
res.cl.Dialer = &net.Dialer{
Timeout: 500 * time.Millisecond,
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
actualAd, actualAddrs, actualErr := res.AuthLookupIPAddr(ctx, "maddy.test")
if (actualErr != nil) != err {
t.Fatal("actualErr:", actualErr, "expectedErr:", err)
}
if actualAd != ad {
t.Error("actualAd:", actualAd, "expectedAd:", ad)
}
ipAddrs := make([]net.IPAddr, 0, len(addrs))
if len(addrs) == 0 {
ipAddrs = nil // lookup returns nil addrs for error cases
}
for _, a := range addrs {
ipAddrs = append(ipAddrs, net.IPAddr{IP: a, Zone: ""})
}
if !reflect.DeepEqual(actualAddrs, ipAddrs) {
t.Logf("actualAddrs: %#+v", actualAddrs)
t.Logf("addrs: %#+v", ipAddrs)
t.Fail()
}
})
}
test(TestSrvOk, TestSrvOk, true, true, true, []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvOk, TestSrvOk, true, false, true, []net.IP{net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvOk, TestSrvOk, false, true, false, []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvOk, TestSrvOk, false, false, false, []net.IP{net.ParseIP("::1"), net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvOk, TestSrvTimeout, true, true, true, []net.IP{net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvOk, TestSrvServfail, true, true, true, []net.IP{net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvOk, TestSrvNoAddr, true, true, true, []net.IP{net.ParseIP("127.0.0.1").To4()}, false)
test(TestSrvNoAddr, TestSrvOk, true, true, true, []net.IP{net.ParseIP("::1")}, false)
test(TestSrvServfail, TestSrvServfail, true, true, false, nil, true)
// actualAd is false, we don't want to risk reporting positive AD result if
// something is wrong with IPv4 lookup.
test(TestSrvTimeout, TestSrvOk, true, true, false, []net.IP{net.ParseIP("::1")}, false)
test(TestSrvServfail, TestSrvOk, true, true, false, []net.IP{net.ParseIP("::1")}, false)
}
================================================
FILE: framework/dns/idna.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
"golang.org/x/net/idna"
"golang.org/x/text/unicode/norm"
)
// SelectIDNA is a convenience function for encoding to/from Punycode.
//
// If ulabel is true, it returns U-label encoded domain in the Unicode NFC
// form.
// If ulabel is false, it returns A-label encoded domain.
func SelectIDNA(ulabel bool, domain string) (string, error) {
if ulabel {
uDomain, err := idna.ToUnicode(domain)
return norm.NFC.String(uDomain), err
}
return idna.ToASCII(domain)
}
================================================
FILE: framework/dns/norm.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
"strings"
"github.com/miekg/dns"
"golang.org/x/net/idna"
"golang.org/x/text/unicode/norm"
)
func FQDN(domain string) string {
return dns.Fqdn(domain)
}
// ForLookup converts the domain into a canonical form suitable for table
// lookups and other comparisons.
//
// TL;DR Use this instead of strings.ToLower to prepare domain for lookups.
//
// Domains that contain invalid UTF-8 or invalid A-label
// domains are simply converted to local-case using strings.ToLower, but the
// error is also returned.
func ForLookup(domain string) (string, error) {
uDomain, err := idna.ToUnicode(domain)
if err != nil {
return strings.ToLower(domain), err
}
// Side note: strings.ToLower does not support full case-folding, so it is
// important to apply NFC normalization first.
uDomain = norm.NFC.String(uDomain)
uDomain = strings.ToLower(uDomain)
uDomain = strings.TrimSuffix(uDomain, ".")
return uDomain, nil
}
// Equal reports whether domain1 and domain2 are equivalent as defined by
// IDNA2008 (RFC 5890).
//
// TL;DR Use this instead of strings.EqualFold to compare domains.
//
// Equivalence for malformed A-label domains is defined using regular
// byte-string comparison with case-folding applied.
func Equal(domain1, domain2 string) bool {
// Short circult. If they are bit-equivalent, then they are also semantically
// equivalent.
if domain1 == domain2 {
return true
}
uDomain1, _ := ForLookup(domain1)
uDomain2, _ := ForLookup(domain2)
return uDomain1 == uDomain2
}
================================================
FILE: framework/dns/override.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
"context"
"net"
"time"
)
var overrideServ string
// override globally overrides the used DNS server address with one provided.
// This function is meant only for testing. It should be called before any modules are
// initialized to have full effect.
//
// The server argument is in form of "IP:PORT". It is expected that the server
// will be available both using TCP and UDP on the same port.
func override(server string) { // nolint: unused // used in debugflags.go
net.DefaultResolver.PreferGo = true
net.DefaultResolver.Dial = func(ctx context.Context, network, _ string) (net.Conn, error) {
dialer := net.Dialer{
// This is localhost, it is either running or not. Fail quickly if
// we can't connect.
Timeout: 1 * time.Second,
}
switch network {
case "udp", "udp4", "udp6":
return dialer.DialContext(ctx, "udp4", server)
case "tcp", "tcp4", "tcp6":
return dialer.DialContext(ctx, "tcp4", server)
default:
panic("OverrideDNS.Dial: unknown network")
}
}
}
================================================
FILE: framework/dns/resolver.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package dns defines interfaces used by maddy modules to perform DNS
// lookups.
//
// Currently, there is only Resolver interface which is implemented
// by dns.DefaultResolver(). In the future, DNSSEC-enabled stub resolver
// implementation will be added here.
package dns
import (
"context"
"net"
"strings"
)
// Resolver is an interface that describes DNS-related methods used by maddy.
//
// It is implemented by dns.DefaultResolver(). Methods behave the same way.
type Resolver interface {
LookupAddr(ctx context.Context, addr string) (names []string, err error)
LookupHost(ctx context.Context, host string) (addrs []string, err error)
LookupMX(ctx context.Context, name string) ([]*net.MX, error)
LookupTXT(ctx context.Context, name string) ([]string, error)
LookupIPAddr(ctx context.Context, host string) ([]net.IPAddr, error)
}
// LookupAddr is a convenience wrapper for Resolver.LookupAddr.
//
// It returns the first name with trailing dot stripped.
func LookupAddr(ctx context.Context, r Resolver, ip net.IP) (string, error) {
names, err := r.LookupAddr(ctx, ip.String())
if err != nil || len(names) == 0 {
return "", err
}
return strings.TrimRight(names[0], "."), nil
}
func DefaultResolver() Resolver {
return net.DefaultResolver
}
================================================
FILE: framework/exterrors/dns.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package exterrors
import (
"net"
)
func UnwrapDNSErr(err error) (reason string, misc map[string]interface{}) {
dnsErr, ok := err.(*net.DNSError)
if !ok {
// Return non-nil in case the user will try to 'extend' it with its own
// values.
return "", map[string]interface{}{}
}
// Nor server name, nor DNS name are usually useful, so exclude them.
return dnsErr.Err, map[string]interface{}{}
}
================================================
FILE: framework/exterrors/exterrors.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package errors defines error-handling and primitives
// used across maddy, notably to pass additional error
// information across module boundaries.
package exterrors
================================================
FILE: framework/exterrors/fields.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package exterrors
type fieldsErr interface {
Fields() map[string]interface{}
}
type unwrapper interface {
Unwrap() error
}
type fieldsWrap struct {
err error
fields map[string]interface{}
}
func (fw fieldsWrap) Error() string {
return fw.err.Error()
}
func (fw fieldsWrap) Unwrap() error {
return fw.err
}
func (fw fieldsWrap) Fields() map[string]interface{} {
return fw.fields
}
func Fields(err error) map[string]interface{} {
fields := make(map[string]interface{}, 5)
for err != nil {
errFields, ok := err.(fieldsErr)
if ok {
for k, v := range errFields.Fields() {
// Outer errors override fields of the inner ones.
// Not the reverse.
if fields[k] != nil {
continue
}
fields[k] = v
}
}
unwrap, ok := err.(unwrapper)
if !ok {
break
}
err = unwrap.Unwrap()
}
return fields
}
func WithFields(err error, fields map[string]interface{}) error {
return fieldsWrap{err: err, fields: fields}
}
================================================
FILE: framework/exterrors/smtp.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package exterrors
import (
"fmt"
"github.com/emersion/go-smtp"
)
type EnhancedCode smtp.EnhancedCode
func (ec EnhancedCode) FormatLog() string {
return fmt.Sprintf("%d.%d.%d", ec[0], ec[1], ec[2])
}
// SMTPError type is a copy of emersion/go-smtp.SMTPError type
// that extends it with Fields method for logging and reporting
// in maddy. It should be used instead of the go-smtp library type for all
// errors.
type SMTPError struct {
// SMTP status code. Most of these codes are overly generic and are barely
// useful. Nonetheless, take a look at the 'Associated basic status code'
// in the SMTP Enhanced Status Codes registry (below), then check RFC 5321
// (Section 4.3.2) and pick what you like. Stick to 451 and 554 if there are
// no useful codes.
Code int
// Enhanced SMTP status code. If you are unsure, take a look at
// https://www.iana.org/assignments/smtp-enhanced-status-codes/smtp-enhanced-status-codes.xhtml
EnhancedCode EnhancedCode
// Error message that should be returned to the SMTP client.
// Usually, it should be a short and generic description of the error
// that excludes any details. Especially, for checks, avoid
// mentioning the exact policy mechanism used to avoid disclosing the
// server configuration details. Don't say "DNS error during DMARC check",
// say "DNS error during policy check". Same goes for network and file I/O
// errors. ESPECIALLY, don't include any configuration variables or object
// identifiers in it.
Message string
// If the error was generated by a message check
// this field includes module name.
CheckName string
// If the error was generated by a delivery target
// this field includes module name.
TargetName string
// If the error was generated by a message modifier
// this field includes module name.
ModifierName string
// If the error was generated as a result of another
// error - this field contains the original error object.
//
// Err.Error() will be copied into the 'reason' field returned
// by the Fields method unless a different values is specified
// using the Reason field below.
Err error
// Textual explanation of the actual error reason. Defaults to the
// Err.Error() value if Err is not nil, empty string otherwise.
Reason string
Misc map[string]interface{}
}
func (se *SMTPError) Unwrap() error {
return se.Err
}
func (se *SMTPError) Fields() map[string]interface{} {
ctx := make(map[string]interface{}, len(se.Misc)+3)
for k, v := range se.Misc {
ctx[k] = v
}
ctx["smtp_code"] = se.Code
ctx["smtp_enchcode"] = se.EnhancedCode
ctx["smtp_msg"] = se.Message
if se.CheckName != "" {
ctx["check"] = se.CheckName
}
if se.TargetName != "" {
ctx["target"] = se.TargetName
}
if se.Reason != "" {
ctx["reason"] = se.Reason
} else if se.Err != nil {
ctx["reason"] = se.Err.Error()
}
return ctx
}
// Temporary reports whether
func (se *SMTPError) Temporary() bool {
return se.Code/100 == 4
}
func (se *SMTPError) Error() string {
if se.Reason != "" {
return se.Reason
}
if se.Err != nil {
return se.Err.Error()
}
return se.Message
}
// SMTPCode is a convenience function that returns one of its arguments
// depending on the result of exterrors.IsTemporary for the specified error
// object.
func SMTPCode(err error, temporaryCode, permanentCode int) int {
if IsTemporary(err) {
return temporaryCode
}
return permanentCode
}
// SMTPEnchCode is a convenience function changes the first number of the SMTP enhanced
// status code based on the value exterrors.IsTemporary returns for the specified
// error object.
func SMTPEnchCode(err error, code EnhancedCode) EnhancedCode {
if IsTemporary(err) {
code[0] = 4
}
code[0] = 5
return code
}
================================================
FILE: framework/exterrors/temporary.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package exterrors
import (
"errors"
)
type TemporaryErr interface {
Temporary() bool
}
// IsTemporaryOrUnspec is similar to IsTemporary except that it returns true
// if error does not have a Temporary() method. Basically, it assumes that
// errors are temporary by default compared to IsTemporary that assumes
// errors are permanent by default.
func IsTemporaryOrUnspec(err error) bool {
var temp TemporaryErr
if errors.As(err, &temp) {
return temp.Temporary()
}
return true
}
// IsTemporary returns true whether the passed error object
// have a Temporary() method and it returns true.
func IsTemporary(err error) bool {
var temp TemporaryErr
if errors.As(err, &temp) {
return temp.Temporary()
}
return false
}
type temporaryErr struct {
err error
temp bool
}
func (t temporaryErr) Unwrap() error {
return t.err
}
func (t temporaryErr) Error() string {
return t.err.Error()
}
func (t temporaryErr) Temporary() bool {
return t.temp
}
// WithTemporary wraps the passed error object with the implementation of the
// Temporary() method that will return the specified value.
//
// Original error value can be obtained using errors.Unwrap.
func WithTemporary(err error, temporary bool) error {
return temporaryErr{err, temporary}
}
================================================
FILE: framework/future/future.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package future
import (
"context"
"runtime/debug"
"sync"
"github.com/foxcpp/maddy/framework/log"
)
// The Future object implements a container for (value, error) pair that "will
// be populated later" and allows multiple users to wait for it to be set.
//
// It should not be copied after first use.
type Future struct {
mu sync.RWMutex
set bool
val interface{}
err error
notify chan struct{}
}
func New() *Future {
return &Future{notify: make(chan struct{})}
}
// Set sets the Future (value, error) pair. All currently blocked and future
// Get calls will return it.
func (f *Future) Set(val interface{}, err error) {
if f == nil {
panic("nil future used")
}
f.mu.Lock()
defer f.mu.Unlock()
if f.set {
stack := debug.Stack()
log.Println("Future.Set called multiple times", stack)
log.Println("value=", val, "err=", err)
return
}
f.set = true
f.val = val
f.err = err
close(f.notify)
}
func (f *Future) Get() (interface{}, error) {
if f == nil {
panic("nil future used")
}
return f.GetContext(context.Background())
}
func (f *Future) GetContext(ctx context.Context) (interface{}, error) {
if f == nil {
panic("nil future used")
}
f.mu.RLock()
if f.set {
val := f.val
err := f.err
f.mu.RUnlock()
return val, err
}
f.mu.RUnlock()
select {
case <-f.notify:
case <-ctx.Done():
return nil, ctx.Err()
}
f.mu.RLock()
defer f.mu.RUnlock()
if !f.set {
panic("future: Notification received, but value is not set")
}
return f.val, f.err
}
================================================
FILE: framework/future/future_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package future
import (
"context"
"errors"
"testing"
"time"
)
func TestFuture_SetBeforeGet(t *testing.T) {
f := New()
f.Set(1, errors.New("1"))
val, err := f.Get()
if err.Error() != "1" {
t.Error("Wrong error:", err)
}
if val, _ := val.(int); val != 1 {
t.Fatal("wrong val received from Get")
}
}
func TestFuture_Wait(t *testing.T) {
f := New()
go func() {
time.Sleep(500 * time.Millisecond)
f.Set(1, errors.New("1"))
}()
val, err := f.Get()
if val, _ := val.(int); val != 1 {
t.Fatal("wrong val received from Get")
}
if err.Error() != "1" {
t.Error("Wrong error:", err)
}
val, err = f.Get()
if val, _ := val.(int); val != 1 {
t.Fatal("wrong val received from Get on second try")
}
if err.Error() != "1" {
t.Error("Wrong error:", err)
}
}
func TestFuture_WaitCtx(t *testing.T) {
f := New()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
_, err := f.GetContext(ctx)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("context is not cancelled")
}
}
================================================
FILE: framework/hooks/hooks.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package hooks
import "sync"
type Event int
const (
// EventShutdown is triggered when the server process is about to stop.
EventShutdown Event = iota
// EventReload is triggered when the server process receives the SIGUSR2
// signal (on POSIX platforms) and indicates the request to reload the
// server configuration from persistent storage.
//
// Since it is by design problematic to reload the modules configuration,
// this event only applies to secondary files such as aliases mapping and
// TLS certificates.
EventReload
// EventLogRotate is triggered when the server process receives the SIGUSR1
// signal (on POSIX platforms) and indicates the request to reopen used log
// files since they might have rotated.
EventLogRotate
)
var (
hooks = make(map[Event][]func())
hooksLck sync.Mutex
)
func hooksToRun(eventName Event) []func() {
hooksLck.Lock()
defer hooksLck.Unlock()
hooksEv := hooks[eventName]
if hooksEv == nil {
return nil
}
// The slice is copied so hooks can be run without holding the lock what
// might be important since they are likely to do a lot of I/O.
hooksEvCpy := make([]func(), 0, len(hooksEv))
hooksEvCpy = append(hooksEvCpy, hooksEv...)
return hooksEvCpy
}
// RunHooks runs the hooks installed for the specified eventName in the reverse
// order.
func RunHooks(eventName Event) {
hooks := hooksToRun(eventName)
for i := len(hooks) - 1; i >= 0; i-- {
hooks[i]()
}
}
// AddHook installs the hook to be executed when certain event occurs.
func AddHook(eventName Event, f func()) {
hooksLck.Lock()
defer hooksLck.Unlock()
hooks[eventName] = append(hooks[eventName], f)
}
================================================
FILE: framework/log/log.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package log implements a minimalistic logging library.
package log
import (
"fmt"
"io"
"os"
"strings"
"time"
"github.com/foxcpp/maddy/framework/exterrors"
"go.uber.org/zap"
)
// Logger is the structure that writes formatted output to the underlying
// log.Output object.
//
// Logger is stateless and can be copied freely. However, consider that
// underlying log.Output will not be copied.
//
// Each log message is prefixed with logger name. Timestamp and debug flag
// formatting is done by log.Output.
//
// No serialization is provided by Logger, its log.Output responsibility to
// ensure goroutine-safety if necessary.
type Logger struct {
Parent *Logger
Out Output
Name string
Debug bool
// Additional fields that will be added
// to the Msg output.
Fields map[string]interface{}
}
func (l *Logger) Zap() *zap.Logger {
// TODO: Migrate to using zap natively.
return zap.New(zapLogger{L: l})
}
func (l *Logger) IsDebug() bool {
return l.Debug || (l.Parent != nil && l.Parent.IsDebug())
}
func (l *Logger) Debugf(format string, val ...interface{}) {
if !l.IsDebug() {
return
}
l.log(true, l.formatMsg(fmt.Sprintf(format, val...), nil))
}
func (l *Logger) Debugln(val ...interface{}) {
if !l.IsDebug() {
return
}
l.log(true, l.formatMsg(strings.TrimRight(fmt.Sprintln(val...), "\n"), nil))
}
func (l *Logger) Printf(format string, val ...interface{}) {
l.log(false, l.formatMsg(fmt.Sprintf(format, val...), nil))
}
func (l *Logger) Println(val ...interface{}) {
l.log(false, l.formatMsg(strings.TrimRight(fmt.Sprintln(val...), "\n"), nil))
}
// Msg writes an event log message in a machine-readable format (currently
// JSON).
//
// name: msg\t{"key":"value","key2":"value2"}
//
// Key-value pairs are built from fields slice which should contain key strings
// followed by corresponding values. That is, for example, []interface{"key",
// "value", "key2", "value2"}.
//
// If value in fields implements Formatter, it will be represented by the
// string returned by FormatLog method. Same goes for fmt.Stringer and error
// interfaces.
//
// Additionally, time.Time is written as a string in ISO 8601 format.
// time.Duration follows fmt.Stringer rule above.
func (l *Logger) Msg(msg string, fields ...interface{}) {
m := make(map[string]interface{}, len(fields)/2)
fieldsToMap(fields, m)
l.log(false, l.formatMsg(msg, m))
}
// Error writes an event log message in a machine-readable format (currently
// JSON) containing information about the error. If err does have a Fields
// method that returns map[string]interface{}, its result will be added to the
// message.
//
// name: msg\t{"key":"value","key2":"value2"}
//
// Additionally, values from fields will be added to it, as handled by
// Logger.Msg.
//
// In the context of Error method, "msg" typically indicates the top-level
// context in which the error is *handled*. For example, if error leads to
// rejection of SMTP DATA command, msg will probably be "DATA error".
func (l *Logger) Error(msg string, err error, fields ...interface{}) {
if err == nil {
return
}
errFields := exterrors.Fields(err)
allFields := make(map[string]interface{}, len(fields)+len(errFields)+2)
for k, v := range errFields {
allFields[k] = v
}
// If there is already a 'reason' field - use it, it probably
// provides a better explanation than error text itself.
if allFields["reason"] == nil {
allFields["reason"] = err.Error()
}
fieldsToMap(fields, allFields)
l.log(false, l.formatMsg(msg, allFields))
}
func (l *Logger) DebugMsg(kind string, fields ...interface{}) {
if !l.IsDebug() {
return
}
m := make(map[string]interface{}, len(fields)/2)
fieldsToMap(fields, m)
l.log(true, l.formatMsg(kind, m))
}
func fieldsToMap(fields []interface{}, out map[string]interface{}) {
var lastKey string
for i, val := range fields {
if i%2 == 0 {
// Key
key, ok := val.(string)
if !ok {
// Misformatted arguments, attempt to provide useful message
// anyway.
out[fmt.Sprint("field", i)] = key
continue
}
lastKey = key
} else {
// Value
out[lastKey] = val
}
}
}
func (l *Logger) formatMsg(msg string, fields map[string]interface{}) string {
formatted := strings.Builder{}
formatted.WriteString(msg)
formatted.WriteRune('\t')
if len(l.Fields)+len(fields) != 0 {
if fields == nil {
fields = make(map[string]interface{})
}
for k, v := range l.Fields {
fields[k] = v
}
if err := marshalOrderedJSON(&formatted, fields); err != nil {
// Fallback to printing the message with minimal processing.
return fmt.Sprintf("[BROKEN FORMATTING: %v] %v %+v", err, msg, fields)
}
}
return formatted.String()
}
type Formatter interface {
FormatLog() string
}
// Write implements io.Writer, all bytes sent
// to it will be written as a separate log messages.
// No line-buffering is done.
func (l *Logger) Write(s []byte) (int, error) {
if !l.IsDebug() {
return len(s), nil
}
l.log(false, strings.TrimRight(string(s), "\n"))
return len(s), nil
}
// DebugWriter returns a writer that will act like Logger.Write
// but will use debug flag on messages. If Logger.Debug is false,
// Write method of returned object will be no-op.
func (l *Logger) DebugWriter() io.Writer {
l2 := l.Sublogger("")
l2.Debug = true
return l2
}
func (l *Logger) output() Output {
if l.Out != nil {
return l.Out
}
if l.Parent != nil {
return l.Parent.output()
}
if DefaultLogger.Out == nil {
panic("DefaultLogger.Out is not set")
}
if l.Parent == nil && l != &DefaultLogger {
DefaultLogger.Out.Write(time.Now(), true, "logger "+l.Name+" has no parent, this is a bug")
}
return DefaultLogger.Out
}
func (l *Logger) log(debug bool, s string) {
if l.Name != "" {
s = l.Name + ": " + s
}
out := l.output()
out.Write(time.Now(), debug, s)
// Logging is disabled - do nothing.
}
func (l *Logger) Sublogger(name string) *Logger {
if l.Name != "" && name != "" {
name = l.Name + "/" + name
}
return &Logger{
Parent: l,
Name: name,
}
}
// DefaultLogger is the global Logger object that is used by
// package-level logging functions.
//
// As with all other Loggers, it is not gorountine-safe on its own,
// however underlying log.Output may provide necessary serialization.
var DefaultLogger = Logger{Out: WriterOutput(os.Stderr, false)}
// NopLogger is the logger that discards all messages written to it.
var NopLogger = Logger{
Parent: &DefaultLogger,
Out: NopOutput{},
}
func Debugf(format string, val ...interface{}) { DefaultLogger.Debugf(format, val...) }
func Debugln(val ...interface{}) { DefaultLogger.Debugln(val...) }
func Printf(format string, val ...interface{}) { DefaultLogger.Printf(format, val...) }
func Println(val ...interface{}) { DefaultLogger.Println(val...) }
================================================
FILE: framework/log/orderedjson.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package log
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
// To support ad-hoc parsing in a better way we want to make order of fields in
// output JSON documents determistics. Additionally, this will make them more
// human-readable when values from multiple messages are lined up to each
// other.
type module interface {
Name() string
InstanceName() string
}
func marshalOrderedJSON(output *strings.Builder, m map[string]interface{}) error {
order := make([]string, 0, len(m))
for k := range m {
order = append(order, k)
}
sort.Strings(order)
output.WriteRune('{')
for i, key := range order {
if i != 0 {
output.WriteRune(',')
}
jsonKey, err := json.Marshal(key)
if err != nil {
return err
}
output.Write(jsonKey)
output.WriteString(":")
val := m[key]
switch casted := val.(type) {
case time.Time:
val = casted.Format("2006-01-02T15:04:05.000")
case time.Duration:
val = casted.String()
case Formatter:
val = casted.FormatLog()
case fmt.Stringer:
val = casted.String()
case module:
val = casted.Name() + "/" + casted.InstanceName()
case error:
val = casted.Error()
}
jsonValue, err := json.Marshal(val)
if err != nil {
return err
}
output.Write(jsonValue)
}
output.WriteRune('}')
return nil
}
================================================
FILE: framework/log/output.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package log
import (
"time"
)
type Output interface {
Write(stamp time.Time, debug bool, msg string)
Close() error
}
type multiOut struct {
outs []Output
}
func (m multiOut) Write(stamp time.Time, debug bool, msg string) {
for _, out := range m.outs {
out.Write(stamp, debug, msg)
}
}
func (m multiOut) Close() error {
for _, out := range m.outs {
if err := out.Close(); err != nil {
return err
}
}
return nil
}
func MultiOutput(outputs ...Output) Output {
return multiOut{outputs}
}
type funcOut struct {
out func(time.Time, bool, string)
close func() error
}
func (f funcOut) Write(stamp time.Time, debug bool, msg string) {
f.out(stamp, debug, msg)
}
func (f funcOut) Close() error {
return f.close()
}
func FuncOutput(f func(time.Time, bool, string), close func() error) Output {
return funcOut{f, close}
}
type NopOutput struct{}
func (NopOutput) Write(time.Time, bool, string) {}
func (NopOutput) Close() error { return nil }
================================================
FILE: framework/log/syslog.go
================================================
//go:build !windows && !plan9
// +build !windows,!plan9
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package log
import (
"fmt"
"log/syslog"
"os"
"time"
)
type syslogOut struct {
w *syslog.Writer
}
func (s syslogOut) Write(stamp time.Time, debug bool, msg string) {
var err error
if debug {
err = s.w.Debug(msg + "\n")
} else {
err = s.w.Info(msg + "\n")
}
if err != nil {
fmt.Fprintf(os.Stderr, "!!! Failed to send message to syslog daemon: %v\n", err)
}
}
func (s syslogOut) Close() error {
return s.w.Close()
}
// SyslogOutput returns a log.Output implementation that will send
// messages to the system syslog daemon.
//
// Regular messages will be written with INFO priority,
// debug messages will be written with DEBUG priority.
//
// Returned log.Output object is goroutine-safe.
func SyslogOutput() (Output, error) {
w, err := syslog.New(syslog.LOG_MAIL|syslog.LOG_INFO, "maddy")
return syslogOut{w}, err
}
================================================
FILE: framework/log/syslog_stub.go
================================================
//go:build windows || plan9
// +build windows plan9
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package log
import (
"errors"
)
// SyslogOutput returns a log.Output implementation that will send
// messages to the system syslog daemon.
//
// Regular messages will be written with INFO priority,
// debug messages will be written with DEBUG priority.
//
// Returned log.Output object is goroutine-safe.
func SyslogOutput() (Output, error) {
return nil, errors.New("log: syslog output is not supported on windows")
}
================================================
FILE: framework/log/writer.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package log
import (
"fmt"
"io"
"os"
"strings"
"time"
)
type wcOutput struct {
timestamps bool
wc io.WriteCloser
}
func (w wcOutput) Write(stamp time.Time, debug bool, msg string) {
builder := strings.Builder{}
if w.timestamps {
builder.WriteString(stamp.UTC().Format("2006-01-02T15:04:05.000Z "))
}
if debug {
builder.WriteString("[debug] ")
}
builder.WriteString(msg)
builder.WriteRune('\n')
if _, err := io.WriteString(w.wc, builder.String()); err != nil {
fmt.Fprintf(os.Stderr, "!!! Failed to write message to log: %v\n", err)
}
}
func (w wcOutput) Close() error {
return w.wc.Close()
}
// WriteCloserOutput returns a log.Output implementation that
// will write formatted messages to the provided io.Writer.
//
// Closing returned log.Output object will close the underlying
// io.WriteCloser.
//
// Written messages will include timestamp formatted with millisecond
// precision and [debug] prefix for debug messages.
// If timestamps argument is false, timestamps will not be added.
//
// Returned log.Output does not provide its own serialization
// so goroutine-safety depends on the io.Writer. Most operating
// systems have atomic (read: thread-safe) implementations for
// stream I/O, so it should be safe to use WriterOutput with os.File.
func WriteCloserOutput(wc io.WriteCloser, timestamps bool) Output {
return wcOutput{timestamps, wc}
}
type nopCloser struct {
io.Writer
}
func (nc nopCloser) Close() error {
return nil
}
// WriterOutput returns a log.Output implementation that
// will write formatted messages to the provided io.Writer.
//
// Closing returned log.Output object will have no effect on the
// underlying io.Writer.
//
// Written messages will include timestamp formatted with millisecond
// precision and [debug] prefix for debug messages.
// If timestamps argument is false, timestamps will not be added.
//
// Returned log.Output does not provide its own serialization
// so goroutine-safety depends on the io.Writer. Most operating
// systems have atomic (read: thread-safe) implementations for
// stream I/O, so it should be safe to use WriterOutput with os.File.
func WriterOutput(w io.Writer, timestamps bool) Output {
return wcOutput{timestamps, nopCloser{os.Stderr}}
}
================================================
FILE: framework/log/zap.go
================================================
package log
import (
"go.uber.org/zap/zapcore"
)
// TODO: Migrate to using actual zapcore to improve logging performance
type zapLogger struct {
L *Logger
}
func (l zapLogger) Enabled(level zapcore.Level) bool {
if l.L.Debug {
return true
}
return level > zapcore.DebugLevel
}
func (l zapLogger) With(fields []zapcore.Field) zapcore.Core {
enc := zapcore.NewMapObjectEncoder()
for _, f := range fields {
f.AddTo(enc)
}
newF := make(map[string]interface{}, len(l.L.Fields)+len(enc.Fields))
for k, v := range l.L.Fields {
newF[k] = v
}
for k, v := range enc.Fields {
newF[k] = v
}
l.L.Fields = newF
return l
}
func (l zapLogger) Check(entry zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry {
if l.Enabled(entry.Level) {
return ce.AddCore(entry, l)
}
return ce
}
func (l zapLogger) Write(entry zapcore.Entry, fields []zapcore.Field) error {
enc := zapcore.NewMapObjectEncoder()
for _, f := range fields {
f.AddTo(enc)
}
if entry.LoggerName != "" {
l.L.Name += "/" + entry.LoggerName
}
l.L.log(entry.Level == zapcore.DebugLevel, l.L.formatMsg(entry.Message, enc.Fields))
return nil
}
func (zapLogger) Sync() error {
return nil
}
================================================
FILE: framework/logparser/parse.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package parser provides utilities for parsing of structured log messsages
// generated by maddy.
package parser
import (
"encoding/json"
"strings"
"time"
"unicode"
)
type (
Msg struct {
Stamp time.Time
Debug bool
Module string
Message string
Context map[string]interface{}
}
MalformedMsg struct {
Desc string
Err error
}
)
const (
ISO8601_UTC = "2006-01-02T15:04:05.000Z"
)
func (m MalformedMsg) Error() string {
if m.Err != nil {
return "parse: " + m.Desc + ": " + m.Err.Error()
}
return "parse: " + m.Desc
}
// Parse parses the message from the maddy log file.
//
// It assumes standard file output, including the [debug] tag and
// ISO 8601 timestamp at the start of each line. Timestamp is assumed to be in
// the UTC, as it is enforced by maddy.
//
// JSON context values are unmarshalled without any additional processing,
// notably that means that all numbers are represented as float64.
func Parse(line string) (Msg, error) {
parts := strings.Split(line, "\t")
if len(parts) != 2 {
// All messages even without a Context have a trailing \t,
// so this one is obviously malformed.
return Msg{}, MalformedMsg{Desc: "missing a tab separator"}
}
m := Msg{
Context: map[string]interface{}{},
}
// After that, the second part is the context. It can be empty, so don't fail
// if there is none.
if len(parts[1]) != 0 {
if err := json.Unmarshal([]byte(parts[1]), &m.Context); err != nil {
return Msg{}, MalformedMsg{Desc: "context unmarshal", Err: err}
}
}
// Okay, the first one might contain the timestamp at start.
// Cut it away.
msgParts := strings.SplitN(parts[0], " ", 2)
if len(msgParts) == 1 {
return Msg{}, MalformedMsg{Desc: "missing a timestamp"}
}
var err error
m.Stamp, err = time.ParseInLocation(ISO8601_UTC, msgParts[0], time.UTC)
if err != nil {
return Msg{}, MalformedMsg{Desc: "timestamp parse", Err: err}
}
msgText := msgParts[1]
if strings.HasPrefix(msgText, "[debug] ") {
msgText = strings.TrimPrefix(msgText, "[debug] ")
m.Debug = true
}
moduleText := strings.SplitN(msgText, ": ", 2)
if len(moduleText) == 1 {
// No module prefix, that's fine.
m.Message = msgText
return m, nil
}
for _, ch := range moduleText[0] {
switch {
case unicode.IsDigit(ch), unicode.IsLetter(ch), ch == '/':
default:
// This is not a module prefix, don't treat it as such.
m.Message = msgText
return m, nil
}
}
m.Module = moduleText[0]
m.Message = moduleText[1]
return m, nil
}
================================================
FILE: framework/logparser/parse_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package parser
import (
"reflect"
"testing"
"time"
)
func TestParse(t *testing.T) {
test := func(line string, msg Msg, errDesc string) {
t.Helper()
parsed, err := Parse(line)
if errDesc != "" {
if err == nil {
t.Errorf("Expected an error, got none")
return
}
if err.(MalformedMsg).Desc != errDesc {
t.Errorf("Wrong error desc returned: %v", err.(MalformedMsg).Desc)
return
}
}
if errDesc == "" && err != nil {
t.Errorf("Unexpected error: %v", err)
return
}
if !reflect.DeepEqual(parsed, msg) {
t.Errorf("Wrong Parse result,\n got %#+v\n want %#+v", parsed, msg)
}
}
test("2006-01-02T15:04:05.000Z module: hello\t", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Module: "module",
Message: "hello",
Context: map[string]interface{}{},
}, "")
test("2006-01-02T15:04:05.000Z module: hello: whatever\t", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Module: "module",
Message: "hello: whatever",
Context: map[string]interface{}{},
}, "")
test("2006-01-02T15:04:05.000Z module: hello: whatever\t{\"a\":1}", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Module: "module",
Message: "hello: whatever",
Context: map[string]interface{}{
"a": float64(1),
},
}, "")
test("2006-01-02T15:04:05.000Z module: hello: whatever\t{\"a\":1,\"b\":\"bbb\"}", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Module: "module",
Message: "hello: whatever",
Context: map[string]interface{}{
"a": float64(1),
"b": "bbb",
},
}, "")
test("2006-01-02T15:04:05.000Z [debug] module: hello: whatever\t{\"a\":1,\"b\":\"bbb\"}", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Debug: true,
Module: "module",
Message: "hello: whatever",
Context: map[string]interface{}{
"a": float64(1),
"b": "bbb",
},
}, "")
test("2006-01-02T15:04:05.000Z [debug] oink oink: hello: whatever\t{\"a\":1,\"b\":\"bbb\"}", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Debug: true,
Message: "oink oink: hello: whatever",
Context: map[string]interface{}{
"a": float64(1),
"b": "bbb",
},
}, "")
test("2006-01-02T15:04:05.000Z [debug] whatever\t{\"a\":1,\"b\":\"bbb\"}", Msg{
Stamp: time.Date(2006, time.January, 2, 15, 4, 5, 0, time.UTC),
Debug: true,
Message: "whatever",
Context: map[string]interface{}{
"a": float64(1),
"b": "bbb",
},
}, "")
test("module: hello\t", Msg{}, "timestamp parse")
test("hello\t", Msg{}, "missing a timestamp")
test("2006-01-02T15:04:05.000Z module: hello", Msg{}, "missing a tab separator")
test("2006-01-02T15:04:05.000Z [BROKEN FORMATTING: json: wtf lol omg]: hello map[stringasdasd]", Msg{}, "missing a tab separator")
}
================================================
FILE: framework/module/auth.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import "errors"
// ErrUnknownCredentials should be returned by auth. provider if supplied
// credentials are valid for it but are not recognized (e.g. not found in
// used DB).
var ErrUnknownCredentials = errors.New("unknown credentials")
// PlainAuth is the interface implemented by modules providing authentication using
// username:password pairs.
//
// Modules implementing this interface should be registered with "auth." prefix in name.
type PlainAuth interface {
AuthPlain(username, password string) error
}
// PlainUserDB is a local credentials store that can be managed using maddy command
// utility.
type PlainUserDB interface {
PlainAuth
ListUsers() ([]string, error)
CreateUser(username, password string) error
SetUserPassword(username, password string) error
DeleteUser(username string) error
}
================================================
FILE: framework/module/blob_store.go
================================================
package module
import (
"context"
"errors"
"io"
)
type Blob interface {
Sync() error
io.Writer
io.Closer
}
var ErrNoSuchBlob = errors.New("blob_store: no such object")
const UnknownBlobSize int64 = -1
// BlobStore is the interface used by modules providing large binary object
// storage.
type BlobStore interface {
// Create creates a new blob for writing.
//
// Sync will be called on the returned Blob object after -all- data has
// been successfully written.
//
// Close without Sync can be assumed to happen due to an unrelated error
// and stored data can be discarded.
//
// blobSize indicates the exact amount of bytes that will be written
// If -1 is passed - it is unknown and implementation will not make
// any assumptions about the blob size. Error can be returned by any
// Blob method if more than than blobSize bytes get written.
//
// Passed context will cover the entire blob write operation.
Create(ctx context.Context, key string, blobSize int64) (Blob, error)
// Open returns the reader for the object specified by
// passed key.
//
// If no such object exists - ErrNoSuchBlob is returned.
Open(ctx context.Context, key string) (io.ReadCloser, error)
// Delete removes a set of keys from store. Non-existent keys are ignored.
Delete(ctx context.Context, keys []string) error
}
================================================
FILE: framework/module/check.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"context"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/foxcpp/maddy/framework/buffer"
)
// Check is the module interface that is meant for read-only (with the
// exception of the message header modifications) (meta-)data checking.
//
// Modules implementing this interface should be registered with "check."
// prefix in name.
type Check interface {
// CheckStateForMsg initializes the "internal" check state required for
// processing of the new message.
//
// NOTE: Returned CheckState object must be hashable (usable as a map key).
// This is used to deduplicate Check* calls, the easiest way to achieve
// this is to have CheckState as a pointer to some struct, all pointers
// are hashable.
CheckStateForMsg(ctx context.Context, msgMeta *MsgMetadata) (CheckState, error)
}
// EarlyCheck is an optional module interface that can be implemented
// by module implementing Check.
//
// It is used as an optimization to reject obviously malicious connections
// before allocating resources for SMTP session.
//
// The Status of this check is accept (no error) or reject (error) only, no
// advanced handling is available (such as 'quarantine' action and headers
// prepending).
//
// If it s necessary to defer or affect further message processing
// without outright killing the session, ConnState.ModData can be
// used to store necessary information.
//
// It may be called multiple times for the same connection if TLS is negotiated
// via STARTTLS. In this case, no state will be passed between before-TLS
// context to the TLS one.
type EarlyCheck interface {
CheckConnection(ctx context.Context, state *ConnState) error
}
type CheckState interface {
// CheckConnection is executed once when client sends a new message.
CheckConnection(ctx context.Context) CheckResult
// CheckSender is executed once when client sends the message sender
// information (e.g. on the MAIL FROM command).
CheckSender(ctx context.Context, mailFrom string) CheckResult
// CheckRcpt is executed for each recipient when its address is received
// from the client (e.g. on the RCPT TO command).
CheckRcpt(ctx context.Context, rcptTo string) CheckResult
// CheckBody is executed once after the message body is received and
// buffered in memory or on disk.
//
// Check code should use passed mutex when working with the message header.
// Body can be read without locking it since it is read-only.
CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) CheckResult
// Close is called after the message processing ends, even if any of the
// Check* functions return an error.
Close() error
}
type CheckResult struct {
// Reason is the error that is reported to the message source
// if check decided that the message should be rejected.
Reason error
// Reject is the flag that specifies that the message
// should be rejected.
Reject bool
// Quarantine is the flag that specifies that the message
// is considered "possibly malicious" and should be
// put into Junk mailbox.
//
// This value is copied into MsgMetadata by the msgpipeline.
Quarantine bool
// AuthResult is the information that is supposed to
// be included in Authentication-Results header.
AuthResult []authres.Result
// Header is the header fields that should be
// added to the header after all checks.
Header textproto.Header
}
================================================
FILE: framework/module/delivery_target.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"context"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/buffer"
)
// DeliveryTarget interface represents abstract storage for the message data
// (typically persistent) or other kind of component that can be used as a
// final destination for the message.
//
// Modules implementing this interface should be registered with "target."
// prefix in name.
type DeliveryTarget interface {
// StartDelivery starts the delivery of a new message.
//
// The domain part of the MAIL FROM address is assumed to be U-labels with
// NFC normalization and case-folding applied. The message source should
// ensure that by calling address.CleanDomain if necessary.
StartDelivery(ctx context.Context, msgMeta *MsgMetadata, mailFrom string) (Delivery, error)
}
type Delivery interface {
// AddRcpt adds the target address for the message.
//
// The domain part of the address is assumed to be U-labels with NFC normalization
// and case-folding applied. The message source should ensure that by
// calling address.CleanDomain if necessary.
//
// Implementation should assume that no case-folding or deduplication was
// done by caller code. Its implementation responsibility to do so if it is
// necessary. It is not recommended to reject duplicated recipients,
// however. They should be silently ignored.
//
// Implementation should do as much checks as possible here and reject
// recipients that can't be used. Note: MsgMetadata object passed to StartDelivery
// contains BodyLength field. If it is non-zero, it can be used to check
// storage quota for the user before Body.
AddRcpt(ctx context.Context, rcptTo string, opts smtp.RcptOptions) error
// Body sets the body and header contents for the message.
// If this method fails, message is assumed to be undeliverable
// to all recipients.
//
// Implementation should avoid doing any persistent changes to the
// underlying storage until Commit is called. If that is not possible,
// Abort should (attempt to) rollback any such changes.
//
// If Body can't be implemented without per-recipient failures,
// then delivery object should also implement PartialDelivery interface
// for use by message sources that are able to make sense of per-recipient
// errors.
//
// Here is the example of possible implementation for maildir-based
// storage:
// Calling Body creates a file in tmp/ directory.
// Commit moves the created file to new/ directory.
// Abort removes the created file.
Body(ctx context.Context, header textproto.Header, body buffer.Buffer) error
// Abort cancels message delivery.
//
// All changes made to the underlying storage should be aborted at this
// point, if possible.
Abort(ctx context.Context) error
// Commit completes message delivery.
//
// It generally should never fail, since failures here jeopardize
// atomicity of the delivery if multiple targets are used.
Commit(ctx context.Context) error
}
================================================
FILE: framework/module/imap_filter.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
)
// IMAPFilter is interface used by modules that want to modify IMAP-specific message
// attributes on delivery.
//
// Modules implementing this interface should be registered with namespace prefix
// "imap.filter".
type IMAPFilter interface {
// IMAPFilter is called when message is about to be stored in IMAP-compatible
// storage. It is called only for messages delivered over SMTP, hdr and body
// contain the message exactly how it will be stored.
//
// Filter can change the target directory by returning non-empty folder value.
// Additionally it can add additional IMAP flags to the message by returning
// them.
//
// Errors returned by IMAPFilter will be just logged and will not cause delivery
// to fail.
IMAPFilter(accountName string, rcptTo string, meta *MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error)
}
================================================
FILE: framework/module/modifier.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"context"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
)
// Modifier is the module interface for modules that can mutate the
// processed message or its meta-data.
//
// Currently, the message body can't be mutated for efficiency and
// correctness reasons: It would require "rebuffering" (see buffer.Buffer doc),
// can invalidate assertions made on the body contents before modification and
// will break DKIM signatures.
//
// Only message header can be modified. Furthermore, it is highly discouraged for
// modifiers to remove or change existing fields to prevent issues outlined
// above.
//
// Calls on ModifierState are always strictly ordered.
// RewriteRcpt is newer called before RewriteSender and RewriteBody is never called
// before RewriteRcpts. This allows modificator code to save values
// passed to previous calls for use in later operations.
//
// Modules implementing this interface should be registered with "modify." prefix in name.
type Modifier interface {
// ModStateForMsg initializes modifier "internal" state
// required for processing of the message.
ModStateForMsg(ctx context.Context, msgMeta *MsgMetadata) (ModifierState, error)
}
type ModifierState interface {
// RewriteSender allows modifier to replace MAIL FROM value.
// If no changes are required, this method returns its
// argument, otherwise it returns a new value.
//
// Note that per-source/per-destination modifiers are executed
// after routing decision is made so changed value will have no
// effect on it.
//
// Also note that MsgMeta.OriginalFrom will still contain the original value
// for purposes of tracing. It should not be modified by this method.
RewriteSender(ctx context.Context, mailFrom string) (string, error)
// RewriteRcpt replaces RCPT TO value.
// If no changed are required, this method returns its argument as slice,
// otherwise it returns a slice with 1 or more new values.
//
// MsgPipeline will take of populating MsgMeta.OriginalRcpts. RewriteRcpt
// doesn't do it.
RewriteRcpt(ctx context.Context, rcptTo string) ([]string, error)
// RewriteBody modifies passed Header argument and may optionally
// inspect the passed body buffer to make a decision on new header field values.
//
// There is no way to modify the body and RewriteBody should avoid
// removing existing header fields and changing their values.
RewriteBody(ctx context.Context, h *textproto.Header, body buffer.Buffer) error
// Close is called after the message processing ends, even if any of the
// Rewrite* functions return an error.
Close() error
}
================================================
FILE: framework/module/module.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package module contains modules registry and interfaces implemented
// by modules.
//
// Interfaces are placed here to prevent circular dependencies.
//
// Each interface required by maddy for operation is provided by some object
// called "module". This includes authentication, storage backends, DKIM,
// email filters, etc. Each module may serve multiple functions. I.e. it can
// be IMAP storage backend, SMTP downstream and authentication provider at the
// same moment.
//
// Each module gets its own unique name (sql for go-imap-sql, proxy for
// proxy module, local for local delivery perhaps, etc). Each module instance
// also can have its own unique name can be used to refer to it in
// configuration.
package module
import (
"github.com/foxcpp/maddy/framework/config"
)
// Module is the interface implemented by all maddy module instances.
type Module interface {
Configure(inlineArgs []string, config *config.Map) error
// Name method reports module name.
//
// It is used to reference module in the configuration and in logs.
Name() string
// InstanceName method reports unique name of this module instance or empty
// string if module instance is unnamed.
InstanceName() string
}
================================================
FILE: framework/module/module_specific_data.go
================================================
package module
import (
"encoding/json"
"fmt"
"sync"
)
// ModSpecificData is a container that allows modules to attach
// additional context data to framework objects such as SMTP connections
// without conflicting with each other and ensuring each module
// gets its own namespace.
//
// It must not be used to store stateful objects that may need
// a specific cleanup routine as ModSpecificData does not provide
// any lifetime management.
//
// Stored data must be serializable to JSON for state persistence
// e.g. when message is stored in a on-disk queue.
type ModSpecificData struct {
modDataLck sync.RWMutex
modData map[string]interface{}
}
func (msd *ModSpecificData) modKey(m Module, perInstance bool) string {
if !perInstance {
return m.Name()
}
instName := m.InstanceName()
if instName == "" {
instName = fmt.Sprintf("%x", m)
}
return m.Name() + "/" + instName
}
func (msd *ModSpecificData) MarshalJSON() ([]byte, error) {
msd.modDataLck.RLock()
defer msd.modDataLck.RUnlock()
return json.Marshal(msd.modData)
}
func (msd *ModSpecificData) UnmarshalJSON(b []byte) error {
msd.modDataLck.Lock()
defer msd.modDataLck.Unlock()
return json.Unmarshal(b, &msd.modData)
}
func (msd *ModSpecificData) Set(m Module, perInstance bool, value interface{}) {
key := msd.modKey(m, perInstance)
msd.modDataLck.Lock()
defer msd.modDataLck.Unlock()
if msd.modData == nil {
msd.modData = make(map[string]interface{})
}
msd.modData[key] = value
}
func (msd *ModSpecificData) Get(m Module, perInstance bool) interface{} {
key := msd.modKey(m, perInstance)
msd.modDataLck.RLock()
defer msd.modDataLck.RUnlock()
return msd.modData[key]
}
================================================
FILE: framework/module/modules/dummy.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package modules
import (
"context"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
)
// Dummy is a struct that implements PlainAuth and DeliveryTarget
// interfaces but does nothing. Useful for testing.
//
// It is always registered under the 'dummy' name and can be used in both tests
// and the actual server code (but the latter is kinda pointless).
type Dummy struct{ instName string }
func (d *Dummy) AuthPlain(username, _ string) error {
return nil
}
func (d *Dummy) Lookup(_ context.Context, _ string) (string, bool, error) {
return "", false, nil
}
func (d *Dummy) LookupMulti(_ context.Context, _ string) ([]string, error) {
return []string{""}, nil
}
func (d *Dummy) Name() string {
return "dummy"
}
func (d *Dummy) InstanceName() string {
return d.instName
}
func (d *Dummy) Configure(_ []string, _ *config.Map) error {
return nil
}
func (d *Dummy) StartDelivery(ctx context.Context, msgMeta *module.MsgMetadata, mailFrom string) (module.Delivery, error) {
return dummyDelivery{}, nil
}
type dummyDelivery struct{}
func (dd dummyDelivery) AddRcpt(ctx context.Context, rcptTo string, opts smtp.RcptOptions) error {
return nil
}
func (dd dummyDelivery) Body(ctx context.Context, header textproto.Header, body buffer.Buffer) error {
return nil
}
func (dd dummyDelivery) Abort(ctx context.Context) error {
return nil
}
func (dd dummyDelivery) Commit(ctx context.Context) error {
return nil
}
func NewDummy(_ *container.C, _, instName string) (module.Module, error) {
return &Dummy{instName: instName}, nil
}
================================================
FILE: framework/module/modules/modules.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package modules
import (
"sync"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
)
// FuncNewModule is function that creates new instance of module with specified name.
//
// Module.InstanceName() of the returned module object should return instName.
// If module is defined inline, instName will be empty.
//
// Returned Module may additionally implement LifetimeModule.
type FuncNewModule func(c *container.C, modName, instName string) (module.Module, error)
// FuncNewEndpoint is a function that creates new instance of endpoint
// module.
//
// Compared to regular modules, endpoint module instances are:
// - Not registered in the global registry.
// - Can't be defined inline.
// - Don't have an unique name
// - All config arguments are always passed as an 'addrs' slice and not used as
// names.
//
// As a consequence of having no per-instance name, InstanceName of the module
// object always returns the same value as Name.
type FuncNewEndpoint func(c *container.C, modName string, addrs []string) (container.LifetimeModule, error)
var (
modules = make(map[string]FuncNewModule)
endpoints = make(map[string]FuncNewEndpoint)
modulesLock sync.RWMutex
)
// Register adds module factory function to global registry.
//
// name must be unique. Register will panic if module with specified name
// already exists in registry.
//
// You probably want to call this function from func init() of module package.
func Register(name string, factory FuncNewModule) {
modulesLock.Lock()
defer modulesLock.Unlock()
if _, ok := modules[name]; ok {
panic("Register: module with specified name is already registered: " + name)
}
modules[name] = factory
}
// RegisterDeprecated adds module factory function to global registry.
//
// It prints warning to the log about name being deprecated and suggests using
// a new name.
func RegisterDeprecated(name, newName string, factory FuncNewModule) {
Register(name, func(c *container.C, modName, instName string) (module.Module, error) {
log.Printf("module initialized via deprecated name %s, %s should be used instead; deprecated name may be removed in the next version", name, newName)
return factory(c, modName, instName)
})
}
// Get returns module from global registry.
//
// This function does not return endpoint-type modules, use GetEndpoint for
// that.
// Nil is returned if no module with specified name is registered.
func Get(name string) FuncNewModule {
modulesLock.RLock()
defer modulesLock.RUnlock()
return modules[name]
}
// GetEndpoint returns an endpoint module from global registry.
//
// Nil is returned if no module with specified name is registered.
func GetEndpoint(name string) FuncNewEndpoint {
modulesLock.RLock()
defer modulesLock.RUnlock()
return endpoints[name]
}
// RegisterEndpoint registers an endpoint module.
//
// See FuncNewEndpoint for information about
// differences of endpoint modules from regular modules.
func RegisterEndpoint(name string, factory FuncNewEndpoint) {
modulesLock.Lock()
defer modulesLock.Unlock()
if _, ok := endpoints[name]; ok {
panic("Register: module with specified name is already registered: " + name)
}
endpoints[name] = factory
}
func init() {
Register("dummy", NewDummy)
}
================================================
FILE: framework/module/msgmetadata.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"io"
"net"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/future"
)
// ConnState structure holds the state information of the protocol used to
// accept this message.
type ConnState struct {
// IANA name (ESMTP, ESMTPS, etc) of the protocol message was received
// over. If the message was generated locally, this field is empty.
Proto string
// Information about the SMTP connection, including HELO hostname and
// source IP. Valid only if Proto refers the SMTP protocol or its variant
// (e.g. LMTP).
Hostname string
LocalAddr net.Addr
RemoteAddr net.Addr
TLS tls.ConnectionState
// The RDNSName field contains the result of Reverse DNS lookup on the
// client IP.
//
// The underlying type is the string or untyped nil value. It is the
// message source responsibility to populate this field.
//
// Valid values of this field consumers need to be aware of:
// RDNSName = nil
// The reverse DNS lookup is not applicable for that message source.
// Typically the case for messages generated locally.
// RDNSName != nil, but Get returns nil
// The reverse DNS lookup was attempted, but resulted in an error.
// Consumers should assume that the PTR record doesn't exist.
RDNSName *future.Future
// If the client successfully authenticated using a username/password pair.
// This field contains the username.
AuthUser string
// If the client successfully authenticated using a username/password pair.
// This field should be cleaned if the ConnState object is serialized
AuthPassword string
ModData ModSpecificData
}
// MsgMetadata structure contains all information about the origin of
// the message and all associated flags indicating how it should be handled
// by components.
//
// All fields should be considered read-only except when otherwise is noted.
// Module instances should avoid keeping reference to the instance passed to it
// and copy the structure using DeepCopy method instead.
//
// Compatibility with older values should be considered when changing this
// structure since it is serialized to the disk by the queue module using
// JSON. Modules should correctly handle missing or invalid values.
type MsgMetadata struct {
// Unique identifier for this message. Randomly generated by the
// message source module.
ID string
// Original message sender address as it was received by the message source.
//
// Note that this field is meant for use for tracing purposes.
// All routing and other decisions should be made based on the sender address
// passed separately (for example, mailFrom argument for CheckSender function)
// Note that addresses may contain unescaped Unicode characters.
OriginalFrom string
// If set - no SrcHostname and SrcAddr will be added to Received
// header. These fields are still written to the server log.
DontTraceSender bool
// Quarantine is a message flag that is should be set if message is
// considered "suspicious" and should be put into "Junk" folder
// in the storage.
//
// This field should not be modified by the checks that verify
// the message. It is set only by the message pipeline.
Quarantine bool
// OriginalRcpts contains the mapping from the final recipient to the
// recipient that was presented by the client.
//
// MsgPipeline will update that field when recipient modifiers
// are executed.
//
// It should be used when reporting information back to client (via DSN,
// for example) to prevent disclosing information about aliases
// which is usually unwanted.
OriginalRcpts map[string]string
// SMTPOpts contains the SMTP MAIL FROM command arguments, if the message
// was accepted over SMTP or SMTP-like protocol (such as LMTP).
//
// Note that the Size field should not be used as source of information about
// the body size. Especially since it counts the header too whereas
// Buffer.Len does not.
SMTPOpts smtp.MailOptions
// Conn contains the information about the underlying protocol connection
// that was used to accept this message. The referenced instance may be shared
// between multiple messages.
//
// It can be nil for locally generated messages.
Conn *ConnState
// This is set by endpoint/smtp to indicate that body contains "TLS-Required: No"
// header. It is only meaningful if server has seen the body at least once
// (e.g. the message was passed via queue).
TLSRequireOverride bool
}
// DeepCopy creates a copy of the MsgMetadata structure, also
// copying contents of the maps and slices.
//
// There are a few exceptions, however:
// - SrcAddr is not copied and copy field references original value.
func (msgMeta *MsgMetadata) DeepCopy() *MsgMetadata {
cpy := *msgMeta
// There is no good way to copy net.Addr, but it should not be
// modified by anything anyway so we are safe.
return &cpy
}
// GenerateMsgID generates a string usable as MsgID field in module.MsgMeta.
func GenerateMsgID() (string, error) {
rawID := make([]byte, 4)
_, err := io.ReadFull(rand.Reader, rawID)
return hex.EncodeToString(rawID), err
}
================================================
FILE: framework/module/mxauth.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"context"
"crypto/tls"
)
const (
AuthDisabled = "off"
AuthMTASTS = "mtasts"
AuthDNSSEC = "dnssec"
AuthCommonDomain = "common_domain"
)
type (
TLSLevel int
MXLevel int
)
const (
TLSNone TLSLevel = iota
TLSEncrypted
TLSAuthenticated
)
const (
MXNone MXLevel = iota
MX_MTASTS
MX_DNSSEC
)
func (l TLSLevel) String() string {
switch l {
case TLSNone:
return "none"
case TLSEncrypted:
return "encrypted"
case TLSAuthenticated:
return "authenticated"
}
return "???"
}
func (l MXLevel) String() string {
switch l {
case MXNone:
return "none"
case MX_MTASTS:
return "mtasts"
case MX_DNSSEC:
return "dnssec"
}
return "???"
}
type (
// MXAuthPolicy is an object that provides security check for outbound connections.
// It can do one of the following:
//
// - Check effective TLS level or MX level against some configured or
// discovered value.
// E.g. local policy.
//
// - Raise the security level if certain condition about used MX or
// connection is met.
// E.g. DANE MXAuthPolicy raises TLS level to Authenticated if a matching
// TLSA record is discovered.
//
// - Reject the connection if certain condition about used MX or
// connection is _not_ met.
// E.g. An enforced MTA-STS MXAuthPolicy rejects MX records not matching it.
//
// It is not recommended to mix different types of behavior described above
// in the same implementation.
// Specifically, the first type is used mostly for local policies and is not
// really practical.
//
// Modules implementing this interface should be registered with "mx_auth."
// prefix in name.
MXAuthPolicy interface {
StartDelivery(*MsgMetadata) DeliveryMXAuthPolicy
// Weight is an integer in range 0-1000 that represents relative
// ordering of policy application.
Weight() int
}
// DeliveryMXAuthPolicy is an interface of per-delivery object that estabilishes
// and verifies required and effective security for MX records and TLS
// connections.
DeliveryMXAuthPolicy interface {
// PrepareDomain is called before DNS MX lookup and may asynchronously
// start additional lookups necessary for policy application in CheckMX
// or CheckConn.
//
// If there any errors - they should be deferred to the CheckMX or
// CheckConn call.
PrepareDomain(ctx context.Context, domain string)
// PrepareConn is called before connection and may asynchronously
// start additional lookups necessary for policy application in
// CheckConn.
//
// If there are any errors - they should be deferred to the CheckConn
// call.
PrepareConn(ctx context.Context, mx string)
// CheckMX is called to check whether the policy permits to use a MX.
//
// mxLevel contains the MX security level estabilished by checks
// executed before.
//
// domain is passed to the CheckMX to allow simpler implementation
// of stateless policy objects.
//
// dnssec is true if the MX lookup was performed using DNSSEC-enabled
// resolver and the zone is signed and its signature is valid.
CheckMX(ctx context.Context, mxLevel MXLevel, domain, mx string, dnssec bool) (MXLevel, error)
// CheckConn is called to check whether the policy permits to use this
// connection.
//
// tlsLevel and mxLevel contain the TLS security level estabilished by
// checks executed before.
//
// domain is passed to the CheckConn to allow simpler implementation
// of stateless policy objects.
//
// If tlsState.HandshakeCompleted is false, TLS is not used. If
// tlsState.VerifiedChains is nil, InsecureSkipVerify was used (no
// ServerName or PKI check was done).
CheckConn(ctx context.Context, mxLevel MXLevel, tlsLevel TLSLevel, domain, mx string, tlsState tls.ConnectionState) (TLSLevel, error)
// Reset cleans the internal object state for use with another message.
// newMsg may be nil if object is not needed anymore.
Reset(newMsg *MsgMetadata)
}
)
================================================
FILE: framework/module/partial_delivery.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"context"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
)
// StatusCollector is an object that is passed by message source
// that is interested in intermediate status reports about partial
// delivery failures.
type StatusCollector interface {
// SetStatus sets the error associated with the recipient.
//
// rcptTo should match exactly the value that was passed to the
// AddRcpt, i.e. if any translations was made by the target,
// they should not affect the rcptTo argument here.
//
// It should not be called multiple times for the same
// value of rcptTo. It also should not be called
// after BodyNonAtomic returns.
//
// SetStatus is goroutine-safe. Implementations
// provide necessary serialization.
SetStatus(rcptTo string, err error)
}
// PartialDelivery is an optional interface that may be implemented
// by the object returned by DeliveryTarget.StartDelivery. See PartialDelivery.BodyNonAtomic
// documentation for details.
type PartialDelivery interface {
// BodyNonAtomic is similar to Body method of the regular Delivery interface
// with the except that it allows target to reject the body only for some
// recipients by setting statuses using passed collector object.
//
// This interface is preferred by the LMTP endpoint and queue implementation
// to ensure correct handling of partial failures.
BodyNonAtomic(ctx context.Context, c StatusCollector, header textproto.Header, body buffer.Buffer)
}
================================================
FILE: framework/module/storage.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
imapbackend "github.com/emersion/go-imap/backend"
)
// Storage interface is a slightly modified go-imap's Backend interface
// (authentication is removed).
//
// Modules implementing this interface should be registered with prefix
// "storage." in name.
type Storage interface {
// GetOrCreateIMAPAcct returns User associated with storage account specified by
// the name.
//
// If it doesn't exists - it should be created.
GetOrCreateIMAPAcct(username string) (imapbackend.User, error)
GetIMAPAcct(username string) (imapbackend.User, error)
// Extensions returns list of IMAP extensions supported by backend.
IMAPExtensions() []string
}
// ManageableStorage is an extended Storage interface that allows to
// list existing accounts, create and delete them.
type ManageableStorage interface {
Storage
ListIMAPAccts() ([]string, error)
CreateIMAPAcct(username string) error
DeleteIMAPAcct(username string) error
}
================================================
FILE: framework/module/table.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import "context"
// Table is the interface implemented by module that implementation string-to-string
// translation.
//
// Modules implementing this interface should be registered with prefix
// "table." in name.
type Table interface {
Lookup(ctx context.Context, s string) (string, bool, error)
}
// MultiTable is the interface that module can implement in addition to Table
// if it can provide multiple values as a lookup result.
type MultiTable interface {
LookupMulti(ctx context.Context, s string) ([]string, error)
}
type MutableTable interface {
Table
Keys() ([]string, error)
RemoveKey(k string) error
SetKey(k, v string) error
}
================================================
FILE: framework/module/tls_loader.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package module
import (
"crypto/tls"
)
// TLSLoader interface is module interface that can be used to supply TLS
// certificates to TLS-enabled endpoints.
//
// The interface is intentionally kept simple, all configuration and parameters
// necessary are to be provided using conventional module configuration.
//
// If loader returns multiple certificate chains - endpoint will serve them
// based on SNI matching.
//
// Note that loading function will be called for each connections - it is
// highly recommended to cache parsed form.
//
// Modules implementing this interface should be registered with prefix
// "tls.loader." in name.
type TLSLoader interface {
ConfigureTLS(c *tls.Config) error
}
================================================
FILE: framework/resource/netresource/dup.go
================================================
package netresource
import "net"
func dupTCPListener(l *net.TCPListener) (*net.TCPListener, error) {
f, err := l.File()
if err != nil {
return nil, err
}
l2, err := net.FileListener(f)
if err != nil {
return nil, err
}
return l2.(*net.TCPListener), nil
}
func dupUnixListener(l *net.UnixListener) (*net.UnixListener, error) {
f, err := l.File()
if err != nil {
return nil, err
}
l2, err := net.FileListener(f)
if err != nil {
return nil, err
}
return l2.(*net.UnixListener), nil
}
================================================
FILE: framework/resource/netresource/fd.go
================================================
package netresource
import (
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
)
func ListenFD(fd uint) (net.Listener, error) {
file := os.NewFile(uintptr(fd), strconv.FormatUint(uint64(fd), 10))
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
return net.FileListener(file)
}
func ListenFDName(name string) (net.Listener, error) {
listenPDStr := os.Getenv("LISTEN_PID")
if listenPDStr == "" {
return nil, errors.New("$LISTEN_PID is not set")
}
listenPid, err := strconv.Atoi(listenPDStr)
if err != nil {
return nil, errors.New("$LISTEN_PID is not integer")
}
if listenPid != os.Getpid() {
return nil, fmt.Errorf("$LISTEN_PID (%d) is not our PID (%d)", listenPid, os.Getpid())
}
names := strings.Split(os.Getenv("LISTEN_FDNAMES"), ":")
fd := uintptr(0)
for i, fdName := range names {
if fdName == name {
fd = uintptr(3 + i)
break
}
}
if fd == 0 {
return nil, fmt.Errorf("name %s not found in $LISTEN_FDNAMES", name)
}
file := os.NewFile(3+fd, name)
defer func() {
if err := file.Close(); err != nil {
panic(err)
}
}()
return net.FileListener(file)
}
================================================
FILE: framework/resource/netresource/listen.go
================================================
package netresource
import (
"fmt"
"net"
"strconv"
"github.com/foxcpp/maddy/framework/log"
)
var (
tracker = NewListenerTracker(log.DefaultLogger.Sublogger("netresource"))
)
func CloseUnusedListeners() error {
return tracker.CloseUnused()
}
func CloseAllListeners() error {
return tracker.Close()
}
func ResetListenersUsage() {
tracker.ResetUsage()
}
func Listen(network, addr string) (net.Listener, error) {
switch network {
case "fd":
fd, err := strconv.ParseUint(addr, 10, strconv.IntSize)
if err != nil {
return nil, fmt.Errorf("invalid FD number: %v", addr)
}
return ListenFD(uint(fd))
case "fdname":
return ListenFDName(addr)
case "tcp", "tcp4", "tcp6", "unix":
return tracker.Get(network, addr)
default:
return nil, fmt.Errorf("unsupported network: %v", network)
}
}
================================================
FILE: framework/resource/netresource/tracker.go
================================================
package netresource
import (
"fmt"
"net"
"net/netip"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/resource"
)
type ListenerTracker struct {
logger *log.Logger
tcp *resource.Tracker[*net.TCPListener]
unix *resource.Tracker[*net.UnixListener]
}
func (lt *ListenerTracker) Get(network, addr string) (net.Listener, error) {
switch network {
case "tcp", "tcp4", "tcp6":
l, err := lt.tcp.GetOpen(addr, func() (*net.TCPListener, error) {
addrPort, err := netip.ParseAddrPort(addr)
if err != nil {
return nil, err
}
lt.logger.DebugMsg("new listener", "network", network, "address", addr)
return net.ListenTCP(network, net.TCPAddrFromAddrPort(addrPort))
})
if err != nil {
return nil, err
}
// We return duplicated listener so when listener is closed by user endpoint
// the tracked resource remains available and listening on the port doesn't
// actually stop.
l2, err := dupTCPListener(l)
if err != nil {
return nil, err
}
return l2, nil
case "unix":
l, err := lt.unix.GetOpen(addr, func() (*net.UnixListener, error) {
addr, err := net.ResolveUnixAddr(network, addr)
if err != nil {
return nil, err
}
lt.logger.DebugMsg("new listener", "network", network, "address", addr)
return net.ListenUnix(network, addr)
})
if err != nil {
return nil, err
}
l2, err := dupUnixListener(l)
if err != nil {
return nil, err
}
return l2, nil
default:
return nil, fmt.Errorf("unsupported network type: %s", network)
}
}
func (lt *ListenerTracker) ResetUsage() {
lt.tcp.MarkAllUnused()
lt.unix.MarkAllUnused()
}
func (lt *ListenerTracker) CloseUnused() error {
if err := lt.tcp.CloseUnused(func(key string) bool { return true }); err != nil {
lt.logger.Error("CloseUnused for TCP failed", err)
}
if err := lt.unix.CloseUnused(func(key string) bool { return true }); err != nil {
lt.logger.Error("CloseUnused for Unix failed", err)
}
return nil
}
func (lt *ListenerTracker) Close() error {
if err := lt.tcp.Close(); err != nil {
lt.logger.Error("Close for TCP failed", err)
}
if err := lt.unix.Close(); err != nil {
lt.logger.Error("Close for Unix failed", err)
}
return nil
}
func NewListenerTracker(log *log.Logger) *ListenerTracker {
lt := &ListenerTracker{
logger: log,
tcp: resource.NewTracker[*net.TCPListener](resource.NewSingleton[*net.TCPListener](log.Sublogger("tcp"))),
unix: resource.NewTracker[*net.UnixListener](resource.NewSingleton[*net.UnixListener](log.Sublogger("unix"))),
}
return lt
}
================================================
FILE: framework/resource/resource.go
================================================
package resource
import (
"io"
)
type Resource = io.Closer
type CheckableResource interface {
Resource
IsUsable() bool
}
type Container[T Resource] interface {
io.Closer
GetOpen(key string, open func() (T, error)) (T, error)
CloseUnused(isUsed func(key string) bool) error
}
================================================
FILE: framework/resource/singleton.go
================================================
package resource
import (
"sync"
"github.com/foxcpp/maddy/framework/log"
)
// Singleton represents a set of resources identified by an unique key.
type Singleton[T Resource] struct {
log *log.Logger
lock sync.RWMutex
resources map[string]T
}
func NewSingleton[T Resource](log *log.Logger) *Singleton[T] {
return &Singleton[T]{
log: log,
resources: make(map[string]T),
}
}
func (s *Singleton[T]) GetOpen(key string, open func() (T, error)) (T, error) {
s.lock.Lock()
defer s.lock.Unlock()
existing, ok := s.resources[key]
if ok {
s.log.DebugMsg("resource reused", "key", key)
return existing, nil
}
res, err := open()
if err != nil {
var empty T
return empty, err
}
s.log.DebugMsg("new resource", "key", key)
s.resources[key] = res
return res, nil
}
func (s *Singleton[T]) CloseUnused(isUsed func(key string) bool) error {
s.lock.Lock()
defer s.lock.Unlock()
for key, res := range s.resources {
if isUsed(key) {
continue
}
if err := res.Close(); err != nil {
s.log.Error("resource close failed", err, "key", key)
}
s.log.DebugMsg("resource released", "key", key)
delete(s.resources, key)
}
return nil
}
func (s *Singleton[T]) Close() error {
s.lock.Lock()
defer s.lock.Unlock()
for key, res := range s.resources {
if err := res.Close(); err != nil {
s.log.Error("resource close failed", err, "key", key)
}
s.log.DebugMsg("resource released", "key", key)
delete(s.resources, key)
}
return nil
}
================================================
FILE: framework/resource/tracker.go
================================================
package resource
import (
"sync"
)
// Tracker is a container wrapper that tracks whether resources were used since
// last MarkAllUnused call.
type Tracker[T Resource] struct {
C Container[T]
usedLock sync.Mutex
used map[string]bool
}
func NewTracker[T Resource](c Container[T]) *Tracker[T] {
return &Tracker[T]{C: c, used: make(map[string]bool)}
}
func (t *Tracker[T]) Close() error {
return t.C.Close()
}
func (t *Tracker[T]) MarkAllUnused() {
t.usedLock.Lock()
defer t.usedLock.Unlock()
t.used = make(map[string]bool)
}
func (t *Tracker[T]) GetOpen(key string, open func() (T, error)) (T, error) {
t.usedLock.Lock()
t.used[key] = true
t.usedLock.Unlock()
return t.C.GetOpen(key, open)
}
func (t *Tracker[T]) CloseUnused(isUsed func(key string) bool) error {
t.usedLock.Lock()
defer t.usedLock.Unlock()
return t.C.CloseUnused(func(key string) bool {
used := t.used[key]
used = used && isUsed(key)
if !used {
delete(t.used, key)
}
return used
})
}
================================================
FILE: go.mod
================================================
module github.com/foxcpp/maddy
go 1.23.1
toolchain go1.23.5
require (
blitiri.com.ar/go/spf v1.5.1
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5
github.com/c0va23/go-proxyprotocol v0.9.1
github.com/caddyserver/certmagic v0.21.7
github.com/emersion/go-imap v1.2.2-0.20220928192137-6fac715be9cf
github.com/emersion/go-imap-compress v0.0.0-20201103190257-14809af1d1b9
github.com/emersion/go-imap-sortthread v1.2.0
github.com/emersion/go-message v0.18.2
github.com/emersion/go-milter v0.4.1
github.com/emersion/go-msgauth v0.6.8
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.21.3
github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba
github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16
github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005
github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613
github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed
github.com/foxcpp/go-imap-sql v0.5.1-0.20260412184517-b5e85e90f14d
github.com/foxcpp/go-mockdns v1.1.0
github.com/foxcpp/go-mtasts v0.0.0-20240130093538-1438da2e5932
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-sql-driver/mysql v1.8.1
github.com/google/uuid v1.6.0
github.com/hashicorp/go-hclog v1.6.3
github.com/johannesboyne/gofakes3 v0.0.0-20210704111953-6a9f95c2941c
github.com/lib/pq v1.10.9
github.com/libdns/acmedns v0.2.0
github.com/libdns/alidns v1.0.3
github.com/libdns/cloudflare v0.1.1
github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea
github.com/libdns/gandi v1.0.3
github.com/libdns/gcore v0.0.0-20250127070537-4a9d185c9d20
github.com/libdns/googleclouddns v1.1.0
github.com/libdns/hetzner v0.0.1
github.com/libdns/leaseweb v0.4.0
github.com/libdns/libdns v0.2.2
github.com/libdns/metaname v0.3.0
github.com/libdns/namecheap v0.0.0-20211109042440-fc7440785c8e
github.com/libdns/namedotcom v0.3.3
github.com/libdns/rfc2136 v0.1.1
github.com/libdns/route53 v1.5.1
github.com/libdns/vultr v1.0.0
github.com/mattn/go-sqlite3 v1.14.24
github.com/miekg/dns v1.1.63
github.com/minio/minio-go/v7 v7.0.84
github.com/netauth/netauth v0.6.2
github.com/prometheus/client_golang v1.20.5
github.com/stretchr/testify v1.10.0
github.com/urfave/cli/v2 v2.27.5
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.32.0
golang.org/x/net v0.34.0
golang.org/x/sync v0.10.0
golang.org/x/text v0.21.0
modernc.org/sqlite v1.34.5
)
require (
cloud.google.com/go/auth v0.14.0 // indirect
cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect
cloud.google.com/go/compute/metadata v0.6.0 // indirect
filippo.io/edwards25519 v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/G-Core/gcore-dns-sdk-go v0.2.9 // indirect
github.com/aws/aws-sdk-go v1.44.40 // indirect
github.com/aws/aws-sdk-go-v2 v1.33.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.1 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.54 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 // indirect
github.com/aws/aws-sdk-go-v2/service/route53 v1.48.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/digitalocean/godo v1.134.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.8.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/s2a-go v0.1.9 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jimlambrt/gldap v0.1.14 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/magiconair/properties v1.8.9 // indirect
github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/acmez/v3 v3.0.1 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/netauth/protocol v0.0.0-20210918062754-7fee492ffcbd // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.12.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.19.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/vultr/govultr/v3 v3.14.1 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect
go.opentelemetry.io/otel v1.34.0 // indirect
go.opentelemetry.io/otel/metric v1.34.0 // indirect
go.opentelemetry.io/otel/trace v1.34.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/oauth2 v0.25.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/time v0.9.0 // indirect
golang.org/x/tools v0.29.0 // indirect
google.golang.org/api v0.218.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
gotest.tools v2.2.0+incompatible // indirect
modernc.org/libc v1.61.9 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
)
replace github.com/emersion/go-imap => github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887
replace github.com/emersion/go-smtp => github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23 // v1.21.3+maddy.1
replace github.com/libdns/gandi => github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e // v1.0.3+maddy.1
================================================
FILE: go.sum
================================================
blitiri.com.ar/go/spf v1.5.1 h1:CWUEasc44OrANJD8CzceRnRn1Jv0LttY68cYym2/pbE=
blitiri.com.ar/go/spf v1.5.1/go.mod h1:E71N92TfL4+Yyd5lpKuE9CAF2pd4JrUq1xQfkTxoNdk=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc=
cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk=
cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs=
cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc=
cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY=
cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI=
cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk=
cloud.google.com/go v0.78.0/go.mod h1:QjdrLG0uq+YwhjoVOLsS1t7TW8fs36kLs4XO5R5ECHg=
cloud.google.com/go v0.79.0/go.mod h1:3bzgcEeQlzbuEAYu4mrWhKqWjmpprinYgKJLgKHnbb8=
cloud.google.com/go v0.81.0/go.mod h1:mk/AM35KwGk/Nm2YSeZbxXdrNK3KZOYHmLkOqC2V6E0=
cloud.google.com/go v0.83.0/go.mod h1:Z7MJUsANfY0pYPdw0lbnivPx4/vhy/e2FEkSkF7vAVY=
cloud.google.com/go v0.84.0/go.mod h1:RazrYuxIK6Kb7YrzzhPoLmCVzl7Sup4NrbKPg8KHSUM=
cloud.google.com/go v0.87.0/go.mod h1:TpDYlFy7vuLzZMMZ+B6iRiELaY7z/gJPaqbMx6mlWcY=
cloud.google.com/go v0.90.0/go.mod h1:kRX0mNRHe0e2rC6oNakvwQqzyDmg57xJ+SZU1eT2aDQ=
cloud.google.com/go v0.93.3/go.mod h1:8utlLll2EF5XMAV15woO4lSbWQlk8rer9aLOfLh7+YI=
cloud.google.com/go v0.94.1/go.mod h1:qAlAugsXlC+JWO+Bke5vCtc9ONxjQT3drlTTnAplMW4=
cloud.google.com/go v0.97.0/go.mod h1:GF7l59pYBVlXQIBLx3a761cZ41F9bBH3JUlihCt2Udc=
cloud.google.com/go v0.99.0/go.mod h1:w0Xx2nLzqWJPuozYQX+hFfCSI8WioryfRDzkoI/Y2ZA=
cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w99A=
cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc=
cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU=
cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw=
cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY=
cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI=
cloud.google.com/go/analytics v0.12.0/go.mod h1:gkfj9h6XRf9+TS4bmuhPEShsh3hH8PAZzm/41OOhQd4=
cloud.google.com/go/area120 v0.5.0/go.mod h1:DE/n4mp+iqVyvxHN41Vf1CR602GiHQjFPusMFW6bGR4=
cloud.google.com/go/area120 v0.6.0/go.mod h1:39yFJqWVgm0UZqWTOdqkLhjoC7uFfgXRC8g/ZegeAh0=
cloud.google.com/go/artifactregistry v1.6.0/go.mod h1:IYt0oBPSAGYj/kprzsBjZ/4LnG/zOcHyFHjWPCi6SAQ=
cloud.google.com/go/artifactregistry v1.7.0/go.mod h1:mqTOFOnGZx8EtSqK/ZWcsm/4U8B77rbcLP6ruDU2Ixk=
cloud.google.com/go/asset v1.5.0/go.mod h1:5mfs8UvcM5wHhqtSv8J1CtxxaQq3AdBxxQi2jGW/K4o=
cloud.google.com/go/asset v1.7.0/go.mod h1:YbENsRK4+xTiL+Ofoj5Ckf+O17kJtgp3Y3nn4uzZz5s=
cloud.google.com/go/asset v1.8.0/go.mod h1:mUNGKhiqIdbr8X7KNayoYvyc4HbbFO9URsjbytpUaW0=
cloud.google.com/go/assuredworkloads v1.5.0/go.mod h1:n8HOZ6pff6re5KYfBXcFvSViQjDwxFkAkmUFffJRbbY=
cloud.google.com/go/assuredworkloads v1.6.0/go.mod h1:yo2YOk37Yc89Rsd5QMVECvjaMKymF9OP+QXWlKXUkXw=
cloud.google.com/go/assuredworkloads v1.7.0/go.mod h1:z/736/oNmtGAyU47reJgGN+KVoYoxeLBoj4XkKYscNI=
cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM=
cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/automl v1.5.0/go.mod h1:34EjfoFGMZ5sgJ9EoLsRtdPSNZLcfflJR39VbVNS2M0=
cloud.google.com/go/automl v1.6.0/go.mod h1:ugf8a6Fx+zP0D59WLhqgTDsQI9w07o64uf/Is3Nh5p8=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc=
cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg=
cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc=
cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ=
cloud.google.com/go/bigquery v1.42.0/go.mod h1:8dRTJxhtG+vwBKzE5OseQn/hiydoQN3EedCaOdYmxRA=
cloud.google.com/go/billing v1.4.0/go.mod h1:g9IdKBEFlItS8bTtlrZdVLWSSdSyFUZKXNS02zKMOZY=
cloud.google.com/go/billing v1.5.0/go.mod h1:mztb1tBc3QekhjSgmpf/CV4LzWXLzCArwpLmP2Gm88s=
cloud.google.com/go/binaryauthorization v1.1.0/go.mod h1:xwnoWu3Y84jbuHa0zd526MJYmtnVXn0syOjaJgy4+dM=
cloud.google.com/go/binaryauthorization v1.2.0/go.mod h1:86WKkJHtRcv5ViNABtYMhhNWRrD1Vpi//uKEy7aYEfI=
cloud.google.com/go/cloudtasks v1.5.0/go.mod h1:fD92REy1x5woxkKEkLdvavGnPJGEn8Uic9nWuLzqCpY=
cloud.google.com/go/cloudtasks v1.6.0/go.mod h1:C6Io+sxuke9/KNRkbQpihnW93SWDU3uXt92nu85HkYI=
cloud.google.com/go/compute v0.1.0/go.mod h1:GAesmwr110a34z04OlxYkATPBEfVhkymfTBXtfbBFow=
cloud.google.com/go/compute v1.3.0/go.mod h1:cCZiE1NHEtai4wiufUhW8I8S1JKkAnhnQJWM7YD99wM=
cloud.google.com/go/compute v1.5.0/go.mod h1:9SMHyhJlzhlkJqrPAc839t2BZFTSk6Jdj6mkzQJeu0M=
cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz/FMzPu0s=
cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU=
cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U=
cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU=
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I=
cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4=
cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0=
cloud.google.com/go/datacatalog v1.5.0/go.mod h1:M7GPLNQeLfWqeIm3iuiruhPzkt65+Bx8dAKvScX8jvs=
cloud.google.com/go/datacatalog v1.6.0/go.mod h1:+aEyF8JKg+uXcIdAmmaMUmZ3q1b/lKLtXCmXdnc0lbc=
cloud.google.com/go/dataflow v0.6.0/go.mod h1:9QwV89cGoxjjSR9/r7eFDqqjtvbKxAK2BaYU6PVk9UM=
cloud.google.com/go/dataflow v0.7.0/go.mod h1:PX526vb4ijFMesO1o202EaUmouZKBpjHsTlCtB4parQ=
cloud.google.com/go/dataform v0.3.0/go.mod h1:cj8uNliRlHpa6L3yVhDOBrUXH+BPAO1+KFMQQNSThKo=
cloud.google.com/go/dataform v0.4.0/go.mod h1:fwV6Y4Ty2yIFL89huYlEkwUPtS7YZinZbzzj5S9FzCE=
cloud.google.com/go/datalabeling v0.5.0/go.mod h1:TGcJ0G2NzcsXSE/97yWjIZO0bXj0KbVlINXMG9ud42I=
cloud.google.com/go/datalabeling v0.6.0/go.mod h1:WqdISuk/+WIGeMkpw/1q7bK/tFEZxsrFJOJdY2bXvTQ=
cloud.google.com/go/dataqna v0.5.0/go.mod h1:90Hyk596ft3zUQ8NkFfvICSIfHFh1Bc7C4cK3vbhkeo=
cloud.google.com/go/dataqna v0.6.0/go.mod h1:1lqNpM7rqNLVgWBJyk5NF6Uen2PHym0jtVJonplVsDA=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk=
cloud.google.com/go/datastream v1.2.0/go.mod h1:i/uTP8/fZwgATHS/XFu0TcNUhuA0twZxxQ3EyCUQMwo=
cloud.google.com/go/datastream v1.3.0/go.mod h1:cqlOX8xlyYF/uxhiKn6Hbv6WjwPPuI9W2M9SAXwaLLQ=
cloud.google.com/go/dialogflow v1.15.0/go.mod h1:HbHDWs33WOGJgn6rfzBW1Kv807BE3O1+xGbn59zZWI4=
cloud.google.com/go/dialogflow v1.16.1/go.mod h1:po6LlzGfK+smoSmTBnbkIZY2w8ffjz/RcGSS+sh1el0=
cloud.google.com/go/dialogflow v1.17.0/go.mod h1:YNP09C/kXA1aZdBgC/VtXX74G/TKn7XVCcVumTflA+8=
cloud.google.com/go/documentai v1.7.0/go.mod h1:lJvftZB5NRiFSX4moiye1SMxHx0Bc3x1+p9e/RfXYiU=
cloud.google.com/go/documentai v1.8.0/go.mod h1:xGHNEB7CtsnySCNrCFdCyyMz44RhFEEX2Q7UD0c5IhU=
cloud.google.com/go/domains v0.6.0/go.mod h1:T9Rz3GasrpYk6mEGHh4rymIhjlnIuB4ofT1wTxDeT4Y=
cloud.google.com/go/domains v0.7.0/go.mod h1:PtZeqS1xjnXuRPKE/88Iru/LdfoRyEHYA9nFQf4UKpg=
cloud.google.com/go/edgecontainer v0.1.0/go.mod h1:WgkZ9tp10bFxqO8BLPqv2LlfmQF1X8lZqwW4r1BTajk=
cloud.google.com/go/edgecontainer v0.2.0/go.mod h1:RTmLijy+lGpQ7BXuTDa4C4ssxyXT34NIuHIgKuP4s5w=
cloud.google.com/go/functions v1.6.0/go.mod h1:3H1UA3qiIPRWD7PeZKLvHZ9SaQhR26XIJcC0A5GbvAk=
cloud.google.com/go/functions v1.7.0/go.mod h1:+d+QBcWM+RsrgZfV9xo6KfA1GlzJfxcfZcRPEhDDfzg=
cloud.google.com/go/gaming v1.5.0/go.mod h1:ol7rGcxP/qHTRQE/RO4bxkXq+Fix0j6D4LFPzYTIrDM=
cloud.google.com/go/gaming v1.6.0/go.mod h1:YMU1GEvA39Qt3zWGyAVA9bpYz/yAhTvaQ1t2sK4KPUA=
cloud.google.com/go/gkeconnect v0.5.0/go.mod h1:c5lsNAg5EwAy7fkqX/+goqFsU1Da/jQFqArp+wGNr/o=
cloud.google.com/go/gkeconnect v0.6.0/go.mod h1:Mln67KyU/sHJEBY8kFZ0xTeyPtzbq9StAVvEULYK16A=
cloud.google.com/go/gkehub v0.9.0/go.mod h1:WYHN6WG8w9bXU0hqNxt8rm5uxnk8IH+lPY9J2TV7BK0=
cloud.google.com/go/gkehub v0.10.0/go.mod h1:UIPwxI0DsrpsVoWpLB0stwKCP+WFVG9+y977wO+hBH0=
cloud.google.com/go/grafeas v0.2.0/go.mod h1:KhxgtF2hb0P191HlY5besjYm6MqTSTj3LSI+M+ByZHc=
cloud.google.com/go/iam v0.3.0/go.mod h1:XzJPvDayI+9zsASAFO68Hk07u3z+f+JrT2xXNdp4bnY=
cloud.google.com/go/language v1.4.0/go.mod h1:F9dRpNFQmJbkaop6g0JhSBXCNlO90e1KWx5iDdxbWic=
cloud.google.com/go/language v1.6.0/go.mod h1:6dJ8t3B+lUYfStgls25GusK04NLh3eDLQnWM3mdEbhI=
cloud.google.com/go/lifesciences v0.5.0/go.mod h1:3oIKy8ycWGPUyZDR/8RNnTOYevhaMLqh5vLUXs9zvT8=
cloud.google.com/go/lifesciences v0.6.0/go.mod h1:ddj6tSX/7BOnhxCSd3ZcETvtNr8NZ6t/iPhY2Tyfu08=
cloud.google.com/go/mediatranslation v0.5.0/go.mod h1:jGPUhGTybqsPQn91pNXw0xVHfuJ3leR1wj37oU3y1f4=
cloud.google.com/go/mediatranslation v0.6.0/go.mod h1:hHdBCTYNigsBxshbznuIMFNe5QXEowAuNmmC7h8pu5w=
cloud.google.com/go/memcache v1.4.0/go.mod h1:rTOfiGZtJX1AaFUrOgsMHX5kAzaTQ8azHiuDoTPzNsE=
cloud.google.com/go/memcache v1.5.0/go.mod h1:dk3fCK7dVo0cUU2c36jKb4VqKPS22BTkf81Xq617aWM=
cloud.google.com/go/metastore v1.5.0/go.mod h1:2ZNrDcQwghfdtCwJ33nM0+GrBGlVuh8rakL3vdPY3XY=
cloud.google.com/go/metastore v1.6.0/go.mod h1:6cyQTls8CWXzk45G55x57DVQ9gWg7RiH65+YgPsNh9s=
cloud.google.com/go/networkconnectivity v1.4.0/go.mod h1:nOl7YL8odKyAOtzNX73/M5/mGZgqqMeryi6UPZTk/rA=
cloud.google.com/go/networkconnectivity v1.5.0/go.mod h1:3GzqJx7uhtlM3kln0+x5wyFvuVH1pIBJjhCpjzSt75o=
cloud.google.com/go/networksecurity v0.5.0/go.mod h1:xS6fOCoqpVC5zx15Z/MqkfDwH4+m/61A3ODiDV1xmiQ=
cloud.google.com/go/networksecurity v0.6.0/go.mod h1:Q5fjhTr9WMI5mbpRYEbiexTzROf7ZbDzvzCrNl14nyU=
cloud.google.com/go/notebooks v1.2.0/go.mod h1:9+wtppMfVPUeJ8fIWPOq1UnATHISkGXGqTkxeieQ6UY=
cloud.google.com/go/notebooks v1.3.0/go.mod h1:bFR5lj07DtCPC7YAAJ//vHskFBxA5JzYlH68kXVdk34=
cloud.google.com/go/osconfig v1.7.0/go.mod h1:oVHeCeZELfJP7XLxcBGTMBvRO+1nQ5tFG9VQTmYS2Fs=
cloud.google.com/go/osconfig v1.8.0/go.mod h1:EQqZLu5w5XA7eKizepumcvWx+m8mJUhEwiPqWiZeEdg=
cloud.google.com/go/oslogin v1.4.0/go.mod h1:YdgMXWRaElXz/lDk1Na6Fh5orF7gvmJ0FGLIs9LId4E=
cloud.google.com/go/oslogin v1.5.0/go.mod h1:D260Qj11W2qx/HVF29zBg+0fd6YCSjSqLUkY/qEenQU=
cloud.google.com/go/phishingprotection v0.5.0/go.mod h1:Y3HZknsK9bc9dMi+oE8Bim0lczMU6hrX0UpADuMefr0=
cloud.google.com/go/phishingprotection v0.6.0/go.mod h1:9Y3LBLgy0kDTcYET8ZH3bq/7qni15yVUoAxiFxnlSUA=
cloud.google.com/go/privatecatalog v0.5.0/go.mod h1:XgosMUvvPyxDjAVNDYxJ7wBW8//hLDDYmnsNcMGq1K0=
cloud.google.com/go/privatecatalog v0.6.0/go.mod h1:i/fbkZR0hLN29eEWiiwue8Pb+GforiEIBnV9yrRUOKI=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA=
cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU=
cloud.google.com/go/recaptchaenterprise v1.3.1/go.mod h1:OdD+q+y4XGeAlxRaMn1Y7/GveP6zmq76byL6tjPE7d4=
cloud.google.com/go/recaptchaenterprise/v2 v2.1.0/go.mod h1:w9yVqajwroDNTfGuhmOjPDN//rZGySaf6PtFVcSCa7o=
cloud.google.com/go/recaptchaenterprise/v2 v2.2.0/go.mod h1:/Zu5jisWGeERrd5HnlS3EUGb/D335f9k51B/FVil0jk=
cloud.google.com/go/recaptchaenterprise/v2 v2.3.0/go.mod h1:O9LwGCjrhGHBQET5CA7dd5NwwNQUErSgEDit1DLNTdo=
cloud.google.com/go/recommendationengine v0.5.0/go.mod h1:E5756pJcVFeVgaQv3WNpImkFP8a+RptV6dDLGPILjvg=
cloud.google.com/go/recommendationengine v0.6.0/go.mod h1:08mq2umu9oIqc7tDy8sx+MNJdLG0fUi3vaSVbztHgJ4=
cloud.google.com/go/recommender v1.5.0/go.mod h1:jdoeiBIVrJe9gQjwd759ecLJbxCDED4A6p+mqoqDvTg=
cloud.google.com/go/recommender v1.6.0/go.mod h1:+yETpm25mcoiECKh9DEScGzIRyDKpZ0cEhWGo+8bo+c=
cloud.google.com/go/redis v1.7.0/go.mod h1:V3x5Jq1jzUcg+UNsRvdmsfuFnit1cfe3Z/PGyq/lm4Y=
cloud.google.com/go/redis v1.8.0/go.mod h1:Fm2szCDavWzBk2cDKxrkmWBqoCiL1+Ctwq7EyqBCA/A=
cloud.google.com/go/retail v1.8.0/go.mod h1:QblKS8waDmNUhghY2TI9O3JLlFk8jybHeV4BF19FrE4=
cloud.google.com/go/retail v1.9.0/go.mod h1:g6jb6mKuCS1QKnH/dpu7isX253absFl6iE92nHwlBUY=
cloud.google.com/go/scheduler v1.4.0/go.mod h1:drcJBmxF3aqZJRhmkHQ9b3uSSpQoltBPGPxGAWROx6s=
cloud.google.com/go/scheduler v1.5.0/go.mod h1:ri073ym49NW3AfT6DZi21vLZrG07GXr5p3H1KxN5QlI=
cloud.google.com/go/secretmanager v1.6.0/go.mod h1:awVa/OXF6IiyaU1wQ34inzQNc4ISIDIrId8qE5QGgKA=
cloud.google.com/go/security v1.5.0/go.mod h1:lgxGdyOKKjHL4YG3/YwIL2zLqMFCKs0UbQwgyZmfJl4=
cloud.google.com/go/security v1.7.0/go.mod h1:mZklORHl6Bg7CNnnjLH//0UlAlaXqiG7Lb9PsPXLfD0=
cloud.google.com/go/security v1.8.0/go.mod h1:hAQOwgmaHhztFhiQ41CjDODdWP0+AE1B3sX4OFlq+GU=
cloud.google.com/go/securitycenter v1.13.0/go.mod h1:cv5qNAqjY84FCN6Y9z28WlkKXyWsgLO832YiWwkCWcU=
cloud.google.com/go/securitycenter v1.14.0/go.mod h1:gZLAhtyKv85n52XYWt6RmeBdydyxfPeTrpToDPw4Auc=
cloud.google.com/go/servicedirectory v1.4.0/go.mod h1:gH1MUaZCgtP7qQiI+F+A+OpeKF/HQWgtAddhTbhL2bs=
cloud.google.com/go/servicedirectory v1.5.0/go.mod h1:QMKFL0NUySbpZJ1UZs3oFAmdvVxhhxB6eJ/Vlp73dfg=
cloud.google.com/go/speech v1.6.0/go.mod h1:79tcr4FHCimOp56lwC01xnt/WPJZc4v3gzyT7FoBkCM=
cloud.google.com/go/speech v1.7.0/go.mod h1:KptqL+BAQIhMsj1kOP2la5DSEEerPDuOP/2mmkhHhZQ=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk=
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y=
cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc=
cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw=
cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g=
cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU=
cloud.google.com/go/videointelligence v1.7.0/go.mod h1:k8pI/1wAhjznARtVT9U1llUaFNPh7muw8QyOUpavru4=
cloud.google.com/go/vision v1.2.0/go.mod h1:SmNwgObm5DpFBme2xpyOyasvBc1aPdjvMk2bBk0tKD0=
cloud.google.com/go/vision/v2 v2.2.0/go.mod h1:uCdV4PpN1S0jyCyq8sIM42v2Y6zOLkZs+4R9LrGYwFo=
cloud.google.com/go/vision/v2 v2.3.0/go.mod h1:UO61abBx9QRMFkNBbf1D8B1LXdS2cGiiCRx0vSpZoUo=
cloud.google.com/go/webrisk v1.4.0/go.mod h1:Hn8X6Zr+ziE2aNd8SliSDWpEnSS1u4R9+xXZmFiHmGE=
cloud.google.com/go/webrisk v1.5.0/go.mod h1:iPG6fr52Tv7sGk0H6qUFzmL3HHZev1htXuWDEEsqMTg=
cloud.google.com/go/workflows v1.6.0/go.mod h1:6t9F5h/unJz41YqfBmqSASJSXccBLtD1Vwf+KmJENM0=
cloud.google.com/go/workflows v1.7.0/go.mod h1:JhSrZuVZWuiDfKEFxU0/F1PQjmpnpcoISEXH2bcHC3M=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/G-Core/gcore-dns-sdk-go v0.2.9 h1:LMMZIRX8y3aJJuAviNSpFmLbovZUw+6Om+8VElp1F90=
github.com/G-Core/gcore-dns-sdk-go v0.2.9/go.mod h1:35t795gOfzfVanhzkFyUXEzaBuMXwETmJldPpP28MN4=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5 h1:IEjq88XO4PuBDcvmjQJcQGg+w+UaafSy8G5Kcb5tBhI=
github.com/GehirnInc/crypt v0.0.0-20230320061759-8cc1b52080c5/go.mod h1:exZ0C/1emQJAw5tHOaUDyY1ycttqBAPcxuzf7QbY6ec=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/aws/aws-sdk-go v1.17.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go v1.44.40 h1:MR0qefjBJrZuXE0VoeKMQFtjS2tUeVpbQNfb7NzQNgI=
github.com/aws/aws-sdk-go v1.44.40/go.mod h1:y4AeaBuwd2Lk+GepC1E9v0qOiTws0MIWAX4oIKwKHZo=
github.com/aws/aws-sdk-go-v2 v1.33.0 h1:Evgm4DI9imD81V0WwD+TN4DCwjUMdc94TrduMLbgZJs=
github.com/aws/aws-sdk-go-v2 v1.33.0/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2/config v1.29.1 h1:JZhGawAyZ/EuJeBtbQYnaoftczcb2drR2Iq36Wgz4sQ=
github.com/aws/aws-sdk-go-v2/config v1.29.1/go.mod h1:7bR2YD5euaxBhzt2y/oDkt3uNRb6tjFp98GlTFueRwk=
github.com/aws/aws-sdk-go-v2/credentials v1.17.54 h1:4UmqeOqJPvdvASZWrKlhzpRahAulBfyTJQUaYy4+hEI=
github.com/aws/aws-sdk-go-v2/credentials v1.17.54/go.mod h1:RTdfo0P0hbbTxIhmQrOsC/PquBZGabEPnCaxxKRPSnI=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24 h1:5grmdTdMsovn9kPZPI23Hhvp0ZyNm5cRO+IZFIYiAfw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.24/go.mod h1:zqi7TVKTswH3Ozq28PkmBmgzG1tona7mo9G2IJg4Cis=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28 h1:igORFSiH3bfq4lxKFkTSYDhJEUCYo6C8VKiWJjYwQuQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.28/go.mod h1:3So8EA/aAYm36L7XIvCVwLa0s5N0P7o2b1oqnx/2R4g=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28 h1:1mOW9zAUMhTSrMDssEHS/ajx8JcAj/IcftzcmNlmVLI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.28/go.mod h1:kGlXVIWDfvt2Ox5zEaNglmq0hXPHgQFNMix33Tw22jA=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 h1:iXtILhvDxB6kPvEXgsDhGaZCSC6LQET5ZHSdJozeI0Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1/go.mod h1:9nu0fVANtYiAePIBh2/pFUSwtJ402hLnp854CNoDOeE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9 h1:TQmKDyETFGiXVhZfQ/I0cCFziqqX58pi4tKJGYGFSz0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.9/go.mod h1:HVLPK2iHQBUx7HfZeOQSEu3v2ubZaAY2YPbAm5/WUyY=
github.com/aws/aws-sdk-go-v2/service/route53 v1.48.2 h1:Rxg1R0CHxVb9ggQLufOkr4an3yFEkTDN+N5+LFU4aEg=
github.com/aws/aws-sdk-go-v2/service/route53 v1.48.2/go.mod h1:TN4PcCL0lvqmYcv+AV8iZFC4Sd0FM06QDaoBXrFEftU=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11 h1:kuIyu4fTT38Kj7YCC7ouNbVZSSpqkZ+LzIfhCr6Dg+I=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.11/go.mod h1:Ro744S4fKiCCuZECXgOi760TiYylUM8ZBf6OGiZzJtY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10 h1:l+dgv/64iVlQ3WsBbnn+JSbkj01jIi+SM0wYsj3y/hY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.10/go.mod h1:Fzsj6lZEb8AkTE5S68OhcbBqeWPsR8RnGuKPr8Todl8=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9 h1:BRVDbewN6VZcwr+FBOszDKvYeXY1kJ+GGMCcpghlw0U=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.9/go.mod h1:f6vjfZER1M17Fokn0IzssOTMT2N8ZSq+7jnNF0tArvw=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/c0va23/go-proxyprotocol v0.9.1 h1:5BCkp0fDJOhzzH1lhjUgHhmZz9VvRMMif1U2D31hb34=
github.com/c0va23/go-proxyprotocol v0.9.1/go.mod h1:TNjUV+llvk8TvWJxlPYAeAYZgSzT/iicNr3nWBWX320=
github.com/caddyserver/certmagic v0.21.7 h1:66KJioPFJwttL43KYSWk7ErSmE6LfaJgCQuhm8Sg6fg=
github.com/caddyserver/certmagic v0.21.7/go.mod h1:LCPG3WLxcnjVKl/xpjzM0gqh0knrKKKiO5WVttX2eEI=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/digitalocean/godo v1.41.0/go.mod h1:p7dOjjtSBqCTUksqtA5Fd3uaKs9kyTq2xcz76ulEJRU=
github.com/digitalocean/godo v1.134.0 h1:dT7aQR9jxNOQEZwzP+tAYcxlj5szFZScC33+PAYGQVM=
github.com/digitalocean/godo v1.134.0/go.mod h1:PU8JB6I1XYkQIdHFop8lLAY9ojp6M0XcU0TWaQSxbrc=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-imap-appendlimit v0.0.0-20190308131241-25671c986a6a/go.mod h1:ikgISoP7pRAolqsVP64yMteJa2FIpS6ju88eBT6K1yQ=
github.com/emersion/go-imap-compress v0.0.0-20201103190257-14809af1d1b9 h1:7dmV11mle4UAQ7lX+Hdzx6akKFg3hVm/UUmQ7t6VgTQ=
github.com/emersion/go-imap-compress v0.0.0-20201103190257-14809af1d1b9/go.mod h1:2Ro1PbmiqYiRe5Ct2sGR5hHaKSVHeRpVZwXx8vyYt98=
github.com/emersion/go-imap-move v0.0.0-20180601155324-5eb20cb834bf/go.mod h1:QuMaZcKFDVI0yCrnAbPLfbwllz1wtOrZH8/vZ5yzp4w=
github.com/emersion/go-imap-sortthread v1.2.0 h1:EMVEJXPWAhXMWECjR82Rn/tza6MddcvTwGAdTu1vJKU=
github.com/emersion/go-imap-sortthread v1.2.0/go.mod h1:UhenCBupR+vSYRnqJkpjSq84INUCsyAK1MLpogv14pE=
github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4=
github.com/emersion/go-message v0.18.0/go.mod h1:Zi69ACvzaoV/MBnrxfVBPV3xWEuCmC2nEN39oJF4B8A=
github.com/emersion/go-message v0.18.1/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg=
github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA=
github.com/emersion/go-milter v0.4.1 h1:gLs9QD0zEHF8omgEw8M+aGz6iwBNpWLAcwgSur0ra4M=
github.com/emersion/go-milter v0.4.1/go.mod h1:erCQVl0mH4SX9jEvwe+wyndit0rQtmvMLH86V6NGtkI=
github.com/emersion/go-msgauth v0.6.8 h1:kW/0E9E8Zx5CdKsERC/WnAvnXvX7q9wTHia1OA4944A=
github.com/emersion/go-msgauth v0.6.8/go.mod h1:YDwuyTCUHu9xxmAeVj0eW4INnwB6NNZoPdLerpSxRrc=
github.com/emersion/go-sasl v0.0.0-20191210011802-430746ea8b9b/go.mod h1:G/dpzLu16WtQpBfQ/z3LYiYJn3ZhKSGWn83fyoyQe/k=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20231106173351-e73c9f7bad43/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po=
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210217033140-668b12f5399d/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0=
github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go.mod h1:KJwIaB5Mv44NWtYuAOFCVOjcI94vtpEz2JU/D2v6IjE=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf h1:rmBPY5fryjp9zLQYsUmQqqgsYq7qeVfrjtr96Tf9vD8=
github.com/foxcpp/go-dovecot-sasl v0.0.0-20200522223722-c4699d7a24bf/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E=
github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba h1:yxQhqX9RQCvECZKBtqwCZoKy/6CLaozDZeWH9Lvndy0=
github.com/foxcpp/go-dovecot-sasl v0.0.0-20260303144336-f7632c6ec0ba/go.mod h1:5yZUmwr851vgjyAfN7OEfnrmKOh/qLA5dbGelXYsu1E=
github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887 h1:qUoaaHyrRpQw85ru6VQcC6JowdhrWl7lSbI1zRX1FTM=
github.com/foxcpp/go-imap v1.0.0-beta.1.0.20220623182312-df940c324887/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY=
github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16 h1:qheFPDpteiUy7Ym18R68OYenpk85UyKYGkhYTmddSBg=
github.com/foxcpp/go-imap-backend-tests v0.0.0-20220105184719-e80aa29a5e16/go.mod h1:OPP1AgKxMPo3aHX5pcEZLQhhh5sllFcB8aUN9f6a6X8=
github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005 h1:pfoFtkTTQ473qStSN79jhCFBWqMQt/3DQ3NGuXvT+50=
github.com/foxcpp/go-imap-i18nlevel v0.0.0-20200208001533-d6ec88553005/go.mod h1:34FwxnjC2N+EFs2wMtsHevrZLWRKRuVU8wEcHWKq/nE=
github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613 h1:fw9OWfPxP1CK4D+XAEEg0JzhvFGo04L+F5Xw55t9s3E=
github.com/foxcpp/go-imap-mess v0.0.0-20230108134257-b7ec3a649613/go.mod h1:P/O/qz4gaVkefzJ40BUtN/ZzBnaEg0YYe1no/SMp7Aw=
github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed h1:1Jo7geyvunrPSjL6F6D9EcXoNApS5v3LQaro7aUNPnE=
github.com/foxcpp/go-imap-namespace v0.0.0-20200802091432-08496dd8e0ed/go.mod h1:Shows1vmkBWO40ChOClaUe6DUnZrsP1UPAuoWzIUdgQ=
github.com/foxcpp/go-imap-sql v0.5.1-0.20250124140007-8da5567429d5 h1:jMxhw9qmwqg70qfMDWq0ImRHAduQjkTZOC9vBs5t2ug=
github.com/foxcpp/go-imap-sql v0.5.1-0.20250124140007-8da5567429d5/go.mod h1:LMlfyNkVs7v2zE6OVeGe9qWPmKFdXDmLNddPLodPVIw=
github.com/foxcpp/go-imap-sql v0.5.1-0.20260412133145-20097edd35ec h1:Jm71K60qrrnyISeLXMYKzSZe0RVco+aO/RJugJvafIM=
github.com/foxcpp/go-imap-sql v0.5.1-0.20260412133145-20097edd35ec/go.mod h1:LMlfyNkVs7v2zE6OVeGe9qWPmKFdXDmLNddPLodPVIw=
github.com/foxcpp/go-imap-sql v0.5.1-0.20260412184517-b5e85e90f14d h1:oiq5MLSSqd3sl4VNHKTlrwszWTHIx8+x8y/olInMJRo=
github.com/foxcpp/go-imap-sql v0.5.1-0.20260412184517-b5e85e90f14d/go.mod h1:LMlfyNkVs7v2zE6OVeGe9qWPmKFdXDmLNddPLodPVIw=
github.com/foxcpp/go-mockdns v0.0.0-20191216195825-5eabd8dbfe1f/go.mod h1:tPg4cp4nseejPd+UKxtCVQ2hUxNTZ7qQZJa7CLriIeo=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/foxcpp/go-mtasts v0.0.0-20240130093538-1438da2e5932 h1:p04U/s8IZEc+PVWIDWGUgdqGq3xsixI7XRZ6Bp/xZbQ=
github.com/foxcpp/go-mtasts v0.0.0-20240130093538-1438da2e5932/go.mod h1:RtHIZCsScdjIzXpTTjmEljtUrIjQbPBTvw7F1tKQbKk=
github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23 h1:JSnsCrRrHNBlgfKVFBxFzp3fN/wS21t8fAHcZ9B1uWI=
github.com/foxcpp/go-smtp v1.21.4-0.20250124171104-c8519ae4fb23/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e h1:hKk+CGUtwnKDGKINPEojeo91kx0tnV6V4tlzHehJPfg=
github.com/foxcpp/libdns-gandi v1.0.4-0.20240127130558-4782f9d5ce3e/go.mod h1:G6dw58Xnji2xX+lb+uZxGbtmfxKllm1CGHE2bOPG3WA=
github.com/frankban/quicktest v1.5.0/go.mod h1:jaStnuzAqU1AJdCO0l53JDCJrVDKcS03DbaAcR7Ks/o=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
github.com/golang/mock v1.5.0/go.mod h1:CWnOUgYIOo4TcNZ0wHX3YZCqsaM1I1Jvs6v3mP3KVu8=
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/martian/v3 v3.2.1/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/martian/v3 v3.3.2 h1:IqNFLAmvJOgVlpdEBiQbDc2EwKW77amAycfTuWKdfvw=
github.com/google/martian/v3 v3.3.2/go.mod h1:oBOf6HBosgwRXnUGWUB05QECsc6uvmMiJ3+6W4l/CUk=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.0.0-20220520183353-fd19c99a87aa/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.1.0/go.mod h1:17drOmN3MwGY7t0e+Ei9b45FFGA3fBs3x36SsCg1hq8=
github.com/googleapis/enterprise-certificate-proxy v0.2.0/go.mod h1:8C0jb7/mgJe/9KK8Lm7X9ctZC2t60YyIpYEI16jx0Qg=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c=
github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo=
github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY=
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU=
github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jimlambrt/gldap v0.1.14 h1:InG9kldhIu6OoQK0hvfkW1Lqpc5eLJhxiiDTNmRnrDM=
github.com/jimlambrt/gldap v0.1.14/go.mod h1:yobW9JIAmqe23dVNOaMWewPaff6jGaHgYjspPIIgYmg=
github.com/jmespath/go-jmespath v0.0.0-20180206201540-c2b33e8439af/go.mod h1:Nht3zPeWKUH0NzdCt2Blrr5ys8VGpn0CEB0cQHVjt7k=
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/johannesboyne/gofakes3 v0.0.0-20210704111953-6a9f95c2941c h1:lx/uPI+mUWlqEQ9e6CtNvaK/zD64s/mQ9+yMh16PgY0=
github.com/johannesboyne/gofakes3 v0.0.0-20210704111953-6a9f95c2941c/go.mod h1:LIAXxPvcUXwOcTIj9LSNSUpE9/eMHalTWxsP/kmWxQI=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/acmedns v0.2.0 h1:zTXdHZwe3r2issdVRyqt5/4X2yHpiBVmFnTrwBA29ik=
github.com/libdns/acmedns v0.2.0/go.mod h1:XlKHilQQK/IGHYY//vCb903PdG4Wc/XnDQzcMp2hV3g=
github.com/libdns/alidns v1.0.3 h1:LFHuGnbseq5+HCeGa1aW8awyX/4M2psB9962fdD2+yQ=
github.com/libdns/alidns v1.0.3/go.mod h1:e18uAG6GanfRhcJj6/tps2rCMzQJaYVcGKT+ELjdjGE=
github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054=
github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU=
github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea h1:IGlMNZCUp8Ho7NYYorpP5ZJgg2mFXARs6eHs/pSqFkA=
github.com/libdns/digitalocean v0.0.0-20230728223659-4f9064657aea/go.mod h1:B2TChhOTxvBflpRTHlguXWtwa1Ha5WI6JkB6aCViM+0=
github.com/libdns/gcore v0.0.0-20250127070537-4a9d185c9d20 h1:bQwFw+C9sX/zYZlV53ey0KnNkxrfWYIFpvptuAVhJ1Y=
github.com/libdns/gcore v0.0.0-20250127070537-4a9d185c9d20/go.mod h1:JGoT1mbmqQwtYQqN5F/vGc9j4TTTMKw/hDm5vXADHUI=
github.com/libdns/googleclouddns v1.1.0 h1:murPR1LfTZZObLV2OLxUVmymWH25glkMFKpDjkk2m0E=
github.com/libdns/googleclouddns v1.1.0/go.mod h1:3tzd056dfqKlf71V8Oy19En4WjJ3ybyuWx6P9bQSCIw=
github.com/libdns/hetzner v0.0.1 h1:WsmcsOKnfpKmzwhfyqhGQEIlEeEaEUvb7ezoJgBKaqU=
github.com/libdns/hetzner v0.0.1/go.mod h1:Jj12aJipO9Ir7OGaXueJ5J1RnerFMD0auGa6k9kujG4=
github.com/libdns/leaseweb v0.4.0 h1:WG9R5AwewpYM4goymFwnG2SB0qwL8gMsSzwRHZHee/U=
github.com/libdns/leaseweb v0.4.0/go.mod h1:dvTvEn11JN6+ebhAQ60l+jiaBiEqyJFs3EIo0YBcQkU=
github.com/libdns/libdns v0.1.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/libdns/libdns v0.2.0/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/libdns/libdns v0.2.1/go.mod h1:yQCXzk1lEZmmCPa857bnk4TsOiqYasqpyOEeSObbb40=
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/libdns/metaname v0.3.0 h1:HJudLYthdv52TupOPczojip/nEQHW7xqk5+whGReva4=
github.com/libdns/metaname v0.3.0/go.mod h1:a3hqEgj59tjWaWlF4WxQGhvMVtjz1E4Ngs1GfVS+VhQ=
github.com/libdns/namecheap v0.0.0-20211109042440-fc7440785c8e h1:WCcKyxiiK/sJnST1ulVBKNg4J8luCYDdgUrp2ySMO2s=
github.com/libdns/namecheap v0.0.0-20211109042440-fc7440785c8e/go.mod h1:dED6sMLZxIcilF1GjrcpwgVoCglXGMn86irqQzRhqRY=
github.com/libdns/namedotcom v0.3.3 h1:R10C7+IqQGVeC4opHHMiFNBxdNBg1bi65ZwqLESl+jE=
github.com/libdns/namedotcom v0.3.3/go.mod h1:GbYzsAF2yRUpI0WgIK5fs5UX+kDVUPaYCFLpTnKQm0s=
github.com/libdns/rfc2136 v0.1.1 h1:GKh2r08xt4aYeGlXR9eFrJMfFKD5i9QHBOpT1FIww/U=
github.com/libdns/rfc2136 v0.1.1/go.mod h1:tgXWavE+5OiAfdKxBnuG8OBEwQFAu7uuiS3+laspAGs=
github.com/libdns/route53 v1.5.1 h1:dkdcc2CKY/EHBBzAKqE0Cko7MKR8uVJ3GvpzwKu/UKM=
github.com/libdns/route53 v1.5.1/go.mod h1:joT4hKmaTNKHEwb7GmZ65eoDz1whTu7KKYPS8ZqIh6Q=
github.com/libdns/vultr v1.0.0 h1:W8B4+k2bm9ro3bZLSZV9hMOQI+uO6Svu+GmD+Olz7ZI=
github.com/libdns/vultr v1.0.0/go.mod h1:8K1HJExcbeHS4YPkFHRZpqpXZzZ+DZAA0m0VikJgEqk=
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/martinlindhe/base36 v1.0.0/go.mod h1:+AtEs8xrBpCeYgSLoY/aJ6Wf37jtBuR0s35750M27+8=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.19/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mholt/acmez/v3 v3.0.1 h1:4PcjKjaySlgXK857aTfDuRbmnM5gb3Ruz3tvoSJAUp8=
github.com/mholt/acmez/v3 v3.0.1/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.22/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.25/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/miekg/dns v1.1.63 h1:8M5aAw6OMZfFXTT7K5V0Eu5YiiL8l7nUAkyN6C9YwaY=
github.com/miekg/dns v1.1.63/go.mod h1:6NGHfjhpmr5lt3XPLuyfDJi5AXbNIPM9PY6H6sF1Nfs=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.84 h1:D1HVmAF8JF8Bpi6IU4V9vIEj+8pc+xU88EWMs2yed0E=
github.com/minio/minio-go/v7 v7.0.84/go.mod h1:57YXpvc5l3rjPdhqNrDsvVlY0qPI6UTk1bflAe+9doY=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/netauth/netauth v0.6.2 h1:Gtx/Xxa6YUaGny+iVvWyp+FAmtLQ1IlbB2uWTZEpWxQ=
github.com/netauth/netauth v0.6.2/go.mod h1:4PEbISVqRCQaXaDAt289w3nK9UhoF8/ZOLy31Hbv7ds=
github.com/netauth/protocol v0.0.0-20210918062754-7fee492ffcbd h1:4yVpQ/+li28lQ/daYCWeDB08obRmjaoAw2qfFFaCQ40=
github.com/netauth/protocol v0.0.0-20210918062754-7fee492ffcbd/go.mod h1:wpK5wqysOJU1w2OxgG65du8M7UqBkxzsNaJdjwiRqAs=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pierrec/lz4 v2.6.1+incompatible h1:9UY3+iC23yxF0UfGaYrGplQ+79Rg+h/q9FV9ix19jjM=
github.com/pierrec/lz4 v2.6.1+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a8tTFrMLUcfWwyC0pnifVo2ClaLq+hP8=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63 h1:J6qvD6rbmOil46orKqJaRPG+zTpoGlBTUdyv8ki63L0=
github.com/shabbyrobe/gocovmerge v0.0.0-20180507124511-f6ea450bfb63/go.mod h1:n+VKSARF5y/tS9XFSP7vWDfS+GUC5vs/YT7M5XDTUEM=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.2.1/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk=
github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs=
github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA=
github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w=
github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ=
github.com/vultr/govultr/v3 v3.14.1 h1:9BpyZgsWasuNoR39YVMcq44MSaF576Z4D+U3ro58eJQ=
github.com/vultr/govultr/v3 v3.14.1/go.mod h1:q34Wd76upKmf+vxFMgaNMH3A8BbsPBmSYZUGC8oZa5w=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190923035154-9ee001bba392/go.mod h1:/lpIB1dKB+9EgE3H3cr1v9wB50oz8l4C4h62xy7jSTY=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/lint v0.0.0-20210508222113-6edffad5e616/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190310074541-c10a0554eabf/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20201216054612-986b41b23924/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220617184016-355a448f1bc9/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210220000619-9bb904979d93/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210313182246-cd4f82c27b84/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210805134026-6f1e6394065a/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.0.0-20220608161450-d0670ef3b1eb/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220622183110-fd043fe589d2/go.mod h1:jaDAt6Dkxork7LmZnYtzbRWj0W47D86a3TGe0YHBvmE=
golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg=
golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A=
golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70=
golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210305230114-8fe3ee5dd75b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210320140829-1e4c9ba3b0c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210908233432-aa78b53d3365/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220502124256-b6088ccd6cba/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220610221304-9f5ed59c137d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220624220833-87e55d714810/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190308174544-00c44ba9c14f/go.mod h1:25r3+/G6/xytQM8iWZKq3Hn0kr0rgFKPUNVEL/dr3z4=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190907020128-2ca718005c18/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw=
golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8=
golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220609144429-65e65417b02f/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE=
google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM=
google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc=
google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg=
google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE=
google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8=
google.golang.org/api v0.41.0/go.mod h1:RkxM5lITDfTzmyKFPt+wGrCJbVfniCr2ool8kTBzRTU=
google.golang.org/api v0.43.0/go.mod h1:nQsDGjRXMo4lvh5hP0TKqF244gqhGcr/YSIykhUk/94=
google.golang.org/api v0.47.0/go.mod h1:Wbvgpq1HddcWVtzsVLyfLp8lDg6AA241LmgIL59tHXo=
google.golang.org/api v0.48.0/go.mod h1:71Pr1vy+TAZRPkPs/xlCf5SsU8WjuAWv1Pfjbtukyy4=
google.golang.org/api v0.50.0/go.mod h1:4bNT5pAuq5ji4SRZm+5QIkjny9JAyVD/3gaSihNefaw=
google.golang.org/api v0.51.0/go.mod h1:t4HdrdoNgyN5cbEfm7Lum0lcLDLiise1F8qDKX00sOU=
google.golang.org/api v0.54.0/go.mod h1:7C4bFFOvVDGXjfDTAsgGwDgAxRDeQ4X8NvUedIt6z3k=
google.golang.org/api v0.55.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.56.0/go.mod h1:38yMfeP1kfjsl8isn0tliTjIb1rJXcQi4UXlbqivdVE=
google.golang.org/api v0.57.0/go.mod h1:dVPlbZyBo2/OjBpmvNdpn2GRm6rPy75jyU7bmhdrMgI=
google.golang.org/api v0.61.0/go.mod h1:xQRti5UdCmoCEqFxcz93fTl338AVqDgyaDRuOZ3hg9I=
google.golang.org/api v0.63.0/go.mod h1:gs4ij2ffTRXwuzzgJl/56BdwJaA194ijkfn++9tDuPo=
google.golang.org/api v0.67.0/go.mod h1:ShHKP8E60yPsKNw/w8w+VYaj9H6buA5UqDp8dhbQZ6g=
google.golang.org/api v0.70.0/go.mod h1:Bs4ZM2HGifEvXwd50TtW70ovgJffJYw2oRCOFU/SkfA=
google.golang.org/api v0.71.0/go.mod h1:4PyU6e6JogV1f9eA4voyrTY2batOLdgZ5qZ5HOCc4j8=
google.golang.org/api v0.74.0/go.mod h1:ZpfMZOVRMywNyvJFeqL9HRWBgAuRfSjJFpe9QtRRyDs=
google.golang.org/api v0.75.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
google.golang.org/api v0.77.0/go.mod h1:pU9QmyHLnzlpar1Mjt4IbapUCy8J+6HD6GeELN69ljA=
google.golang.org/api v0.78.0/go.mod h1:1Sg78yoMLOhlQTeF+ARBoytAcH1NNyyl390YMy6rKmw=
google.golang.org/api v0.80.0/go.mod h1:xY3nI94gbvBrE0J6NHXhxOmW97HG7Khjkku6AFB3Hyg=
google.golang.org/api v0.84.0/go.mod h1:NTsGnUFJMYROtiquksZHBWtHfeMC7iYthki7Eq3pa8o=
google.golang.org/api v0.85.0/go.mod h1:AqZf8Ep9uZ2pyTvgL+x0D3Zt0eoT9b5E8fmzfu6FO2g=
google.golang.org/api v0.90.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.93.0/go.mod h1:+Sem1dnrKlrXMR/X0bPnMWyluQe4RsNoYfmNLhOIkzw=
google.golang.org/api v0.95.0/go.mod h1:eADj+UBuxkh5zlrSntJghuNeg8HwQ1w5lTKkuqaETEI=
google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s=
google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70=
google.golang.org/api v0.218.0 h1:x6JCjEWeZ9PFCRe9z0FBrNwj7pB7DOAqT35N+IPnAUA=
google.golang.org/api v0.218.0/go.mod h1:5VGHBAkxrA/8EFjLVEYmMUJ8/8+gWWQ3s4cFH0FxG2M=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210222152913-aa3ee6e6a81c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210303154014-9728d6b83eeb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210310155132-4ce2db91004e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210319143718-93e7006c17a6/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210329143202-679c6ae281ee/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210402141018-6c239bbf2bb1/go.mod h1:9lPAdzaEmUacj36I+k7YKbEc5CXzPIeORRgDAUOu28A=
google.golang.org/genproto v0.0.0-20210513213006-bf773b8c8384/go.mod h1:P3QM42oQyzQSnHPnZ/vqoCdDmzH28fzWByN9asMeM8A=
google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210604141403-392c879c8b08/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210608205507-b6d2f5bf0d7d/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0=
google.golang.org/genproto v0.0.0-20210624195500-8bfb893ecb84/go.mod h1:SzzZ/N+nwJDaO1kznhnlzqS8ocJICar6hYhVyhi++24=
google.golang.org/genproto v0.0.0-20210713002101-d411969a0d9a/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210716133855-ce7ef5c701ea/go.mod h1:AxrInvYm1dci+enl5hChSFPOmmUF1+uAa/UsgNRWd7k=
google.golang.org/genproto v0.0.0-20210728212813-7823e685a01f/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210805201207-89edb61ffb67/go.mod h1:ob2IJxKrgPT52GcgX759i1sleT07tiKowYBGbczaW48=
google.golang.org/genproto v0.0.0-20210813162853-db860fec028c/go.mod h1:cFeNkxwySK631ADgubI+/XFU/xp8FD5KIVV4rj8UC5w=
google.golang.org/genproto v0.0.0-20210821163610-241b8fcbd6c8/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210828152312-66f60bf46e71/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210903162649-d08c68adba83/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210909211513-a8c4777a87af/go.mod h1:eFjDcFEctNawg4eG61bRv87N7iHBWyVhJu7u1kqDUXY=
google.golang.org/genproto v0.0.0-20210924002016-3dee208752a0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211221195035-429b39de9b1c/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220126215142-9970aeb2e350/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220207164111-0872dc986b00/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20220218161850-94dd64e39d7c/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220222213610-43724f9ea8cf/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220304144024-325a89244dc8/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220310185008-1973136f34c6/go.mod h1:kGP+zUP2Ddo0ayMi4YuN7C3WZyJvGLZRh8Z5wnAqvEI=
google.golang.org/genproto v0.0.0-20220324131243-acbaeb5b85eb/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E=
google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220413183235-5e96e2839df9/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220414192740-2d67ff6cf2b4/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220421151946-72621c1f0bd3/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220429170224-98d788798c3e/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo=
google.golang.org/genproto v0.0.0-20220502173005-c8bf987b8c21/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220505152158-f39f71e6c8f3/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220518221133-4f43b3371335/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220523171625-347a074981d8/go.mod h1:RAyBrSAP7Fh3Nc84ghnVLDPuV51xc9agzmm4Ph6i0Q4=
google.golang.org/genproto v0.0.0-20220608133413-ed9918b62aac/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220616135557-88e70c0c3a90/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220617124728-180714bec0ad/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220624142145-8cd45d7dbd1f/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220628213854-d9e0b6570c03/go.mod h1:KEWEmljWE5zPzLBa/oHl6DaEt9LmfH6WtH1OHIvleBA=
google.golang.org/genproto v0.0.0-20220722212130-b98a9ff5e252/go.mod h1:GkXuJDJ6aQ7lnJcRF+SJVgFdQhypqgl3LB1C9vabdRE=
google.golang.org/genproto v0.0.0-20220801145646-83ce21fca29f/go.mod h1:iHe1svFLAZg9VWz891+QbRMwUv9O/1Ww+/mngYeThbc=
google.golang.org/genproto v0.0.0-20220815135757-37a418bb8959/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220817144833-d7fd3f11b9b1/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220822174746-9e6da59bd2fc/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220829144015-23454907ede3/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220829175752-36a9c930ecbf/go.mod h1:dbqgFATTzChvnt+ujMdZwITVAJHFtfyN1qUhDqEiIlk=
google.golang.org/genproto v0.0.0-20220913154956-18f8339a66a5/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220914142337-ca0e39ece12f/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220915135415-7fd63a7952de/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220916172020-2692e8806bfa/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220919141832-68c03719ef51/go.mod h1:0Nb8Qy+Sk5eDzHnzlStwW3itdNaWoZA5XeSG+R3JHSo=
google.golang.org/genproto v0.0.0-20220920201722-2b89144ce006/go.mod h1:ht8XFiar2npT/g4vkk7O0WYS1sHOHbdujxbEp7CJWbw=
google.golang.org/genproto v0.0.0-20220926165614-551eb538f295/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI=
google.golang.org/genproto v0.0.0-20220926220553-6981cbe3cfce/go.mod h1:woMGP53BroOrRY3xTxlbr8Y3eB/nzAvvFM83q7kG2OI=
google.golang.org/genproto v0.0.0-20221010155953-15ba04fc1c0e/go.mod h1:3526vdqwhZAwq4wsRUaVG555sVgsNmIjRtO7t/JH29U=
google.golang.org/genproto v0.0.0-20221014173430-6e2ab493f96b/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM=
google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz9N9Jx0QA82PqRVauvCz1SGSz739p0f183jM=
google.golang.org/genproto v0.0.0-20221018160656-63c7b68cfc55/go.mod h1:45EK0dUbEZ2NHjCeAd2LXmyjAgGUGrpGROgjhC3ADck=
google.golang.org/genproto v0.0.0-20241118233622-e639e219e697 h1:ToEetK57OidYuqD4Q5w+vfEnPvPpuTwedCNVohYJfNk=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47 h1:91mG8dNTpkC0uChJUQ9zCiRqx3GEEFOWaRZ0mI6Oj2I=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250124145028-65684f501c47/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60=
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.36.1/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.37.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.37.1/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM=
google.golang.org/grpc v1.39.0/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.39.1/go.mod h1:PImNr+rS9TWYb2O4/emRugxiyHZ5JyHW5F+RPnDzfrE=
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.40.1/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
google.golang.org/grpc v1.44.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU=
google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ=
google.golang.org/grpc v1.46.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.46.2/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.47.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACuMGWk=
google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.13 h1:PFiaemQwE/jdwi8XEHyEV+qYWoIuikLP3T4rvDeJb00=
modernc.org/ccgo/v4 v4.23.13/go.mod h1:vdN4h2WR5aEoNondUx26K7G8X+nuBscYnAEWSRmN2/0=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.6.1 h1:+Qf6xdG8l7B27TQ8D8lw/iFMUj1RXRBOuMUWziJOsk8=
modernc.org/gc/v2 v2.6.1/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.9 h1:PLSBXVkifXGELtJ5BOnBUyAHr7lsatNwFU/RRo4kfJM=
modernc.org/libc v1.61.9/go.mod h1:61xrnzk/aR8gr5bR7Uj/lLFLuXu2/zMpIjcry63Eumk=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
================================================
FILE: internal/README.md
================================================
maddy source tree
------------------
Main maddy code base lives here. No packages are intended to be used in
third-party software hence API is not stable.
Subdirectories are organized as follows:
```
/
auxiliary libraries
endpoint/
modules - protocol listeners (e.g. SMTP server, etc)
target/
modules - final delivery targets (including outbound delivery, such as
target.smtp, remote)
auth/
modules - authentication providers
check/
modules - message checkers (module.Check)
modify/
modules - message modifiers (module.Modifier)
storage/
modules - local messages storage implementations (module.Storage)
```
================================================
FILE: internal/auth/auth.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package auth
import "strings"
func CheckDomainAuth(username string, perDomain bool, allowedDomains []string) (loginName string, allowed bool) {
var accountName, domain string
if perDomain {
parts := strings.Split(username, "@")
if len(parts) != 2 {
return "", false
}
domain = parts[1]
accountName = username
} else {
parts := strings.Split(username, "@")
accountName = parts[0]
if len(parts) == 2 {
domain = parts[1]
}
}
allowed = domain == ""
if allowedDomains != nil && domain != "" {
for _, allowedDomain := range allowedDomains {
if strings.EqualFold(domain, allowedDomain) {
allowed = true
}
}
if !allowed {
return "", false
}
}
return accountName, allowed
}
================================================
FILE: internal/auth/auth_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package auth
import (
"fmt"
"testing"
)
func TestCheckDomainAuth(t *testing.T) {
cases := []struct {
rawUsername string
perDomain bool
allowedDomains []string
loginName string
}{
{
rawUsername: "username",
loginName: "username",
},
{
rawUsername: "username",
allowedDomains: []string{"example.org"},
loginName: "username",
},
{
rawUsername: "username@example.org",
allowedDomains: []string{"example.org"},
loginName: "username",
},
{
rawUsername: "username@example.com",
allowedDomains: []string{"example.org"},
},
{
rawUsername: "username",
allowedDomains: []string{"example.org"},
perDomain: true,
},
{
rawUsername: "username@example.com",
allowedDomains: []string{"example.org"},
perDomain: true,
},
{
rawUsername: "username@EXAMPLE.Org",
allowedDomains: []string{"exaMPle.org"},
perDomain: true,
loginName: "username@EXAMPLE.Org",
},
{
rawUsername: "username@example.org",
allowedDomains: []string{"example.org"},
perDomain: true,
loginName: "username@example.org",
},
}
for _, case_ := range cases {
t.Run(fmt.Sprintf("%+v", case_), func(t *testing.T) {
loginName, allowed := CheckDomainAuth(case_.rawUsername, case_.perDomain, case_.allowedDomains)
if case_.loginName != "" && !allowed {
t.Fatalf("Unexpected authentication fail")
}
if case_.loginName == "" && allowed {
t.Fatalf("Expected authentication fail, got %s as login name", loginName)
}
if loginName != case_.loginName {
t.Errorf("Incorrect login name, got %s, wanted %s", loginName, case_.loginName)
}
})
}
}
================================================
FILE: internal/auth/dovecot_sasl/dovecot_sasl.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dovecotsasl
import (
"fmt"
"net"
"github.com/emersion/go-sasl"
dovecotsasl "github.com/foxcpp/go-dovecot-sasl"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/auth"
)
type Auth struct {
instName string
serverEndpoint string
log *log.Logger
network string
addr string
mechanisms map[string]dovecotsasl.Mechanism
}
const modName = "dovecot_sasl"
func New(c *container.C, _, instName string) (module.Module, error) {
a := &Auth{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}
return a, nil
}
func (a *Auth) Name() string {
return modName
}
func (a *Auth) InstanceName() string {
return a.instName
}
func (a *Auth) getConn() (*dovecotsasl.Client, error) {
// TODO: Connection pooling
conn, err := net.Dial(a.network, a.addr)
if err != nil {
return nil, fmt.Errorf("%s: unable to contact server: %v", modName, err)
}
cl, err := dovecotsasl.NewClient(conn)
if err != nil {
return nil, fmt.Errorf("%s: unable to contact server: %v", modName, err)
}
return cl, nil
}
func (a *Auth) returnConn(cl *dovecotsasl.Client) {
if err := cl.Close(); err != nil {
a.log.Error("connection close failed", err)
}
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
switch len(inlineArgs) {
case 0:
case 1:
a.serverEndpoint = inlineArgs[0]
default:
return fmt.Errorf("%s: one or none arguments needed", modName)
}
cfg.String("endpoint", false, false, a.serverEndpoint, &a.serverEndpoint)
if _, err := cfg.Process(); err != nil {
return err
}
if a.serverEndpoint == "" {
return fmt.Errorf("%s: missing server endpoint", modName)
}
endp, err := config.ParseEndpoint(a.serverEndpoint)
if err != nil {
return fmt.Errorf("%s: invalid server endpoint: %v", modName, err)
}
// Dial once to check usability and also to get list of mechanisms.
conn, err := net.Dial(endp.Scheme, endp.Address())
if err != nil {
return fmt.Errorf("%s: unable to contact server: %v", modName, err)
}
cl, err := dovecotsasl.NewClient(conn)
if err != nil {
return fmt.Errorf("%s: unable to contact server: %v", modName, err)
}
defer func() {
if err := cl.Close(); err != nil {
a.log.Error("connection close failed", err)
}
}()
a.mechanisms = make(map[string]dovecotsasl.Mechanism, len(cl.ConnInfo().Mechs))
for name, mech := range cl.ConnInfo().Mechs {
if mech.Private {
continue
}
a.mechanisms[name] = mech
}
a.network = endp.Scheme
a.addr = endp.Address()
return nil
}
func (a *Auth) AuthPlain(username, password string) error {
if _, ok := a.mechanisms[sasl.Plain]; ok {
cl, err := a.getConn()
if err != nil {
return exterrors.WithTemporary(err, true)
}
defer a.returnConn(cl)
// Pretend it is SMTPS even though we really don't know.
// We also have no connection information to pass to the server...
return cl.Do("SMTP", sasl.NewPlainClient("", username, password),
dovecotsasl.Secured, dovecotsasl.NoPenalty)
}
if _, ok := a.mechanisms[sasl.Login]; ok {
cl, err := a.getConn()
if err != nil {
return err
}
defer a.returnConn(cl)
return cl.Do("SMTP", sasl.NewLoginClient(username, password),
dovecotsasl.Secured, dovecotsasl.NoPenalty)
}
return auth.ErrUnsupportedMech
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/auth/external/externalauth.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package external
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/auth"
)
type ExternalAuth struct {
modName string
instName string
helperPath string
perDomain bool
domains []string
log *log.Logger
}
func New(c *container.C, modName, instName string) (module.Module, error) {
ea := &ExternalAuth{
modName: modName,
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}
return ea, nil
}
func (ea *ExternalAuth) Name() string {
return ea.modName
}
func (ea *ExternalAuth) InstanceName() string {
return ea.instName
}
func (ea *ExternalAuth) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return errors.New("external: inline arguments are not used")
}
cfg.Bool("debug", false, false, &ea.log.Debug)
cfg.Bool("perdomain", false, false, &ea.perDomain)
cfg.StringList("domains", false, false, nil, &ea.domains)
cfg.String("helper", false, false, "", &ea.helperPath)
if _, err := cfg.Process(); err != nil {
return err
}
if ea.perDomain && ea.domains == nil {
return errors.New("auth_domains must be set if auth_perdomain is used")
}
if ea.helperPath != "" {
ea.log.Debugln("using helper:", ea.helperPath)
} else {
ea.helperPath = filepath.Join(config.LibexecDirectory, "maddy-auth-helper")
}
if _, err := os.Stat(ea.helperPath); err != nil {
return fmt.Errorf("%s doesn't exist", ea.helperPath)
}
ea.log.Debugln("using helper:", ea.helperPath)
return nil
}
func (ea *ExternalAuth) AuthPlain(username, password string) error {
accountName, ok := auth.CheckDomainAuth(username, ea.perDomain, ea.domains)
if !ok {
return module.ErrUnknownCredentials
}
return AuthUsingHelper(ea.helperPath, accountName, password)
}
func init() {
modules.Register("auth.external", New)
}
================================================
FILE: internal/auth/external/helperauth.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package external
import (
"fmt"
"io"
"os/exec"
"github.com/foxcpp/maddy/framework/module"
)
func AuthUsingHelper(binaryPath, accountName, password string) error {
cmd := exec.Command(binaryPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("helperauth: stdin init: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("helperauth: process start: %w", err)
}
if _, err := io.WriteString(stdin, accountName+"\n"); err != nil {
return fmt.Errorf("helperauth: stdin write: %w", err)
}
if _, err := io.WriteString(stdin, password+"\n"); err != nil {
return fmt.Errorf("helperauth: stdin write: %w", err)
}
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
// Exit code 1 is for authentication failure.
if exitErr.ExitCode() != 1 {
return fmt.Errorf("helperauth: %w: %v", err, string(exitErr.Stderr))
}
return module.ErrUnknownCredentials
}
return fmt.Errorf("helperauth: process wait: %w", err)
}
return nil
}
================================================
FILE: internal/auth/ldap/ldap.go
================================================
package ldap
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/url"
"strings"
"sync"
"time"
"github.com/foxcpp/maddy/framework/config"
tls2 "github.com/foxcpp/maddy/framework/config/tls"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/go-ldap/ldap/v3"
)
const modName = "auth.ldap"
type Auth struct {
instName string
urls []string
readBind func(*ldap.Conn) error
startls bool
tlsCfg *tls.Config
dialer *net.Dialer
requestTimeout time.Duration
dnTemplate string
// or
baseDN string
filterTemplate string
conn *ldap.Conn
connLock sync.Mutex
log *log.Logger
}
func New(c *container.C, modName, instName string) (module.Module, error) {
return &Auth{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
a.urls = inlineArgs
a.dialer = &net.Dialer{}
cfg.Bool("debug", true, false, &a.log.Debug)
cfg.Custom("tls_client", true, false, func() (interface{}, error) {
return &tls.Config{}, nil
}, tls2.TLSClientBlock, &a.tlsCfg)
cfg.Callback("urls", func(m *config.Map, node config.Node) error {
a.urls = append(a.urls, node.Args...)
return nil
})
cfg.Custom("bind", false, false, func() (interface{}, error) {
return func(*ldap.Conn) error {
return nil
}, nil
}, readBindDirective, &a.readBind)
cfg.Bool("starttls", false, false, &a.startls)
cfg.Duration("connect_timeout", false, false, time.Minute, &a.dialer.Timeout)
cfg.Duration("request_timeout", false, false, time.Minute, &a.requestTimeout)
cfg.String("dn_template", false, false, "", &a.dnTemplate)
cfg.String("base_dn", false, false, "", &a.baseDN)
cfg.String("filter", false, false, "", &a.filterTemplate)
if _, err := cfg.Process(); err != nil {
return err
}
if a.dnTemplate == "" {
if a.baseDN == "" {
return fmt.Errorf("auth.ldap: base_dn not set")
}
if a.filterTemplate == "" {
return fmt.Errorf("auth.ldap: filter not set")
}
} else {
if a.baseDN != "" || a.filterTemplate != "" {
return fmt.Errorf("auth.ldap: search directives set when dn_template is used")
}
}
return nil
}
func readBindDirective(c *config.Map, n config.Node) (interface{}, error) {
if len(n.Args) == 0 {
return nil, fmt.Errorf("auth.ldap: auth expects at least one argument")
}
switch n.Args[0] {
case "off":
return func(*ldap.Conn) error { return nil }, nil
case "unauth":
if len(n.Args) == 2 {
return func(c *ldap.Conn) error {
return c.UnauthenticatedBind(n.Args[1])
}, nil
}
return func(c *ldap.Conn) error {
return c.UnauthenticatedBind("")
}, nil
case "plain":
if len(n.Args) != 3 {
return nil, fmt.Errorf("auth.ldap: username and password expected for plaintext bind")
}
return func(c *ldap.Conn) error {
return c.Bind(n.Args[1], n.Args[2])
}, nil
case "external":
return (*ldap.Conn).ExternalBind, nil
}
return nil, fmt.Errorf("auth.ldap: unknown bind authentication: %v", n.Args[0])
}
func (a *Auth) Name() string {
return modName
}
func (a *Auth) InstanceName() string {
return a.instName
}
func (a *Auth) newConn() (*ldap.Conn, error) {
var (
conn *ldap.Conn
tlsCfg *tls.Config
)
for _, u := range a.urls {
parsedURL, err := url.Parse(u)
if err != nil {
return nil, fmt.Errorf("auth.ldap: invalid server URL: %w", err)
}
hostname := parsedURL.Host
a.tlsCfg.ServerName = strings.Split(hostname, ":")[0]
tlsCfg = a.tlsCfg.Clone()
conn, err = ldap.DialURL(u, ldap.DialWithDialer(a.dialer), ldap.DialWithTLSConfig(tlsCfg))
if err != nil {
a.log.Error("cannot contact directory server", err, "url", u)
continue
}
break
}
if conn == nil {
return nil, fmt.Errorf("auth.ldap: all directory servers are unreachable")
}
if a.requestTimeout != 0 {
conn.SetTimeout(a.requestTimeout)
}
if a.startls {
if err := conn.StartTLS(tlsCfg); err != nil {
return nil, fmt.Errorf("auth.ldap: %w", err)
}
}
if err := a.readBind(conn); err != nil {
return nil, fmt.Errorf("auth.ldap: %w", err)
}
return conn, nil
}
func (a *Auth) getConn() (*ldap.Conn, error) {
a.connLock.Lock()
if a.conn == nil {
conn, err := a.newConn()
if err != nil {
a.connLock.Unlock()
return nil, err
}
a.conn = conn
}
if a.conn.IsClosing() {
if err := a.conn.Close(); err != nil {
a.log.Error("Connection close failed", err)
}
conn, err := a.newConn()
if err != nil {
a.connLock.Unlock()
return nil, err
}
a.conn = conn
}
return a.conn, nil
}
func (a *Auth) returnConn(conn *ldap.Conn) {
defer a.connLock.Unlock()
if err := a.readBind(conn); err != nil {
a.log.Error("failed to rebind for reading", err)
if err := a.conn.Close(); err != nil {
a.log.Error("Connection close failed", err)
}
a.conn = nil
}
if a.conn != conn {
if err := a.conn.Close(); err != nil {
a.log.Error("Connection close failed", err)
}
}
a.conn = conn
}
func (a *Auth) Lookup(_ context.Context, username string) (string, bool, error) {
conn, err := a.getConn()
if err != nil {
return "", false, err
}
defer a.returnConn(conn)
var userDN string
if a.dnTemplate != "" {
return "", false, fmt.Errorf("auth.ldap: lookups require search config but dn_template is used")
} else {
req := ldap.NewSearchRequest(
a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, 0, false,
strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)),
[]string{"dn"}, nil)
res, err := conn.Search(req)
if err != nil {
return "", false, fmt.Errorf("auth.ldap: search: %w", err)
}
if len(res.Entries) > 1 {
return "", false, fmt.Errorf("auth.ldap: too manu entries returned (%d)", len(res.Entries))
}
if len(res.Entries) == 0 {
return "", false, nil
}
userDN = res.Entries[0].DN
}
return userDN, true, nil
}
func (a *Auth) AuthPlain(username, password string) error {
conn, err := a.getConn()
if err != nil {
return err
}
defer a.returnConn(conn)
var userDN string
if a.dnTemplate != "" {
userDN = strings.ReplaceAll(a.dnTemplate, "{username}", ldap.EscapeDN(username))
} else {
req := ldap.NewSearchRequest(
a.baseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
2, 0, false,
strings.ReplaceAll(a.filterTemplate, "{username}", ldap.EscapeFilter(username)),
[]string{"dn"}, nil)
res, err := conn.Search(req)
if err != nil {
return fmt.Errorf("auth.ldap: search: %w", err)
}
if len(res.Entries) > 1 {
return fmt.Errorf("auth.ldap: too manu entries returned (%d)", len(res.Entries))
}
if len(res.Entries) == 0 {
return module.ErrUnknownCredentials
}
userDN = res.Entries[0].DN
}
if err := conn.Bind(userDN, password); err != nil {
return module.ErrUnknownCredentials
}
return nil
}
func (a *Auth) Start() error {
var err error
a.conn, err = a.newConn()
if err != nil {
return fmt.Errorf("auth.ldap: %w", err)
}
return nil
}
func (a *Auth) Stop() error {
a.connLock.Lock()
defer a.connLock.Unlock()
return a.conn.Close()
}
func init() {
var _ module.PlainAuth = &Auth{}
var _ module.Table = &Auth{}
modules.Register(modName, New)
modules.Register("table.ldap", New)
}
================================================
FILE: internal/auth/netauth/netauth.go
================================================
package netauth
import (
"context"
"fmt"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/hashicorp/go-hclog"
"github.com/netauth/netauth/pkg/netauth"
)
const modName = "auth.netauth"
func init() {
var _ module.PlainAuth = &Auth{}
var _ module.Table = &Auth{}
modules.Register(modName, New)
modules.Register("table.netauth", New)
}
// Auth binds all methods related to the NetAuth client library.
type Auth struct {
instName string
mustGroup string
nacl *netauth.Client
log *log.Logger
}
// New creates a new instance of the NetAuth module.
func New(c *container.C, modName, instName string) (module.Module, error) {
return &Auth{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) > 0 {
return fmt.Errorf("%s: inline arguments are not used", modName)
}
l := hclog.New(&hclog.LoggerOptions{Output: a.log})
n, err := netauth.NewWithLog(l)
if err != nil {
return err
}
a.nacl = n
a.nacl.SetServiceName("maddy")
cfg.String("require_group", false, false, "", &a.mustGroup)
cfg.Bool("debug", true, false, &a.log.Debug)
if _, err := cfg.Process(); err != nil {
return err
}
return nil
}
// Name returns "auth.netauth" as the fixed module name.
func (a *Auth) Name() string {
return modName
}
// InstanceName returns the configured name for this instance of the
// plugin. Given the way that NetAuth works it doesn't really make
// sense to have more than one instance, but this is part of the API.
func (a *Auth) InstanceName() string {
return a.instName
}
// Lookup requests the entity from the remote NetAuth server,
// potentially returning that the user does not exist at all.
func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error) {
e, err := a.nacl.EntityInfo(ctx, username)
if err != nil {
return "", false, fmt.Errorf("%s: search: %w", modName, err)
}
if a.mustGroup != "" {
if err := a.checkMustGroup(username); err != nil {
return "", false, err
}
}
return e.GetID(), true, nil
}
// AuthPlain attempts straightforward authentication of the entity on
// the remote NetAuth server.
func (a *Auth) AuthPlain(username, password string) error {
a.log.Debugf("attempting to auth user: %s", username)
if err := a.nacl.AuthEntity(context.Background(), username, password); err != nil {
return module.ErrUnknownCredentials
}
a.log.Debugln("netauth returns successful auth")
if a.mustGroup != "" {
if err := a.checkMustGroup(username); err != nil {
return err
}
}
return nil
}
func (a *Auth) checkMustGroup(username string) error {
a.log.Debugf("Performing require_group check: must=%s", a.mustGroup)
groups, err := a.nacl.EntityGroups(context.Background(), username)
if err != nil {
return fmt.Errorf("%s: groups: %w", modName, err)
}
for _, g := range groups {
if g.GetName() == a.mustGroup {
return nil
}
}
return fmt.Errorf("%s: missing required group (%s not in %s)", modName, username, a.mustGroup)
}
================================================
FILE: internal/auth/pam/module.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package pam
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/auth/external"
)
type Auth struct {
instName string
useHelper bool
helperPath string
Log *log.Logger
}
func New(c *container.C, modName, instName string) (module.Module, error) {
return &Auth{
instName: instName,
Log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (a *Auth) Name() string {
return "pam"
}
func (a *Auth) InstanceName() string {
return a.instName
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return errors.New("pam: inline arguments are not used")
}
cfg.Bool("debug", true, false, &a.Log.Debug)
cfg.Bool("use_helper", false, false, &a.useHelper)
if _, err := cfg.Process(); err != nil {
return err
}
if !canCallDirectly && !a.useHelper {
return errors.New("pam: this build lacks support for direct libpam invocation, use helper binary")
}
if a.useHelper {
a.helperPath = filepath.Join(config.LibexecDirectory, "maddy-pam-helper")
if _, err := os.Stat(a.helperPath); err != nil {
return fmt.Errorf("pam: no helper binary (maddy-pam-helper) found in %s", config.LibexecDirectory)
}
}
return nil
}
func (a *Auth) AuthPlain(username, password string) error {
if a.useHelper {
if err := external.AuthUsingHelper(a.helperPath, username, password); err != nil {
return err
}
}
err := runPAMAuth(username, password)
if err != nil {
return err
}
return nil
}
func init() {
modules.Register("auth.pam", New)
}
================================================
FILE: internal/auth/pam/pam.c
================================================
//+build libpam
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2022 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
#define _POSIX_C_SOURCE 200809L
#include
#include
#include
#include
#include "pam.h"
static int conv_func(int num_msg, const struct pam_message **msg, struct pam_response **resp, void *appdata_ptr) {
struct pam_response *reply = malloc(sizeof(struct pam_response));
if (reply == NULL) {
return PAM_CONV_ERR;
}
char* password_cpy = malloc(strlen((char*)appdata_ptr)+1);
if (password_cpy == NULL) {
return PAM_CONV_ERR;
}
memcpy(password_cpy, (char*)appdata_ptr, strlen((char*)appdata_ptr)+1);
reply->resp = password_cpy;
reply->resp_retcode = 0;
// PAM frees pam_response for us.
*resp = reply;
return PAM_SUCCESS;
}
struct error_obj run_pam_auth(const char *username, char *password) {
const struct pam_conv local_conv = { conv_func, password };
pam_handle_t *local_auth = NULL;
int status = pam_start("maddy", username, &local_conv, &local_auth);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
ret_val.status = 2;
ret_val.func_name = "pam_start";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
status = pam_authenticate(local_auth, PAM_SILENT|PAM_DISALLOW_NULL_AUTHTOK);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
if (status == PAM_AUTH_ERR || status == PAM_USER_UNKNOWN) {
ret_val.status = 1;
} else {
ret_val.status = 2;
}
ret_val.func_name = "pam_authenticate";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
status = pam_acct_mgmt(local_auth, PAM_SILENT|PAM_DISALLOW_NULL_AUTHTOK);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
if (status == PAM_AUTH_ERR || status == PAM_USER_UNKNOWN || status == PAM_NEW_AUTHTOK_REQD) {
ret_val.status = 1;
} else {
ret_val.status = 2;
}
ret_val.func_name = "pam_acct_mgmt";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
status = pam_end(local_auth, status);
if (status != PAM_SUCCESS) {
struct error_obj ret_val;
ret_val.status = 2;
ret_val.func_name = "pam_end";
ret_val.error_msg = pam_strerror(local_auth, status);
return ret_val;
}
struct error_obj ret_val;
ret_val.status = 0;
ret_val.func_name = NULL;
ret_val.error_msg = NULL;
return ret_val;
}
================================================
FILE: internal/auth/pam/pam.go
================================================
//go:build cgo && libpam
// +build cgo,libpam
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package pam
/*
#cgo LDFLAGS: -lpam
#cgo CFLAGS: -DCGO -Wall -Wextra -Werror -Wno-unused-parameter -Wno-error=unused-parameter -Wpedantic -std=c99
#include
#include "pam.h"
*/
import "C"
import (
"errors"
"fmt"
"unsafe"
)
const canCallDirectly = true
var ErrInvalidCredentials = errors.New("pam: invalid credentials or unknown user")
func runPAMAuth(username, password string) error {
usernameC := C.CString(username)
passwordC := C.CString(password)
defer C.free(unsafe.Pointer(usernameC))
defer C.free(unsafe.Pointer(passwordC))
errObj := C.run_pam_auth(usernameC, passwordC)
if errObj.status == 1 {
return ErrInvalidCredentials
}
if errObj.status == 2 {
return fmt.Errorf("%s: %s", C.GoString(errObj.func_name), C.GoString(errObj.error_msg))
}
return nil
}
================================================
FILE: internal/auth/pam/pam.h
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
#pragma once
struct error_obj {
int status;
const char* func_name;
const char* error_msg;
};
struct error_obj run_pam_auth(const char *username, char *password);
================================================
FILE: internal/auth/pam/pam_stub.go
================================================
//go:build !cgo || !libpam
// +build !cgo !libpam
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package pam
import (
"errors"
)
const canCallDirectly = false
var ErrInvalidCredentials = errors.New("pam: invalid credentials or unknown user")
func runPAMAuth(username, password string) error {
return errors.New("pam: Can't call libpam directly")
}
================================================
FILE: internal/auth/pass_table/hash.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package pass_table
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"fmt"
"io"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
const (
HashSHA256 = "sha256"
HashBcrypt = "bcrypt"
HashArgon2 = "argon2"
DefaultHash = HashBcrypt
Argon2Salt = 16
Argon2Size = 64
)
type (
// HashOpts is the structure that holds additional parameters for used hash
// functions. They are used for new passwords.
//
// These parameters should be stored together with the hashed password
// so it can be verified independently of the used HashOpts.
HashOpts struct {
// Bcrypt cost value to use. Should be at least 10.
BcryptCost int
Argon2Time uint32
Argon2Memory uint32
Argon2Threads uint8
}
FuncHashCompute func(opts HashOpts, pass string) (string, error)
FuncHashVerify func(pass, hashSalt string) error
)
var (
HashCompute = map[string]FuncHashCompute{
HashBcrypt: computeBcrypt,
HashArgon2: computeArgon2,
}
HashVerify = map[string]FuncHashVerify{
HashBcrypt: verifyBcrypt,
HashArgon2: verifyArgon2,
}
Hashes = []string{HashSHA256, HashBcrypt, HashArgon2}
)
func computeArgon2(opts HashOpts, pass string) (string, error) {
salt := make([]byte, Argon2Salt)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return "", fmt.Errorf("pass_table: failed to generate salt: %w", err)
}
hash := argon2.IDKey([]byte(pass), salt, opts.Argon2Time, opts.Argon2Memory, opts.Argon2Threads, Argon2Size)
var out strings.Builder
out.WriteString(strconv.FormatUint(uint64(opts.Argon2Time), 10))
out.WriteRune(':')
out.WriteString(strconv.FormatUint(uint64(opts.Argon2Memory), 10))
out.WriteRune(':')
out.WriteString(strconv.FormatUint(uint64(opts.Argon2Threads), 10))
out.WriteRune(':')
out.WriteString(base64.StdEncoding.EncodeToString(salt))
out.WriteRune(':')
out.WriteString(base64.StdEncoding.EncodeToString(hash))
return out.String(), nil
}
func verifyArgon2(pass, hashSalt string) error {
parts := strings.SplitN(hashSalt, ":", 5)
time, err := strconv.ParseUint(parts[0], 10, 32)
if err != nil {
return fmt.Errorf("pass_table: malformed hash string: %w", err)
}
memory, err := strconv.ParseUint(parts[1], 10, 32)
if err != nil {
return fmt.Errorf("pass_table: malformed hash string: %w", err)
}
threads, err := strconv.ParseUint(parts[2], 10, 8)
if err != nil {
return fmt.Errorf("pass_table: malformed hash string: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(parts[3])
if err != nil {
return fmt.Errorf("pass_table: malformed hash string: %w", err)
}
hash, err := base64.StdEncoding.DecodeString(parts[4])
if err != nil {
return fmt.Errorf("pass_table: malformed hash string: %w", err)
}
passHash := argon2.IDKey([]byte(pass), salt, uint32(time), uint32(memory), uint8(threads), Argon2Size)
if subtle.ConstantTimeCompare(passHash, hash) != 1 {
return fmt.Errorf("pass_table: hash mismatch")
}
return nil
}
func computeSHA256(_ HashOpts, pass string) (string, error) {
salt := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return "", fmt.Errorf("pass_table: failed to generate salt: %w", err)
}
hashInput := salt
hashInput = append(hashInput, []byte(pass)...)
sum := sha256.Sum256(hashInput)
return base64.StdEncoding.EncodeToString(salt) + ":" + base64.StdEncoding.EncodeToString(sum[:]), nil
}
func verifySHA256(pass, hashSalt string) error {
parts := strings.Split(hashSalt, ":")
if len(parts) != 2 {
return fmt.Errorf("pass_table: malformed hash string, no salt")
}
salt, err := base64.StdEncoding.DecodeString(parts[0])
if err != nil {
return fmt.Errorf("pass_table: malformed hash string, cannot decode pass: %w", err)
}
hash, err := base64.StdEncoding.DecodeString(parts[1])
if err != nil {
return fmt.Errorf("pass_table: malformed hash string, cannot decode pass: %w", err)
}
hashInput := salt
hashInput = append(hashInput, []byte(pass)...)
sum := sha256.Sum256(hashInput)
if subtle.ConstantTimeCompare(sum[:], hash) != 1 {
return fmt.Errorf("pass_table: hash mismatch")
}
return nil
}
func computeBcrypt(opts HashOpts, pass string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(pass), opts.BcryptCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func verifyBcrypt(pass, hashSalt string) error {
return bcrypt.CompareHashAndPassword([]byte(hashSalt), []byte(pass))
}
func addSHA256() {
HashCompute[HashSHA256] = computeSHA256
HashVerify[HashSHA256] = verifySHA256
}
================================================
FILE: internal/auth/pass_table/table.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package pass_table
import (
"context"
"fmt"
"strings"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"golang.org/x/crypto/bcrypt"
"golang.org/x/text/secure/precis"
)
type Auth struct {
modName string
instName string
table module.Table
}
func New(_ *container.C, modName, instName string) (module.Module, error) {
return &Auth{
modName: modName,
instName: instName,
}, nil
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return modconfig.ModuleFromNode("table", inlineArgs, cfg.Block, cfg.Globals, &a.table)
}
cfg.Custom("table", false, true, nil, modconfig.TableDirective, &a.table)
_, err := cfg.Process()
return err
}
func (a *Auth) Name() string {
return a.modName
}
func (a *Auth) InstanceName() string {
return a.instName
}
func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error) {
key, err := precis.UsernameCaseMapped.CompareKey(username)
if err != nil {
return "", false, err
}
return a.table.Lookup(ctx, key)
}
func (a *Auth) AuthPlain(username, password string) error {
key, err := precis.UsernameCaseMapped.CompareKey(username)
if err != nil {
return err
}
hash, ok, err := a.table.Lookup(context.TODO(), key)
if !ok {
return module.ErrUnknownCredentials
}
if err != nil {
return err
}
parts := strings.SplitN(hash, ":", 2)
if len(parts) != 2 {
return fmt.Errorf("%s: auth plain %s: no hash tag", a.modName, key)
}
hashVerify := HashVerify[parts[0]]
if hashVerify == nil {
return fmt.Errorf("%s: auth plain %s: unknown hash: %s", a.modName, key, parts[0])
}
return hashVerify(password, parts[1])
}
func (a *Auth) ListUsers() ([]string, error) {
tbl, ok := a.table.(module.MutableTable)
if !ok {
return nil, fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
}
l, err := tbl.Keys()
if err != nil {
return nil, fmt.Errorf("%s: list users: %w", a.modName, err)
}
return l, nil
}
func (a *Auth) CreateUser(username, password string) error {
return a.CreateUserHash(username, password, HashBcrypt, HashOpts{
BcryptCost: bcrypt.DefaultCost,
})
}
func (a *Auth) CreateUserHash(username, password string, hashAlgo string, opts HashOpts) error {
tbl, ok := a.table.(module.MutableTable)
if !ok {
return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
}
if _, ok := HashCompute[hashAlgo]; !ok {
return fmt.Errorf("%s: unknown hash function: %v", a.modName, hashAlgo)
}
key, err := precis.UsernameCaseMapped.CompareKey(username)
if err != nil {
return fmt.Errorf("%s: create user %s (raw): %w", a.modName, username, err)
}
_, ok, err = tbl.Lookup(context.TODO(), key)
if err != nil {
return fmt.Errorf("%s: create user %s: %w", a.modName, key, err)
}
if ok {
return fmt.Errorf("%s: credentials for %s already exist", a.modName, key)
}
hash, err := HashCompute[hashAlgo](opts, password)
if err != nil {
return fmt.Errorf("%s: create user %s: hash generation: %w", a.modName, key, err)
}
if err := tbl.SetKey(key, hashAlgo+":"+hash); err != nil {
return fmt.Errorf("%s: create user %s: %w", a.modName, key, err)
}
return nil
}
func (a *Auth) SetUserPassword(username, password string) error {
tbl, ok := a.table.(module.MutableTable)
if !ok {
return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
}
key, err := precis.UsernameCaseMapped.CompareKey(username)
if err != nil {
return fmt.Errorf("%s: set password %s (raw): %w", a.modName, username, err)
}
// TODO: Allow to customize hash function.
hash, err := HashCompute[HashBcrypt](HashOpts{
BcryptCost: bcrypt.DefaultCost,
}, password)
if err != nil {
return fmt.Errorf("%s: set password %s: hash generation: %w", a.modName, key, err)
}
if err := tbl.SetKey(key, "bcrypt:"+hash); err != nil {
return fmt.Errorf("%s: set password %s: %w", a.modName, key, err)
}
return nil
}
func (a *Auth) DeleteUser(username string) error {
tbl, ok := a.table.(module.MutableTable)
if !ok {
return fmt.Errorf("%s: table is not mutable, no management functionality available", a.modName)
}
key, err := precis.UsernameCaseMapped.CompareKey(username)
if err != nil {
return fmt.Errorf("%s: del user %s (raw): %w", a.modName, username, err)
}
if err := tbl.RemoveKey(key); err != nil {
return fmt.Errorf("%s: del user %s: %w", a.modName, key, err)
}
return nil
}
func init() {
modules.Register("auth.pass_table", New)
}
================================================
FILE: internal/auth/pass_table/table_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package pass_table
import (
"testing"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/internal/testutils"
)
func TestAuth_AuthPlain(t *testing.T) {
addSHA256()
mod, err := New(container.New(), "pass_table", "")
if err != nil {
t.Fatal(err)
}
err = mod.Configure([]string{"dummy"}, config.NewMap(nil, config.Node{
Children: []config.Node{},
}))
if err != nil {
t.Fatal(err)
}
a := mod.(*Auth)
a.table = testutils.Table{
M: map[string]string{
"foxcpp": "sha256:U0FMVA==:8PDRAgaUqaLSk34WpYniXjaBgGM93Lc6iF4pw2slthw=",
"not-foxcpp": "bcrypt:$2y$10$4tEJtJ6dApmhETg8tJ4WHOeMtmYXQwmHDKIyfg09Bw1F/smhLjlaa",
"not-foxcpp-2": "argon2:1:8:1:U0FBQUFBTFQ=:KHUshl3DcpHR3AoVd28ZeBGmZ1Fj1gwJgNn98Ia8DAvGHqI0BvFOMJPxtaAfO8F+qomm2O3h0P0yV50QGwXI/Q==",
},
}
check := func(user, pass string, ok bool) {
t.Helper()
err := a.AuthPlain(user, pass)
if (err == nil) != ok {
t.Errorf("ok=%v, err: %v", ok, err)
}
}
check("foxcpp", "password", true)
check("foxcpp", "different-password", false)
check("not-foxcpp", "password", true)
check("not-foxcpp", "different-password", false)
check("not-foxcpp-2", "password", true)
}
================================================
FILE: internal/auth/plain_separate/plain_separate.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package plain_separate
import (
"context"
"errors"
"fmt"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
)
type Auth struct {
modName string
instName string
userTbls []module.Table
passwd []module.PlainAuth
onlyFirstID bool
log *log.Logger
}
func New(c *container.C, modName, instName string) (module.Module, error) {
a := &Auth{
modName: modName,
instName: instName,
onlyFirstID: false,
log: c.DefaultLogger.Sublogger(modName),
}
return a, nil
}
func (a *Auth) Name() string {
return a.modName
}
func (a *Auth) InstanceName() string {
return a.instName
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return errors.New("plain_separate: inline arguments are not used")
}
cfg.Bool("debug", false, false, &a.log.Debug)
cfg.Callback("user", func(m *config.Map, node config.Node) error {
var tbl module.Table
err := modconfig.ModuleFromNode("table", node.Args, node, m.Globals, &tbl)
if err != nil {
return err
}
a.userTbls = append(a.userTbls, tbl)
return nil
})
cfg.Callback("pass", func(m *config.Map, node config.Node) error {
var auth module.PlainAuth
err := modconfig.ModuleFromNode("auth", node.Args, node, m.Globals, &auth)
if err != nil {
return err
}
a.passwd = append(a.passwd, auth)
return nil
})
if _, err := cfg.Process(); err != nil {
return err
}
return nil
}
func (a *Auth) Lookup(ctx context.Context, username string) (string, bool, error) {
ok := len(a.userTbls) == 0
for _, tbl := range a.userTbls {
_, tblOk, err := tbl.Lookup(ctx, username)
if err != nil {
return "", false, fmt.Errorf("plain_separate: underlying table error: %w", err)
}
if tblOk {
ok = true
break
}
}
if !ok {
return "", false, nil
}
return "", true, nil
}
func (a *Auth) AuthPlain(username, password string) error {
ok := len(a.userTbls) == 0
for _, tbl := range a.userTbls {
_, tblOk, err := tbl.Lookup(context.TODO(), username)
if err != nil {
return err
}
if tblOk {
ok = true
break
}
}
if !ok {
return errors.New("user not found in tables")
}
var lastErr error
for _, p := range a.passwd {
if err := p.AuthPlain(username, password); err != nil {
lastErr = err
continue
}
return nil
}
return lastErr
}
func init() {
modules.Register("auth.plain_separate", New)
}
================================================
FILE: internal/auth/plain_separate/plain_separate_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package plain_separate
import (
"context"
"errors"
"testing"
"github.com/emersion/go-sasl"
"github.com/foxcpp/maddy/framework/module"
)
type mockAuth struct {
db map[string]bool
}
func (mockAuth) SASLMechanisms() []string {
return []string{sasl.Plain, sasl.Login}
}
func (m mockAuth) AuthPlain(username, _ string) error {
ok := m.db[username]
if !ok {
return errors.New("invalid creds")
}
return nil
}
type mockTable struct {
db map[string]string
}
func (m mockTable) Lookup(_ context.Context, a string) (string, bool, error) {
b, ok := m.db[a]
return b, ok, nil
}
func TestPlainSplit_NoUser(t *testing.T) {
a := Auth{
passwd: []module.PlainAuth{
mockAuth{
db: map[string]bool{
"user1": true,
},
},
},
}
err := a.AuthPlain("user1", "aaa")
if err != nil {
t.Fatal("Unexpected error:", err)
}
}
func TestPlainSplit_NoUser_MultiPass(t *testing.T) {
a := Auth{
passwd: []module.PlainAuth{
mockAuth{
db: map[string]bool{
"user2": true,
},
},
mockAuth{
db: map[string]bool{
"user1": true,
},
},
},
}
err := a.AuthPlain("user1", "aaa")
if err != nil {
t.Fatal("Unexpected error:", err)
}
}
func TestPlainSplit_UserPass(t *testing.T) {
a := Auth{
userTbls: []module.Table{
mockTable{
db: map[string]string{
"user1": "",
},
},
},
passwd: []module.PlainAuth{
mockAuth{
db: map[string]bool{
"user2": true,
},
},
mockAuth{
db: map[string]bool{
"user1": true,
},
},
},
}
err := a.AuthPlain("user1", "aaa")
if err != nil {
t.Fatal("Unexpected error:", err)
}
}
func TestPlainSplit_MultiUser_Pass(t *testing.T) {
a := Auth{
userTbls: []module.Table{
mockTable{
db: map[string]string{
"userWH": "",
},
},
mockTable{
db: map[string]string{
"user1": "",
},
},
},
passwd: []module.PlainAuth{
mockAuth{
db: map[string]bool{
"user2": true,
},
},
mockAuth{
db: map[string]bool{
"user1": true,
},
},
},
}
err := a.AuthPlain("user1", "aaa")
if err != nil {
t.Fatal("Unexpected error:", err)
}
}
================================================
FILE: internal/auth/sasl.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package auth
import (
"context"
"errors"
"fmt"
"net"
"github.com/emersion/go-sasl"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/auth/sasllogin"
"github.com/foxcpp/maddy/internal/authz"
)
var (
ErrUnsupportedMech = errors.New("unsupported SASL mechanism")
ErrInvalidAuthCred = errors.New("auth: invalid credentials")
)
// SASLAuth is a wrapper that initializes sasl.Server using authenticators that
// call maddy module objects.
//
// It also handles username translation using auth_map and auth_map_normalize
// (AuthMap and AuthMapNormalize should be set).
//
// It supports reporting of multiple authorization identities so multiple
// accounts can be associated with a single set of credentials.
type SASLAuth struct {
Log *log.Logger
OnlyFirstID bool
EnableLogin bool
AuthMap module.Table
AuthNormalize authz.NormalizeFunc
ErrorMap func(err error) error
Plain []module.PlainAuth
}
func (s *SASLAuth) SASLMechanisms() []string {
var mechs []string
if len(s.Plain) != 0 {
mechs = append(mechs, sasl.Plain)
if s.EnableLogin {
mechs = append(mechs, sasl.Login)
}
}
return mechs
}
func (s *SASLAuth) usernameForAuth(ctx context.Context, saslUsername string) (string, error) {
if s.AuthNormalize != nil {
var err error
saslUsername, err = s.AuthNormalize(saslUsername)
if err != nil {
return "", err
}
}
if s.AuthMap == nil {
return saslUsername, nil
}
mapped, ok, err := s.AuthMap.Lookup(ctx, saslUsername)
if err != nil {
return "", err
}
if !ok {
return "", ErrInvalidAuthCred
}
if saslUsername != mapped {
s.Log.DebugMsg("using mapped username for authentication", "username", saslUsername, "mapped_username", mapped)
}
return mapped, nil
}
func (s *SASLAuth) AuthPlain(username, password string) error {
if len(s.Plain) == 0 {
return ErrUnsupportedMech
}
var lastErr error
for _, p := range s.Plain {
mappedUsername, err := s.usernameForAuth(context.TODO(), username)
if err != nil {
return err
}
s.Log.DebugMsg("attempting authentication",
"mapped_username", mappedUsername, "original_username", username,
"module", p)
lastErr = p.AuthPlain(mappedUsername, password)
if lastErr == nil {
return nil
}
}
return fmt.Errorf("no auth. provider accepted creds, last err: %w", lastErr)
}
type ContextData struct {
// Authentication username. May be different from identity.
Username string
// Password used for password-based mechanisms.
Password string
}
// CreateSASL creates the sasl.Server instance for the corresponding mechanism.
func (s *SASLAuth) CreateSASL(
mech string, remoteAddr net.Addr,
successCb func(identity string, data ContextData) error,
) sasl.Server {
switch mech {
case sasl.Plain:
return sasl.NewPlainServer(func(identity, username, password string) error {
if identity == "" {
identity = username
}
if identity != username {
if s.ErrorMap != nil {
return s.ErrorMap(ErrInvalidAuthCred)
}
return ErrInvalidAuthCred
}
err := s.AuthPlain(username, password)
if err != nil {
s.Log.Error("authentication failed", err, "username", username, "src_ip", remoteAddr)
if s.ErrorMap != nil {
return s.ErrorMap(ErrInvalidAuthCred)
}
return ErrInvalidAuthCred
}
return successCb(identity, ContextData{
Username: username,
Password: password,
})
})
case sasl.Login:
if !s.EnableLogin {
return FailingSASLServ{Err: ErrUnsupportedMech}
}
return sasllogin.NewLoginServer(func(username, password string) error {
username, err := s.usernameForAuth(context.Background(), username)
if err != nil {
if s.ErrorMap != nil {
return s.ErrorMap(ErrInvalidAuthCred)
}
return err
}
err = s.AuthPlain(username, password)
if err != nil {
s.Log.Error("authentication failed", err, "username", username, "src_ip", remoteAddr)
if s.ErrorMap != nil {
return s.ErrorMap(ErrInvalidAuthCred)
}
return ErrInvalidAuthCred
}
return successCb(username, ContextData{
Username: username,
Password: password,
})
})
}
return FailingSASLServ{Err: ErrUnsupportedMech}
}
// AddProvider adds the SASL authentication provider to its mapping by parsing
// the 'auth' configuration directive.
func (s *SASLAuth) AddProvider(m *config.Map, node config.Node) error {
var any interface{}
if err := modconfig.ModuleFromNode("auth", node.Args, node, m.Globals, &any); err != nil {
return err
}
hasAny := false
if plainAuth, ok := any.(module.PlainAuth); ok {
s.Plain = append(s.Plain, plainAuth)
hasAny = true
}
if !hasAny {
return config.NodeErr(node, "auth: specified module does not provide any SASL mechanism")
}
return nil
}
type FailingSASLServ struct{ Err error }
func (s FailingSASLServ) Next([]byte) ([]byte, bool, error) {
return nil, true, s.Err
}
================================================
FILE: internal/auth/sasl_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package auth
import (
"errors"
"net"
"testing"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/testutils"
)
type mockAuth struct {
db map[string]bool
}
func (m mockAuth) AuthPlain(username, _ string) error {
ok := m.db[username]
if !ok {
return errors.New("invalid creds")
}
return nil
}
func TestCreateSASL(t *testing.T) {
a := SASLAuth{
Log: testutils.Logger(t, "saslauth"),
Plain: []module.PlainAuth{
&mockAuth{
db: map[string]bool{
"user1": true,
},
},
},
}
t.Run("XWHATEVER", func(t *testing.T) {
srv := a.CreateSASL("XWHATEVER", &net.TCPAddr{}, func(string, ContextData) error { return nil })
_, _, err := srv.Next([]byte(""))
if err == nil {
t.Error("No error for XWHATEVER use")
}
})
t.Run("PLAIN", func(t *testing.T) {
srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string, data ContextData) error {
if id != "user1" {
t.Fatal("Wrong auth. identities passed to callback:", id)
}
return nil
})
_, _, err := srv.Next([]byte("\x00user1\x00aa"))
if err != nil {
t.Error("Unexpected error:", err)
}
})
t.Run("PLAIN with authorization identity", func(t *testing.T) {
srv := a.CreateSASL("PLAIN", &net.TCPAddr{}, func(id string, data ContextData) error {
if id != "user1" {
t.Fatal("Wrong authorization identity passed:", id)
}
return nil
})
_, _, err := srv.Next([]byte("user1\x00user1\x00aa"))
if err != nil {
t.Error("Unexpected error:", err)
}
})
}
================================================
FILE: internal/auth/sasllogin/sasllogin.go
================================================
package sasllogin
import "github.com/emersion/go-sasl"
// Copy-pasted from old emersion/go-sasl version
// Authenticates users with an username and a password.
type LoginAuthenticator func(username, password string) error
type loginState int
const (
loginNotStarted loginState = iota
loginWaitingUsername
loginWaitingPassword
)
type loginServer struct {
state loginState
username, password string
authenticate LoginAuthenticator
}
// A server implementation of the LOGIN authentication mechanism, as described
// in https://tools.ietf.org/html/draft-murchison-sasl-login-00.
//
// LOGIN is obsolete and should only be enabled for legacy clients that cannot
// be updated to use PLAIN.
func NewLoginServer(authenticator LoginAuthenticator) sasl.Server {
return &loginServer{authenticate: authenticator}
}
func (a *loginServer) Next(response []byte) (challenge []byte, done bool, err error) {
switch a.state {
case loginNotStarted:
// Check for initial response field, as per RFC4422 section 3
if response == nil {
challenge = []byte("Username:")
break
}
a.state++
fallthrough
case loginWaitingUsername:
a.username = string(response)
challenge = []byte("Password:")
case loginWaitingPassword:
a.password = string(response)
err = a.authenticate(a.username, a.password)
done = true
default:
err = sasl.ErrUnexpectedClientResponse
}
a.state++
return
}
================================================
FILE: internal/auth/shadow/module.go
================================================
//go:build !windows
// +build !windows
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package shadow
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/auth/external"
)
type Auth struct {
instName string
useHelper bool
helperPath string
log *log.Logger
}
func New(c *container.C, modName, instName string) (module.Module, error) {
return &Auth{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (a *Auth) Name() string {
return "shadow"
}
func (a *Auth) InstanceName() string {
return a.instName
}
func (a *Auth) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return errors.New("shadow: inline arguments are not used")
}
cfg.Bool("debug", true, false, &a.log.Debug)
cfg.Bool("use_helper", false, false, &a.useHelper)
if _, err := cfg.Process(); err != nil {
return err
}
if a.useHelper {
a.helperPath = filepath.Join(config.LibexecDirectory, "maddy-shadow-helper")
if _, err := os.Stat(a.helperPath); err != nil {
return fmt.Errorf("shadow: no helper binary (maddy-shadow-helper) found in %s", config.LibexecDirectory)
}
} else {
f, err := os.Open("/etc/shadow")
if err != nil {
if os.IsPermission(err) {
return fmt.Errorf("shadow: can't read /etc/shadow due to permission error, use helper binary or run maddy as a privileged user")
}
return fmt.Errorf("shadow: can't read /etc/shadow: %v", err)
}
if err := f.Close(); err != nil {
a.log.Error("can't close /etc/shadow file", err)
}
}
return nil
}
func (a *Auth) Lookup(username string) (string, bool, error) {
if a.useHelper {
return "", false, fmt.Errorf("shadow: table lookup are not possible when using a helper")
}
ent, err := Lookup(username)
if err != nil {
if errors.Is(err, ErrNoSuchUser) {
return "", false, nil
}
return "", false, err
}
if !ent.IsAccountValid() {
return "", false, nil
}
return "", true, nil
}
func (a *Auth) AuthPlain(username, password string) error {
if a.useHelper {
return external.AuthUsingHelper(a.helperPath, username, password)
}
ent, err := Lookup(username)
if err != nil {
return err
}
if !ent.IsAccountValid() {
return fmt.Errorf("shadow: account is expired")
}
if !ent.IsPasswordValid() {
return fmt.Errorf("shadow: password is expired")
}
if err := ent.VerifyPassword(password); err != nil {
if errors.Is(err, ErrWrongPassword) {
return module.ErrUnknownCredentials
}
return err
}
return nil
}
func init() {
modules.Register("auth.shadow", New)
}
================================================
FILE: internal/auth/shadow/read.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package shadow
import (
"bufio"
"errors"
"fmt"
"os"
"strconv"
"strings"
)
var (
ErrNoSuchUser = errors.New("shadow: user entry is not present in database")
ErrWrongPassword = errors.New("shadow: wrong password")
)
// Read reads system shadow passwords database and returns all entires in it.
func Read() ([]Entry, error) {
f, err := os.Open("/etc/shadow")
if err != nil {
return nil, err
}
scnr := bufio.NewScanner(f)
var res []Entry
for scnr.Scan() {
ent, err := parseEntry(scnr.Text())
if err != nil {
return res, err
}
res = append(res, *ent)
}
if err := scnr.Err(); err != nil {
return res, err
}
return res, nil
}
func parseEntry(line string) (*Entry, error) {
parts := strings.Split(line, ":")
if len(parts) != 9 {
return nil, errors.New("read: malformed entry")
}
res := &Entry{
Name: parts[0],
Pass: parts[1],
}
for i, value := range [...]*int{
&res.LastChange, &res.MinPassAge, &res.MaxPassAge,
&res.WarnPeriod, &res.InactivityPeriod, &res.AcctExpiry, &res.Flags,
} {
if parts[2+i] == "" {
*value = -1
} else {
var err error
*value, err = strconv.Atoi(parts[2+i])
if err != nil {
return nil, fmt.Errorf("read: invalid value for field %d", 2+i)
}
}
}
return res, nil
}
func Lookup(name string) (*Entry, error) {
entries, err := Read()
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.Name == name {
return &entry, nil
}
}
return nil, ErrNoSuchUser
}
================================================
FILE: internal/auth/shadow/shadow.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// shadow package implements utilities for parsing and using shadow password
// database on Unix systems.
package shadow
type Entry struct {
// User login name.
Name string
// Hashed user password.
Pass string
// Days since Jan 1, 1970 password was last changed.
LastChange int
// The number of days the user will have to wait before she will be allowed to
// change her password again.
//
// -1 if password aging is disabled.
MinPassAge int
// The number of days after which the user will have to change her password.
//
// -1 is password aging is disabled.
MaxPassAge int
// The number of days before a password is going to expire (see the maximum
// password age above) during which the user should be warned.
//
// -1 is password aging is disabled.
WarnPeriod int
// The number of days after a password has expired (see the maximum
// password age above) during which the password should still be accepted.
//
// -1 is password aging is disabled.
InactivityPeriod int
// The date of expiration of the account, expressed as the number of days
// since Jan 1, 1970.
//
// -1 is account never expires.
AcctExpiry int
// Unused now.
Flags int
}
================================================
FILE: internal/auth/shadow/verify.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package shadow
import (
"errors"
"fmt"
"time"
"github.com/GehirnInc/crypt"
_ "github.com/GehirnInc/crypt/sha256_crypt"
_ "github.com/GehirnInc/crypt/sha512_crypt"
)
const secsInDay = 86400
func (e *Entry) IsAccountValid() bool {
if e.AcctExpiry == -1 {
return true
}
nowDays := int(time.Now().Unix() / secsInDay)
return nowDays < e.AcctExpiry
}
func (e *Entry) IsPasswordValid() bool {
if e.LastChange == -1 || e.MaxPassAge == -1 || e.InactivityPeriod == -1 {
return true
}
nowDays := int(time.Now().Unix() / secsInDay)
return nowDays < e.LastChange+e.MaxPassAge+e.InactivityPeriod
}
func (e *Entry) VerifyPassword(pass string) (err error) {
// Do not permit null and locked passwords.
if e.Pass == "" {
return errors.New("verify: null password")
}
if e.Pass[0] == '!' {
return errors.New("verify: locked password")
}
// crypt.NewFromHash may panic on unknown hash function.
defer func() {
if rcvr := recover(); rcvr != nil {
err = fmt.Errorf("%v", rcvr)
}
}()
if err := crypt.NewFromHash(e.Pass).Verify(e.Pass, []byte(pass)); err != nil {
if errors.Is(err, crypt.ErrKeyMismatch) {
return ErrWrongPassword
}
return err
}
return nil
}
================================================
FILE: internal/authz/lookup.go
================================================
package authz
import (
"context"
"fmt"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/module"
)
func AuthorizeEmailUse(ctx context.Context, username string, addrs []string, mapping module.Table) (bool, error) {
var validEmails []string
if multi, ok := mapping.(module.MultiTable); ok {
var err error
validEmails, err = multi.LookupMulti(ctx, username)
if err != nil {
return false, fmt.Errorf("authz: %w", err)
}
} else {
validEmail, ok, err := mapping.Lookup(ctx, username)
if err != nil {
return false, fmt.Errorf("authz: %w", err)
}
if ok {
validEmails = []string{validEmail}
}
}
for _, addr := range addrs {
_, domain, err := address.Split(addr)
if err != nil {
return false, fmt.Errorf("authz: %w", err)
}
for _, ent := range validEmails {
if ent == domain || ent == "*" || ent == addr {
return true, nil
}
}
}
return false, nil
}
================================================
FILE: internal/authz/normalization.go
================================================
package authz
import (
"strings"
"github.com/foxcpp/maddy/framework/address"
"golang.org/x/text/secure/precis"
)
type NormalizeFunc func(string) (string, error)
func NormalizeNoop(s string) (string, error) {
return s, nil
}
// NormalizeAuto applies address.PRECISFold to valid emails and
// plain UsernameCaseMapped profile to other strings.
func NormalizeAuto(s string) (string, error) {
if address.Valid(s) {
return address.PRECISFold(s)
}
return precis.UsernameCaseMapped.CompareKey(s)
}
// NormalizeFuncs defines configurable normalization functions to be used
// in authentication and authorization routines.
var NormalizeFuncs = map[string]NormalizeFunc{
"auto": NormalizeAuto,
"precis_casefold_email": address.PRECISFold,
"precis_casefold": precis.UsernameCaseMapped.CompareKey,
"precis_email": address.PRECIS,
"precis": precis.UsernameCasePreserved.CompareKey,
"casefold": func(s string) (string, error) {
return strings.ToLower(s), nil
},
"noop": NormalizeNoop,
}
================================================
FILE: internal/check/authorize_sender/authorize_sender.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package authorize_sender
import (
"context"
"fmt"
"net/mail"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/authz"
"github.com/foxcpp/maddy/internal/table"
"github.com/foxcpp/maddy/internal/target"
)
const modName = "check.authorize_sender"
type Check struct {
instName string
log *log.Logger
checkHeader bool
emailPrepare module.Table
userToEmail module.Table
unauthAction modconfig.FailAction
noMatchAction modconfig.FailAction
errAction modconfig.FailAction
fromNorm authz.NormalizeFunc
authNorm authz.NormalizeFunc
}
func New(c *container.C, modName, instName string) (module.Module, error) {
return &Check{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return fmt.Errorf("%s: inline arguments are not used", modName)
}
cfg.Bool("debug", true, false, &c.log.Debug)
cfg.Bool("check_header", false, true, &c.checkHeader)
cfg.Custom("prepare_email", false, false, func() (interface{}, error) {
return &table.Identity{}, nil
}, modconfig.TableDirective, &c.emailPrepare)
cfg.Custom("user_to_email", false, false, func() (interface{}, error) {
return &table.Identity{}, nil
}, modconfig.TableDirective, &c.userToEmail)
cfg.Custom("unauth_action", false, false, func() (interface{}, error) {
return modconfig.FailAction{Reject: true}, nil
}, modconfig.FailActionDirective, &c.unauthAction)
cfg.Custom("no_match_action", false, false, func() (interface{}, error) {
return modconfig.FailAction{Reject: true}, nil
}, modconfig.FailActionDirective, &c.noMatchAction)
cfg.Custom("err_action", false, false, func() (interface{}, error) {
return modconfig.FailAction{Reject: true}, nil
}, modconfig.FailActionDirective, &c.errAction)
config.EnumMapped(cfg, "auth_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&c.authNorm)
config.EnumMapped(cfg, "from_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&c.fromNorm)
if _, err := cfg.Process(); err != nil {
return err
}
return nil
}
type state struct {
c *Check
msgMeta *module.MsgMetadata
log *log.Logger
}
func (c *Check) CheckStateForMsg(_ context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
c: c,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) authzSender(ctx context.Context, authName, email string) module.CheckResult {
if authName == "" {
return s.c.unauthAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 530,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Authentication required",
CheckName: modName,
}})
}
fromEmailNorm, err := s.c.fromNorm(email)
if err != nil {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 553,
EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
Message: "Unable to normalize sender address",
CheckName: modName,
Err: err,
}})
}
authNameNorm, err := s.c.authNorm(authName)
if err != nil {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 535,
EnhancedCode: exterrors.EnhancedCode{5, 7, 8},
Message: "Unable to normalize authorization username",
CheckName: modName,
}})
}
var preparedEmail []string
var ok bool
s.log.DebugMsg("normalized names", "from", fromEmailNorm, "auth", authNameNorm)
if emailPrepareMulti, isMulti := s.c.emailPrepare.(module.MultiTable); isMulti {
preparedEmail, err = emailPrepareMulti.LookupMulti(ctx, fromEmailNorm)
ok = len(preparedEmail) > 0
} else {
var preparedEmail_single string
preparedEmail_single, ok, err = s.c.emailPrepare.Lookup(ctx, fromEmailNorm)
preparedEmail = []string{preparedEmail_single}
}
s.log.DebugMsg("authorized emails", "preparedEmail", preparedEmail, "ok", ok)
if err != nil {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 454,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: err,
}})
}
if !ok {
preparedEmail = []string{fromEmailNorm}
}
ok, err = authz.AuthorizeEmailUse(ctx, authNameNorm, preparedEmail, s.c.userToEmail)
if err != nil {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 454,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: err,
}})
}
if !ok {
return s.c.noMatchAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 553,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Unauthorized use of sender address",
CheckName: modName,
}})
}
return module.CheckResult{}
}
func (s *state) CheckConnection(_ context.Context) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckSender(ctx context.Context, fromEmail string) module.CheckResult {
if s.msgMeta.Conn == nil {
s.log.Msg("skipping locally generated message")
return module.CheckResult{}
}
authName := s.msgMeta.Conn.AuthUser
return s.authzSender(ctx, authName, fromEmail)
}
func (s *state) CheckRcpt(_ context.Context, _ string) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, _ buffer.Buffer) module.CheckResult {
if !s.c.checkHeader {
return module.CheckResult{}
}
if s.msgMeta.Conn == nil {
s.log.Msg("skipping locally generated message")
return module.CheckResult{}
}
authName := s.msgMeta.Conn.AuthUser
fromHdr := hdr.Get("From")
if fromHdr == "" {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Missing From header",
CheckName: modName,
}})
}
list, err := mail.ParseAddressList(fromHdr)
if err != nil || len(list) == 0 {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Malformed From header",
CheckName: modName,
Err: err,
}})
}
fromEmail := list[0].Address
if len(list) > 1 {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Multiple From addresses are not allowed",
CheckName: modName,
Err: err,
}})
}
var senderAddr string
if senderHdr := hdr.Get("Sender"); senderHdr != "" {
sender, err := mail.ParseAddress(senderHdr)
if err != nil {
return s.c.errAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Malformed Sender header",
CheckName: modName,
Err: err,
}})
}
senderAddr = sender.Address
}
res := s.authzSender(ctx, authName, fromEmail)
if res.Reason == nil {
return res
}
if senderAddr != "" && senderAddr != fromEmail {
res = s.authzSender(ctx, authName, senderAddr)
if res.Reason == nil {
return res
}
}
// Neither matched.
return s.c.noMatchAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 553,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Unauthorized use of sender address",
CheckName: modName,
}})
}
func (s *state) Close() error {
return nil
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/check/command/command.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package command
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"regexp"
"runtime/trace"
"strconv"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
)
const modName = "check.command"
type Stage string
const (
StageConnection = "conn"
StageSender = "sender"
StageRcpt = "rcpt"
StageBody = "body"
)
var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`)
type Check struct {
instName string
log *log.Logger
stage Stage
actions map[int]modconfig.FailAction
cmd string
cmdArgs []string
}
func New(c *container.C, modName, instName string) (module.Module, error) {
chk := &Check{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
actions: map[int]modconfig.FailAction{
1: {
Reject: true,
},
2: {
Quarantine: true,
},
},
}
return chk, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) == 0 {
return errors.New("command: at least one argument is required (command name)")
}
c.cmd = inlineArgs[0]
c.cmdArgs = inlineArgs[1:]
// Check whether the inline argument command is usable.
if _, err := exec.LookPath(c.cmd); err != nil {
return fmt.Errorf("command: %w", err)
}
cfg.Enum("run_on", false, false,
[]string{StageConnection, StageSender, StageRcpt, StageBody}, StageBody,
(*string)(&c.stage))
cfg.AllowUnknown()
unknown, err := cfg.Process()
if err != nil {
return err
}
for _, node := range unknown {
switch node.Name {
case "code":
if len(node.Args) < 2 {
return config.NodeErr(node, "at least two arguments are required: ")
}
exitCode, err := strconv.Atoi(node.Args[0])
if err != nil {
return config.NodeErr(node, "%v", err)
}
action, err := modconfig.ParseActionDirective(node.Args[1:])
if err != nil {
return config.NodeErr(node, "%v", err)
}
c.actions[exitCode] = action
default:
return config.NodeErr(node, "unexpected directive: %v", node.Name)
}
}
return nil
}
type state struct {
c *Check
msgMeta *module.MsgMetadata
log *log.Logger
mailFrom string
rcpts []string
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
c: c,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) expandCommand(address string) (string, []string) {
expArgs := make([]string, len(s.c.cmdArgs))
for i, arg := range s.c.cmdArgs {
expArgs[i] = placeholderRe.ReplaceAllStringFunc(arg, func(placeholder string) string {
switch placeholder {
case "{auth_user}":
if s.msgMeta.Conn == nil {
return ""
}
return s.msgMeta.Conn.AuthUser
case "{source_ip}":
if s.msgMeta.Conn == nil {
return ""
}
tcpAddr, _ := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
if tcpAddr == nil {
return ""
}
return tcpAddr.IP.String()
case "{source_host}":
if s.msgMeta.Conn == nil {
return ""
}
return s.msgMeta.Conn.Hostname
case "{source_rdns}":
if s.msgMeta.Conn == nil {
return ""
}
valI, err := s.msgMeta.Conn.RDNSName.Get()
if err != nil {
return ""
}
if valI == nil {
return ""
}
return valI.(string)
case "{msg_id}":
return s.msgMeta.ID
case "{sender}":
return s.mailFrom
case "{rcpts}":
return strings.Join(s.rcpts, "\n")
case "{address}":
return address
}
return placeholder
})
}
return s.c.cmd, expArgs
}
func (s *state) run(cmdName string, args []string, stdin io.Reader) module.CheckResult {
cmd := exec.Command(cmdName, args...)
cmd.Stdin = stdin
stdout, err := cmd.StdoutPipe()
if err != nil {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
Message: "Internal server error",
CheckName: "command",
Err: err,
Misc: map[string]interface{}{
"cmd": cmd.String(),
},
},
Reject: true,
}
}
if err := cmd.Start(); err != nil {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
Message: "Internal server error",
CheckName: "command",
Err: err,
Misc: map[string]interface{}{
"cmd": cmd.String(),
},
},
Reject: true,
}
}
bufOut := bufio.NewReader(stdout)
hdr, err := textproto.ReadHeader(bufOut)
if err != nil && !errors.Is(err, io.EOF) {
if err := cmd.Process.Signal(os.Interrupt); err != nil {
s.log.Error("failed to kill process", err)
}
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
Message: "Internal server error",
CheckName: "command",
Err: err,
Misc: map[string]interface{}{
"cmd": cmd.String(),
},
},
Reject: true,
}
}
res := module.CheckResult{}
res.Header = hdr
err = cmd.Wait()
if err != nil {
if _, ok := err.(*exec.ExitError); !ok {
// If that's not ExitError, the process may still be running. We do
// not want this.
if err := cmd.Process.Signal(os.Interrupt); err != nil {
s.log.Error("failed to kill process", err)
}
}
return s.errorRes(err, res, cmd.String())
}
return res
}
func (s *state) errorRes(err error, res module.CheckResult, cmdLine string) module.CheckResult {
exitErr, ok := err.(*exec.ExitError)
if !ok {
res.Reason = &exterrors.SMTPError{
Code: 450,
Message: "Internal server error",
CheckName: "command",
Err: err,
Misc: map[string]interface{}{
"cmd": cmdLine,
},
}
res.Reject = true
return res
}
action, ok := s.c.actions[exitErr.ExitCode()]
if !ok {
res.Reason = &exterrors.SMTPError{
Code: 450,
Message: "Internal server error",
CheckName: "command",
Err: err,
Reason: "unexpected exit code",
Misc: map[string]interface{}{
"cmd": cmdLine,
"exit_code": exitErr.ExitCode(),
},
}
res.Reject = true
return res
}
res.Reason = &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 1},
Message: "Message rejected for due to a local policy",
CheckName: "command",
Misc: map[string]interface{}{
"cmd": cmdLine,
"exit_code": exitErr.ExitCode(),
},
}
return action.Apply(res)
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
if s.c.stage != StageConnection {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, "command/CheckConnection-"+s.c.cmd).End()
cmdName, cmdArgs := s.expandCommand("")
return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
}
func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {
s.mailFrom = addr
if s.c.stage != StageSender {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, "command/CheckSender"+s.c.cmd).End()
cmdName, cmdArgs := s.expandCommand(addr)
return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
}
func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {
s.rcpts = append(s.rcpts, addr)
if s.c.stage != StageRcpt {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, "command/CheckRcpt"+s.c.cmd).End()
cmdName, cmdArgs := s.expandCommand(addr)
return s.run(cmdName, cmdArgs, bytes.NewReader(nil))
}
func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer.Buffer) module.CheckResult {
if s.c.stage != StageBody {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, "command/CheckBody"+s.c.cmd).End()
cmdName, cmdArgs := s.expandCommand("")
var buf bytes.Buffer
_ = textproto.WriteHeader(&buf, hdr)
bR, err := body.Open()
if err != nil {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
Message: "Internal server error",
CheckName: "command",
Err: err,
Misc: map[string]interface{}{
"cmd": cmdName + " " + strings.Join(cmdArgs, " "),
},
},
Reject: true,
}
}
return s.run(cmdName, cmdArgs, io.MultiReader(bytes.NewReader(buf.Bytes()), bR))
}
func (s *state) Close() error {
return nil
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/check/dkim/dkim.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dkim
import (
"bytes"
"context"
"errors"
"io"
nettextproto "net/textproto"
"runtime/trace"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/emersion/go-msgauth/dkim"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
)
type Check struct {
instName string
log *log.Logger
requiredFields map[string]struct{}
brokenSigAction modconfig.FailAction
noSigAction modconfig.FailAction
failOpen bool
resolver dns.Resolver
}
func New(c *container.C, modName, instName string) (module.Module, error) {
return &Check{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
resolver: dns.DefaultResolver(),
}, nil
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return errors.New("check.dkim: inline arguments are not used")
}
var requiredFields []string
cfg.Bool("debug", true, false, &c.log.Debug)
cfg.StringList("required_fields", false, false, []string{"From", "Subject"}, &requiredFields)
cfg.Bool("fail_open", false, false, &c.failOpen)
cfg.Custom("broken_sig_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.brokenSigAction)
cfg.Custom("no_sig_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.noSigAction)
_, err := cfg.Process()
if err != nil {
return err
}
c.requiredFields = make(map[string]struct{})
for _, field := range requiredFields {
c.requiredFields[nettextproto.CanonicalMIMEHeaderKey(field)] = struct{}{}
}
return nil
}
func (c *Check) Name() string {
return "check.dkim"
}
func (c *Check) InstanceName() string {
return c.instName
}
type dkimCheckState struct {
c *Check
msgMeta *module.MsgMetadata
log *log.Logger
}
func (d *dkimCheckState) CheckConnection(ctx context.Context) module.CheckResult {
return module.CheckResult{}
}
func (d *dkimCheckState) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
return module.CheckResult{}
}
func (d *dkimCheckState) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
return module.CheckResult{}
}
func (d *dkimCheckState) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
defer trace.StartRegion(ctx, "check.dkim/CheckBody").End()
if !header.Has("DKIM-Signature") {
if d.c.noSigAction.Reject || d.c.noSigAction.Quarantine {
d.log.Printf("no signatures present")
} else {
d.log.Debugf("no signatures present")
}
return d.c.noSigAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 20},
Message: "No DKIM signatures",
CheckName: "check.dkim",
},
AuthResult: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultNone,
},
},
})
}
b := bytes.Buffer{}
_ = textproto.WriteHeader(&b, header)
bodyRdr, err := body.Open()
if err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithTemporary(
exterrors.WithFields(err, map[string]interface{}{
"check": "check.dkim",
"smtp_msg": "Internal I/O error",
}),
true,
),
}
}
verifications, err := dkim.VerifyWithOptions(io.MultiReader(&b, bodyRdr), &dkim.VerifyOptions{
LookupTXT: func(domain string) ([]string, error) {
return d.c.resolver.LookupTXT(ctx, domain)
},
})
if err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithTemporary(
exterrors.WithFields(err, map[string]interface{}{
"check": "check.dkim",
"smtp_msg": "Internal error during policy check",
}),
true,
),
}
}
goodSigs := false
res := module.CheckResult{AuthResult: make([]authres.Result, 0, len(verifications))}
for _, verif := range verifications {
val := authres.ResultValue(authres.ResultPass)
reason := ""
if verif.Err != nil {
val = authres.ResultFail
reason = strings.TrimPrefix(verif.Err.Error(), "dkim: ")
if !d.c.brokenSigAction.Reject || !d.c.brokenSigAction.Quarantine {
d.log.DebugMsg("bad signature", "domain", verif.Domain, "identifier", verif.Identifier)
}
if dkim.IsPermFail(verif.Err) {
val = authres.ResultPermError
}
if dkim.IsTempFail(verif.Err) {
if !d.c.failOpen {
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 421,
EnhancedCode: exterrors.EnhancedCode{4, 7, 20},
Message: "Temporary error during DKIM verification",
CheckName: "check.dkim",
Err: verif.Err,
},
}
}
val = authres.ResultTempError
}
res.AuthResult = append(res.AuthResult, &authres.DKIMResult{
Value: val,
Reason: reason,
Domain: verif.Domain,
Identifier: verif.Identifier,
})
continue
}
signedFields := make(map[string]struct{}, len(verif.HeaderKeys))
for _, field := range verif.HeaderKeys {
signedFields[nettextproto.CanonicalMIMEHeaderKey(field)] = struct{}{}
}
for field := range d.c.requiredFields {
if _, ok := signedFields[field]; !ok {
val = authres.ResultPermError
reason = "some header fields are not signed"
}
}
if val == authres.ResultPass {
goodSigs = true
d.log.DebugMsg("good signature", "domain", verif.Domain, "identifier", verif.Identifier)
}
res.AuthResult = append(res.AuthResult, &authres.DKIMResult{
Value: val,
Reason: reason,
Domain: verif.Domain,
Identifier: verif.Identifier,
})
}
if !goodSigs {
res.Reason = &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 20},
Message: "No passing DKIM signatures",
CheckName: "check.dkim",
}
return d.c.brokenSigAction.Apply(res)
}
return res
}
func (d *dkimCheckState) Name() string {
return "check.dkim"
}
func (d *dkimCheckState) Close() error {
return nil
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &dkimCheckState{
c: c,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func init() {
modules.Register("check.dkim", New)
}
================================================
FILE: internal/check/dkim/dkim_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dkim
import (
"context"
"errors"
"net"
"testing"
"github.com/emersion/go-msgauth/authres"
"github.com/foxcpp/go-mockdns"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/testutils"
)
const unsignedMailString = `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.
`
const dnsPublicKey = "v=DKIM1; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQ" +
"KBgQDwIRP/UC3SBsEmGqZ9ZJW3/DkMoGeLnQg1fWn7/zYt" +
"IxN2SnFCjxOCKG9v3b4jYfcTNh5ijSsq631uBItLa7od+v" +
"/RtdC2UzJ1lWT947qR+Rcac2gbto/NMqJ0fzfVjH4OuKhi" +
"tdY9tf6mcwGjaNBcWToIMmPSPDdQPNUYckcQ2QIDAQAB"
var testZones = map[string]mockdns.Zone{
"brisbane._domainkey.example.com.": {
TXT: []string{dnsPublicKey},
},
}
const verifiedMailString = `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.
`
func testCheck(t *testing.T, zones map[string]mockdns.Zone, cfg []config.Node) *Check {
t.Helper()
mod, err := New(container.New(), "check.dkim", "")
if err != nil {
t.Fatal(err)
}
check := mod.(*Check)
check.resolver = &mockdns.Resolver{Zones: zones}
check.log = testutils.Logger(t, mod.Name())
if err := check.Configure(nil, config.NewMap(nil, config.Node{Children: cfg})); err != nil {
t.Fatal(err)
}
return check
}
func TestDkimVerify_NoSig(t *testing.T) {
check := testCheck(t, nil, nil) // No zones since this test requires no lookups.
// Force certain reason so we can assert for it.
check.noSigAction.Reject = true
check.noSigAction.ReasonOverride = &exterrors.SMTPError{Code: 555}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// The usual checking flow.
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
hdr, buf := testutils.BodyFromStr(t, unsignedMailString)
result := s.CheckBody(ctx, hdr, buf)
if result.Reason == nil {
t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
}
if result.Reason.(*exterrors.SMTPError).Code != 555 {
t.Fatal("Different fail reason:", result.Reason)
}
}
func TestDkimVerify_InvalidSig(t *testing.T) {
check := testCheck(t, testZones, nil)
// Force certain reason so we can assert for it.
check.brokenSigAction.Reject = true
check.brokenSigAction.ReasonOverride = &exterrors.SMTPError{Code: 555}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
// Mess up the signature.
hdr.Set("From", "nope")
result := s.CheckBody(ctx, hdr, buf)
if result.Reason == nil {
t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
}
if result.Reason.(*exterrors.SMTPError).Code != 555 {
t.Fatal("Different fail reason:", result.Reason)
}
}
func TestDkimVerify_ValidSig(t *testing.T) {
check := testCheck(t, testZones, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
result := s.CheckBody(ctx, hdr, buf)
if result.Reason != nil {
t.Log(authres.Format("", result.AuthResult))
t.Fatal("Check fail reason set, auth. result:", result.Reason, exterrors.Fields(result.Reason))
}
}
func TestDkimVerify_RequiredFields(t *testing.T) {
check := testCheck(t, testZones, []config.Node{
{
// Require field that is not covered by the signature.
Name: "required_fields",
Args: []string{"From", "X-Important"},
},
})
// Force certain reason so we can assert for it.
check.brokenSigAction.Reject = true
check.brokenSigAction.ReasonOverride = &exterrors.SMTPError{Code: 555}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
result := s.CheckBody(ctx, hdr, buf)
if result.Reason == nil {
t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
}
if result.Reason.(*exterrors.SMTPError).Code != 555 {
t.Fatal("Different fail reason:", result.Reason)
}
}
func TestDkimVerify_BufferOpenFail(t *testing.T) {
check := testCheck(t, testZones, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
var buf buffer.Buffer
hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
buf = testutils.FailingBuffer{Blob: buf.(buffer.MemoryBuffer).Slice, OpenError: errors.New("No!")}
result := s.CheckBody(ctx, hdr, buf)
t.Log("auth. result:", authres.Format("", result.AuthResult))
if result.Reason == nil {
t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
}
}
func TestDkimVerify_FailClosed(t *testing.T) {
zones := map[string]mockdns.Zone{
"brisbane._domainkey.example.com.": {
Err: &net.DNSError{
Err: "DNS server is not having a great time",
IsTemporary: true,
IsTimeout: true,
},
},
}
check := testCheck(t, zones, []config.Node{
{
Name: "fail_open",
Args: []string{"false"},
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
result := s.CheckBody(ctx, hdr, buf)
t.Log("auth. result:", authres.Format("", result.AuthResult))
if result.Reason == nil {
t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
}
if !result.Reject {
t.Fatal("No reject requested")
}
if !exterrors.IsTemporary(result.Reason) {
t.Fatal("Fail reason is not marked as temporary:", result.Reason)
}
}
func TestDkimVerify_FailOpen(t *testing.T) {
zones := map[string]mockdns.Zone{
"brisbane._domainkey.example.com.": {
Err: &net.DNSError{
Err: "DNS server is not having a great time",
IsTemporary: true,
IsTimeout: true,
},
},
}
check := testCheck(t, zones, []config.Node{
{
Name: "fail_open",
Args: []string{"true"},
},
})
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s, err := check.CheckStateForMsg(ctx, &module.MsgMetadata{
ID: "test_unsigned",
})
if err != nil {
t.Fatal(err)
}
s.CheckConnection(ctx)
s.CheckSender(ctx, "joe@football.example.com")
s.CheckRcpt(ctx, "suzie@shopping.example.net")
hdr, buf := testutils.BodyFromStr(t, verifiedMailString)
result := s.CheckBody(ctx, hdr, buf)
t.Log("auth. result:", authres.Format("", result.AuthResult))
if result.Reason == nil {
t.Fatal("No check fail reason set, auth. result:", authres.Format("", result.AuthResult))
}
if result.Reject {
t.Fatal("Reject requested")
}
if exterrors.IsTemporary(result.Reason) {
t.Fatal("Fail reason is not marked as temporary:", result.Reason)
}
if len(result.AuthResult) != 1 {
t.Fatal("Wrong amount of auth. result fields:", len(result.AuthResult))
}
resVal := result.AuthResult[0].(*authres.DKIMResult).Value
if resVal != authres.ResultTempError {
t.Fatal("Result is not temp. error:", resVal)
}
}
================================================
FILE: internal/check/dns/dns.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
"strings"
"github.com/foxcpp/maddy/framework/address"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/check"
)
func requireMatchingRDNS(ctx check.StatelessCheckContext) module.CheckResult {
if ctx.MsgMeta.Conn == nil {
ctx.Logger.Msg("locally-generated message, skipping")
return module.CheckResult{}
}
if ctx.MsgMeta.Conn.RDNSName == nil {
ctx.Logger.Msg("rDNS lookup is disabled, skipping")
return module.CheckResult{}
}
rdnsNameI, err := ctx.MsgMeta.Conn.RDNSName.Get()
if err != nil {
reason, misc := exterrors.UnwrapDNSErr(err)
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: exterrors.SMTPCode(err, 450, 550),
EnhancedCode: exterrors.SMTPEnchCode(err, exterrors.EnhancedCode{0, 7, 25}),
Message: "DNS error during policy check",
CheckName: "require_matching_rdns",
Err: err,
Reason: reason,
Misc: misc,
},
}
}
if rdnsNameI == nil {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 25},
Message: "No PTR record found",
CheckName: "require_matching_rdns",
Err: err,
},
}
}
rdnsName := rdnsNameI.(string)
srcDomain := strings.TrimSuffix(ctx.MsgMeta.Conn.Hostname, ".")
rdnsName = strings.TrimSuffix(rdnsName, ".")
if dns.Equal(rdnsName, srcDomain) {
ctx.Logger.Debugf("PTR record %s matches source domain, OK", rdnsName)
return module.CheckResult{}
}
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 25},
Message: "rDNS name does not match source hostname",
CheckName: "require_matching_rdns",
},
}
}
func requireMXRecord(ctx check.StatelessCheckContext, mailFrom string) module.CheckResult {
if mailFrom == "" {
// Permit null reverse-path for bounces.
return module.CheckResult{}
}
_, domain, err := address.Split(mailFrom)
if err != nil {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 501,
EnhancedCode: exterrors.EnhancedCode{5, 1, 8},
Message: "Malformed sender address",
CheckName: "require_mx_record",
},
}
}
if domain == "" {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 501,
EnhancedCode: exterrors.EnhancedCode{5, 1, 8},
Message: "No domain part in address",
CheckName: "require_mx_record",
},
}
}
srcMx, err := ctx.Resolver.LookupMX(ctx, domain)
if err != nil {
reason, misc := exterrors.UnwrapDNSErr(err)
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: exterrors.SMTPCode(err, 450, 550),
EnhancedCode: exterrors.SMTPEnchCode(err, exterrors.EnhancedCode{0, 7, 0}),
Message: "DNS error during policy check",
CheckName: "require_mx_record",
Err: err,
Reason: reason,
Misc: misc,
},
}
}
if len(srcMx) == 0 {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 501,
EnhancedCode: exterrors.EnhancedCode{5, 7, 27},
Message: "Domain in MAIL FROM does not have any MX records",
CheckName: "require_mx_record",
},
}
}
for _, mx := range srcMx {
if mx.Host == "." {
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 501,
EnhancedCode: exterrors.EnhancedCode{5, 7, 27},
Message: "Domain in MAIL FROM has null MX record",
CheckName: "require_mx_record",
},
}
}
}
return module.CheckResult{}
}
func init() {
check.RegisterStatelessCheck("require_matching_rdns", modconfig.FailAction{Quarantine: true},
requireMatchingRDNS, nil, nil, nil)
check.RegisterStatelessCheck("require_mx_record", modconfig.FailAction{Quarantine: true},
nil, requireMXRecord, nil, nil)
}
================================================
FILE: internal/check/dns/dns_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dns
import (
"net"
"testing"
"github.com/foxcpp/go-mockdns"
"github.com/foxcpp/maddy/framework/future"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/check"
"github.com/foxcpp/maddy/internal/testutils"
)
func TestRequireMatchingRDNS(t *testing.T) {
test := func(rdns, srcHost string, fail bool) {
rdnsFut := future.New()
var ptr []string
if rdns != "" {
rdnsFut.Set(rdns, nil)
ptr = []string{rdns}
} else {
rdnsFut.Set(nil, nil)
}
res := requireMatchingRDNS(check.StatelessCheckContext{
Resolver: &mockdns.Resolver{
Zones: map[string]mockdns.Zone{
"4.3.2.1.in-addr.arpa.": {
PTR: ptr,
},
},
},
MsgMeta: &module.MsgMetadata{
Conn: &module.ConnState{
RemoteAddr: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 55555},
Hostname: srcHost,
RDNSName: rdnsFut,
},
},
Logger: testutils.Logger(t, "require_matching_rdns"),
})
actualFail := res.Reason != nil
if fail && !actualFail {
t.Errorf("%v, %s: expected failure but check succeeded", rdns, srcHost)
}
if !fail && actualFail {
t.Errorf("%v, %s: unexpected failure", rdns, srcHost)
}
}
test("", "example.org", true)
test("example.org", "[1.2.3.4]", true)
test("example.org", "[IPv6:beef::1]", true)
test("example.org", "example.org", false)
test("example.org.", "example.org", false)
test("example.org", "example.org.", false)
test("example.org.", "example.org.", false)
test("example.com.", "example.org.", true)
}
func TestRequireMXRecord(t *testing.T) {
test := func(mailFrom, mxDomain string, mx []net.MX, fail bool) {
res := requireMXRecord(check.StatelessCheckContext{
Resolver: &mockdns.Resolver{
Zones: map[string]mockdns.Zone{
mxDomain + ".": {
MX: mx,
},
},
},
MsgMeta: &module.MsgMetadata{
Conn: &module.ConnState{
RemoteAddr: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 55555},
},
},
Logger: testutils.Logger(t, "require_mx_record"),
}, mailFrom)
actualFail := res.Reason != nil
if fail && !actualFail {
t.Errorf("%v, %v: expected failure but check succeeded", mailFrom, mx)
}
if !fail && actualFail {
t.Errorf("%v, %v: unexpected failure", mailFrom, mx)
}
}
test("foo@example.org", "example.org", nil, true)
test("foo@example.com", "", nil, true) // NXDOMAIN
test("foo@[1.2.3.4]", "", nil, true)
test("[IPv6:beef::1]", "", nil, true)
test("[IPv6:beef::1]", "", nil, true)
test("foo@example.org", "example.org", []net.MX{{Host: "a.com"}}, false)
test("foo@", "", nil, true)
test("", "", nil, false) // Permit <> for bounces.
test("foo@example.org", "example.org", []net.MX{{Host: "."}}, true)
}
================================================
FILE: internal/check/dnsbl/common.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dnsbl
import (
"context"
"net"
"strconv"
"strings"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
)
type ListedErr struct {
Identity string
List string
Reason string
Score int
Message string
}
func (le ListedErr) Fields() map[string]interface{} {
msg := "Client identity listed in the used DNSBL"
if le.Message != "" {
msg = le.Message
}
return map[string]interface{}{
"check": "dnsbl",
"list": le.List,
"listed_identity": le.Identity,
"reason": le.Reason,
"smtp_code": 554,
"smtp_enchcode": exterrors.EnhancedCode{5, 7, 0},
"smtp_msg": msg,
}
}
func (le ListedErr) Error() string {
return le.Identity + " is listed in the used DNSBL"
}
func checkDomain(ctx context.Context, resolver dns.Resolver, cfg List, domain string) error {
query := domain + "." + cfg.Zone
addrs, err := resolver.LookupHost(ctx, query)
if err != nil {
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return nil
}
return err
}
if len(addrs) == 0 {
return nil
}
var score int
var customMessage string
var filteredAddrs []string
// If ResponseRules is configured, use new behavior
if len(cfg.ResponseRules) > 0 {
// Convert string addresses to IPAddr for matching
ipAddrs := make([]net.IPAddr, 0, len(addrs))
for _, addr := range addrs {
if ip := net.ParseIP(addr); ip != nil {
ipAddrs = append(ipAddrs, net.IPAddr{IP: ip})
}
}
matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(ipAddrs, cfg.ResponseRules)
if !matched {
return nil
}
score = matchedScore
// Use first matched message if available
if len(matchedMessages) > 0 {
customMessage = matchedMessages[0]
}
filteredAddrs = matchedReasons
} else {
// Legacy behavior: accept all addresses
filteredAddrs = addrs
}
// Attempt to extract explanation string from TXT records (shared by both paths)
txts, err := resolver.LookupTXT(ctx, query)
var reason string
if err == nil && len(txts) > 0 {
reason = strings.Join(txts, "; ")
} else {
// Not significant, include addresses as reason. Usually they are
// mapped to some predefined 'reasons' by BL.
reason = strings.Join(filteredAddrs, "; ")
}
return ListedErr{
Identity: domain,
List: cfg.Zone,
Reason: reason,
Score: score,
Message: customMessage,
}
}
func matchResponseRules(addrs []net.IPAddr, rules []ResponseRule) (score int, messages []string, reasons []string, matched bool) {
// Track which rules have been matched to avoid counting the same rule multiple times
matchedRules := make(map[int]bool)
for _, addr := range addrs {
for ruleIdx, rule := range rules {
// Skip if this rule has already been matched
if matchedRules[ruleIdx] {
continue
}
for _, respNet := range rule.Networks {
if respNet.Contains(addr.IP) {
score += rule.Score
if rule.Message != "" {
messages = append(messages, rule.Message)
}
reasons = append(reasons, addr.IP.String())
matchedRules[ruleIdx] = true
matched = true
break // Move to next rule
}
}
}
}
return
}
func checkIP(ctx context.Context, resolver dns.Resolver, cfg List, ip net.IP) error {
ipv6 := true
if ipv4 := ip.To4(); ipv4 != nil {
ip = ipv4
ipv6 = false
}
if ipv6 && !cfg.ClientIPv6 {
return nil
}
if !ipv6 && !cfg.ClientIPv4 {
return nil
}
query := queryString(ip) + "." + cfg.Zone
addrs, err := resolver.LookupIPAddr(ctx, query)
if err != nil {
if dnsErr, ok := err.(*net.DNSError); ok && dnsErr.IsNotFound {
return nil
}
return err
}
var filteredAddrs []net.IPAddr
var score int
var customMessage string
// If ResponseRules is configured, use new behavior
if len(cfg.ResponseRules) > 0 {
matchedScore, matchedMessages, matchedReasons, matched := matchResponseRules(addrs, cfg.ResponseRules)
if !matched {
return nil
}
score = matchedScore
// Use first matched message if available
if len(matchedMessages) > 0 {
customMessage = matchedMessages[0]
}
// Build filteredAddrs from matched reasons for TXT lookup fallback
for _, reason := range matchedReasons {
filteredAddrs = append(filteredAddrs, net.IPAddr{IP: net.ParseIP(reason)})
}
} else {
// Legacy behavior: use flat Responses filter
filteredAddrs = make([]net.IPAddr, 0, len(addrs))
addrsLoop:
for _, addr := range addrs {
// No responses whitelist configured - permit all.
if len(cfg.Responses) == 0 {
filteredAddrs = append(filteredAddrs, addr)
continue
}
for _, respNet := range cfg.Responses {
if respNet.Contains(addr.IP) {
filteredAddrs = append(filteredAddrs, addr)
continue addrsLoop
}
}
}
if len(filteredAddrs) == 0 {
return nil
}
}
// Attempt to extract explanation string from TXT records (shared by both paths)
txts, err := resolver.LookupTXT(ctx, query)
var reason string
if err == nil && len(txts) > 0 {
reason = strings.Join(txts, "; ")
} else {
// Not significant, include addresses as reason. Usually they are
// mapped to some predefined 'reasons' by BL.
reasonParts := make([]string, 0, len(filteredAddrs))
for _, addr := range filteredAddrs {
reasonParts = append(reasonParts, addr.IP.String())
}
reason = strings.Join(reasonParts, "; ")
}
return ListedErr{
Identity: ip.String(),
List: cfg.Zone,
Reason: reason,
Score: score,
Message: customMessage,
}
}
func queryString(ip net.IP) string {
ipv6 := true
if ipv4 := ip.To4(); ipv4 != nil {
ip = ipv4
ipv6 = false
}
res := strings.Builder{}
if ipv6 {
res.Grow(63) // 0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0
} else {
res.Grow(15) // 000.000.000.000
}
for i := len(ip) - 1; i >= 0; i-- {
octet := ip[i]
if ipv6 {
// X.X
res.WriteString(strconv.FormatInt(int64(octet&0xf), 16))
res.WriteRune('.')
res.WriteString(strconv.FormatInt(int64((octet&0xf0)>>4), 16))
} else {
// X
res.WriteString(strconv.Itoa(int(octet)))
}
if i != 0 {
res.WriteRune('.')
}
}
return res.String()
}
================================================
FILE: internal/check/dnsbl/common_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dnsbl
import (
"context"
"net"
"reflect"
"testing"
"github.com/foxcpp/go-mockdns"
)
func TestQueryString(t *testing.T) {
test := func(ip, queryStr string) {
t.Helper()
parsed := net.ParseIP(ip)
if parsed == nil {
panic("Malformed IP in test")
}
actual := queryString(parsed)
if actual != queryStr {
t.Errorf("want queryString(%s) to be %s, got %s", ip, queryStr, actual)
}
}
test("2001:db8:1:2:3:4:567:89ab", "b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2")
test("2001::1:2:3:4:567:89ab", "b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.0.0.0.0.1.0.0.2")
test("192.0.2.99", "99.2.0.192")
}
func TestCheckDomain(t *testing.T) {
test := func(zones map[string]mockdns.Zone, cfg List, domain string, expectedErr error) {
t.Helper()
resolver := mockdns.Resolver{Zones: zones}
err := checkDomain(context.Background(), &resolver, cfg, domain)
if !reflect.DeepEqual(err, expectedErr) {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
}
}
test(nil, List{Zone: "example.org"}, "example.com", nil)
test(map[string]mockdns.Zone{
"example.com.example.org.": {
Err: &net.DNSError{
Err: "i/o timeout",
IsTimeout: true,
IsTemporary: true,
},
},
}, List{Zone: "example.org"}, "example.com", &net.DNSError{
Err: "i/o timeout",
IsTimeout: true,
IsTemporary: true,
})
test(map[string]mockdns.Zone{
"example.com.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org"}, "example.com", ListedErr{
Identity: "example.com",
List: "example.org",
Reason: "127.0.0.1",
})
test(map[string]mockdns.Zone{
"example.org.example.com.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org"}, "example.com", nil)
test(map[string]mockdns.Zone{
"example.com.example.org.": {
A: []string{"127.0.0.1"},
TXT: []string{"Reason"},
},
}, List{Zone: "example.org"}, "example.com", ListedErr{
Identity: "example.com",
List: "example.org",
Reason: "Reason",
})
test(map[string]mockdns.Zone{
"example.com.example.org.": {
A: []string{"127.0.0.1"},
TXT: []string{"Reason 1", "Reason 2"},
},
}, List{Zone: "example.org"}, "example.com", ListedErr{
Identity: "example.com",
List: "example.org",
Reason: "Reason 1; Reason 2",
})
test(map[string]mockdns.Zone{
"example.com.example.org.": {
A: []string{"127.0.0.1", "127.0.0.2"},
},
}, List{Zone: "example.org"}, "example.com", ListedErr{
Identity: "example.com",
List: "example.org",
Reason: "127.0.0.1; 127.0.0.2",
})
}
func TestCheckIP(t *testing.T) {
test := func(zones map[string]mockdns.Zone, cfg List, ip net.IP, expectedErr error) {
t.Helper()
resolver := mockdns.Resolver{Zones: zones}
err := checkIP(context.Background(), &resolver, cfg, ip)
if !reflect.DeepEqual(err, expectedErr) {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
}
}
test(nil, List{Zone: "example.org"}, net.IPv4(1, 2, 3, 4), nil)
test(nil, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4), nil)
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Reason: "127.0.0.1",
})
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"128.0.0.1"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
Responses: []net.IPNet{
{
IP: net.IPv4(127, 0, 0, 1),
Mask: net.IPv4Mask(255, 255, 255, 0),
},
},
}, net.IPv4(1, 2, 3, 4), nil)
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"128.0.0.1"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
Responses: []net.IPNet{
{
IP: net.IPv4(127, 0, 0, 0),
Mask: net.IPv4Mask(255, 255, 255, 0),
},
{
IP: net.IPv4(128, 0, 0, 0),
Mask: net.IPv4Mask(255, 255, 255, 0),
},
},
}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Reason: "128.0.0.1",
})
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org"}, net.IPv4(1, 2, 3, 4), nil)
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
Err: &net.DNSError{
Err: "i/o timeout",
IsTimeout: true,
IsTemporary: true,
},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4), &net.DNSError{
Err: "i/o timeout",
IsTimeout: true,
IsTemporary: true,
})
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
TXT: []string{"Reason"},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Reason: "Reason",
})
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1", "127.0.0.2"},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Reason: "127.0.0.1; 127.0.0.2",
})
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1", "127.0.0.2"},
TXT: []string{"Reason", "Reason 2"},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Reason: "Reason; Reason 2",
})
test(map[string]mockdns.Zone{
"b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.ParseIP("2001:db8:1:2:3:4:567:89ab"), nil)
test(map[string]mockdns.Zone{
"b.a.9.8.7.6.5.0.4.0.0.0.3.0.0.0.2.0.0.0.1.0.0.0.8.b.d.0.1.0.0.2.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", ClientIPv6: true}, net.ParseIP("2001:db8:1:2:3:4:567:89ab"), ListedErr{
Identity: "2001:db8:1:2:3:4:567:89ab",
List: "example.org",
Reason: "127.0.0.1",
})
}
func TestCheckDomainWithResponseRules(t *testing.T) {
test := func(zones map[string]mockdns.Zone, cfg List, domain string, expectedErr error) {
t.Helper()
resolver := mockdns.Resolver{Zones: zones}
err := checkDomain(context.Background(), &resolver, cfg, domain)
if expectedErr == nil {
if err != nil {
t.Errorf("expected no error, got '%#v'", err)
}
} else {
if err == nil {
t.Errorf("expected err to be '%#v', got nil", expectedErr)
} else {
expectedLE, okExpected := expectedErr.(ListedErr)
actualLE, okActual := err.(ListedErr)
if !okExpected || !okActual {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
} else {
if expectedLE.Identity != actualLE.Identity ||
expectedLE.List != actualLE.List ||
expectedLE.Score != actualLE.Score ||
expectedLE.Message != actualLE.Message {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
}
}
}
}
}
// Test domain with single response code and custom message
test(map[string]mockdns.Zone{
"spam.example.com.dnsbl.example.org.": {
A: []string{"127.0.0.2"},
},
}, List{
Zone: "dnsbl.example.org",
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Domain listed as spam source",
},
},
}, "spam.example.com", ListedErr{
Identity: "spam.example.com",
List: "dnsbl.example.org",
Score: 10,
Message: "Domain listed as spam source",
})
// Test domain with multiple response codes - scores should sum
test(map[string]mockdns.Zone{
"multi.example.com.dnsbl.example.org.": {
A: []string{"127.0.0.2", "127.0.0.11"},
},
}, List{
Zone: "dnsbl.example.org",
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "High severity",
},
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 5,
Message: "Low severity",
},
},
}, "multi.example.com", ListedErr{
Identity: "multi.example.com",
List: "dnsbl.example.org",
Score: 15, // 10 + 5
Message: "High severity",
})
// Test domain with no matching response codes
test(map[string]mockdns.Zone{
"unknown.example.com.dnsbl.example.org.": {
A: []string{"127.0.0.99"},
},
}, List{
Zone: "dnsbl.example.org",
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed",
},
},
}, "unknown.example.com", nil)
}
================================================
FILE: internal/check/dnsbl/dnsbl.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dnsbl
import (
"context"
"errors"
"net"
"runtime/trace"
"strings"
"sync"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
"golang.org/x/sync/errgroup"
)
type ResponseRule struct {
Networks []net.IPNet
Score int
Message string
}
type List struct {
Zone string
ClientIPv4 bool
ClientIPv6 bool
EHLO bool
MAILFROM bool
ScoreAdj int
Responses []net.IPNet
ResponseRules []ResponseRule
}
var defaultBL = List{
ClientIPv4: true,
}
type DNSBL struct {
instName string
checkEarly bool
bls []List
quarantineThres int
rejectThres int
resolver dns.Resolver
log *log.Logger
}
func New(c *container.C, modName, instName string) (module.Module, error) {
return &DNSBL{
instName: instName,
resolver: dns.DefaultResolver(),
log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (bl *DNSBL) Name() string {
return "dnsbl"
}
func (bl *DNSBL) InstanceName() string {
return bl.instName
}
func (bl *DNSBL) Configure(inlineArgs []string, cfg *config.Map) error {
cfg.Bool("debug", false, false, &bl.log.Debug)
cfg.Bool("check_early", false, false, &bl.checkEarly)
cfg.Int("quarantine_threshold", false, false, 1, &bl.quarantineThres)
cfg.Int("reject_threshold", false, false, 9999, &bl.rejectThres)
cfg.AllowUnknown()
unknown, err := cfg.Process()
if err != nil {
return err
}
for _, inlineBl := range inlineArgs {
cfg := defaultBL
cfg.Zone = inlineBl
go bl.testList(cfg)
bl.bls = append(bl.bls, cfg)
}
for _, node := range unknown {
if err := bl.readListCfg(node); err != nil {
return err
}
}
return nil
}
func (bl *DNSBL) readListCfg(node config.Node) error {
var (
listCfg List
responseNets []string
)
cfg := config.NewMap(nil, node)
cfg.Bool("client_ipv4", false, defaultBL.ClientIPv4, &listCfg.ClientIPv4)
cfg.Bool("client_ipv6", false, defaultBL.ClientIPv4, &listCfg.ClientIPv6)
cfg.Bool("ehlo", false, defaultBL.EHLO, &listCfg.EHLO)
cfg.Bool("mailfrom", false, defaultBL.EHLO, &listCfg.MAILFROM)
cfg.Int("score", false, false, 1, &listCfg.ScoreAdj)
cfg.StringList("responses", false, false, []string{"127.0.0.1/24"}, &responseNets)
cfg.Callback("response", func(_ *config.Map, node config.Node) error {
rule, err := parseResponseRule(node)
if err != nil {
return err
}
listCfg.ResponseRules = append(listCfg.ResponseRules, rule)
return nil
})
if _, err := cfg.Process(); err != nil {
return err
}
for _, resp := range responseNets {
// If there is no / - it is a plain IP address, append
// '/32'.
if !strings.Contains(resp, "/") {
resp += "/32"
}
_, ipNet, err := net.ParseCIDR(resp)
if err != nil {
return err
}
listCfg.Responses = append(listCfg.Responses, *ipNet)
}
// Warn if both response and responses are configured
if len(listCfg.ResponseRules) > 0 && len(responseNets) > 0 {
bl.log.Msg("both 'response' blocks and 'responses' directive are specified, 'response' blocks take precedence", "list", node.Name)
}
for _, zone := range append([]string{node.Name}, node.Args...) {
zoneCfg := listCfg
zoneCfg.Zone = zone
if listCfg.ScoreAdj < 0 {
if zoneCfg.EHLO {
return errors.New("dnsbl: 'ehlo' should not be used with negative score")
}
if zoneCfg.MAILFROM {
return errors.New("dnsbl: 'mailfrom' should not be used with negative score")
}
}
bl.bls = append(bl.bls, zoneCfg)
// From RFC 5782 Section 7:
// >To avoid this situation, systems that use
// >DNSxLs SHOULD check for the test entries described in Section 5 to
// >ensure that a domain actually has the structure of a DNSxL, and
// >SHOULD NOT use any DNSxL domain that does not have correct test
// >entries.
// Sadly, however, many DNSBLs lack test records so at most we can
// log a warning. Also, DNS is kinda slow so we do checks
// asynchronously to prevent slowing down server start-up.
go bl.testList(zoneCfg)
}
return nil
}
func parseResponseRule(node config.Node) (ResponseRule, error) {
var rule ResponseRule
if len(node.Args) == 0 {
return rule, config.NodeErr(node, "response block requires at least one IP address or CIDR as argument")
}
// Parse IP addresses/CIDRs from arguments
for _, arg := range node.Args {
// If there is no / - it is a plain IP address, append '/32' or '/128'
resp := arg
if !strings.Contains(resp, "/") {
// Check if it's IPv6 to determine the mask
if strings.Contains(resp, ":") {
resp += "/128"
} else {
resp += "/32"
}
}
_, ipNet, err := net.ParseCIDR(resp)
if err != nil {
return rule, config.NodeErr(node, "invalid IP address or CIDR: %s: %v", arg, err)
}
rule.Networks = append(rule.Networks, *ipNet)
}
// Parse directives within the response block
cfg := config.NewMap(nil, node)
cfg.Int("score", false, true, 0, &rule.Score)
cfg.String("message", false, false, "", &rule.Message)
if _, err := cfg.Process(); err != nil {
return rule, err
}
return rule, nil
}
func (bl *DNSBL) testList(listCfg List) {
// Check RFC 5782 Section 5 requirements.
bl.log.DebugMsg("testing list for RFC 5782 requirements...", "list", listCfg.Zone)
// 1. IPv4-based DNSxLs MUST contain an entry for 127.0.0.2 for testing purposes.
if listCfg.ClientIPv4 {
err := checkIP(context.Background(), bl.resolver, listCfg, net.IPv4(127, 0, 0, 2))
if err == nil {
bl.log.Msg("List does not contain a test record for 127.0.0.2", "list", listCfg.Zone)
} else if _, ok := err.(ListedErr); !ok {
bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
return
}
// 2. IPv4-based DNSxLs MUST NOT contain an entry for 127.0.0.1.
err = checkIP(context.Background(), bl.resolver, listCfg, net.IPv4(127, 0, 0, 1))
if err != nil {
_, ok := err.(ListedErr)
if !ok {
bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
return
}
bl.log.Msg("List contains a record for 127.0.0.1", "list", listCfg.Zone)
}
}
if listCfg.ClientIPv6 {
// 1. IPv6-based DNSxLs MUST contain an entry for ::FFFF:7F00:2
mustIP := net.ParseIP("::FFFF:7F00:2")
err := checkIP(context.Background(), bl.resolver, listCfg, mustIP)
if err == nil {
bl.log.Msg("List does not contain a test record for ::FFFF:7F00:2", "list", listCfg.Zone)
} else if _, ok := err.(ListedErr); !ok {
bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
return
}
// 2. IPv4-based DNSxLs MUST NOT contain an entry for ::FFFF:7F00:1
mustNotIP := net.ParseIP("::FFFF:7F00:1")
err = checkIP(context.Background(), bl.resolver, listCfg, mustNotIP)
if err != nil {
_, ok := err.(ListedErr)
if !ok {
bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
return
}
bl.log.Msg("List contains a record for ::FFFF:7F00:1", "list", listCfg.Zone)
}
}
if listCfg.EHLO || listCfg.MAILFROM {
// Domain-name-based DNSxLs MUST contain an entry for the reserved
// domain name "TEST".
err := checkDomain(context.Background(), bl.resolver, listCfg, "test")
if err == nil {
bl.log.Msg("List does not contain a test record for 'test' TLD", "list", listCfg.Zone)
} else if _, ok := err.(ListedErr); !ok {
bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
return
}
// ... and MUST NOT contain an entry for the reserved domain name
// "INVALID".
err = checkDomain(context.Background(), bl.resolver, listCfg, "invalid")
if err != nil {
_, ok := err.(ListedErr)
if !ok {
bl.log.Error("lookup error, bailing out", err, "list", listCfg.Zone)
return
}
bl.log.Msg("List contains a record for 'invalid' TLD", "list", listCfg.Zone)
}
}
}
func (bl *DNSBL) checkList(ctx context.Context, list List, ip net.IP, ehlo, mailFrom string) error {
if list.ClientIPv4 || list.ClientIPv6 {
if err := checkIP(ctx, bl.resolver, list, ip); err != nil {
return err
}
}
if list.EHLO && ehlo != "" {
// Skip IPs in EHLO.
if strings.HasPrefix(ehlo, "[") && strings.HasSuffix(ehlo, "]") {
return nil
}
if err := checkDomain(ctx, bl.resolver, list, ehlo); err != nil {
return err
}
}
if list.MAILFROM && mailFrom != "" {
_, domain, err := address.Split(mailFrom)
if err != nil || domain == "" {
// Probably or <>, not much we can check.
return nil
}
// If EHLO == domain (usually the case for small/private email servers)
// then don't do a second lookup for the same domain.
if list.EHLO && dns.Equal(domain, ehlo) {
return nil
}
if err := checkDomain(ctx, bl.resolver, list, domain); err != nil {
return err
}
}
return nil
}
func (bl *DNSBL) checkLists(ctx context.Context, ip net.IP, ehlo, mailFrom string) module.CheckResult {
var (
eg = errgroup.Group{}
// Protects variables below.
lck sync.Mutex
score int
listedOn []string
reasons []string
messages []string
)
for _, list := range bl.bls {
eg.Go(func() error {
err := bl.checkList(ctx, list, ip, ehlo, mailFrom)
if err != nil {
listErr, listed := err.(ListedErr)
if !listed {
return err
}
lck.Lock()
defer lck.Unlock()
listedOn = append(listedOn, listErr.List)
reasons = append(reasons, listErr.Reason)
// Use score from ListedErr if set (new behavior), otherwise use legacy ScoreAdj
if listErr.Score != 0 {
score += listErr.Score
} else {
score += list.ScoreAdj
}
// Collect custom messages if available
if listErr.Message != "" {
messages = append(messages, listErr.Message)
}
}
return nil
})
}
err := eg.Wait()
if err != nil {
// Lookup error for BL, hard-fail.
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: exterrors.SMTPCode(err, 451, 554),
EnhancedCode: exterrors.SMTPEnchCode(err, exterrors.EnhancedCode{0, 7, 0}),
Message: "DNS error during policy check",
Err: err,
CheckName: "dnsbl",
},
}
}
// Use custom message if available, otherwise use default
message := "Client identity is listed in the used DNSBL"
if len(messages) > 0 {
message = strings.Join(messages, "; ")
}
if score >= bl.rejectThres {
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: message,
Err: err,
CheckName: "dnsbl",
},
}
}
if score >= bl.quarantineThres {
return module.CheckResult{
Quarantine: true,
Reason: &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: message,
Err: err,
CheckName: "dnsbl",
},
}
}
return module.CheckResult{}
}
// CheckConnection implements module.EarlyCheck.
func (bl *DNSBL) CheckConnection(ctx context.Context, state *module.ConnState) error {
defer trace.StartRegion(ctx, "dnsbl/CheckConnection (Early)").End()
ip, ok := state.RemoteAddr.(*net.TCPAddr)
if !ok {
bl.log.Msg("non-TCP/IP source",
"src_addr", state.RemoteAddr,
"src_host", state.Hostname)
return nil
}
result := bl.checkLists(ctx, ip.IP, state.Hostname, "")
if result.Reject && bl.checkEarly {
return result.Reason
}
state.ModData.Set(bl, true, result)
return nil
}
type state struct {
bl *DNSBL
msgMeta *module.MsgMetadata
log *log.Logger
}
func (bl *DNSBL) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
bl: bl,
msgMeta: msgMeta,
log: target.DeliveryLogger(bl.log, msgMeta),
}, nil
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
defer trace.StartRegion(ctx, "dnsbl/CheckConnection").End()
if s.msgMeta.Conn == nil {
s.log.Msg("locally generated message, ignoring")
return module.CheckResult{}
}
result := s.msgMeta.Conn.ModData.Get(s.bl, true)
if result != nil {
return result.(module.CheckResult)
}
return module.CheckResult{}
}
func (*state) CheckSender(context.Context, string) module.CheckResult {
return module.CheckResult{}
}
func (*state) CheckRcpt(context.Context, string) module.CheckResult {
return module.CheckResult{}
}
func (*state) CheckBody(context.Context, textproto.Header, buffer.Buffer) module.CheckResult {
return module.CheckResult{}
}
func (*state) Close() error {
return nil
}
func init() {
modules.Register("check.dnsbl", New)
}
================================================
FILE: internal/check/dnsbl/dnsbl_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dnsbl
import (
"context"
"errors"
"net"
"testing"
"github.com/foxcpp/go-mockdns"
"github.com/foxcpp/maddy/internal/testutils"
)
func TestCheckList(t *testing.T) {
test := func(zones map[string]mockdns.Zone, cfg List, ip net.IP, ehlo, mailFrom string, expectedErr error) {
mod := &DNSBL{
resolver: &mockdns.Resolver{Zones: zones},
log: testutils.Logger(t, "dnsbl"),
}
err := mod.checkList(context.Background(), cfg, ip, ehlo, mailFrom)
if !errors.Is(err, expectedErr) {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
}
}
test(nil, List{Zone: "example.org"}, net.IPv4(1, 2, 3, 4),
"example.com", "foo@example.com", nil)
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", ClientIPv4: true}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Reason: "127.0.0.1",
},
)
test(map[string]mockdns.Zone{
"mx.example.com.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org"}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", nil,
)
test(map[string]mockdns.Zone{
"mx.example.com.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", EHLO: true}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", ListedErr{
Identity: "mx.example.com",
List: "example.org",
Reason: "127.0.0.1",
},
)
test(map[string]mockdns.Zone{
"[1.2.3.4].example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", EHLO: true}, net.IPv4(1, 2, 3, 4),
"[1.2.3.4]", "foo@example.com", nil,
)
test(map[string]mockdns.Zone{
"[IPv6:beef::1].example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", EHLO: true}, net.IPv4(1, 2, 3, 4),
"[IPv6:beef::1]", "foo@example.com", nil,
)
test(map[string]mockdns.Zone{
"example.com.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org"}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", nil,
)
test(map[string]mockdns.Zone{
"postmaster.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", MAILFROM: true}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "postmaster", nil,
)
test(map[string]mockdns.Zone{
".example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", MAILFROM: true}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "", nil,
)
test(map[string]mockdns.Zone{
"example.com.example.org.": {
A: []string{"127.0.0.1"},
},
}, List{Zone: "example.org", MAILFROM: true}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", ListedErr{
Identity: "example.com",
List: "example.org",
Reason: "127.0.0.1",
},
)
}
func TestCheckLists(t *testing.T) {
test := func(zones map[string]mockdns.Zone, bls []List, ip net.IP, ehlo, mailFrom string, reject, quarantine bool) {
mod := &DNSBL{
bls: bls,
resolver: &mockdns.Resolver{Zones: zones},
log: testutils.Logger(t, "dnsbl"),
quarantineThres: 1,
rejectThres: 2,
}
result := mod.checkLists(context.Background(), ip, ehlo, mailFrom)
if result.Reject && !reject {
t.Errorf("Expected message to not be rejected")
}
if !result.Reject && reject {
t.Errorf("Expected message to be rejected")
}
if result.Quarantine && !quarantine {
t.Errorf("Expected message to not be quarantined")
}
if !result.Quarantine && quarantine {
t.Errorf("Expected message to be quarantined")
}
}
// Score 2 >= 2, reject
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
}, []List{
{
Zone: "example.org",
ClientIPv4: true,
ScoreAdj: 2,
},
}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", true, false,
)
// Score 1 >= 1, quarantine
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
}, []List{
{
Zone: "example.org",
ClientIPv4: true,
ScoreAdj: 1,
},
}, net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com", false, true,
)
// Score 0, no action
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
"4.3.2.1.example.net.": {
A: []string{"127.0.0.1"},
},
},
[]List{
{Zone: "example.org", ClientIPv4: true, ScoreAdj: 1},
{Zone: "example.net", ClientIPv4: true, ScoreAdj: -1},
},
net.IPv4(1, 2, 3, 4),
"mx.example.com", "foo@example.com",
false, false,
)
// DNS error, hard-fail (reject)
test(map[string]mockdns.Zone{
"4.3.2.2.example.org.": {
Err: &net.DNSError{
Err: "i/o timeout",
IsTimeout: true,
IsTemporary: true,
},
},
},
[]List{
{Zone: "example.org", ClientIPv4: true, ScoreAdj: 1},
{Zone: "example.net", ClientIPv4: true, ScoreAdj: 2},
},
net.IPv4(2, 2, 3, 4),
"mx.example.com", "foo@example.com",
true, false,
)
}
func TestCheckIPWithResponseRules(t *testing.T) {
test := func(zones map[string]mockdns.Zone, cfg List, ip net.IP, expectedErr error) {
t.Helper()
resolver := mockdns.Resolver{Zones: zones}
err := checkIP(context.Background(), &resolver, cfg, ip)
if expectedErr == nil {
if err != nil {
t.Errorf("expected no error, got '%#v'", err)
}
} else {
if err == nil {
t.Errorf("expected err to be '%#v', got nil", expectedErr)
} else {
expectedLE, okExpected := expectedErr.(ListedErr)
actualLE, okActual := err.(ListedErr)
if !okExpected || !okActual {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
} else {
if expectedLE.Identity != actualLE.Identity ||
expectedLE.List != actualLE.List ||
expectedLE.Score != actualLE.Score ||
expectedLE.Message != actualLE.Message {
t.Errorf("expected err to be '%#v', got '%#v'", expectedErr, err)
}
}
}
}
}
// Test single response code with score and message
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.2"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
},
}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Score: 10,
Message: "Listed in SBL",
})
// Test multiple response codes with different scores - scores should sum
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.2", "127.0.0.11"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
{IP: net.IPv4(127, 0, 0, 3), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)},
{IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 5,
Message: "Listed in PBL",
},
},
}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Score: 15, // 10 + 5
Message: "Listed in SBL",
})
// Test response code that doesn't match any rule - should return nil
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.99"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
},
}, net.IPv4(1, 2, 3, 4), nil)
// Test low severity only - should get score 5
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.10"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 5,
Message: "Listed in PBL",
},
},
}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Score: 5,
Message: "Listed in PBL",
})
// Test high severity - should get score 10
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.2"},
},
}, List{
Zone: "example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
},
}, net.IPv4(1, 2, 3, 4), ListedErr{
Identity: "1.2.3.4",
List: "example.org",
Score: 10,
Message: "Listed in SBL",
})
}
func TestCheckListsWithResponseRules(t *testing.T) {
test := func(zones map[string]mockdns.Zone, bls []List, ip net.IP, ehlo, mailFrom string, reject, quarantine bool) {
mod := &DNSBL{
bls: bls,
resolver: &mockdns.Resolver{Zones: zones},
log: testutils.Logger(t, "dnsbl"),
quarantineThres: 5,
rejectThres: 10,
}
result := mod.checkLists(context.Background(), ip, ehlo, mailFrom)
if result.Reject && !reject {
t.Errorf("Expected message to not be rejected")
}
if !result.Reject && reject {
t.Errorf("Expected message to be rejected")
}
if result.Quarantine && !quarantine {
t.Errorf("Expected message to not be quarantined")
}
if !result.Quarantine && quarantine {
t.Errorf("Expected message to be quarantined")
}
}
// Test: Only low-severity code returned -> quarantine but not reject
test(map[string]mockdns.Zone{
"4.3.2.1.zen.example.org.": {
A: []string{"127.0.0.11"},
},
}, []List{
{
Zone: "zen.example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 10), Mask: net.IPv4Mask(255, 255, 255, 255)},
{IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 5,
Message: "Listed in PBL",
},
},
},
}, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", false, true)
// Test: High-severity code returned -> reject
test(map[string]mockdns.Zone{
"4.3.2.1.zen.example.org.": {
A: []string{"127.0.0.2"},
},
}, []List{
{
Zone: "zen.example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 2), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 10,
Message: "Listed in SBL",
},
},
},
}, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", true, false)
// Test: Legacy configuration without response blocks -> existing behavior preserved
test(map[string]mockdns.Zone{
"4.3.2.1.example.org.": {
A: []string{"127.0.0.1"},
},
}, []List{
{
Zone: "example.org",
ClientIPv4: true,
ScoreAdj: 10,
},
}, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", true, false)
// Test: Mixed configuration (some lists with response blocks, some without) -> both work correctly
test(map[string]mockdns.Zone{
"4.3.2.1.zen.example.org.": {
A: []string{"127.0.0.11"},
},
"4.3.2.1.legacy.example.org.": {
A: []string{"127.0.0.1"},
},
}, []List{
{
Zone: "zen.example.org",
ClientIPv4: true,
ResponseRules: []ResponseRule{
{
Networks: []net.IPNet{
{IP: net.IPv4(127, 0, 0, 11), Mask: net.IPv4Mask(255, 255, 255, 255)},
},
Score: 5,
Message: "Listed in PBL",
},
},
},
{
Zone: "legacy.example.org",
ClientIPv4: true,
ScoreAdj: 3,
},
}, net.IPv4(1, 2, 3, 4), "mx.example.com", "foo@example.com", false, true) // 5 + 3 = 8, quarantine but not reject
}
================================================
FILE: internal/check/milter/milter.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package milter
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"time"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-milter"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
)
const modName = "check.milter"
type Check struct {
cl *milter.Client
milterUrl string
failOpen bool
instName string
log *log.Logger
}
func New(c *container.C, _, instName string) (module.Module, error) {
chk := &Check{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}
return chk, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
switch len(inlineArgs) {
case 1:
c.milterUrl = inlineArgs[0]
case 0:
default:
return fmt.Errorf("%s: unexpected amount of arguments, want 1 or 0", modName)
}
cfg.String("endpoint", false, false, c.milterUrl, &c.milterUrl)
cfg.Bool("fail_open", false, false, &c.failOpen)
if _, err := cfg.Process(); err != nil {
return err
}
if c.milterUrl == "" {
return fmt.Errorf("%s: milter endpoint is not set", modName)
}
endp, err := config.ParseEndpoint(c.milterUrl)
if err != nil {
return fmt.Errorf("%s: %v", modName, err)
}
switch endp.Scheme {
case "tcp", "unix":
default:
return fmt.Errorf("%s: scheme unsupported: %v", modName, endp.Scheme)
}
c.cl = milter.NewClientWithOptions(endp.Network(), endp.Address(), milter.ClientOptions{
Dialer: &net.Dialer{
Timeout: 10 * time.Second,
},
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
ActionMask: milter.OptAddHeader | milter.OptQuarantine,
ProtocolMask: 0,
})
return nil
}
type state struct {
c *Check
session *milter.ClientSession
msgMeta *module.MsgMetadata
skipChecks bool
log *log.Logger
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
session, err := c.cl.Session()
if err != nil {
return nil, err
}
return &state{
c: c,
session: session,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) handleAction(act *milter.Action) module.CheckResult {
switch act.Code {
case milter.ActAccept:
s.skipChecks = true
return module.CheckResult{}
case milter.ActContinue:
return module.CheckResult{}
case milter.ActReplyCode:
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: act.SMTPCode,
EnhancedCode: exterrors.EnhancedCode{5, 7, 1},
Message: "Message rejected due to local policy",
Reason: "reply code action",
CheckName: "milter",
Misc: map[string]interface{}{
"milter": s.c.milterUrl,
},
},
}
case milter.ActDiscard:
s.log.Msg("silent discard is not supported, rejecting message")
fallthrough
case milter.ActTempFail:
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 1},
Message: "Message rejected due to local policy",
Reason: "reject action",
CheckName: "milter",
Misc: map[string]interface{}{
"milter": s.c.milterUrl,
},
},
}
case milter.ActReject:
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 1},
Message: "Message rejected due to local policy",
Reason: "reject action",
CheckName: "milter",
Misc: map[string]interface{}{
"milter": s.c.milterUrl,
},
},
}
default:
s.log.Msg("unknown action code ignored", "code", act.Code, "milter", s.c.milterUrl)
return module.CheckResult{}
}
}
// apply applies the modification actions returned by milter to the check results object.
func (s *state) apply(modifyActs []milter.ModifyAction, res module.CheckResult) module.CheckResult {
out := res
for _, act := range modifyActs {
switch act.Code {
case milter.ActAddRcpt, milter.ActDelRcpt:
s.log.Msg("envelope changes are not supported", "rcpt", act.Rcpt, "code", act.Code, "milter", s.c.milterUrl)
case milter.ActChangeFrom:
s.log.Msg("envelope changes are not supported", "from", act.From, "code", act.Code, "milter", s.c.milterUrl)
case milter.ActChangeHeader:
s.log.Msg("header field changes are not supported", "field", act.HeaderName, "milter", s.c.milterUrl)
case milter.ActInsertHeader:
if act.HeaderIndex != 1 {
s.log.Msg("header inserting not on top is not supported, prepending instead", "field", act.HeaderName, "milter", s.c.milterUrl)
}
fallthrough
case milter.ActAddHeader:
// Header field might be arbitarly folded by the caller and we want
// to preserve that exact format in case it is important (DKIM
// signature is added by milter).
field := make([]byte, 0, len(act.HeaderName)+2+len(act.HeaderValue)+2)
field = append(field, act.HeaderName...)
field = append(field, ':', ' ')
field = append(field, act.HeaderValue...)
field = append(field, '\r', '\n')
out.Header.AddRaw(field)
case milter.ActQuarantine:
out.Quarantine = true
out.Reason = exterrors.WithFields(errors.New("milter quarantine action"), map[string]interface{}{
"check": "milter",
"milter": s.c.milterUrl,
"reason": act.Reason,
})
}
}
return out
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
if s.msgMeta.Conn == nil {
// Submit some dummy values as the message is likely generated locally.
act, err := s.session.Conn("localhost", milter.FamilyInet, 25, "127.0.0.1")
if err != nil {
return s.ioError(err)
}
if act.Code != milter.ActContinue {
return s.handleAction(act)
}
act, err = s.session.Helo("localhost")
if err != nil {
return s.ioError(err)
}
return s.handleAction(act)
}
if !s.session.ProtocolOption(milter.OptNoConnect) {
if err := s.session.Macros(milter.CodeConn,
"daemon_name", "maddy",
"if_name", "unknown",
"if_addr", "0.0.0.0",
// TODO: $j
// TODO: $_
); err != nil {
return s.ioError(err)
}
var (
protoFamily milter.ProtoFamily
port uint16
addr string
)
switch rAddr := s.msgMeta.Conn.RemoteAddr.(type) {
case *net.TCPAddr:
port = uint16(rAddr.Port)
if v4 := rAddr.IP.To4(); v4 != nil {
// Make sure to not accidentally send IPv6-mapped IPv4 address.
protoFamily = milter.FamilyInet
addr = v4.String()
} else {
protoFamily = milter.FamilyInet6
addr = rAddr.IP.String()
}
case *net.UnixAddr:
protoFamily = milter.FamilyUnix
addr = rAddr.Name
default:
protoFamily = milter.FamilyUnknown
}
act, err := s.session.Conn(s.msgMeta.Conn.Hostname, protoFamily, port, addr)
if err != nil {
return s.ioError(err)
}
if act.Code != milter.ActContinue {
return s.handleAction(act)
}
}
if !s.session.ProtocolOption(milter.OptNoHelo) {
if s.msgMeta.Conn.TLS.HandshakeComplete {
fields := make([]string, 0, 4*2)
tlsState := s.msgMeta.Conn.TLS
switch tlsState.Version {
case tls.VersionTLS10:
fields = append(fields, "tls_version", "TLSv1")
case tls.VersionTLS11:
fields = append(fields, "tls_version", "TLSv1.1")
case tls.VersionTLS12:
fields = append(fields, "tls_version", "TLSv1.2")
case tls.VersionTLS13:
fields = append(fields, "tls_version", "TLSv1.3")
}
fields = append(fields, "cipher", tls.CipherSuiteName(tlsState.CipherSuite))
if len(tlsState.PeerCertificates) != 0 {
fields = append(fields, "cert_subject",
tlsState.PeerCertificates[len(tlsState.PeerCertificates)-1].Subject.String())
fields = append(fields, "cert_issuer",
tlsState.PeerCertificates[len(tlsState.PeerCertificates)-1].Issuer.String())
}
if err := s.session.Macros(milter.CodeHelo, fields...); err != nil {
return s.ioError(err)
}
}
act, err := s.session.Helo(s.msgMeta.Conn.Hostname)
if err != nil {
return s.ioError(err)
}
return s.handleAction(act)
}
return module.CheckResult{}
}
func (s *state) ioError(err error) module.CheckResult {
if s.c.failOpen {
s.skipChecks = true // silently permit processing to continue
s.c.log.Error("I/O error", err)
return module.CheckResult{}
}
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 1},
Message: "I/O error during policy check",
Err: err,
CheckName: "milter",
Misc: map[string]interface{}{
"milter": s.c.milterUrl,
},
},
}
}
func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
if s.skipChecks || s.session.ProtocolOption(milter.OptNoMailFrom) {
return module.CheckResult{}
}
fields := make([]string, 0, 2)
fields = append(fields, "i", s.msgMeta.ID)
// TODO: fields = append(fields, "auth_type", s.msgMeta.???)
if s.msgMeta.Conn.AuthUser != "" {
fields = append(fields, "auth_authen", s.msgMeta.Conn.AuthUser)
}
if err := s.session.Macros(milter.CodeMail, fields...); err != nil {
return s.ioError(err)
}
esmtpArgs := make([]string, 0, 2)
if s.msgMeta.SMTPOpts.UTF8 {
esmtpArgs = append(esmtpArgs, "SMTPUTF8")
}
act, err := s.session.Mail(mailFrom, esmtpArgs)
if err != nil {
return s.ioError(err)
}
return s.handleAction(act)
}
func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
if s.skipChecks {
return module.CheckResult{}
}
act, err := s.session.Rcpt(rcptTo, nil)
if err != nil {
return s.ioError(err)
}
return s.handleAction(act)
}
func (s *state) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
if s.skipChecks {
return module.CheckResult{}
}
act, err := s.session.Header(header)
if err != nil {
return s.ioError(err)
}
if act.Code != milter.ActContinue {
return s.handleAction(act)
}
var modifyAct []milter.ModifyAction
if !s.session.ProtocolOption(milter.OptNoBody) {
// body.Open can be expensive for on-disk buffering.
r, err := body.Open()
if err != nil {
// Not ioError(err) because fail_open directive is applied only for external I/O.
return module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 1},
Message: "Internal error during policy check",
Err: err,
CheckName: "milter",
Misc: map[string]interface{}{
"milter": s.c.milterUrl,
},
},
}
}
modifyAct, act, err = s.session.BodyReadFrom(r)
if err != nil {
return s.ioError(err)
}
} else {
modifyAct, act, err = s.session.End()
if err != nil {
return s.ioError(err)
}
}
result := s.handleAction(act)
return s.apply(modifyAct, result)
}
func (s *state) Close() error {
return s.session.Close()
}
var (
_ module.Check = &Check{}
_ module.CheckState = &state{}
)
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/check/milter/milter_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package milter
import (
"testing"
"github.com/foxcpp/maddy/framework/config"
)
func TestAcceptValidEndpoints(t *testing.T) {
for _, endpoint := range []string{
"tcp://0.0.0.0:10025",
"tcp://[::]:10025",
"tcp:127.0.0.1:10025",
"unix://path",
"unix:path",
"unix:/path",
"unix:///path",
"unix://also/path",
"unix:///also/path",
} {
c := &Check{milterUrl: endpoint}
err := c.Configure(nil, &config.Map{})
if err != nil {
t.Errorf("Unexpected failure for %s: %v", endpoint, err)
return
}
}
}
func TestRejectInvalidEndpoints(t *testing.T) {
for _, endpoint := range []string{
"tls://0.0.0.0:10025",
"tls:0.0.0.0:10025",
} {
c := &Check{milterUrl: endpoint}
err := c.Configure(nil, &config.Map{})
if err == nil {
t.Errorf("Accepted invalid endpoint: %s", endpoint)
return
}
}
}
================================================
FILE: internal/check/requiretls/requiretls.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package requiretls
import (
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/check"
)
func requireTLS(ctx check.StatelessCheckContext) module.CheckResult {
if ctx.MsgMeta.Conn != nil && ctx.MsgMeta.Conn.TLS.HandshakeComplete {
return module.CheckResult{}
}
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 1},
Message: "TLS conversation required",
CheckName: "require_tls",
},
}
}
func init() {
check.RegisterStatelessCheck("require_tls", modconfig.FailAction{Reject: true}, requireTLS, nil, nil, nil)
}
================================================
FILE: internal/check/rspamd/rspamd.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package rspamd
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"strconv"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
tls2 "github.com/foxcpp/maddy/framework/config/tls"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
)
const modName = "check.rspamd"
type Check struct {
instName string
log *log.Logger
apiPath string
flags string
settingsID string
tag string
mtaName string
ioErrAction modconfig.FailAction
errorRespAction modconfig.FailAction
addHdrAction modconfig.FailAction
rewriteSubjAction modconfig.FailAction
rejectAction modconfig.FailAction
softRejectAction modconfig.FailAction
client *http.Client
}
func New(c *container.C, modName, instName string) (module.Module, error) {
chk := &Check{
instName: instName,
client: http.DefaultClient,
log: c.DefaultLogger.Sublogger(modName),
}
return chk, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
switch len(inlineArgs) {
case 1:
c.apiPath = inlineArgs[0]
case 0:
c.apiPath = "http://127.0.0.1:11333"
default:
return fmt.Errorf("%s: unexpected amount of inline arguments", modName)
}
var (
tlsConfig *tls.Config
flags []string
)
cfg.Custom("tls_client", true, false, func() (interface{}, error) {
return &tls.Config{}, nil
}, tls2.TLSClientBlock, &tlsConfig)
cfg.String("api_path", false, false, c.apiPath, &c.apiPath)
cfg.String("settings_id", false, false, "", &c.settingsID)
cfg.String("tag", false, false, "maddy", &c.tag)
cfg.String("hostname", true, false, "", &c.mtaName)
cfg.Custom("io_error_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.ioErrAction)
cfg.Custom("error_resp_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.errorRespAction)
cfg.Custom("add_header_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Quarantine: true}, nil
}, modconfig.FailActionDirective, &c.addHdrAction)
cfg.Custom("rewrite_subj_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Quarantine: true}, nil
}, modconfig.FailActionDirective, &c.rewriteSubjAction)
cfg.Custom("reject_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Reject: true}, nil
}, modconfig.FailActionDirective, &c.rejectAction)
cfg.Custom("soft_reject_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Reject: true}, nil
}, modconfig.FailActionDirective, &c.softRejectAction)
cfg.StringList("flags", false, false, []string{"pass_all"}, &flags)
if _, err := cfg.Process(); err != nil {
return err
}
c.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
},
}
c.flags = strings.Join(flags, ",")
return nil
}
type state struct {
c *Check
msgMeta *module.MsgMetadata
log *log.Logger
mailFrom string
rcpt []string
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
c: c,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {
s.mailFrom = addr
return module.CheckResult{}
}
func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {
s.rcpt = append(s.rcpt, addr)
return module.CheckResult{}
}
func addConnHeaders(r *http.Request, meta *module.MsgMetadata, mailFrom string, rcpts []string) {
r.Header.Add("From", mailFrom)
for _, rcpt := range rcpts {
r.Header.Add("Rcpt", rcpt)
}
r.Header.Add("Queue-ID", meta.ID)
conn := meta.Conn
if conn != nil {
if meta.Conn.AuthUser != "" {
r.Header.Add("User", meta.Conn.AuthUser)
}
if tcpAddr, ok := conn.RemoteAddr.(*net.TCPAddr); ok {
r.Header.Add("IP", tcpAddr.IP.String())
}
r.Header.Add("Helo", conn.Hostname)
name, err := conn.RDNSName.Get()
if err == nil && name != nil {
r.Header.Add("Hostname", name.(string))
}
if conn.TLS.HandshakeComplete {
r.Header.Add("TLS-Cipher", tls.CipherSuiteName(conn.TLS.CipherSuite))
switch conn.TLS.Version {
case tls.VersionTLS13:
r.Header.Add("TLS-Version", "1.3")
case tls.VersionTLS12:
r.Header.Add("TLS-Version", "1.2")
case tls.VersionTLS11:
r.Header.Add("TLS-Version", "1.1")
case tls.VersionTLS10:
r.Header.Add("TLS-Version", "1.0")
}
}
}
}
func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer.Buffer) module.CheckResult {
bodyR, err := body.Open()
if err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
}
}
var buf bytes.Buffer
if err := textproto.WriteHeader(&buf, hdr); err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
}
}
r, err := http.NewRequest("POST", s.c.apiPath+"/checkv2", io.MultiReader(&buf, bodyR))
if err != nil {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithFields(err, map[string]interface{}{"check": modName}),
}
}
r.Header.Add("Pass", "all") // TODO: does that need to be configurable?
// TODO: include version (needs maddy.Version moved somewhere to break circular dependency)
r.Header.Add("User-Agent", "maddy")
if s.c.tag != "" {
r.Header.Add("MTA-Tag", s.c.tag)
}
if s.c.settingsID != "" {
r.Header.Add("Settings-ID", s.c.settingsID)
}
if s.c.mtaName != "" {
r.Header.Add("MTA-Name", s.c.mtaName)
}
addConnHeaders(r, s.msgMeta, s.mailFrom, s.rcpt)
r.Header.Add("Content-Length", strconv.Itoa(body.Len()))
resp, err := s.c.client.Do(r)
if err != nil {
return s.c.ioErrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: err,
},
})
}
if resp.StatusCode/100 != 2 {
return s.c.errorRespAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: fmt.Errorf("HTTP %d", resp.StatusCode),
},
})
}
defer func() {
if err := resp.Body.Close(); err != nil {
s.log.Error("failed to close response body", err)
}
}()
var respData response
if err := json.NewDecoder(resp.Body).Decode(&respData); err != nil {
return s.c.ioErrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 9, 0},
Message: "Internal error during policy check",
CheckName: modName,
Err: err,
},
})
}
switch respData.Action {
case "no action":
return module.CheckResult{}
case "greylist":
// uuh... TODO: Implement greylisting?
hdrAdd := textproto.Header{}
hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
return module.CheckResult{
Header: hdrAdd,
}
case "add header":
hdrAdd := textproto.Header{}
hdrAdd.Add("X-Spam-Flag", "Yes")
hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
return s.c.addHdrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "add header"},
},
Header: hdrAdd,
})
case "rewrite subject":
hdrAdd := textproto.Header{}
hdrAdd.Add("X-Spam-Flag", "Yes")
hdrAdd.Add("X-Spam-Score", strconv.FormatFloat(respData.Score, 'f', 2, 64))
return s.c.rewriteSubjAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "rewrite subject"},
},
Header: hdrAdd,
})
case "soft reject":
return s.c.softRejectAction.Apply(module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 450,
EnhancedCode: exterrors.EnhancedCode{4, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "soft reject"},
},
})
case "reject":
return s.c.rejectAction.Apply(module.CheckResult{
Reject: true,
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 0},
Message: "Message rejected due to local policy",
CheckName: modName,
Misc: map[string]interface{}{"action": "reject"},
},
})
}
s.log.Msg("unhandled action", "action", respData.Action)
return module.CheckResult{}
}
type response struct {
Score float64 `json:"score"`
Action string `json:"action"`
Subject string `json:"subject"`
Symbols map[string]struct {
Name string `json:"name"`
Score float64 `json:"score"`
}
}
func (s *state) Close() error {
return nil
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/check/skeleton.go
================================================
//go:build ignore
// +build ignore
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
/*
This is example of a minimal stateful check module implementation.
See HACKING.md in the repo root for implementation recommendations.
*/
package directory_name_here
import (
"context"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
)
const modName = "check_things"
type Check struct {
instName string
log log.Logger
}
func New(modName, instName string, aliases, inlineArgs []string) (module.Module, error) {
return &Check{
instName: instName,
}, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Init(cfg *config.Map) error {
return nil
}
type state struct {
c *Check
msgMeta *module.MsgMetadata
log log.Logger
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
c: c,
msgMeta: msgMeta,
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckSender(ctx context.Context, addr string) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckRcpt(ctx context.Context, addr string) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckBody(ctx context.Context, hdr textproto.Header, body buffer.Buffer) module.CheckResult {
return module.CheckResult{}
}
func (s *state) Close() error {
return nil
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/check/spf/spf.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package spf
import (
"context"
"errors"
"fmt"
"net"
"runtime/debug"
"runtime/trace"
"blitiri.com.ar/go/spf"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/emersion/go-msgauth/dmarc"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
maddydmarc "github.com/foxcpp/maddy/internal/dmarc"
"github.com/foxcpp/maddy/internal/target"
"golang.org/x/net/idna"
)
const modName = "check.spf"
type Check struct {
instName string
enforceEarly bool
noneAction modconfig.FailAction
neutralAction modconfig.FailAction
failAction modconfig.FailAction
softfailAction modconfig.FailAction
permerrAction modconfig.FailAction
temperrAction modconfig.FailAction
log *log.Logger
resolver dns.Resolver
}
func New(c *container.C, _, instName string) (module.Module, error) {
return &Check{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
resolver: dns.DefaultResolver(),
}, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
cfg.Bool("debug", true, false, &c.log.Debug)
cfg.Bool("enforce_early", true, false, &c.enforceEarly)
cfg.Custom("none_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.noneAction)
cfg.Custom("neutral_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.neutralAction)
cfg.Custom("fail_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{Quarantine: true}, nil
}, modconfig.FailActionDirective, &c.failAction)
cfg.Custom("softfail_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.softfailAction)
cfg.Custom("permerr_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.permerrAction)
cfg.Custom("temperr_action", false, false,
func() (interface{}, error) {
return modconfig.FailAction{}, nil
}, modconfig.FailActionDirective, &c.temperrAction)
_, err := cfg.Process()
if err != nil {
return err
}
return nil
}
type spfRes struct {
res spf.Result
err error
}
type state struct {
c *Check
msgMeta *module.MsgMetadata
spfFetch chan spfRes
log *log.Logger
skip bool
}
func (c *Check) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &state{
c: c,
msgMeta: msgMeta,
spfFetch: make(chan spfRes, 1),
log: target.DeliveryLogger(c.log, msgMeta),
}, nil
}
func (s *state) spfResult(res spf.Result, err error) module.CheckResult {
_, fromDomain, _ := address.Split(s.msgMeta.OriginalFrom)
spfAuth := &authres.SPFResult{
Value: authres.ResultNone,
Helo: s.msgMeta.Conn.Hostname,
From: fromDomain,
}
if err != nil {
spfAuth.Reason = err.Error()
} else if res == spf.None {
spfAuth.Reason = "no policy"
}
switch res {
case spf.None:
spfAuth.Value = authres.ResultNone
return s.c.noneAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
Message: "No SPF policy",
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
})
case spf.Neutral:
spfAuth.Value = authres.ResultNeutral
return s.c.neutralAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
Message: "Neutral SPF result is not permitted",
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
})
case spf.Pass:
spfAuth.Value = authres.ResultPass
return module.CheckResult{AuthResult: []authres.Result{spfAuth}}
case spf.Fail:
spfAuth.Value = authres.ResultFail
return s.c.failAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
Message: "SPF authentication failed",
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
})
case spf.SoftFail:
spfAuth.Value = authres.ResultSoftFail
return s.c.softfailAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
Message: "SPF authentication soft-failed",
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
})
case spf.TempError:
spfAuth.Value = authres.ResultTempError
return s.c.temperrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 451,
EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
Message: "SPF authentication failed with a temporary error",
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
})
case spf.PermError:
spfAuth.Value = authres.ResultPermError
return s.c.permerrAction.Apply(module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 7, 23},
Message: "SPF authentication failed with a permanent error",
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
})
}
return module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{4, 7, 23},
Message: fmt.Sprintf("Unknown SPF status: %s", res),
CheckName: modName,
Err: err,
},
AuthResult: []authres.Result{spfAuth},
}
}
func (s *state) relyOnDMARC(ctx context.Context, hdr textproto.Header) bool {
fromDomain, err := maddydmarc.ExtractFromDomain(hdr)
if err != nil {
s.log.Error("DMARC domains extract", err)
return false
}
policyDomain, record, err := maddydmarc.FetchRecord(ctx, s.c.resolver, fromDomain)
if err != nil {
s.log.Error("DMARC fetch", err, "from_domain", fromDomain)
return false
}
if record == nil {
return false
}
policy := record.Policy
// We check for subdomain using non-equality since fromDomain is either the
// subdomain of policyDomain or policyDomain itself (due to the way
// FetchRecord handles it).
if !dns.Equal(policyDomain, fromDomain) && record.SubdomainPolicy != "" {
policy = record.SubdomainPolicy
}
return policy != dmarc.PolicyNone
}
func prepareMailFrom(from string) (string, error) {
// INTERNATIONALIZATION: RFC 8616, Section 4
// Hostname is already in A-labels per SMTPUTF8 requirement.
// MAIL FROM domain should be converted to A-labels before doing
// anything.
fromMbox, fromDomain, err := address.Split(from)
if err != nil {
return "", &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
Message: "Malformed address",
CheckName: "spf",
}
}
fromDomain, err = idna.ToASCII(fromDomain)
if err != nil {
return "", &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
Message: "Malformed address",
CheckName: "spf",
}
}
// %{s} and %{l} do not match anything if it is non-ASCII.
// Since spf lib does not seem to care, strip it.
if !address.IsASCII(fromMbox) {
fromMbox = ""
}
return fromMbox + "@" + dns.FQDN(fromDomain), nil
}
func (s *state) CheckConnection(ctx context.Context) module.CheckResult {
defer trace.StartRegion(ctx, "check.spf/CheckConnection").End()
if s.msgMeta.Conn == nil {
s.skip = true
s.log.Println("locally generated message, skipping")
return module.CheckResult{}
}
ip, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
if !ok {
s.skip = true
s.log.Println("non-IP SrcAddr")
return module.CheckResult{}
}
mailFromOriginal := s.msgMeta.OriginalFrom
if mailFromOriginal == "" {
// RFC 7208 Section 2.4.
// >When the reverse-path is null, this document
// >defines the "MAIL FROM" identity to be the mailbox composed of the
// >local-part "postmaster" and the "HELO" identity (which might or might
// >not have been checked separately before).
mailFromOriginal = "postmaster@" + s.msgMeta.Conn.Hostname
}
mailFrom, err := prepareMailFrom(mailFromOriginal)
if err != nil {
s.skip = true
return module.CheckResult{
Reason: err,
Reject: true,
}
}
if s.c.enforceEarly {
res, err := spf.CheckHostWithSender(ip.IP,
dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
s.log.Debugf("result: %s (%v)", res, err)
return s.spfResult(res, err)
}
// We start evaluation in parallel to other message processing,
// once we get the body, we fetch DMARC policy and see if it exists
// and not p=none. In that case, we rely on DMARC alignment to define result.
// Otherwise, we take action based on SPF only.
go func() {
defer func() {
if err := recover(); err != nil {
stack := debug.Stack()
log.Printf("panic during spf.CheckHostWithSender: %v\n%s", err, stack)
close(s.spfFetch)
}
}()
defer trace.StartRegion(ctx, "check.spf/CheckConnection (Async)").End()
res, err := spf.CheckHostWithSender(ip.IP, dns.FQDN(s.msgMeta.Conn.Hostname), mailFrom,
spf.WithContext(ctx), spf.WithResolver(s.c.resolver))
s.log.Debugf("result: %s (%v)", res, err)
s.spfFetch <- spfRes{res, err}
}()
return module.CheckResult{}
}
func (s *state) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
return module.CheckResult{}
}
func (s *state) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
if s.c.enforceEarly || s.skip {
// Already applied in CheckConnection.
return module.CheckResult{}
}
defer trace.StartRegion(ctx, "check.spf/CheckBody").End()
res, ok := <-s.spfFetch
if !ok {
return module.CheckResult{
Reject: true,
Reason: exterrors.WithTemporary(
exterrors.WithFields(errors.New("panic recovered"), map[string]interface{}{
"check": "spf",
"smtp_msg": "Internal error during policy check",
}),
true,
),
}
}
if s.relyOnDMARC(ctx, header) {
if res.res != spf.Pass {
s.log.Msg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
} else {
s.log.DebugMsg("deferring action due to a DMARC policy", "result", res.res, "err", res.err)
}
checkRes := s.spfResult(res.res, res.err)
checkRes.Quarantine = false
checkRes.Reject = false
return checkRes
}
return s.spfResult(res.res, res.err)
}
func (s *state) Close() error {
return nil
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/check/stateless_check.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package check
import (
"context"
"fmt"
"runtime/trace"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/target"
)
type (
StatelessCheckContext struct {
// Embedded context.Context value, used for tracing, cancellation and
// timeouts.
context.Context
// Resolver that should be used by the check for DNS queries.
Resolver dns.Resolver
MsgMeta *module.MsgMetadata
// Logger that should be used by the check for logging, note that it is
// already wrapped to append Msg ID to all messages so check code
// should not do the same.
Logger *log.Logger
}
FuncConnCheck func(checkContext StatelessCheckContext) module.CheckResult
FuncSenderCheck func(checkContext StatelessCheckContext, mailFrom string) module.CheckResult
FuncRcptCheck func(checkContext StatelessCheckContext, rcptTo string) module.CheckResult
FuncBodyCheck func(checkContext StatelessCheckContext, header textproto.Header, body buffer.Buffer) module.CheckResult
)
type statelessCheck struct {
modName string
instName string
resolver dns.Resolver
logger *log.Logger
// One used by Init if config option is not passed by a user.
defaultFailAction modconfig.FailAction
// The actual fail action that should be applied.
failAction modconfig.FailAction
connCheck FuncConnCheck
senderCheck FuncSenderCheck
rcptCheck FuncRcptCheck
bodyCheck FuncBodyCheck
}
type statelessCheckState struct {
c *statelessCheck
msgMeta *module.MsgMetadata
}
func (s *statelessCheckState) String() string {
return s.c.modName + ":" + s.c.instName
}
func (s *statelessCheckState) CheckConnection(ctx context.Context) module.CheckResult {
if s.c.connCheck == nil {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, s.c.modName+"/CheckConnection").End()
originalRes := s.c.connCheck(StatelessCheckContext{
Context: ctx,
Resolver: s.c.resolver,
MsgMeta: s.msgMeta,
Logger: target.DeliveryLogger(s.c.logger, s.msgMeta),
})
return s.c.failAction.Apply(originalRes)
}
func (s *statelessCheckState) CheckSender(ctx context.Context, mailFrom string) module.CheckResult {
if s.c.senderCheck == nil {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, s.c.modName+"/CheckSender").End()
originalRes := s.c.senderCheck(StatelessCheckContext{
Context: ctx,
Resolver: s.c.resolver,
MsgMeta: s.msgMeta,
Logger: target.DeliveryLogger(s.c.logger, s.msgMeta),
}, mailFrom)
return s.c.failAction.Apply(originalRes)
}
func (s *statelessCheckState) CheckRcpt(ctx context.Context, rcptTo string) module.CheckResult {
if s.c.rcptCheck == nil {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, s.c.modName+"/CheckRcpt").End()
originalRes := s.c.rcptCheck(StatelessCheckContext{
Context: ctx,
Resolver: s.c.resolver,
MsgMeta: s.msgMeta,
Logger: target.DeliveryLogger(s.c.logger, s.msgMeta),
}, rcptTo)
return s.c.failAction.Apply(originalRes)
}
func (s *statelessCheckState) CheckBody(ctx context.Context, header textproto.Header, body buffer.Buffer) module.CheckResult {
if s.c.bodyCheck == nil {
return module.CheckResult{}
}
defer trace.StartRegion(ctx, s.c.modName+"/CheckBody").End()
originalRes := s.c.bodyCheck(StatelessCheckContext{
Context: ctx,
Resolver: s.c.resolver,
MsgMeta: s.msgMeta,
Logger: target.DeliveryLogger(s.c.logger, s.msgMeta),
}, header, body)
return s.c.failAction.Apply(originalRes)
}
func (s *statelessCheckState) Close() error {
return nil
}
func (c *statelessCheck) CheckStateForMsg(ctx context.Context, msgMeta *module.MsgMetadata) (module.CheckState, error) {
return &statelessCheckState{
c: c,
msgMeta: msgMeta,
}, nil
}
func (c *statelessCheck) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) != 0 {
return fmt.Errorf("%s: inline arguments are not used", c.modName)
}
cfg.Bool("debug", true, false, &c.logger.Debug)
cfg.Custom("fail_action", false, false,
func() (interface{}, error) {
return c.defaultFailAction, nil
}, modconfig.FailActionDirective, &c.failAction)
_, err := cfg.Process()
return err
}
func (c *statelessCheck) Name() string {
return c.modName
}
func (c *statelessCheck) InstanceName() string {
return c.instName
}
// RegisterStatelessCheck is helper function to create stateless message check modules
// that run one simple check during one stage.
//
// It creates the module and its instance with the specified name that implement module.Check interface
// and runs passed functions when corresponding module.CheckState methods are called.
//
// Note about CheckResult that is returned by the functions:
// StatelessCheck supports different action types based on the user configuration, but the particular check
// code doesn't need to know about it. It should assume that it is always "Reject" and hence it should
// populate Reason field of the result object with the relevant error description.
func RegisterStatelessCheck(name string, defaultFailAction modconfig.FailAction, connCheck FuncConnCheck, senderCheck FuncSenderCheck, rcptCheck FuncRcptCheck, bodyCheck FuncBodyCheck) {
modules.Register(name, func(c *container.C, modName, instName string) (module.Module, error) {
return &statelessCheck{
modName: modName,
instName: instName,
resolver: dns.DefaultResolver(),
logger: c.DefaultLogger.Sublogger(modName),
defaultFailAction: defaultFailAction,
connCheck: connCheck,
senderCheck: senderCheck,
rcptCheck: rcptCheck,
bodyCheck: bodyCheck,
}, nil
})
}
================================================
FILE: internal/cli/app.go
================================================
package maddycli
import (
"errors"
"fmt"
"os"
"github.com/foxcpp/maddy/framework/log"
"github.com/urfave/cli/v2"
)
var app *cli.App
func init() {
app = cli.NewApp()
app.Usage = "composable all-in-one mail server"
app.Description = `Maddy is Mail Transfer agent (MTA), Mail Delivery Agent (MDA), Mail Submission
Agent (MSA), IMAP server and a set of other essential protocols/schemes
necessary to run secure email server implemented in one executable.
This executable can be used to start the server ('run') and to manipulate
databases used by it (all other subcommands).
`
app.Authors = []*cli.Author{
{
Name: "Maddy Mail Server maintainers & contributors",
Email: "~foxcpp/maddy@lists.sr.ht",
},
}
app.ExitErrHandler = func(c *cli.Context, err error) {
if err == nil {
return
}
var exitErr cli.ExitCoder
if errors.As(err, &exitErr) {
if err.Error() != "" {
if _, ok := exitErr.(cli.ErrorFormatter); ok {
_, _ = fmt.Fprintf(os.Stderr, "Error: %+v\n", err)
} else {
_, _ = fmt.Fprintln(os.Stderr, "Error:", err)
}
}
cli.OsExiter(exitErr.ExitCode())
return
}
}
app.EnableBashCompletion = true
app.Commands = []*cli.Command{
{
Name: "generate-man",
Hidden: true,
Action: func(c *cli.Context) error {
man, err := app.ToMan()
if err != nil {
return err
}
fmt.Println(man)
return nil
},
},
{
Name: "generate-fish-completion",
Hidden: true,
Action: func(c *cli.Context) error {
cp, err := app.ToFishCompletion()
if err != nil {
return err
}
fmt.Println(cp)
return nil
},
},
}
}
func AddGlobalFlag(f cli.Flag) {
app.Flags = append(app.Flags, f)
}
func AddSubcommand(cmd *cli.Command) {
app.Commands = append(app.Commands, cmd)
}
// RunWithoutExit is like Run but returns exit code instead of calling os.Exit
// To be used in maddy.cover.
func RunWithoutExit() int {
code := 0
cli.OsExiter = func(c int) { code = c }
defer func() {
cli.OsExiter = os.Exit
}()
Run()
return code
}
func Run() {
mapStdlibFlags(app)
// Actual entry point is registered in maddy.go.
if err := app.Run(os.Args); err != nil {
log.DefaultLogger.Error("app.Run failed", err)
}
}
================================================
FILE: internal/cli/clitools/clitools.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package clitools
import (
"bufio"
"errors"
"fmt"
"os"
)
var stdinScanner = bufio.NewScanner(os.Stdin)
func Confirmation(prompt string, def bool) bool {
selection := "y/N"
if def {
selection = "Y/n"
}
fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, selection)
if !stdinScanner.Scan() {
fmt.Fprintln(os.Stderr, stdinScanner.Err())
return false
}
switch stdinScanner.Text() {
case "Y", "y":
return true
case "N", "n":
return false
default:
return def
}
}
func readPass(tty *os.File, output []byte) ([]byte, error) {
cursor := output[0:1]
readen := 0
for {
n, err := tty.Read(cursor)
if n != 1 {
return nil, errors.New("ReadPassword: invalid read size when not in canonical mode")
}
if err != nil {
return nil, errors.New("ReadPassword: " + err.Error())
}
if cursor[0] == '\n' {
break
}
// Esc or Ctrl+D or Ctrl+C.
if cursor[0] == '\x1b' || cursor[0] == '\x04' || cursor[0] == '\x03' {
return nil, errors.New("ReadPassword: prompt rejected")
}
if cursor[0] == '\x7F' /* DEL */ {
if readen != 0 {
readen--
cursor = output[readen : readen+1]
}
continue
}
if readen == cap(output) {
return nil, errors.New("ReadPassword: too long password")
}
readen++
cursor = output[readen : readen+1]
}
return output[0:readen], nil
}
func ReadPassword(prompt string) (string, error) {
termios, err := TurnOnRawIO(os.Stdin)
hiddenPass := true
if err != nil {
hiddenPass = false
fmt.Fprintln(os.Stderr, "Failed to disable terminal output:", err)
}
// There is no meaningful way to handle error here.
//nolint:errcheck
defer TcSetAttr(os.Stdin.Fd(), &termios)
fmt.Fprintf(os.Stderr, "%s: ", prompt)
if hiddenPass {
buf := make([]byte, 512)
buf, err = readPass(os.Stdin, buf)
if err != nil {
return "", err
}
fmt.Println()
return string(buf), nil
}
if !stdinScanner.Scan() {
return "", stdinScanner.Err()
}
return stdinScanner.Text(), nil
}
================================================
FILE: internal/cli/clitools/termios.go
================================================
//go:build linux
// +build linux
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package clitools
// Copied from github.com/foxcpp/ttyprompt
// Commit 087a574, terminal/termios.go
import (
"errors"
"os"
"syscall"
"unsafe"
)
type Termios struct {
Iflag uint32
Oflag uint32
Cflag uint32
Lflag uint32
Cc [20]byte
Ispeed uint32
Ospeed uint32
}
/*
TurnOnRawIO sets flags suitable for raw I/O (no echo, per-character input, etc)
and returns original flags.
*/
func TurnOnRawIO(tty *os.File) (orig Termios, err error) {
termios, err := TcGetAttr(tty.Fd())
if err != nil {
return Termios{}, errors.New("TurnOnRawIO: failed to get flags: " + err.Error())
}
termiosOrig := *termios
termios.Lflag &^= syscall.ECHO
termios.Lflag &^= syscall.ICANON
termios.Iflag &^= syscall.IXON
termios.Lflag &^= syscall.ISIG
termios.Iflag |= syscall.IUTF8
err = TcSetAttr(tty.Fd(), termios)
if err != nil {
return Termios{}, errors.New("TurnOnRawIO: flags to set flags: " + err.Error())
}
return termiosOrig, nil
}
func TcSetAttr(fd uintptr, termios *Termios) error {
_, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCSETS, uintptr(unsafe.Pointer(termios)))
if err != 0 {
return err
}
return nil
}
func TcGetAttr(fd uintptr) (*Termios, error) {
termios := &Termios{}
_, _, err := syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.TCGETS, uintptr(unsafe.Pointer(termios)))
if err != 0 {
return nil, err
}
return termios, nil
}
================================================
FILE: internal/cli/clitools/termios_stub.go
================================================
//go:build !linux
// +build !linux
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package clitools
import (
"errors"
"os"
)
type Termios struct {
Iflag uint32
Oflag uint32
Cflag uint32
Lflag uint32
Cc [20]byte
Ispeed uint32
Ospeed uint32
}
func TurnOnRawIO(tty *os.File) (orig Termios, err error) {
return Termios{}, errors.New("not implemented")
}
func TcSetAttr(fd uintptr, termios *Termios) error {
return errors.New("not implemented")
}
func TcGetAttr(fd uintptr) (*Termios, error) {
return nil, errors.New("not implemented")
}
================================================
FILE: internal/cli/ctl/appendlimit.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package ctl
import (
"fmt"
imapbackend "github.com/emersion/go-imap/backend"
"github.com/foxcpp/maddy/framework/module"
"github.com/urfave/cli/v2"
)
// Copied from go-imap-backend-tests.
// AppendLimitUser is extension for backend.User interface which allows to
// set append limit value for testing and administration purposes.
type AppendLimitUser interface {
imapbackend.AppendLimitUser
// SetMessageLimit sets new value for limit.
// nil pointer means no limit.
SetMessageLimit(val *uint32) error
}
func imapAcctAppendlimit(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
userAL, ok := u.(AppendLimitUser)
if !ok {
return cli.Exit("Error: module.Storage does not support per-user append limit", 2)
}
if ctx.IsSet("value") {
val := ctx.Int("value")
var err error
if val == -1 {
err = userAL.SetMessageLimit(nil)
} else {
val32 := uint32(val)
err = userAL.SetMessageLimit(&val32)
}
if err != nil {
return err
}
} else {
lim := userAL.CreateMessageLimit()
if lim == nil {
fmt.Println("No limit")
} else {
fmt.Println(*lim)
}
}
return nil
}
================================================
FILE: internal/cli/ctl/hash.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package ctl
import (
"fmt"
"os"
"strings"
"github.com/foxcpp/maddy/internal/auth/pass_table"
maddycli "github.com/foxcpp/maddy/internal/cli"
clitools2 "github.com/foxcpp/maddy/internal/cli/clitools"
"github.com/urfave/cli/v2"
"golang.org/x/crypto/bcrypt"
)
func init() {
maddycli.AddSubcommand(
&cli.Command{
Name: "hash",
Usage: "Generate password hashes for use with pass_table",
Action: hashCommand,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "password",
Aliases: []string{"p"},
Usage: "Use `PASSWORD instead of reading password from stdin\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
&cli.StringFlag{
Name: "hash",
Usage: "Use specified hash algorithm",
Value: "bcrypt",
},
&cli.IntFlag{
Name: "bcrypt-cost",
Usage: "Specify bcrypt cost value",
Value: bcrypt.DefaultCost,
},
&cli.IntFlag{
Name: "argon2-time",
Usage: "Time factor for Argon2id",
Value: 3,
},
&cli.IntFlag{
Name: "argon2-memory",
Usage: "Memory in KiB to use for Argon2id",
Value: 1024,
},
&cli.IntFlag{
Name: "argon2-threads",
Usage: "Threads to use for Argon2id",
Value: 1,
},
},
})
}
func hashCommand(ctx *cli.Context) error {
hashFunc := ctx.String("hash")
if hashFunc == "" {
hashFunc = pass_table.DefaultHash
}
hashCompute := pass_table.HashCompute[hashFunc]
if hashCompute == nil {
funcs := make([]string, 0, len(pass_table.HashCompute))
for k := range pass_table.HashCompute {
funcs = append(funcs, k)
}
return cli.Exit(fmt.Sprintf("Error: Unknown hash function, available: %s", strings.Join(funcs, ", ")), 2)
}
opts := pass_table.HashOpts{
BcryptCost: bcrypt.DefaultCost,
Argon2Memory: 1024,
Argon2Time: 2,
Argon2Threads: 1,
}
if ctx.IsSet("bcrypt-cost") {
if ctx.Int("bcrypt-cost") > bcrypt.MaxCost {
return cli.Exit("Error: too big bcrypt cost", 2)
}
if ctx.Int("bcrypt-cost") < bcrypt.MinCost {
return cli.Exit("Error: too small bcrypt cost", 2)
}
opts.BcryptCost = ctx.Int("bcrypt-cost")
}
if ctx.IsSet("argon2-memory") {
opts.Argon2Memory = uint32(ctx.Int("argon2-memory"))
}
if ctx.IsSet("argon2-time") {
opts.Argon2Time = uint32(ctx.Int("argon2-time"))
}
if ctx.IsSet("argon2-threads") {
opts.Argon2Threads = uint8(ctx.Int("argon2-threads"))
}
var pass string
if ctx.IsSet("password") {
pass = ctx.String("password")
} else {
var err error
pass, err = clitools2.ReadPassword("Password")
if err != nil {
return err
}
}
if pass == "" {
fmt.Fprintln(os.Stderr, "WARNING: This is the hash of an empty string")
}
if strings.TrimSpace(pass) != pass {
fmt.Fprintln(os.Stderr, "WARNING: There is leading/trailing whitespace in the string")
}
hash, err := hashCompute(opts, pass)
if err != nil {
return err
}
fmt.Println(hashFunc + ":" + hash)
return nil
}
================================================
FILE: internal/cli/ctl/imap.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package ctl
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"strings"
"time"
"github.com/emersion/go-imap"
imapsql "github.com/foxcpp/go-imap-sql"
"github.com/foxcpp/maddy/framework/module"
maddycli "github.com/foxcpp/maddy/internal/cli"
clitools2 "github.com/foxcpp/maddy/internal/cli/clitools"
"github.com/urfave/cli/v2"
)
func init() {
maddycli.AddSubcommand(
&cli.Command{
Name: "imap-mboxes",
Usage: "IMAP mailboxes (folders) management",
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "Show mailboxes of user",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "subscribed",
Aliases: []string{"s"},
Usage: "List only subscribed mailboxes",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return mboxesList(be, ctx)
},
},
{
Name: "create",
Usage: "Create mailbox",
ArgsUsage: "USERNAME NAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.StringFlag{
Name: "special",
Usage: "Set SPECIAL-USE attribute on mailbox; valid values: archive, drafts, junk, sent, trash",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return mboxesCreate(be, ctx)
},
},
{
Name: "remove",
Usage: "Remove mailbox",
Description: "WARNING: All contents of mailbox will be irrecoverably lost.",
ArgsUsage: "USERNAME MAILBOX",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "Don't ask for confirmation",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return mboxesRemove(be, ctx)
},
},
{
Name: "rename",
Usage: "Rename mailbox",
Description: "Rename may cause unexpected failures on client-side so be careful.",
ArgsUsage: "USERNAME OLDNAME NEWNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return mboxesRename(be, ctx)
},
},
},
})
maddycli.AddSubcommand(&cli.Command{
Name: "imap-msgs",
Usage: "IMAP messages management",
Subcommands: []*cli.Command{
{
Name: "add",
Usage: "Add message to mailbox",
ArgsUsage: "USERNAME MAILBOX",
Description: "Reads message body (with headers) from stdin. Prints UID of created message on success.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.StringSliceFlag{
Name: "flag",
Aliases: []string{"f"},
Usage: "Add flag to message. Can be specified multiple times",
},
&cli.TimestampFlag{
Layout: time.RFC3339,
Name: "date",
Aliases: []string{"d"},
Usage: "Set internal date value to specified one in ISO 8601 format (2006-01-02T15:04:05Z07:00)",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsAdd(be, ctx)
},
},
{
Name: "add-flags",
Usage: "Add flags to messages",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Add flags to all messages matched by SEQ.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsFlags(be, ctx)
},
},
{
Name: "rem-flags",
Usage: "Remove flags from messages",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Remove flags from all messages matched by SEQ.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsFlags(be, ctx)
},
},
{
Name: "set-flags",
Usage: "Set flags on messages",
ArgsUsage: "USERNAME MAILBOX SEQ FLAGS...",
Description: "Set flags on all messages matched by SEQ.",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsFlags(be, ctx)
},
},
{
Name: "remove",
Usage: "Remove messages from mailbox",
ArgsUsage: "USERNAME MAILBOX SEQSET",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid,u",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "Don't ask for confirmation",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsRemove(be, ctx)
},
},
{
Name: "copy",
Usage: "Copy messages between mailboxes",
Description: "Note: You can't copy between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.",
ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsCopy(be, ctx)
},
},
{
Name: "move",
Usage: "Move messages between mailboxes",
Description: "Note: You can't move between mailboxes of different users. APPENDLIMIT of target mailbox is not enforced.",
ArgsUsage: "USERNAME SRCMAILBOX SEQSET TGTMAILBOX",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsMove(be, ctx)
},
},
{
Name: "list",
Usage: "List messages in mailbox",
Description: "If SEQSET is specified - only show messages that match it.",
ArgsUsage: "USERNAME MAILBOX [SEQSET]",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQSET instead of sequence numbers",
},
&cli.BoolFlag{
Name: "full,f",
Aliases: []string{"f"},
Usage: "Show entire envelope and all server meta-data",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsList(be, ctx)
},
},
{
Name: "dump",
Usage: "Dump message body",
Description: "If passed SEQ matches multiple messages - they will be joined.",
ArgsUsage: "USERNAME MAILBOX SEQ",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "uid",
Aliases: []string{"u"},
Usage: "Use UIDs for SEQ instead of sequence numbers",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return msgsDump(be, ctx)
},
},
},
})
}
func FormatAddress(addr *imap.Address) string {
return fmt.Sprintf("%s <%s@%s>", addr.PersonalName, addr.MailboxName, addr.HostName)
}
func FormatAddressList(addrs []*imap.Address) string {
res := make([]string, 0, len(addrs))
for _, addr := range addrs {
res = append(res, FormatAddress(addr))
}
return strings.Join(res, ", ")
}
func mboxesList(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
mboxes, err := u.ListMailboxes(ctx.Bool("subscribed,s"))
if err != nil {
return err
}
if len(mboxes) == 0 && !ctx.Bool("quiet") {
fmt.Fprintln(os.Stderr, "No mailboxes.")
}
for _, info := range mboxes {
if len(info.Attributes) != 0 {
fmt.Print(info.Name, "\t", info.Attributes, "\n")
} else {
fmt.Println(info.Name)
}
}
return nil
}
func mboxesCreate(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
name := ctx.Args().Get(1)
if name == "" {
return cli.Exit("Error: NAME is required", 2)
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
if ctx.IsSet("special") {
attr := "\\" + strings.Title(ctx.String("special")) //nolint:staticcheck
// (nolint) strings.Title is perfectly fine there since special mailbox tags will never use Unicode.
suu, ok := u.(SpecialUseUser)
if !ok {
return cli.Exit("Error: storage backend does not support SPECIAL-USE IMAP extension", 2)
}
return suu.CreateMailboxSpecial(name, attr)
}
return u.CreateMailbox(name)
}
func mboxesRemove(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
name := ctx.Args().Get(1)
if name == "" {
return cli.Exit("Error: NAME is required", 2)
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
if !ctx.Bool("yes") {
status, err := u.Status(name, []imap.StatusItem{imap.StatusMessages})
if err != nil {
return err
}
if status.Messages != 0 {
fmt.Fprintf(os.Stderr, "Mailbox %s contains %d messages.\n", name, status.Messages)
}
if !clitools2.Confirmation("Are you sure you want to delete that mailbox?", false) {
return errors.New("Cancelled")
}
}
return u.DeleteMailbox(name)
}
func mboxesRename(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
oldName := ctx.Args().Get(1)
if oldName == "" {
return cli.Exit("Error: OLDNAME is required", 2)
}
newName := ctx.Args().Get(2)
if newName == "" {
return cli.Exit("Error: NEWNAME is required", 2)
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
return u.RenameMailbox(oldName, newName)
}
func msgsAdd(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
name := ctx.Args().Get(1)
if name == "" {
return cli.Exit("Error: MAILBOX is required", 2)
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
flags := ctx.StringSlice("flag")
if flags == nil {
flags = []string{}
}
date := time.Now()
if ctx.IsSet("date") {
date = *ctx.Timestamp("date")
}
buf := bytes.Buffer{}
if _, err := io.Copy(&buf, os.Stdin); err != nil {
return err
}
if buf.Len() == 0 {
return cli.Exit("Error: Empty message, refusing to continue", 2)
}
status, err := u.Status(name, []imap.StatusItem{imap.StatusUidNext})
if err != nil {
return err
}
if err := u.CreateMessage(name, flags, date, &buf, nil); err != nil {
return err
}
// TODO: Use APPENDUID
fmt.Println(status.UidNext)
return nil
}
func msgsRemove(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
name := ctx.Args().Get(1)
if name == "" {
return cli.Exit("Error: MAILBOX is required", 2)
}
seqset := ctx.Args().Get(2)
if seqset == "" {
return cli.Exit("Error: SEQSET is required", 2)
}
if !ctx.Bool("uid") {
fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7")
}
seq, err := imap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
_, mbox, err := u.GetMailbox(name, true, nil)
if err != nil {
return err
}
if !ctx.Bool("yes") {
if !clitools2.Confirmation("Are you sure you want to delete these messages?", false) {
return errors.New("Cancelled")
}
}
mboxB := mbox.(*imapsql.Mailbox)
return mboxB.DelMessages(ctx.Bool("uid"), seq)
}
func msgsCopy(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
srcName := ctx.Args().Get(1)
if srcName == "" {
return cli.Exit("Error: SRCMAILBOX is required", 2)
}
seqset := ctx.Args().Get(2)
if seqset == "" {
return cli.Exit("Error: SEQSET is required", 2)
}
tgtName := ctx.Args().Get(3)
if tgtName == "" {
return cli.Exit("Error: TGTMAILBOX is required", 2)
}
if !ctx.Bool("uid") {
fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7")
}
seq, err := imap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
_, srcMbox, err := u.GetMailbox(srcName, true, nil)
if err != nil {
return err
}
return srcMbox.CopyMessages(ctx.Bool("uid"), seq, tgtName)
}
func msgsMove(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
srcName := ctx.Args().Get(1)
if srcName == "" {
return cli.Exit("Error: SRCMAILBOX is required", 2)
}
seqset := ctx.Args().Get(2)
if seqset == "" {
return cli.Exit("Error: SEQSET is required", 2)
}
tgtName := ctx.Args().Get(3)
if tgtName == "" {
return cli.Exit("Error: TGTMAILBOX is required", 2)
}
if !ctx.Bool("uid") {
fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7")
}
seq, err := imap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
_, srcMbox, err := u.GetMailbox(srcName, true, nil)
if err != nil {
return err
}
moveMbox := srcMbox.(*imapsql.Mailbox)
return moveMbox.MoveMessages(ctx.Bool("uid"), seq, tgtName)
}
func msgsList(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
mboxName := ctx.Args().Get(1)
if mboxName == "" {
return cli.Exit("Error: MAILBOX is required", 2)
}
seqset := ctx.Args().Get(2)
uid := ctx.Bool("uid")
if seqset == "" {
seqset = "1:*"
uid = true
} else if !uid {
fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7")
}
seq, err := imap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
_, mbox, err := u.GetMailbox(mboxName, true, nil)
if err != nil {
return err
}
ch := make(chan *imap.Message, 10)
go func() {
err = mbox.ListMessages(uid, seq, []imap.FetchItem{imap.FetchEnvelope, imap.FetchInternalDate, imap.FetchRFC822Size, imap.FetchFlags, imap.FetchUid}, ch)
}()
for msg := range ch {
if !ctx.Bool("full") {
fmt.Printf("UID %d: %s - %s\n %v, %v\n\n", msg.Uid, FormatAddressList(msg.Envelope.From), msg.Envelope.Subject, msg.Flags, msg.Envelope.Date)
continue
}
fmt.Println("- Server meta-data:")
fmt.Println("UID:", msg.Uid)
fmt.Println("Sequence number:", msg.SeqNum)
fmt.Println("Flags:", msg.Flags)
fmt.Println("Body size:", msg.Size)
fmt.Println("Internal date:", msg.InternalDate.Unix(), msg.InternalDate)
fmt.Println("- Envelope:")
if len(msg.Envelope.From) != 0 {
fmt.Println("From:", FormatAddressList(msg.Envelope.From))
}
if len(msg.Envelope.To) != 0 {
fmt.Println("To:", FormatAddressList(msg.Envelope.To))
}
if len(msg.Envelope.Cc) != 0 {
fmt.Println("CC:", FormatAddressList(msg.Envelope.Cc))
}
if len(msg.Envelope.Bcc) != 0 {
fmt.Println("BCC:", FormatAddressList(msg.Envelope.Bcc))
}
if msg.Envelope.InReplyTo != "" {
fmt.Println("In-Reply-To:", msg.Envelope.InReplyTo)
}
if msg.Envelope.MessageId != "" {
fmt.Println("Message-Id:", msg.Envelope.MessageId)
}
if !msg.Envelope.Date.IsZero() {
fmt.Println("Date:", msg.Envelope.Date.Unix(), msg.Envelope.Date)
}
if msg.Envelope.Subject != "" {
fmt.Println("Subject:", msg.Envelope.Subject)
}
fmt.Println()
}
return err
}
func msgsDump(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
mboxName := ctx.Args().Get(1)
if mboxName == "" {
return cli.Exit("Error: MAILBOX is required", 2)
}
seqset := ctx.Args().Get(2)
uid := ctx.Bool("uid")
if seqset == "" {
seqset = "1:*"
uid = true
} else if !uid {
fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7")
}
seq, err := imap.ParseSeqSet(seqset)
if err != nil {
return err
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
_, mbox, err := u.GetMailbox(mboxName, true, nil)
if err != nil {
return err
}
ch := make(chan *imap.Message, 10)
go func() {
err = mbox.ListMessages(uid, seq, []imap.FetchItem{imap.FetchRFC822}, ch)
}()
for msg := range ch {
for _, v := range msg.Body {
if _, err := io.Copy(os.Stdout, v); err != nil {
return err
}
}
}
return err
}
func msgsFlags(be module.Storage, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
name := ctx.Args().Get(1)
if name == "" {
return cli.Exit("Error: MAILBOX is required", 2)
}
seqStr := ctx.Args().Get(2)
if seqStr == "" {
return cli.Exit("Error: SEQ is required", 2)
}
if !ctx.Bool("uid") {
fmt.Fprintln(os.Stderr, "WARNING: --uid=true will be the default in 0.7")
}
seq, err := imap.ParseSeqSet(seqStr)
if err != nil {
return err
}
u, err := be.GetIMAPAcct(username)
if err != nil {
return err
}
_, mbox, err := u.GetMailbox(name, false, nil)
if err != nil {
return err
}
flags := ctx.Args().Slice()[3:]
if len(flags) == 0 {
return cli.Exit("Error: at least once FLAG is required", 2)
}
var op imap.FlagsOp
switch ctx.Command.Name {
case "add-flags":
op = imap.AddFlags
case "rem-flags":
op = imap.RemoveFlags
case "set-flags":
op = imap.SetFlags
default:
panic("unknown command: " + ctx.Command.Name)
}
return mbox.UpdateMessagesFlags(ctx.Bool("uid"), seq, op, true, flags)
}
================================================
FILE: internal/cli/ctl/imapacct.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package ctl
import (
"errors"
"fmt"
"os"
"github.com/emersion/go-imap"
"github.com/foxcpp/maddy/framework/module"
maddycli "github.com/foxcpp/maddy/internal/cli"
clitools2 "github.com/foxcpp/maddy/internal/cli/clitools"
"github.com/urfave/cli/v2"
)
func init() {
maddycli.AddSubcommand(
&cli.Command{
Name: "imap-acct",
Usage: "IMAP storage accounts management",
Description: `These subcommands can be used to list/create/delete IMAP storage
accounts for any storage backend supported by maddy.
The corresponding storage backend should be configured in maddy.conf and be
defined in a top-level configuration block. By default, the name of that
block should be local_mailboxes but this can be changed using --cfg-block
flag for subcommands.
Note that in default configuration it is not enough to create an IMAP storage
account to grant server access. Additionally, user credentials should
be created using 'creds' subcommand.
`,
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "List storage accounts",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return imapAcctList(be, ctx)
},
},
{
Name: "create",
Usage: "Create IMAP storage account",
Description: `In addition to account creation, this command
creates a set of default folder (mailboxes) with special-use attribute set.`,
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "no-specialuse",
Usage: "Do not create special-use folders",
Value: false,
},
&cli.StringFlag{
Name: "sent-name",
Usage: "Name of special mailbox for sent messages, use empty string to not create any",
Value: "Sent",
},
&cli.StringFlag{
Name: "trash-name",
Usage: "Name of special mailbox for trash, use empty string to not create any",
Value: "Trash",
},
&cli.StringFlag{
Name: "junk-name",
Usage: "Name of special mailbox for 'junk' (spam), use empty string to not create any",
Value: "Junk",
},
&cli.StringFlag{
Name: "drafts-name",
Usage: "Name of special mailbox for drafts, use empty string to not create any",
Value: "Drafts",
},
&cli.StringFlag{
Name: "archive-name",
Usage: "Name of special mailbox for archive, use empty string to not create any",
Value: "Archive",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return imapAcctCreate(be, ctx)
},
},
{
Name: "remove",
Usage: "Delete IMAP storage account",
Description: `If IMAP connections are open and using the specified account,
messages access will be killed off immediately though connection will remain open. No cache
or other buffering takes effect.`,
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "Don't ask for confirmation",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return imapAcctRemove(be, ctx)
},
},
{
Name: "appendlimit",
Usage: "Query or set accounts's APPENDLIMIT value",
Description: `APPENDLIMIT value determines the size of a message that
can be saved into a mailbox using IMAP APPEND command. This does not affect the size
of messages that can be delivered to the mailbox from non-IMAP sources (e.g. SMTP).
Global APPENDLIMIT value set via server configuration takes precedence over
per-account values configured using this command.
APPENDLIMIT value (either global or per-account) cannot be larger than
4 GiB due to IMAP protocol limitations.
`,
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_mailboxes",
},
&cli.IntFlag{
Name: "value",
Aliases: []string{"v"},
Usage: "Set APPENDLIMIT to specified value (in bytes)",
},
},
Action: func(ctx *cli.Context) error {
be, err := openStorage(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return imapAcctAppendlimit(be, ctx)
},
},
},
})
}
type SpecialUseUser interface {
CreateMailboxSpecial(name, specialUseAttr string) error
}
func imapAcctList(be module.Storage, ctx *cli.Context) error {
mbe, ok := be.(module.ManageableStorage)
if !ok {
return cli.Exit("Error: storage backend does not support accounts management using maddy command", 2)
}
list, err := mbe.ListIMAPAccts()
if err != nil {
return err
}
if len(list) == 0 && !ctx.Bool("quiet") {
fmt.Fprintln(os.Stderr, "No users.")
}
for _, user := range list {
fmt.Println(user)
}
return nil
}
func imapAcctCreate(be module.Storage, ctx *cli.Context) error {
mbe, ok := be.(module.ManageableStorage)
if !ok {
return cli.Exit("Error: storage backend does not support accounts management using maddy command", 2)
}
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
if err := mbe.CreateIMAPAcct(username); err != nil {
return err
}
act, err := mbe.GetIMAPAcct(username)
if err != nil {
return fmt.Errorf("failed to get user: %w", err)
}
suu, ok := act.(SpecialUseUser)
if !ok {
fmt.Fprintf(os.Stderr, "Note: Storage backend does not support SPECIAL-USE IMAP extension")
}
if ctx.Bool("no-specialuse") {
return nil
}
createMbox := func(name, specialUseAttr string) error {
if suu == nil {
return act.CreateMailbox(name)
}
return suu.CreateMailboxSpecial(name, specialUseAttr)
}
if name := ctx.String("sent-name"); name != "" {
if err := createMbox(name, imap.SentAttr); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create sent folder: %v", err)
}
}
if name := ctx.String("trash-name"); name != "" {
if err := createMbox(name, imap.TrashAttr); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create trash folder: %v", err)
}
}
if name := ctx.String("junk-name"); name != "" {
if err := createMbox(name, imap.JunkAttr); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create junk folder: %v", err)
}
}
if name := ctx.String("drafts-name"); name != "" {
if err := createMbox(name, imap.DraftsAttr); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create drafts folder: %v", err)
}
}
if name := ctx.String("archive-name"); name != "" {
if err := createMbox(name, imap.ArchiveAttr); err != nil {
fmt.Fprintf(os.Stderr, "Failed to create archive folder: %v", err)
}
}
return nil
}
func imapAcctRemove(be module.Storage, ctx *cli.Context) error {
mbe, ok := be.(module.ManageableStorage)
if !ok {
return cli.Exit("Error: storage backend does not support accounts management using maddy command", 2)
}
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
if !ctx.Bool("yes") {
if !clitools2.Confirmation("Are you sure you want to delete this user account?", false) {
return errors.New("Cancelled")
}
}
return mbe.DeleteIMAPAcct(username)
}
================================================
FILE: internal/cli/ctl/moduleinit.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package ctl
import (
"errors"
"fmt"
"os"
"github.com/foxcpp/maddy"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/updatepipe"
"github.com/urfave/cli/v2"
)
func closeIfNeeded(i any) {
if c, ok := i.(container.LifetimeModule); ok {
if err := c.Stop(); err != nil {
log.DefaultLogger.Error("failed to stop module", err)
}
}
}
type managedStorage struct {
module.ManageableStorage
started bool
}
func (m *managedStorage) Close() error {
if !m.started {
return nil
}
if lm, ok := m.ManageableStorage.(container.LifetimeModule); ok {
return lm.Stop()
}
return nil
}
type managedUserDB struct {
module.PlainUserDB
started bool
}
func (m *managedUserDB) Close() error {
if !m.started {
return nil
}
if lm, ok := m.PlainUserDB.(container.LifetimeModule); ok {
return lm.Stop()
}
return nil
}
func getCfgBlockModule(ctx *cli.Context) (*container.C, module.Module, error) {
cfgPath := ctx.String("config")
if cfgPath == "" {
return nil, nil, cli.Exit("Error: config is required", 2)
}
c := container.New()
container.Global = c
cfg, err := maddy.ReadConfig(cfgPath)
if err != nil {
return nil, nil, cli.Exit(fmt.Sprintf("Error: failed to open config: %v", err), 2)
}
globals, cfgNodes, err := maddy.ReadGlobals(c, cfg)
if err != nil {
return nil, nil, err
}
// For CLI management we force-rollback configured logger and consider only
// --log so messages relevant to command execution will go where admin would
// see them.
c.DefaultLogger.Out = log.DefaultLogger.Out
if err := maddy.InitDirs(c); err != nil {
return nil, nil, err
}
err = maddy.RegisterModules(c, globals, cfgNodes)
if err != nil {
return nil, nil, err
}
cfgBlock := ctx.String("cfg-block")
if cfgBlock == "" {
return nil, nil, cli.Exit("Error: cfg-block is required", 2)
}
mod, err := c.Modules.Get(cfgBlock)
if err != nil {
if errors.Is(err, container.ErrInstanceUnknown) {
return nil, nil, cli.Exit(fmt.Sprintf("Error: unknown configuration block: %s", cfgBlock), 2)
}
return nil, nil, err
}
return c, mod, nil
}
func openStorage(ctx *cli.Context) (module.Storage, error) {
_, mod, err := getCfgBlockModule(ctx)
if err != nil {
return nil, err
}
storage, ok := mod.(module.Storage)
if !ok {
return nil, cli.Exit(fmt.Sprintf("Error: configuration block %s is not an IMAP storage", ctx.String("cfg-block")), 2)
}
started := false
if lt, ok := storage.(container.LifetimeModule); ok {
if err := lt.Start(); err != nil {
return nil, err
}
started = true
}
if updStore, ok := mod.(updatepipe.Backend); ok {
if err := updStore.EnableUpdatePipe(updatepipe.ModePush); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(os.Stderr, "Failed to initialize update pipe, do not remove messages from mailboxes open by clients: %v\n", err)
}
} else {
fmt.Fprintf(os.Stderr, "No update pipe support, do not remove messages from mailboxes open by clients\n")
}
if started {
if ms, ok := storage.(module.ManageableStorage); ok {
return &managedStorage{ManageableStorage: ms, started: started}, nil
}
}
return storage, nil
}
func openUserDB(ctx *cli.Context) (module.PlainUserDB, error) {
_, mod, err := getCfgBlockModule(ctx)
if err != nil {
return nil, err
}
userDB, ok := mod.(module.PlainUserDB)
if !ok {
return nil, cli.Exit(fmt.Sprintf("Error: configuration block %s is not a local credentials store", ctx.String("cfg-block")), 2)
}
started := false
if lt, ok := userDB.(container.LifetimeModule); ok {
if err := lt.Start(); err != nil {
return nil, err
}
started = true
}
if started {
return &managedUserDB{PlainUserDB: userDB, started: started}, nil
}
return userDB, nil
}
================================================
FILE: internal/cli/ctl/users.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package ctl
import (
"errors"
"fmt"
"os"
"strings"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/auth/pass_table"
maddycli "github.com/foxcpp/maddy/internal/cli"
clitools2 "github.com/foxcpp/maddy/internal/cli/clitools"
"github.com/urfave/cli/v2"
"golang.org/x/crypto/bcrypt"
)
func init() {
maddycli.AddSubcommand(
&cli.Command{
Name: "creds",
Usage: "Local credentials management",
Description: `These commands manipulate credential databases used by
maddy mail server.
Corresponding credential database should be defined in maddy.conf as
a top-level config block. By default the block name should be local_authdb (
can be changed using --cfg-block argument for subcommands).
Note that it is not enough to create user credentials in order to grant
IMAP access - IMAP account should be also created using 'imap-acct create' subcommand.
`,
Subcommands: []*cli.Command{
{
Name: "list",
Usage: "List created credentials",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_authdb",
},
},
Action: func(ctx *cli.Context) error {
be, err := openUserDB(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return usersList(be, ctx)
},
},
{
Name: "create",
Usage: "Create user account",
Description: `Reads password from stdin.
If configuration block uses auth.pass_table, then hash algorithm can be configured
using command flags. Otherwise, these options cannot be used.
`,
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_authdb",
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"p"},
Usage: "Use `PASSWORD instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
&cli.StringFlag{
Name: "hash",
Usage: "Use specified hash algorithm. Valid values: " + strings.Join(pass_table.Hashes, ", "),
Value: "bcrypt",
},
&cli.IntFlag{
Name: "bcrypt-cost",
Usage: "Specify bcrypt cost value",
Value: bcrypt.DefaultCost,
},
},
Action: func(ctx *cli.Context) error {
be, err := openUserDB(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return usersCreate(be, ctx)
},
},
{
Name: "remove",
Usage: "Delete user account",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_authdb",
},
&cli.BoolFlag{
Name: "yes",
Aliases: []string{"y"},
Usage: "Don't ask for confirmation",
},
},
Action: func(ctx *cli.Context) error {
be, err := openUserDB(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return usersRemove(be, ctx)
},
},
{
Name: "password",
Usage: "Change account password",
Description: "Reads password from stdin",
ArgsUsage: "USERNAME",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "cfg-block",
Usage: "Module configuration block to use",
EnvVars: []string{"MADDY_CFGBLOCK"},
Value: "local_authdb",
},
&cli.StringFlag{
Name: "password",
Aliases: []string{"p"},
Usage: "Use `PASSWORD` instead of reading password from stdin.\n\t\tWARNING: Provided only for debugging convenience. Don't leave your passwords in shell history!",
},
},
Action: func(ctx *cli.Context) error {
be, err := openUserDB(ctx)
if err != nil {
return err
}
defer closeIfNeeded(be)
return usersPassword(be, ctx)
},
},
},
})
}
func usersList(be module.PlainUserDB, ctx *cli.Context) error {
list, err := be.ListUsers()
if err != nil {
return err
}
if len(list) == 0 && !ctx.Bool("quiet") {
fmt.Fprintln(os.Stderr, "No users.")
}
for _, user := range list {
fmt.Println(user)
}
return nil
}
func usersCreate(be module.PlainUserDB, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return cli.Exit("Error: USERNAME is required", 2)
}
var pass string
if ctx.IsSet("password") {
pass = ctx.String("password")
} else {
var err error
pass, err = clitools2.ReadPassword("Enter password for new user")
if err != nil {
return err
}
}
if beHash, ok := be.(*pass_table.Auth); ok {
return beHash.CreateUserHash(username, pass, ctx.String("hash"), pass_table.HashOpts{
BcryptCost: ctx.Int("bcrypt-cost"),
})
} else if ctx.IsSet("hash") || ctx.IsSet("bcrypt-cost") {
return cli.Exit("Error: --hash cannot be used with non-pass_table credentials DB", 2)
} else {
return be.CreateUser(username, pass)
}
}
func usersRemove(be module.PlainUserDB, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return errors.New("error: USERNAME is required")
}
if !ctx.Bool("yes") {
if !clitools2.Confirmation("Are you sure you want to delete this user account?", false) {
return errors.New("cancelled")
}
}
return be.DeleteUser(username)
}
func usersPassword(be module.PlainUserDB, ctx *cli.Context) error {
username := ctx.Args().First()
if username == "" {
return errors.New("error: USERNAME is required")
}
var pass string
if ctx.IsSet("password") {
pass = ctx.String("password")
} else {
var err error
pass, err = clitools2.ReadPassword("Enter new password")
if err != nil {
return err
}
}
return be.SetUserPassword(username, pass)
}
================================================
FILE: internal/cli/extflag.go
================================================
package maddycli
import (
"flag"
"github.com/urfave/cli/v2"
)
// extFlag implements cli.Flag via standard flag.Flag.
type extFlag struct {
f *flag.Flag
}
func (e *extFlag) Apply(fs *flag.FlagSet) error {
fs.Var(e.f.Value, e.f.Name, e.f.Usage)
return nil
}
func (e *extFlag) Names() []string {
return []string{e.f.Name}
}
func (e *extFlag) IsSet() bool {
return false
}
func (e *extFlag) String() string {
return cli.FlagStringer(e)
}
func (e *extFlag) IsVisible() bool {
return true
}
func (e *extFlag) TakesValue() bool {
return false
}
func (e *extFlag) GetUsage() string {
return e.f.Usage
}
func (e *extFlag) GetValue() string {
return e.f.Value.String()
}
func (e *extFlag) GetDefaultText() string {
return e.f.DefValue
}
func (e *extFlag) GetEnvVars() []string {
return nil
}
func mapStdlibFlags(app *cli.App) {
// Modified AllowExtFlags from cli lib with -test.* exception removed.
flag.VisitAll(func(f *flag.Flag) {
app.Flags = append(app.Flags, &extFlag{f})
})
}
================================================
FILE: internal/dmarc/dmarc.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dmarc
import (
"context"
"github.com/emersion/go-msgauth/dmarc"
)
type (
Resolver interface {
LookupTXT(context.Context, string) ([]string, error)
}
Record = dmarc.Record
Policy = dmarc.Policy
AlignmentMode = dmarc.AlignmentMode
FailureOptions = dmarc.FailureOptions
)
const (
PolicyNone = dmarc.PolicyNone
PolicyReject = dmarc.PolicyReject
PolicyQuarantine = dmarc.PolicyQuarantine
)
================================================
FILE: internal/dmarc/evaluate.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dmarc
import (
"context"
"errors"
"fmt"
"net"
"net/mail"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/emersion/go-msgauth/dmarc"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/dns"
"golang.org/x/net/publicsuffix"
)
// FetchRecord looks up the DMARC record relevant for the RFC5322.From domain.
// It returns the record and the domain it was found with (may not be
// equal to the RFC5322.From domain).
func FetchRecord(ctx context.Context, r Resolver, fromDomain string) (policyDomain string, rec *Record, err error) {
policyDomain = fromDomain
// 1. Lookup using From Domain.
txts, err := r.LookupTXT(ctx, dns.FQDN("_dmarc."+fromDomain))
if err != nil {
dnsErr, ok := err.(*net.DNSError)
if !ok || !dnsErr.IsNotFound {
return "", nil, err
}
}
if len(txts) == 0 {
// No records or 'no such host', try orgDomain.
orgDomain, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)
if err != nil {
return "", nil, err
}
policyDomain = orgDomain
txts, err = r.LookupTXT(ctx, dns.FQDN("_dmarc."+orgDomain))
if err != nil {
dnsErr, ok := err.(*net.DNSError)
if !ok || !dnsErr.IsNotFound {
return "", nil, err
}
}
// Still nothing? Bail out.
if len(txts) == 0 {
return "", nil, nil
}
}
// Exclude records that are not DMARC policies.
records := txts[:0]
for _, txt := range txts {
if strings.HasPrefix(txt, "v=DMARC1") {
records = append(records, txt)
}
}
// Multiple records => no record.
if len(records) > 1 || len(records) == 0 {
return "", nil, nil
}
rec, err = dmarc.Parse(records[0])
return policyDomain, rec, err
}
type EvalResult struct {
// The Authentication-Results field generated as a result of the DMARC
// check.
Authres authres.DMARCResult
// The Authentication-Results field for SPF that was considered during
// alignment check. May be empty.
SPFResult authres.SPFResult
// Whether HELO or MAIL FROM match the RFC5322.From domain.
SPFAligned bool
// The Authentication-Results field for the DKIM signature that is aligned,
// if no signatures are aligned - this field contains the result for the
// first signature. May be empty.
DKIMResult authres.DKIMResult
// Whether there is a DKIM signature with the d= field matching the
// RFC5322.From domain.
DKIMAligned bool
}
// EvaluateAlignment checks whether identifiers authenticated by SPF and DKIM are in alignment
// with the RFC5322.Domain.
//
// It returns EvalResult which contains the Authres field with the actual check result and
// a bunch of other trace information that can be useful for troubleshooting
// (and also report generation).
func EvaluateAlignment(fromDomain string, record *Record, results []authres.Result) EvalResult {
var (
spfAligned = false
spfResult = authres.SPFResult{}
dkimAligned = false
dkimResult = authres.DKIMResult{}
dkimPresent = false
dkimTempFail = false
)
for _, res := range results {
if dkimRes, ok := res.(*authres.DKIMResult); ok {
dkimPresent = true
// We want to return DKIM result for a signature provided by the orgDomain,
// in case there is none - return any (possibly misaligned) for reference.
if dkimResult.Value == "" {
dkimResult = *dkimRes
}
if isAligned(fromDomain, dkimRes.Domain, record.DKIMAlignment) {
dkimResult = *dkimRes
switch dkimRes.Value {
case authres.ResultPass:
dkimAligned = true
case authres.ResultTempError:
dkimTempFail = true
}
}
}
if spfRes, ok := res.(*authres.SPFResult); ok {
spfResult = *spfRes
var aligned bool
if spfRes.From == "" {
aligned = isAligned(fromDomain, spfRes.Helo, record.SPFAlignment)
} else {
aligned = isAligned(fromDomain, spfRes.From, record.SPFAlignment)
}
if aligned && spfRes.Value == authres.ResultPass {
spfAligned = true
}
}
}
res := EvalResult{
SPFResult: spfResult,
SPFAligned: spfAligned,
DKIMResult: dkimResult,
DKIMAligned: dkimAligned,
}
if !dkimPresent || spfResult.Value == "" {
res.Authres = authres.DMARCResult{
Value: authres.ResultNone,
Reason: "Not enough information (required checks are disabled)",
From: fromDomain,
}
return res
}
if dkimTempFail && !dkimAligned && !spfAligned {
// We can't be sure whether it is aligned or not. Bail out.
res.Authres = authres.DMARCResult{
Value: authres.ResultTempError,
Reason: "DKIM authentication temp error",
From: fromDomain,
}
return res
}
if !dkimAligned && spfResult.Value == authres.ResultTempError {
// We can't be sure whether it is aligned or not. Bail out.
res.Authres = authres.DMARCResult{
Value: authres.ResultTempError,
Reason: "SPF authentication temp error",
From: fromDomain,
}
return res
}
res.Authres.From = fromDomain
if dkimAligned || spfAligned {
res.Authres.Value = authres.ResultPass
} else {
res.Authres.Value = authres.ResultFail
res.Authres.Reason = "No aligned identifiers"
}
return res
}
func isAligned(fromDomain, authDomain string, mode AlignmentMode) bool {
if mode == dmarc.AlignmentStrict {
return strings.EqualFold(fromDomain, authDomain)
}
tld, _ := publicsuffix.PublicSuffix(fromDomain)
if strings.EqualFold(fromDomain, tld) {
return strings.EqualFold(fromDomain, authDomain)
}
orgDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(fromDomain)
if err != nil {
return false
}
authDomainFrom, err := publicsuffix.EffectiveTLDPlusOne(authDomain)
if err != nil {
return false
}
return strings.EqualFold(orgDomainFrom, authDomainFrom)
}
func ExtractFromDomain(hdr textproto.Header) (string, error) {
// TODO(GH emersion/go-message#75): Add textproto.Header.Count method.
var firstFrom string
for fields := hdr.FieldsByKey("From"); fields.Next(); {
if firstFrom == "" {
firstFrom = fields.Value()
} else {
return "", errors.New("dmarc: multiple From header fields are not allowed")
}
}
if firstFrom == "" {
return "", errors.New("dmarc: missing From header field")
}
hdrFromList, err := mail.ParseAddressList(firstFrom)
if err != nil {
return "", fmt.Errorf("dmarc: malformed From header field: %s", strings.TrimPrefix(err.Error(), "mail: "))
}
if len(hdrFromList) > 1 {
return "", errors.New("dmarc: multiple addresses in From field are not allowed")
}
if len(hdrFromList) == 0 {
return "", errors.New("dmarc: missing address in From field")
}
_, domain, err := address.Split(hdrFromList[0].Address)
if err != nil {
return "", fmt.Errorf("dmarc: malformed From header field: %w", err)
}
return domain, nil
}
================================================
FILE: internal/dmarc/evaluate_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dmarc
import (
"bufio"
"strings"
"testing"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/emersion/go-msgauth/dmarc"
)
func TestEvaluateAlignment(t *testing.T) {
type tCase struct {
fromDomain string
record *Record
results []authres.Result
output authres.ResultValue
}
test := func(i int, c tCase) {
out := EvaluateAlignment(c.fromDomain, c.record, c.results)
t.Logf("%d - %+v", i, out)
if out.Authres.Value != c.output {
t.Errorf("%d: Wrong eval result, want '%s', got '%s' (%+v)", i, c.output, out.Authres.Value, out)
}
}
cases := []tCase{
{ // 0
fromDomain: "example.org",
record: &Record{},
output: authres.ResultNone,
},
{ // 1
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultFail,
From: "example.org",
Helo: "mx.example.org",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultFail,
},
{ // 2
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "example.org",
Helo: "mx.example.org",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultPass,
},
{ // 3
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultFail,
From: "example.org",
Helo: "mx.example.org",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultFail,
},
{ // 4
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultFail,
},
{ // 5
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "cbg.bounces.example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultPass,
},
{ // 6
fromDomain: "example.com",
record: &Record{
SPFAlignment: dmarc.AlignmentStrict,
},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "cbg.bounces.example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultFail,
},
{ // 7
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultFail,
Domain: "example.org",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultFail,
},
{ // 8
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.org",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultPass,
},
{ // 9
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "cbg.bounces.example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.com",
},
},
output: authres.ResultPass,
},
{ // 10
fromDomain: "example.com",
record: &Record{
SPFAlignment: dmarc.AlignmentRelaxed,
},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "cbg.bounces.example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultFail,
Domain: "example.com",
},
},
output: authres.ResultPass,
},
{ // 11
fromDomain: "example.com",
record: &Record{
SPFAlignment: dmarc.AlignmentStrict,
},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "cbg.bounces.example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.com",
},
},
output: authres.ResultPass,
},
{ // 12
fromDomain: "example.com",
record: &Record{
SPFAlignment: dmarc.AlignmentStrict,
DKIMAlignment: dmarc.AlignmentStrict,
},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "cbg.bounces.example.com",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultFail,
Domain: "cbg.example.com",
},
},
output: authres.ResultFail,
},
{ // 13
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultFail,
Domain: "example.org",
},
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.net",
},
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.org",
},
&authres.DKIMResult{
Value: authres.ResultFail,
Domain: "example.com",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultPass,
},
{ // 14
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultPass,
},
{ // 15
fromDomain: "example.com",
record: &Record{
SPFAlignment: dmarc.AlignmentStrict,
},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultFail,
},
{ // 16
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultTempError,
From: "",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
},
output: authres.ResultTempError,
},
{ // 17
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultTempError,
Domain: "example.com",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultTempError,
},
{ // 18
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultTempError,
From: "",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.com",
},
},
output: authres.ResultPass,
},
{ // 19
fromDomain: "example.com",
record: &Record{},
results: []authres.Result{
&authres.SPFResult{
Value: authres.ResultPass,
From: "",
Helo: "mx.example.com",
},
&authres.DKIMResult{
Value: authres.ResultTempError,
Domain: "example.com",
},
},
output: authres.ResultPass,
},
{ // 20
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "example.org",
},
&authres.DKIMResult{
Value: authres.ResultTempError,
Domain: "example.org",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultPass,
},
{ // 21
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultFail,
Domain: "example.org",
},
&authres.DKIMResult{
Value: authres.ResultTempError,
Domain: "example.org",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultTempError,
},
{ // 22
fromDomain: "example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultNone,
Domain: "example.org",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultFail,
},
{ // 23
fromDomain: "sub.example.org",
record: &Record{},
results: []authres.Result{
&authres.DKIMResult{
Value: authres.ResultPass,
Domain: "mx.example.org",
},
&authres.SPFResult{
Value: authres.ResultNone,
From: "example.org",
Helo: "mx.example.org",
},
},
output: authres.ResultPass,
},
}
for i, case_ := range cases {
test(i, case_)
}
}
func TestExtractDomains(t *testing.T) {
type tCase struct {
hdr string
fromDomain string
}
test := func(i int, c tCase) {
hdr, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(c.hdr + "\n\n")))
if err != nil {
panic(err)
}
domain, err := ExtractFromDomain(hdr)
if c.fromDomain == "" && err == nil {
t.Errorf("%d: expected failure, got fromDomain = %s", i, domain)
return
}
if c.fromDomain != "" && err != nil {
t.Errorf("%d: unexpected error: %v", i, err)
return
}
if domain != c.fromDomain {
t.Errorf("%d: want fromDomain = %v but got %s", i, c.fromDomain, domain)
}
}
cases := []tCase{
{
hdr: `From: `,
fromDomain: "example.org",
},
{
hdr: `From: `,
fromDomain: "foo.example.org",
},
{
hdr: `From: , `,
},
{
hdr: `From: ,
From: `,
},
{
hdr: `From: `,
},
{
hdr: `From: `,
},
{
hdr: `From: foo`,
},
}
for i, case_ := range cases {
test(i, case_)
}
}
================================================
FILE: internal/dmarc/verifier.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dmarc
import (
"context"
"math/rand"
"net"
"runtime/trace"
"strings"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/emersion/go-msgauth/dmarc"
)
type verifyData struct {
policyDomain string
fromDomain string
record *Record
recordErr error
}
// errPanic is used to propagate the panic() from the FetchRecord
// goroutine to the goroutine that called Apply.
type errPanic struct {
err interface{}
}
func (errPanic) Error() string {
return "panic during policy fetch"
}
// Verifier is the structure that wraps all state necessary to verify a
// single message using DMARC checks.
//
// It cannot be reused.
type Verifier struct {
fetchCh chan verifyData
fetchCancel context.CancelFunc
resolver Resolver
// TODO(GH #206): DMARC reporting
// FailureReportFunc is the callback that is called when a failure report
// is generated. If it is nil - failure reports generation is disabled.
// FailureReportFunc func(textproto.Header, io.Reader)
}
func NewVerifier(r Resolver) *Verifier {
return &Verifier{
fetchCh: make(chan verifyData, 1),
resolver: r,
}
}
func (v *Verifier) Close() error {
if v.fetchCancel != nil {
v.fetchCancel()
}
return nil
}
// FetchRecord prepares the Verifier by starting the policy lookup. Lookup is
// performed asynchronously to improve performance.
//
// If panic occurs in the lookup goroutine - call to Apply will panic.
func (v *Verifier) FetchRecord(ctx context.Context, header textproto.Header) {
fromDomain, err := ExtractFromDomain(header)
if err != nil {
v.fetchCh <- verifyData{
recordErr: err,
}
return
}
ctx, v.fetchCancel = context.WithCancel(ctx)
go func() {
defer func() {
if err := recover(); err != nil {
v.fetchCh <- verifyData{
recordErr: errPanic{err: err},
}
}
}()
defer trace.StartRegion(ctx, "DMARC/FetchRecord").End()
policyDomain, record, err := FetchRecord(ctx, v.resolver, fromDomain)
v.fetchCh <- verifyData{
policyDomain: policyDomain,
fromDomain: fromDomain,
record: record,
recordErr: err,
}
}()
}
// Apply actually performs all actions necessary to apply a DMARC policy to the message.
//
// The authRes slice should contain results for DKIM and SPF checks. FetchRecord should be
// caled before calling this function.
//
// It returns the Authentication-Result field to be included in the message (as
// a part of the EvalResult struct) and the appropriate action that should be
// taken by the MTA. In case of PolicyReject, caller should inspect the
// Result.Value to determine whether to use a temporary or permanent error code
// as Apply implements the 'fail closed' strategy for handling of temporary
// errors.
//
// Additionally, it relies on the math/rand default source to be initialized to determine
// whether to apply a policy with the pct key.
func (v *Verifier) Apply(authRes []authres.Result) (EvalResult, Policy) {
data := <-v.fetchCh
if data.recordErr != nil {
result := authres.DMARCResult{
Value: authres.ResultPermError,
Reason: "Policy lookup failed: " + data.recordErr.Error(),
// If may be empty, but it is fine (it will not be included in the field then).
From: data.fromDomain,
}
if dnsErr, ok := data.recordErr.(*net.DNSError); ok && dnsErr.Temporary() {
result.Value = authres.ResultTempError
// 'fail closed' behavior, reject the message if a temporary error
// occurs.
return EvalResult{
Authres: result,
}, dmarc.PolicyReject
}
return EvalResult{
Authres: result,
}, dmarc.PolicyNone
}
if data.record == nil {
return EvalResult{
Authres: authres.DMARCResult{
Value: authres.ResultNone,
From: data.fromDomain,
},
}, dmarc.PolicyNone
}
result := EvaluateAlignment(data.fromDomain, data.record, authRes)
if result.Authres.Value == authres.ResultPass || result.Authres.Value == authres.ResultNone {
return result, dmarc.PolicyNone
}
if data.record.Percent != nil && rand.Int31n(100) > int32(*data.record.Percent) {
return result, dmarc.PolicyNone
}
policy := data.record.Policy
if !strings.EqualFold(data.policyDomain, data.fromDomain) && data.record.SubdomainPolicy != "" {
policy = data.record.SubdomainPolicy
}
return result, policy
}
================================================
FILE: internal/dmarc/verifier_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dmarc
import (
"bufio"
"context"
"errors"
"net"
"strings"
"testing"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-msgauth/authres"
"github.com/foxcpp/go-mockdns"
"github.com/stretchr/testify/require"
)
func TestDMARC(t *testing.T) {
test := func(zones map[string]mockdns.Zone, hdr string, authres []authres.Result, policyApplied Policy, dmarcRes authres.ResultValue) {
t.Helper()
v := NewVerifier(&mockdns.Resolver{Zones: zones})
defer func() {
require.NoError(t, v.Close())
}()
hdrParsed, err := textproto.ReadHeader(bufio.NewReader(strings.NewReader(hdr)))
if err != nil {
panic(err)
}
v.FetchRecord(context.Background(), hdrParsed)
evalRes, policy := v.Apply(authres)
if policy != policyApplied {
t.Errorf("expected applied policy to be '%v', got '%v'", policyApplied, policy)
}
if evalRes.Authres.Value != dmarcRes {
t.Errorf("expected DMARC result to be '%v', got '%v'", dmarcRes, evalRes.Authres.Value)
}
}
// No policy => DMARC 'none'
test(map[string]mockdns.Zone{}, "From: hello@example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultNone)
// Policy present & identifiers align => DMARC 'pass'
test(map[string]mockdns.Zone{
"_dmarc.example.org.": {
TXT: []string{"v=DMARC1; p=none"},
},
}, "From: hello@example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultPass)
// No SPF check run => DMARC 'none', no action taken
test(map[string]mockdns.Zone{
"_dmarc.example.org.": {
TXT: []string{"v=DMARC1; p=reject"},
},
}, "From: hello@example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
}, PolicyNone, authres.ResultNone)
// No DKIM check run => DMARC 'none', no action taken
test(map[string]mockdns.Zone{
"_dmarc.example.org.": {
TXT: []string{"v=DMARC1; p=reject"},
},
}, "From: hello@example.org\r\n\r\n", []authres.Result{
&authres.SPFResult{Value: authres.ResultPass, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultNone)
// Check org. domain and from domain, prefer from domain.
// https://tools.ietf.org/html/rfc7489#section-6.6.3
test(map[string]mockdns.Zone{
"_dmarc.example.org.": {
TXT: []string{"v=DMARC1; p=none"},
},
}, "From: hello@sub.example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultPass)
test(map[string]mockdns.Zone{
"_dmarc.sub.example.org.": {
TXT: []string{"v=DMARC1; p=none"},
},
}, "From: hello@sub.example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultPass)
test(map[string]mockdns.Zone{
"_dmarc.sub.example.org.": {
TXT: []string{"v=DMARC1; p=none"},
},
"_dmarc.example.org.": {
TXT: []string{"v=malformed"},
},
}, "From: hello@sub.example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultPass)
// Non-DMARC records are ignored.
// https://tools.ietf.org/html/rfc7489#section-6.6.3
test(map[string]mockdns.Zone{
"_dmarc.example.org.": {
TXT: []string{"ignore", "v=DMARC1; p=none"},
},
}, "From: hello@sub.example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultPass)
// Multiple policies => no policy.
// https://tools.ietf.org/html/rfc7489#section-6.6.3
test(map[string]mockdns.Zone{
"_dmarc.example.org.": {
TXT: []string{"v=DMARC1; p=reject", "v=DMARC1; p=none"},
},
}, "From: hello@sub.example.org\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultNone)
// Malformed policy => no policy
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
TXT: []string{"v=aaaa"},
},
}, "From: hello@example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultNone)
// Policy fetch error => DMARC 'permerror' but the message
// is accepted.
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
Err: errors.New("the dns server is going insane"),
},
}, "From: hello@example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultPermError)
// Policy fetch error => DMARC 'temperror' but the message
// is accepted ("fail closed")
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
Err: &net.DNSError{
Err: "the dns server is going insane, temporary",
IsTemporary: true,
},
},
}, "From: hello@example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyReject, authres.ResultTempError)
// Misaligned From vs DKIM => DMARC 'fail'.
// Side note: More comprehensive tests for alignment evaluation
// can be found in check/dmarc/evaluate_test.go. This test merely checks
// that the correct action is taken based on the policy.
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
TXT: []string{"v=DMARC1; p=none"},
},
}, "From: hello@example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultFail)
// Misaligned From vs DKIM => DMARC 'fail', policy says to reject
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
TXT: []string{"v=DMARC1; p=reject"},
},
}, "From: hello@example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyReject, authres.ResultFail)
// Misaligned From vs DKIM => DMARC 'fail'
// Subdomain policy requests no action, main domain policy says to reject.
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
TXT: []string{"v=DMARC1; sp=none; p=reject"},
},
}, "From: hello@sub.example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyNone, authres.ResultFail)
// Misaligned From vs DKIM => DMARC 'fail', policy says to quarantine.
test(map[string]mockdns.Zone{
"_dmarc.example.com.": {
TXT: []string{"v=DMARC1; p=quarantine"},
},
}, "From: hello@example.com\r\n\r\n", []authres.Result{
&authres.DKIMResult{Value: authres.ResultPass, Domain: "example.org"},
&authres.SPFResult{Value: authres.ResultNone, From: "example.org", Helo: "mx.example.org"},
}, PolicyQuarantine, authres.ResultFail)
}
================================================
FILE: internal/dsn/dsn.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package dsn contains the utilities used for dsn message (DSN) generation.
//
// It implements RFC 3464 and RFC 3462.
package dsn
import (
"errors"
"fmt"
"io"
"strings"
"text/template"
"time"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/dns"
)
type ReportingMTAInfo struct {
ReportingMTA string
ReceivedFromMTA string
// Message sender address, included as 'X-Maddy-Sender: rfc822; ADDR' field.
XSender string
// Message identifier, included as 'X-Maddy-MsgId: MSGID' field.
XMessageID string
// Time when message was enqueued for delivery by Reporting MTA.
ArrivalDate time.Time
// Time when message delivery was attempted last time.
LastAttemptDate time.Time
}
func (info ReportingMTAInfo) WriteTo(utf8 bool, w io.Writer) error {
// DSN format uses structure similar to MIME header, so we reuse
// MIME generator here.
h := textproto.Header{}
if info.ReportingMTA == "" {
return errors.New("dsn: Reporting-MTA field is mandatory")
}
reportingMTA, err := dns.SelectIDNA(utf8, info.ReportingMTA)
if err != nil {
return fmt.Errorf("dsn: cannot convert Reporting-MTA to a suitable representation: %w", err)
}
h.Add("Reporting-MTA", "dns; "+reportingMTA)
if info.ReceivedFromMTA != "" {
receivedFromMTA, err := dns.SelectIDNA(utf8, info.ReceivedFromMTA)
if err != nil {
return fmt.Errorf("dsn: cannot convert Received-From-MTA to a suitable representation: %w", err)
}
h.Add("Received-From-MTA", "dns; "+receivedFromMTA)
}
if info.XSender != "" {
sender, err := address.SelectIDNA(utf8, info.XSender)
if err != nil {
return fmt.Errorf("dsn: cannot convert X-Maddy-Sender to a suitable representation: %w", err)
}
if utf8 {
h.Add("X-Maddy-Sender", "utf8; "+sender)
} else {
h.Add("X-Maddy-Sender", "rfc822; "+sender)
}
}
if info.XMessageID != "" {
h.Add("X-Maddy-MsgID", info.XMessageID)
}
if !info.ArrivalDate.IsZero() {
h.Add("Arrival-Date", info.ArrivalDate.Format("Mon, 2 Jan 2006 15:04:05 -0700"))
}
if !info.ArrivalDate.IsZero() {
h.Add("Last-Attempt-Date", info.LastAttemptDate.Format("Mon, 2 Jan 2006 15:04:05 -0700"))
}
return textproto.WriteHeader(w, h)
}
type Action string
const (
ActionFailed Action = "failed"
ActionDelayed Action = "delayed"
ActionDelivered Action = "delivered"
ActionRelayed Action = "relayed"
ActionExpanded Action = "expanded"
)
type RecipientInfo struct {
FinalRecipient string
RemoteMTA string
Action Action
Status smtp.EnhancedCode
// DiagnosticCode is the error that will be returned to the sender.
DiagnosticCode error
}
func (info RecipientInfo) WriteTo(utf8 bool, w io.Writer) error {
// DSN format uses structure similar to MIME header, so we reuse
// MIME generator here.
h := textproto.Header{}
if info.FinalRecipient == "" {
return errors.New("dsn: Final-Recipient is required")
}
finalRcpt, err := address.SelectIDNA(utf8, info.FinalRecipient)
if err != nil {
return fmt.Errorf("dsn: cannot convert Final-Recipient to a suitable representation: %w", err)
}
if utf8 {
h.Add("Final-Recipient", "utf8; "+finalRcpt)
} else {
h.Add("Final-Recipient", "rfc822; "+finalRcpt)
}
if info.Action == "" {
return errors.New("dsn: Action is required")
}
h.Add("Action", string(info.Action))
if info.Status[0] == 0 {
return errors.New("dsn: Status is required")
}
h.Add("Status", fmt.Sprintf("%d.%d.%d", info.Status[0], info.Status[1], info.Status[2]))
if smtpErr, ok := info.DiagnosticCode.(*smtp.SMTPError); ok {
// Error message may contain newlines if it is received from another SMTP server.
// But we cannot directly insert CR/LF into Disagnostic-Code so rewrite it.
h.Add("Diagnostic-Code", fmt.Sprintf("smtp; %d %d.%d.%d %s",
smtpErr.Code, smtpErr.EnhancedCode[0], smtpErr.EnhancedCode[1], smtpErr.EnhancedCode[2],
strings.ReplaceAll(strings.ReplaceAll(smtpErr.Message, "\n", " "), "\r", " ")))
} else if utf8 {
// It might contain Unicode, so don't include it if we are not allowed to.
// ... I didn't bother implementing mangling logic to remove Unicode
// characters.
errorDesc := info.DiagnosticCode.Error()
errorDesc = strings.ReplaceAll(strings.ReplaceAll(errorDesc, "\n", " "), "\r", " ")
h.Add("Diagnostic-Code", "X-Maddy; "+errorDesc)
}
if info.RemoteMTA != "" {
remoteMTA, err := dns.SelectIDNA(utf8, info.RemoteMTA)
if err != nil {
return fmt.Errorf("dsn: cannot convert Remote-MTA to a suitable representation: %w", err)
}
h.Add("Remote-MTA", "dns; "+remoteMTA)
}
return textproto.WriteHeader(w, h)
}
type Envelope struct {
MsgID string
From string
To string
}
// GenerateDSN is a top-level function that should be used for generation of the DSNs.
//
// DSN header will be returned, body itself will be written to outWriter.
func GenerateDSN(utf8 bool, envelope Envelope, mtaInfo ReportingMTAInfo, rcptsInfo []RecipientInfo, failedHeader textproto.Header, outWriter io.Writer) (textproto.Header, error) {
partWriter := textproto.NewMultipartWriter(outWriter)
reportHeader := textproto.Header{}
reportHeader.Add("Date", time.Now().Format("Mon, 2 Jan 2006 15:04:05 -0700"))
reportHeader.Add("Message-Id", envelope.MsgID)
reportHeader.Add("Content-Transfer-Encoding", "8bit")
reportHeader.Add("Content-Type", "multipart/report; report-type=delivery-status; boundary="+partWriter.Boundary())
reportHeader.Add("MIME-Version", "1.0")
reportHeader.Add("Auto-Submitted", "auto-replied")
reportHeader.Add("To", envelope.To)
reportHeader.Add("From", envelope.From)
reportHeader.Add("Subject", "Undelivered Mail Returned to Sender")
if err := writeHumanReadablePart(partWriter, mtaInfo, rcptsInfo); err != nil {
return textproto.Header{}, err
}
if err := writeMachineReadablePart(utf8, partWriter, mtaInfo, rcptsInfo); err != nil {
return textproto.Header{}, err
}
if err := writeHeader(utf8, partWriter, failedHeader); err != nil {
return textproto.Header{}, err
}
return reportHeader, partWriter.Close()
}
func writeHeader(utf8 bool, w *textproto.MultipartWriter, header textproto.Header) error {
partHeader := textproto.Header{}
partHeader.Add("Content-Description", "Undelivered message header")
if utf8 {
partHeader.Add("Content-Type", "message/global-headers")
} else {
partHeader.Add("Content-Type", "message/rfc822-headers")
}
partHeader.Add("Content-Transfer-Encoding", "8bit")
headerWriter, err := w.CreatePart(partHeader)
if err != nil {
return err
}
return textproto.WriteHeader(headerWriter, header)
}
func writeMachineReadablePart(utf8 bool, w *textproto.MultipartWriter, mtaInfo ReportingMTAInfo, rcptsInfo []RecipientInfo) error {
machineHeader := textproto.Header{}
if utf8 {
machineHeader.Add("Content-Type", "message/global-delivery-status")
} else {
machineHeader.Add("Content-Type", "message/delivery-status")
}
machineHeader.Add("Content-Description", "Delivery report")
machineWriter, err := w.CreatePart(machineHeader)
if err != nil {
return err
}
// WriteTo will add an empty line after output.
if err := mtaInfo.WriteTo(utf8, machineWriter); err != nil {
return err
}
for _, rcpt := range rcptsInfo {
if err := rcpt.WriteTo(utf8, machineWriter); err != nil {
return err
}
}
return nil
}
// failedText is the text of the human-readable part of DSN.
var failedText = template.Must(template.New("dsn-text").Parse(`
This is the mail delivery system at {{.ReportingMTA}}.
Unfortunately, your message could not be delivered to one or more
recipients. The usual cause of this problem is invalid
recipient address or maintenance at the recipient side.
Contact the postmaster for further assistance, provide the Message ID (below):
Message ID: {{.XMessageID}}
Arrival: {{.ArrivalDate}}
Last delivery attempt: {{.LastAttemptDate}}
`))
func writeHumanReadablePart(w *textproto.MultipartWriter, mtaInfo ReportingMTAInfo, rcptsInfo []RecipientInfo) error {
humanHeader := textproto.Header{}
humanHeader.Add("Content-Transfer-Encoding", "8bit")
humanHeader.Add("Content-Type", `text/plain; charset="utf-8"`)
humanHeader.Add("Content-Description", "Notification")
humanWriter, err := w.CreatePart(humanHeader)
if err != nil {
return err
}
mtaInfo.ArrivalDate = mtaInfo.ArrivalDate.Truncate(time.Second)
mtaInfo.LastAttemptDate = mtaInfo.LastAttemptDate.Truncate(time.Second)
if err := failedText.Execute(humanWriter, mtaInfo); err != nil {
return err
}
for _, rcpt := range rcptsInfo {
if _, err := fmt.Fprintf(humanWriter, "Delivery to %s failed with error: %v\n", rcpt.FinalRecipient, rcpt.DiagnosticCode); err != nil {
return err
}
}
return nil
}
================================================
FILE: internal/endpoint/dovecot_sasld/dovecot_sasl.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dovecotsasld
import (
"fmt"
stdlog "log"
"net"
"strings"
"sync"
"github.com/emersion/go-sasl"
dovecotsasl "github.com/foxcpp/go-dovecot-sasl"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/framework/resource/netresource"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/authz"
)
const modName = "dovecot_sasld"
type Endpoint struct {
addrs []string
log *log.Logger
saslAuth auth.SASLAuth
endpoints []config.Endpoint
listenersWg sync.WaitGroup
srv *dovecotsasl.Server
}
func New(c *container.C, _ string, addrs []string) (container.LifetimeModule, error) {
logger := c.DefaultLogger.Sublogger(modName)
return &Endpoint{
addrs: addrs,
saslAuth: auth.SASLAuth{
Log: logger.Sublogger("sasl"),
},
log: logger,
}, nil
}
func (endp *Endpoint) Name() string {
return modName
}
func (endp *Endpoint) InstanceName() string {
return modName
}
func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
cfg.Callback("auth", func(m *config.Map, node config.Node) error {
return endp.saslAuth.AddProvider(m, node)
})
cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin)
config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.saslAuth.AuthNormalize)
modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap)
if _, err := cfg.Process(); err != nil {
return err
}
endp.srv = dovecotsasl.NewServer()
endp.saslAuth.Log.Debug = endp.log.Debug
endp.srv.Log = stdlog.New(endp.log, "", 0)
for _, mech := range endp.saslAuth.SASLMechanisms() {
endp.srv.AddMechanism(mech, mechInfo[mech], func(req *dovecotsasl.AuthReq) sasl.Server {
var remoteAddr net.Addr
if req.RemoteIP != nil && req.RemotePort != 0 {
remoteAddr = &net.TCPAddr{IP: req.RemoteIP, Port: int(req.RemotePort)}
}
return endp.saslAuth.CreateSASL(mech, remoteAddr, func(_ string, _ auth.ContextData) error { return nil })
})
}
for _, addr := range endp.addrs {
parsed, err := config.ParseEndpoint(addr)
if err != nil {
return fmt.Errorf("%s: %v", modName, err)
}
endp.endpoints = append(endp.endpoints, parsed)
}
return nil
}
func (endp *Endpoint) Start() error {
for _, addr := range endp.endpoints {
l, err := netresource.Listen(addr.Network(), addr.Address())
if err != nil {
return fmt.Errorf("%s: %v", modName, err)
}
endp.log.Printf("listening on %v", l.Addr())
endp.listenersWg.Add(1)
go func() {
defer endp.listenersWg.Done()
if err := endp.srv.Serve(l); err != nil {
if !strings.HasSuffix(err.Error(), "use of closed network connection") {
endp.log.Printf("failed to serve %v: %v", l.Addr(), err)
}
}
}()
}
return nil
}
func (endp *Endpoint) Stop() error {
defer endp.listenersWg.Wait()
return endp.srv.Close()
}
func init() {
modules.RegisterEndpoint(modName, New)
}
================================================
FILE: internal/endpoint/dovecot_sasld/mech_info.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package dovecotsasld
import (
"github.com/emersion/go-sasl"
dovecotsasl "github.com/foxcpp/go-dovecot-sasl"
)
var mechInfo = map[string]dovecotsasl.Mechanism{
sasl.Plain: {
Plaintext: true,
},
sasl.Login: {
Plaintext: true,
},
}
================================================
FILE: internal/endpoint/imap/imap.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package imap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"strings"
"sync"
"github.com/emersion/go-imap"
compress "github.com/emersion/go-imap-compress"
sortthread "github.com/emersion/go-imap-sortthread"
imapbackend "github.com/emersion/go-imap/backend"
imapserver "github.com/emersion/go-imap/server"
"github.com/emersion/go-message"
_ "github.com/emersion/go-message/charset"
"github.com/emersion/go-sasl"
i18nlevel "github.com/foxcpp/go-imap-i18nlevel"
namespace "github.com/foxcpp/go-imap-namespace"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
tls2 "github.com/foxcpp/maddy/framework/config/tls"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/framework/resource/netresource"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/authz"
"github.com/foxcpp/maddy/internal/proxy_protocol"
"github.com/foxcpp/maddy/internal/updatepipe"
)
type Endpoint struct {
addrs []string
serv *imapserver.Server
proxyProtocol *proxy_protocol.ProxyProtocol
Store module.Storage
tlsConfig *tls.Config
endpoints []config.Endpoint
listeners []net.Listener
listenersWg sync.WaitGroup
saslAuth auth.SASLAuth
storageNormalize authz.NormalizeFunc
storageMap module.Table
log *log.Logger
}
func New(c *container.C, modName string, addrs []string) (container.LifetimeModule, error) {
logger := c.DefaultLogger.Sublogger(modName)
endp := &Endpoint{
addrs: addrs,
log: logger,
saslAuth: auth.SASLAuth{
Log: logger.Sublogger("sasl"),
},
}
return endp, nil
}
func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
var (
insecureAuth bool
ioDebug bool
ioErrors bool
)
cfg.Callback("auth", func(m *config.Map, node config.Node) error {
return endp.saslAuth.AddProvider(m, node)
})
cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin)
cfg.Custom("storage", false, true, nil, modconfig.StorageDirective, &endp.Store)
cfg.Custom("tls", true, true, nil, tls2.TLSDirective, &endp.tlsConfig)
cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol)
cfg.Bool("insecure_auth", false, false, &insecureAuth)
cfg.Bool("io_debug", false, false, &ioDebug)
cfg.Bool("io_errors", false, false, &ioErrors)
cfg.Bool("debug", true, false, &endp.log.Debug)
config.EnumMapped(cfg, "storage_map_normalize", false, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.storageNormalize)
modconfig.Table(cfg, "storage_map", false, false, nil, &endp.storageMap)
config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.saslAuth.AuthNormalize)
modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap)
if _, err := cfg.Process(); err != nil {
return err
}
endp.saslAuth.Log.Debug = endp.log.Debug
addresses := make([]config.Endpoint, 0, len(endp.addrs))
for _, addr := range endp.addrs {
saddr, err := config.ParseEndpoint(addr)
if err != nil {
return fmt.Errorf("imap: invalid address: %s", addr)
}
if saddr.IsTLS() && endp.tlsConfig == nil {
return errors.New("imap: can't bind on IMAPS endpoint without TLS configuration")
}
addresses = append(addresses, saddr)
}
endp.endpoints = addresses
endp.serv = imapserver.New(endp)
endp.serv.AllowInsecureAuth = insecureAuth
endp.serv.TLSConfig = endp.tlsConfig
if ioErrors {
endp.serv.ErrorLog = endp.log
} else {
endp.serv.ErrorLog = &log.NopLogger
}
if ioDebug {
endp.serv.Debug = endp.log.DebugWriter()
endp.log.Println("I/O debugging is on! It may leak passwords in logs, be careful!")
}
if err := endp.enableExtensions(); err != nil {
return err
}
for _, mech := range endp.saslAuth.SASLMechanisms() {
endp.serv.EnableAuth(mech, func(c imapserver.Conn) sasl.Server {
return endp.saslAuth.CreateSASL(mech, c.Info().RemoteAddr, func(identity string, data auth.ContextData) error {
return endp.openAccount(c, identity)
})
})
}
if endp.serv.AllowInsecureAuth {
endp.log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!")
}
if endp.serv.TLSConfig == nil {
endp.log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!")
endp.serv.AllowInsecureAuth = true
}
return nil
}
func (endp *Endpoint) Start() error {
if updBe, ok := endp.Store.(updatepipe.Backend); ok {
if err := updBe.EnableUpdatePipe(updatepipe.ModeReplicate); err != nil {
endp.log.Error("failed to initialize updates pipe", err)
}
}
if err := endp.setupListeners(endp.endpoints); err != nil {
if err := endp.Stop(); err != nil {
endp.log.Error("failed to stop after setupListeners error", err)
}
return err
}
return nil
}
func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
for _, addr := range addresses {
var l net.Listener
var err error
l, err = netresource.Listen(addr.Network(), addr.Address())
if err != nil {
return fmt.Errorf("imap: %v", err)
}
endp.log.Printf("listening on %v", addr)
if addr.IsTLS() {
if endp.tlsConfig == nil {
return errors.New("imap: can't bind on IMAPS endpoint without TLS configuration")
}
l = tls.NewListener(l, endp.tlsConfig)
}
if endp.proxyProtocol != nil {
l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.log)
}
endp.listeners = append(endp.listeners, l)
endp.listenersWg.Add(1)
go func() {
defer endp.listenersWg.Done()
if err := endp.serv.Serve(l); err != nil && !strings.HasSuffix(err.Error(), "use of closed network connection") {
endp.log.Printf("imap: failed to serve %s: %s", addr, err)
}
}()
}
return nil
}
func (endp *Endpoint) Name() string {
return "imap"
}
func (endp *Endpoint) InstanceName() string {
return "imap"
}
func (endp *Endpoint) Stop() error {
for _, l := range endp.listeners {
if err := l.Close(); err != nil {
endp.log.Error("failed to close listener", err)
}
}
if err := endp.serv.Close(); err != nil {
return err
}
endp.listenersWg.Wait()
return nil
}
func (endp *Endpoint) usernameForStorage(ctx context.Context, saslUsername string) (string, error) {
saslUsername, err := endp.storageNormalize(saslUsername)
if err != nil {
return "", err
}
if endp.storageMap == nil {
return saslUsername, nil
}
mapped, ok, err := endp.storageMap.Lookup(ctx, saslUsername)
if err != nil {
return "", err
}
if !ok {
return "", imapbackend.ErrInvalidCredentials
}
if saslUsername != mapped {
endp.log.DebugMsg("using mapped username for storage", "username", saslUsername, "mapped_username", mapped)
}
return mapped, nil
}
func (endp *Endpoint) openAccount(c imapserver.Conn, identity string) error {
username, err := endp.usernameForStorage(context.TODO(), identity)
if err != nil {
if errors.Is(err, imapbackend.ErrInvalidCredentials) {
return err
}
endp.log.Error("failed to determine storage account name", err, "username", username)
return fmt.Errorf("internal server error")
}
u, err := endp.Store.GetOrCreateIMAPAcct(username)
if err != nil {
return err
}
ctx := c.Context()
ctx.State = imap.AuthenticatedState
ctx.User = u
return nil
}
func (endp *Endpoint) Login(connInfo *imap.ConnInfo, username, password string) (imapbackend.User, error) {
// saslAuth handles AuthMap calling.
err := endp.saslAuth.AuthPlain(username, password)
if err != nil {
endp.log.Error("authentication failed", err, "username", username, "src_ip", connInfo.RemoteAddr)
return nil, imapbackend.ErrInvalidCredentials
}
storageUsername, err := endp.usernameForStorage(context.TODO(), username)
if err != nil {
if errors.Is(err, imapbackend.ErrInvalidCredentials) {
return nil, err
}
endp.log.Error("authentication failed due to an internal error", err, "username", username, "src_ip", connInfo.RemoteAddr)
return nil, fmt.Errorf("internal server error")
}
return endp.Store.GetOrCreateIMAPAcct(storageUsername)
}
func (endp *Endpoint) I18NLevel() int {
be, ok := endp.Store.(i18nlevel.Backend)
if !ok {
return 0
}
return be.I18NLevel()
}
func (endp *Endpoint) enableExtensions() error {
exts := endp.Store.IMAPExtensions()
for _, ext := range exts {
switch ext {
case "I18NLEVEL=1", "I18NLEVEL=2":
endp.serv.Enable(i18nlevel.NewExtension())
case "SORT":
endp.serv.Enable(sortthread.NewSortExtension())
}
if strings.HasPrefix(ext, "THREAD") {
endp.serv.Enable(sortthread.NewThreadExtension())
}
}
endp.serv.Enable(compress.NewExtension())
endp.serv.Enable(namespace.NewExtension())
return nil
}
func (endp *Endpoint) SupportedThreadAlgorithms() []sortthread.ThreadAlgorithm {
be, ok := endp.Store.(sortthread.ThreadBackend)
if !ok {
return nil
}
return be.SupportedThreadAlgorithms()
}
func init() {
modules.RegisterEndpoint("imap", New)
imap.CharsetReader = message.CharsetReader
}
================================================
FILE: internal/endpoint/openmetrics/om.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package openmetrics
import (
"errors"
"fmt"
"net/http"
"sync"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/framework/resource/netresource"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
const modName = "openmetrics"
type Endpoint struct {
addrs []string
endpoints []config.Endpoint
logger *log.Logger
listenersWg sync.WaitGroup
serv http.Server
mux *http.ServeMux
}
func New(c *container.C, _ string, args []string) (container.LifetimeModule, error) {
return &Endpoint{
addrs: args,
logger: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (e *Endpoint) Configure(inlineArgs []string, cfg *config.Map) error {
cfg.Bool("debug", false, false, &e.logger.Debug)
if _, err := cfg.Process(); err != nil {
return err
}
e.mux = http.NewServeMux()
e.mux.Handle("/metrics", promhttp.Handler())
e.serv.Handler = e.mux
for _, a := range e.addrs {
endp, err := config.ParseEndpoint(a)
if err != nil {
return fmt.Errorf("%s: malformed endpoint: %v", modName, err)
}
if endp.IsTLS() {
return fmt.Errorf("%s: TLS is not supported yet", modName)
}
e.endpoints = append(e.endpoints, endp)
}
return nil
}
func (e *Endpoint) Name() string {
return modName
}
func (e *Endpoint) InstanceName() string {
return ""
}
func (e *Endpoint) Start() error {
for _, endp := range e.endpoints {
l, err := netresource.Listen(endp.Network(), endp.Address())
if err != nil {
if err := e.Stop(); err != nil {
e.logger.Error("failed to stop after failed listen", err)
}
return fmt.Errorf("%s: %v", modName, err)
}
e.listenersWg.Add(1)
go func() {
e.logger.Println("listening on", endp.String())
err := e.serv.Serve(l)
if err != nil && !errors.Is(err, http.ErrServerClosed) {
e.logger.Error("serve failed", err, "endpoint", endp)
}
e.listenersWg.Done()
}()
}
return nil
}
func (e *Endpoint) Stop() error {
if err := e.serv.Close(); err != nil {
return err
}
e.listenersWg.Wait()
return nil
}
func init() {
modules.RegisterEndpoint(modName, New)
}
================================================
FILE: internal/endpoint/smtp/date.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"fmt"
"regexp"
"time"
)
// Taken from https://github.com/emersion/go-imap/blob/09c1d69/date.go.
var dateTimeLayouts = [...]string{
// Defined in RFC 5322 section 3.3, mentioned as env-date in RFC 3501 page 84.
"Mon, 02 Jan 2006 15:04:05 -0700",
"_2 Jan 2006 15:04:05 -0700",
"_2 Jan 2006 15:04:05 MST",
"_2 Jan 2006 15:04 -0700",
"_2 Jan 2006 15:04 MST",
"_2 Jan 06 15:04:05 -0700",
"_2 Jan 06 15:04:05 MST",
"_2 Jan 06 15:04 -0700",
"_2 Jan 06 15:04 MST",
"Mon, _2 Jan 2006 15:04:05 -0700",
"Mon, _2 Jan 2006 15:04:05 MST",
"Mon, _2 Jan 2006 15:04 -0700",
"Mon, _2 Jan 2006 15:04 MST",
"Mon, _2 Jan 06 15:04:05 -0700",
"Mon, _2 Jan 06 15:04:05 MST",
"Mon, _2 Jan 06 15:04 -0700",
"Mon, _2 Jan 06 15:04 MST",
}
// TODO: this is a blunt way to strip any trailing CFWS (comment). A sharper
// one would strip multiple CFWS, and only if really valid according to
// RFC5322.
var commentRE = regexp.MustCompile(`[ \t]+\(.*\)$`)
// Try parsing the date based on the layouts defined in RFC 5322, section 3.3.
// Inspired by https://github.com/golang/go/blob/master/src/net/mail/message.go
func parseMessageDateTime(maybeDate string) (time.Time, error) {
maybeDate = commentRE.ReplaceAllString(maybeDate, "")
for _, layout := range dateTimeLayouts {
parsed, err := time.Parse(layout, maybeDate)
if err == nil {
return parsed, nil
}
}
return time.Time{}, fmt.Errorf("date %s could not be parsed", maybeDate)
}
================================================
FILE: internal/endpoint/smtp/metrics.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import "github.com/prometheus/client_golang/prometheus"
var (
startedSMTPTransactions = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "maddy",
Subsystem: "smtp",
Name: "started_transactions",
Help: "Amount of SMTP transactions started",
},
[]string{"module"},
)
completedSMTPTransactions = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "maddy",
Subsystem: "smtp",
Name: "smtp_completed_transactions",
Help: "Amount of SMTP transactions successfully completed",
},
[]string{"module"},
)
abortedSMTPTransactions = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "maddy",
Subsystem: "smtp",
Name: "aborted_transactions",
Help: "Amount of SMTP transactions aborted",
},
[]string{"module"},
)
ratelimitDefers = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "maddy",
Subsystem: "smtp",
Name: "ratelimit_deferred",
Help: "Messages rejected with 4xx code due to ratelimiting",
},
[]string{"module"},
)
failedLogins = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "maddy",
Subsystem: "smtp",
Name: "failed_logins",
Help: "AUTH command failures",
},
[]string{"module"},
)
failedCmds = prometheus.NewCounterVec(
prometheus.CounterOpts{
Namespace: "maddy",
Subsystem: "smtp",
Name: "failed_commands",
Help: "Failed transaction commands (MAIL, RCPT, DATA)",
},
[]string{"module", "command", "smtp_code", "smtp_enchcode"},
)
)
func init() {
prometheus.MustRegister(startedSMTPTransactions)
prometheus.MustRegister(completedSMTPTransactions)
prometheus.MustRegister(abortedSMTPTransactions)
prometheus.MustRegister(ratelimitDefers)
prometheus.MustRegister(failedCmds)
}
================================================
FILE: internal/endpoint/smtp/session.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"runtime/trace"
"strconv"
"strings"
"sync"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/address"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/auth"
)
func limitReader(r io.Reader, n int64, err error) *limitedReader {
return &limitedReader{R: r, N: n, E: err, Enabled: true}
}
type limitedReader struct {
R io.Reader
N int64
E error
Enabled bool
}
// same as io.LimitedReader.Read except returning the custom error and the option
// to be disabled
func (l *limitedReader) Read(p []byte) (n int, err error) {
if !l.Enabled {
return l.R.Read(p)
}
if l.N <= 0 {
return 0, l.E
}
if int64(len(p)) > l.N {
p = p[0:l.N]
}
n, err = l.R.Read(p)
l.N -= int64(n)
return
}
type Session struct {
endp *Endpoint
// Specific for this session.
// sessionCtx is not used for cancellation or timeouts, only for tracing.
sessionCtx context.Context
cancelRDNS func()
connState module.ConnState
repeatedMailErrs int
loggedRcptErrors int
// Specific for the currently handled message.
// msgCtx is not used for cancellation or timeouts, only for tracing.
// It is the subcontext of sessionCtx.
// Mutex is used to prevent Close from accessing inconsistent state when it
// is called asynchronously to any SMTP command.
msgLock sync.Mutex
msgCtx context.Context
msgTask *trace.Task
mailFrom string
opts smtp.MailOptions
msgMeta *module.MsgMetadata
delivery module.Delivery
deliveryErr error
log *log.Logger
}
func (s *Session) AuthMechanisms() []string {
return s.endp.saslAuth.SASLMechanisms()
}
func (s *Session) Auth(mech string) (sasl.Server, error) {
return s.endp.saslAuth.CreateSASL(mech, s.connState.RemoteAddr, func(identity string, data auth.ContextData) error {
s.connState.AuthUser = identity
s.connState.AuthPassword = data.Password
return nil
}), nil
}
func (s *Session) Reset() {
s.msgLock.Lock()
defer s.msgLock.Unlock()
if s.delivery != nil {
s.abort(s.msgCtx)
}
s.endp.log.DebugMsg("reset")
}
func (s *Session) releaseLimits() {
domain := ""
if s.mailFrom != "" {
var err error
_, domain, err = address.Split(s.mailFrom)
if err != nil {
return
}
}
addr, ok := s.msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
if !ok {
addr = &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
}
s.endp.limits.ReleaseMsg(addr.IP, domain)
}
func (s *Session) abort(ctx context.Context) {
if err := s.delivery.Abort(ctx); err != nil {
s.endp.log.Error("delivery abort failed", err)
}
s.log.Msg("aborted", "msg_id", s.msgMeta.ID)
abortedSMTPTransactions.WithLabelValues(s.endp.name).Inc()
s.cleanSession()
}
func (s *Session) cleanSession() {
s.releaseLimits()
s.mailFrom = ""
s.opts = smtp.MailOptions{}
s.msgMeta = nil
s.delivery = nil
s.deliveryErr = nil
s.msgCtx = nil
s.msgTask.End()
}
func (s *Session) AuthPlain(username, password string) error {
// Executed before authentication and session initialization.
if err := s.endp.pipeline.RunEarlyChecks(context.TODO(), &s.connState); err != nil {
return s.endp.wrapErr("", true, "AUTH", err)
}
// saslAuth will handle AuthMap and AuthNormalize.
err := s.endp.saslAuth.AuthPlain(username, password)
if err != nil {
s.endp.log.Error("authentication failed", err, "username", username, "src_ip", s.connState.RemoteAddr)
failedLogins.WithLabelValues(s.endp.name).Inc()
return s.endp.authErrorMap(err)
}
s.connState.AuthUser = username
s.connState.AuthPassword = password
return nil
}
func (s *Session) startDelivery(ctx context.Context, from string, opts smtp.MailOptions) (string, error) {
var err error
msgMeta := &module.MsgMetadata{
Conn: &s.connState,
SMTPOpts: opts,
}
msgMeta.ID, err = module.GenerateMsgID()
if err != nil {
return "", err
}
if s.connState.AuthUser != "" {
s.log.Msg("incoming message",
"src_host", msgMeta.Conn.Hostname,
"src_ip", msgMeta.Conn.RemoteAddr.String(),
"sender", from,
"msg_id", msgMeta.ID,
"username", s.connState.AuthUser,
)
} else {
s.log.Msg("incoming message",
"src_host", msgMeta.Conn.Hostname,
"src_ip", msgMeta.Conn.RemoteAddr.String(),
"sender", from,
"msg_id", msgMeta.ID,
)
}
// INTERNATIONALIZATION: Do not permit non-ASCII addresses unless SMTPUTF8 is
// used.
if !opts.UTF8 {
for _, ch := range from {
if ch > 128 {
return "", &exterrors.SMTPError{
Code: 550,
EnhancedCode: exterrors.EnhancedCode{5, 6, 7},
Message: "SMTPUTF8 is required for non-ASCII senders",
}
}
}
}
// Decode punycode, normalize to NFC and case-fold address.
cleanFrom := from
if from != "" {
cleanFrom, err = address.CleanDomain(from)
if err != nil {
return "", &exterrors.SMTPError{
Code: 553,
EnhancedCode: exterrors.EnhancedCode{5, 1, 7},
Message: "Unable to normalize the sender address",
}
}
}
msgMeta.OriginalFrom = from
domain := ""
if cleanFrom != "" {
_, domain, err = address.Split(cleanFrom)
if err != nil {
return "", err
}
}
remoteIP, ok := msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
if !ok {
remoteIP = &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)}
}
if err := s.endp.limits.TakeMsg(context.Background(), remoteIP.IP, domain); err != nil {
return "", err
}
s.msgCtx, s.msgTask = trace.NewTask(ctx, "Incoming Message")
mailCtx, mailTask := trace.NewTask(s.msgCtx, "MAIL FROM")
defer mailTask.End()
delivery, err := s.endp.pipeline.StartDelivery(mailCtx, msgMeta, cleanFrom)
if err != nil {
s.msgCtx = nil
s.msgTask.End()
s.endp.limits.ReleaseMsg(remoteIP.IP, domain)
return msgMeta.ID, err
}
startedSMTPTransactions.WithLabelValues(s.endp.name).Inc()
s.msgMeta = msgMeta
s.mailFrom = cleanFrom
s.delivery = delivery
return msgMeta.ID, nil
}
func (s *Session) Mail(from string, opts *smtp.MailOptions) error {
if s.endp.authAlwaysRequired && s.connState.AuthUser == "" {
return smtp.ErrAuthRequired
}
s.msgLock.Lock()
defer s.msgLock.Unlock()
if !s.endp.deferServerReject {
// Will initialize s.msgCtx.
msgID, err := s.startDelivery(s.sessionCtx, from, *opts)
if err != nil {
if !errors.Is(err, context.DeadlineExceeded) {
s.log.Error("MAIL FROM error", err, "msg_id", msgID)
}
return s.endp.wrapErr(msgID, !opts.UTF8, "MAIL", err)
}
}
// Keep the MAIL FROM argument for deferred startDelivery.
s.mailFrom = from
s.opts = *opts
return nil
}
func (s *Session) fetchRDNSName(ctx context.Context) {
defer trace.StartRegion(ctx, "rDNS fetch").End()
tcpAddr, ok := s.connState.RemoteAddr.(*net.TCPAddr)
if !ok {
s.connState.RDNSName.Set(nil, nil)
return
}
name, err := dns.LookupAddr(ctx, s.endp.resolver, tcpAddr.IP)
if err != nil {
dnsErr, ok := err.(*net.DNSError)
if ok && dnsErr.IsNotFound {
s.connState.RDNSName.Set(nil, nil)
return
}
if !errors.Is(err, context.Canceled) {
// Often occurs when transaction completes before rDNS lookup and
// rDNS name was not actually needed. So do not log cancelation
// error if that's the case.
reason, misc := exterrors.UnwrapDNSErr(err)
misc["reason"] = reason
s.log.Error("rDNS error", exterrors.WithFields(err, misc), "src_ip", s.connState.RemoteAddr)
}
s.connState.RDNSName.Set(nil, err)
return
}
s.connState.RDNSName.Set(name, nil)
}
func (s *Session) Rcpt(to string, opts *smtp.RcptOptions) error {
s.msgLock.Lock()
defer s.msgLock.Unlock()
// deferServerReject = true and this is the first RCPT TO command.
if s.delivery == nil {
// If we already attempted to initialize the delivery -
// fail again.
if s.deliveryErr != nil {
s.repeatedMailErrs++
// The deliveryErr is already wrapped.
return s.deliveryErr
}
// It will initialize s.msgCtx.
msgID, err := s.startDelivery(s.sessionCtx, s.mailFrom, s.opts)
if err != nil {
if !errors.Is(err, context.DeadlineExceeded) {
s.log.Error("MAIL FROM error (deferred)", err, "rcpt", to, "msg_id", msgID)
}
s.deliveryErr = s.endp.wrapErr(msgID, !s.opts.UTF8, "RCPT", err)
return s.deliveryErr
}
}
rcptCtx, rcptTask := trace.NewTask(s.msgCtx, "RCPT TO")
defer rcptTask.End()
if err := s.rcpt(rcptCtx, to, opts); err != nil {
if s.loggedRcptErrors < s.endp.maxLoggedRcptErrors {
s.log.Error("RCPT error", err, "rcpt", to, "msg_id", s.msgMeta.ID)
s.loggedRcptErrors++
if s.loggedRcptErrors == s.endp.maxLoggedRcptErrors {
s.log.Msg("too many RCPT errors, possible dictonary attack", "src_ip", s.connState.RemoteAddr, "msg_id", s.msgMeta.ID)
}
}
return s.endp.wrapErr(s.msgMeta.ID, !s.opts.UTF8, "RCPT", err)
}
s.endp.log.Msg("RCPT ok", "rcpt", to, "msg_id", s.msgMeta.ID)
return nil
}
func (s *Session) rcpt(ctx context.Context, to string, opts *smtp.RcptOptions) error {
// INTERNATIONALIZATION: Do not permit non-ASCII addresses unless SMTPUTF8 is
// used.
if !address.IsASCII(to) && !s.opts.UTF8 {
return &exterrors.SMTPError{
Code: 553,
EnhancedCode: exterrors.EnhancedCode{5, 6, 7},
Message: "SMTPUTF8 is required for non-ASCII recipients",
}
}
cleanTo, err := address.CleanDomain(to)
if err != nil {
return &exterrors.SMTPError{
Code: 501,
EnhancedCode: exterrors.EnhancedCode{5, 1, 2},
Message: "Unable to normalize the recipient address",
}
}
return s.delivery.AddRcpt(ctx, cleanTo, *opts)
}
func (s *Session) Logout() error {
s.msgLock.Lock()
defer s.msgLock.Unlock()
if s.delivery != nil {
s.abort(s.msgCtx)
if s.repeatedMailErrs > s.endp.maxLoggedRcptErrors {
s.log.Msg("MAIL FROM repeated error a lot of times, possible dictonary attack", "count", s.repeatedMailErrs, "src_ip", s.connState.RemoteAddr)
}
}
if s.cancelRDNS != nil {
s.cancelRDNS()
}
s.endp.sessionCnt.Add(-1)
return nil
}
func (s *Session) prepareBody(r io.Reader) (textproto.Header, buffer.Buffer, error) {
limitr := limitReader(r, s.endp.maxHeaderBytes, &exterrors.SMTPError{
Code: 552,
EnhancedCode: exterrors.EnhancedCode{5, 3, 4},
Message: "Message header size exceeds limit",
})
bufr := bufio.NewReader(limitr)
header, err := textproto.ReadHeader(bufr)
if err != nil {
return textproto.Header{}, nil, fmt.Errorf("I/O error while parsing header: %w", err)
}
if s.endp.submission {
// The MsgMetadata is passed by pointer all the way down.
if err := s.submissionPrepare(s.msgMeta, &header); err != nil {
return textproto.Header{}, nil, err
}
}
// the header size check is done. The message size will be checked by go-smtp
limitr.Enabled = false
buf, err := s.endp.buffer(bufr)
if err != nil {
return textproto.Header{}, nil, fmt.Errorf("I/O error while writing buffer: %w", err)
}
return header, buf, nil
}
func (s *Session) Data(r io.Reader) error {
s.msgLock.Lock()
defer s.msgLock.Unlock()
bodyCtx, bodyTask := trace.NewTask(s.msgCtx, "DATA")
defer bodyTask.End()
wrapErr := func(err error) error {
s.log.Error("DATA error", err, "msg_id", s.msgMeta.ID)
return s.endp.wrapErr(s.msgMeta.ID, !s.opts.UTF8, "DATA", err)
}
header, buf, err := s.prepareBody(r)
if err != nil {
return wrapErr(err)
}
defer func() {
if err := buf.Remove(); err != nil {
s.log.Error("failed to remove buffered body", err)
}
// go-smtp will call Reset, but it will call Abort if delivery is non-nil.
s.cleanSession()
}()
if err := s.checkRoutingLoops(header); err != nil {
return wrapErr(err)
}
if strings.EqualFold(header.Get("TLS-Required"), "No") {
s.msgMeta.TLSRequireOverride = true
}
if err := s.delivery.Body(bodyCtx, header, buf); err != nil {
return wrapErr(err)
}
if err := s.delivery.Commit(bodyCtx); err != nil {
return wrapErr(err)
}
s.log.Msg("accepted", "msg_id", s.msgMeta.ID)
return nil
}
type statusWrapper struct {
sc smtp.StatusCollector
s *Session
}
func (sw statusWrapper) SetStatus(rcpt string, err error) {
sw.sc.SetStatus(rcpt, sw.s.endp.wrapErr(sw.s.msgMeta.ID, !sw.s.opts.UTF8, "DATA", err))
}
func (s *Session) LMTPData(r io.Reader, sc smtp.StatusCollector) error {
s.msgLock.Lock()
defer s.msgLock.Unlock()
bodyCtx, bodyTask := trace.NewTask(s.msgCtx, "DATA")
defer bodyTask.End()
wrapErr := func(err error) error {
s.log.Error("DATA error", err, "msg_id", s.msgMeta.ID)
return s.endp.wrapErr(s.msgMeta.ID, !s.opts.UTF8, "DATA", err)
}
header, buf, err := s.prepareBody(r)
if err != nil {
return wrapErr(err)
}
defer func() {
if err := buf.Remove(); err != nil {
s.log.Error("failed to remove buffered body", err)
}
// go-smtp will call Reset, but it will call Abort if delivery is non-nil.
s.cleanSession()
}()
if strings.EqualFold(header.Get("TLS-Required"), "No") {
s.msgMeta.TLSRequireOverride = true
}
if err := s.checkRoutingLoops(header); err != nil {
return wrapErr(err)
}
s.delivery.(module.PartialDelivery).BodyNonAtomic(bodyCtx, statusWrapper{sc, s}, header, buf)
// We can't really tell whether it is failed completely or succeeded
// so always commit. Should be harmless, anyway.
if err := s.delivery.Commit(bodyCtx); err != nil {
return wrapErr(err)
}
s.log.Msg("accepted", "msg_id", s.msgMeta.ID)
return nil
}
func (s *Session) checkRoutingLoops(header textproto.Header) error {
// RFC 5321 Section 6.3:
// >Simple counting of the number of "Received:" header fields in a
// >message has proven to be an effective, although rarely optimal,
// >method of detecting loops in mail systems.
receivedCount := 0
for f := header.FieldsByKey("Received"); f.Next(); {
receivedCount++
}
if receivedCount > s.endp.maxReceived {
return &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 4, 6},
Message: fmt.Sprintf("Too many Received header fields (%d), possible forwarding loop", receivedCount),
}
}
return nil
}
func (endp *Endpoint) wrapErr(msgId string, mangleUTF8 bool, command string, err error) error {
if err == nil {
return nil
}
if errors.Is(err, context.DeadlineExceeded) {
return &smtp.SMTPError{
Code: 451,
EnhancedCode: smtp.EnhancedCode{4, 4, 5},
Message: "High load, try again later",
}
}
res := &smtp.SMTPError{
Code: 554,
EnhancedCode: smtp.EnhancedCodeNotSet,
// Err on the side of caution if the error lacks SMTP annotations. If
// we just pass the error text through, we might accidenetally disclose
// details of server configuration.
Message: "Internal server error",
}
if exterrors.IsTemporary(err) {
res.Code = 451
}
ctxInfo := exterrors.Fields(err)
ctxCode, ok := ctxInfo["smtp_code"].(int)
if ok {
res.Code = ctxCode
}
ctxEnchCode, ok := ctxInfo["smtp_enchcode"].(exterrors.EnhancedCode)
if ok {
res.EnhancedCode = smtp.EnhancedCode(ctxEnchCode)
}
ctxMsg, ok := ctxInfo["smtp_msg"].(string)
if ok {
res.Message = ctxMsg
}
if smtpErr, ok := err.(*smtp.SMTPError); ok {
endp.log.Printf("plain SMTP error returned, this is deprecated")
res.Code = smtpErr.Code
res.EnhancedCode = smtpErr.EnhancedCode
res.Message = smtpErr.Message
}
if msgId != "" {
res.Message += " (msg ID = " + msgId + ")"
}
failedCmds.WithLabelValues(endp.name, command, strconv.Itoa(res.Code),
fmt.Sprintf("%d.%d.%d",
res.EnhancedCode[0],
res.EnhancedCode[1],
res.EnhancedCode[2])).Inc()
// INTERNATIONALIZATION: See RFC 6531 Section 3.7.4.1.
if mangleUTF8 {
b := strings.Builder{}
b.Grow(len(res.Message))
for _, ch := range res.Message {
if ch > 128 {
b.WriteRune('?')
} else {
b.WriteRune(ch)
}
}
res.Message = b.String()
}
return res
}
================================================
FILE: internal/endpoint/smtp/smtp.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"bytes"
"context"
"crypto/tls"
"fmt"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
tls2 "github.com/foxcpp/maddy/framework/config/tls"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/dns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/future"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/framework/resource/netresource"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/authz"
"github.com/foxcpp/maddy/internal/limits"
"github.com/foxcpp/maddy/internal/msgpipeline"
"github.com/foxcpp/maddy/internal/proxy_protocol"
"golang.org/x/net/idna"
)
type Endpoint struct {
saslAuth auth.SASLAuth
serv *smtp.Server
name string
addrs []string
endpoints []config.Endpoint
listeners []net.Listener
proxyProtocol *proxy_protocol.ProxyProtocol
pipeline *msgpipeline.MsgPipeline
resolver dns.Resolver
limits *limits.Group
buffer func(r io.Reader) (buffer.Buffer, error)
authAlwaysRequired bool
submission bool
lmtp bool
deferServerReject bool
maxLoggedRcptErrors int
maxReceived int
maxHeaderBytes int64
sessionCnt atomic.Int32
shutdownTimeout time.Duration
listenersWg sync.WaitGroup
log *log.Logger
}
func (endp *Endpoint) Name() string {
return endp.name
}
func (endp *Endpoint) InstanceName() string {
return endp.name
}
func New(c *container.C, modName string, addrs []string) (container.LifetimeModule, error) {
logger := c.DefaultLogger.Sublogger(modName)
endp := &Endpoint{
name: modName,
addrs: addrs,
submission: modName == "submission",
lmtp: modName == "lmtp",
resolver: dns.DefaultResolver(),
buffer: buffer.BufferInMemory,
log: logger,
saslAuth: auth.SASLAuth{
Log: logger.Sublogger("sasl"),
},
}
return endp, nil
}
func (endp *Endpoint) Configure(_ []string, cfg *config.Map) error {
endp.serv = smtp.NewServer(endp)
endp.serv.ErrorLog = endp.log
endp.serv.LMTP = endp.lmtp
endp.serv.EnableSMTPUTF8 = true
endp.serv.EnableREQUIRETLS = true
if err := endp.setConfig(cfg); err != nil {
return err
}
addresses := make([]config.Endpoint, 0, len(endp.addrs))
for _, addr := range endp.addrs {
saddr, err := config.ParseEndpoint(addr)
if err != nil {
return fmt.Errorf("%s: invalid address: %s", addr, endp.name)
}
addresses = append(addresses, saddr)
}
endp.endpoints = addresses
allLocal := true
for _, addr := range addresses {
if addr.Scheme != "unix" && !strings.HasPrefix(addr.Host, "127.0.0.") {
allLocal = false
}
}
if endp.serv.AllowInsecureAuth && !allLocal {
endp.log.Println("authentication over unencrypted connections is allowed, this is insecure configuration and should be used only for testing!")
}
if endp.serv.TLSConfig == nil {
if !allLocal {
endp.log.Println("TLS is disabled, this is insecure configuration and should be used only for testing!")
}
endp.serv.AllowInsecureAuth = true
}
return nil
}
func autoBufferMode(maxSize int, dir string) func(io.Reader) (buffer.Buffer, error) {
return func(r io.Reader) (buffer.Buffer, error) {
// First try to read up to N bytes.
initial := make([]byte, maxSize)
actualSize, err := io.ReadFull(r, initial)
if err != nil {
if err == io.ErrUnexpectedEOF {
log.Debugln("autobuffer: keeping the message in RAM (read", actualSize, "bytes, got EOF)")
return buffer.MemoryBuffer{Slice: initial[:actualSize]}, nil
}
if err == io.EOF {
// Special case: message with empty body.
return buffer.MemoryBuffer{}, nil
}
// Some I/O error happened, bail out.
return nil, err
}
if actualSize < maxSize {
// Ok, the message is smaller than N. Make a MemoryBuffer and
// handle it in RAM.
log.Debugln("autobuffer: keeping the message in RAM (read", actualSize, "bytes, got short read)")
return buffer.MemoryBuffer{Slice: initial[:actualSize]}, nil
}
log.Debugln("autobuffer: spilling the message to the FS")
// The message is big. Dump what we got to the disk and continue writing it there.
return buffer.BufferInFile(
io.MultiReader(bytes.NewReader(initial[:actualSize]), r),
dir)
}
}
func bufferModeDirective(_ *config.Map, node config.Node) (interface{}, error) {
if len(node.Args) < 1 {
return nil, config.NodeErr(node, "at least one argument required")
}
switch node.Args[0] {
case "ram":
if len(node.Args) > 1 {
return nil, config.NodeErr(node, "no additional arguments for 'ram' mode")
}
return buffer.BufferInMemory, nil
case "fs":
path := filepath.Join(config.StateDirectory, "buffer")
if err := os.MkdirAll(path, 0o700); err != nil {
return nil, err
}
switch len(node.Args) {
case 2:
path = node.Args[1]
fallthrough
case 1:
return func(r io.Reader) (buffer.Buffer, error) {
return buffer.BufferInFile(r, path)
}, nil
default:
return nil, config.NodeErr(node, "too many arguments for 'fs' mode")
}
case "auto":
path := filepath.Join(config.StateDirectory, "buffer")
if err := os.MkdirAll(path, 0o700); err != nil {
return nil, err
}
maxSize := 1 * 1024 * 1024 // 1 MiB
switch len(node.Args) {
case 3:
path = node.Args[2]
fallthrough
case 2:
var err error
maxSize, err = config.ParseDataSize(node.Args[1])
if err != nil {
return nil, config.NodeErr(node, "%v", err)
}
fallthrough
case 1:
return autoBufferMode(maxSize, path), nil
default:
return nil, config.NodeErr(node, "too many arguments for 'auto' mode")
}
default:
return nil, config.NodeErr(node, "unknown buffer mode: %v", node.Args[0])
}
}
func (endp *Endpoint) setConfig(cfg *config.Map) error {
var (
hostname string
err error
ioDebug bool
)
cfg.Callback("auth", func(m *config.Map, node config.Node) error {
return endp.saslAuth.AddProvider(m, node)
})
cfg.Bool("sasl_login", false, false, &endp.saslAuth.EnableLogin)
cfg.String("hostname", true, true, "", &hostname)
config.EnumMapped(cfg, "auth_map_normalize", true, false, authz.NormalizeFuncs, authz.NormalizeAuto,
&endp.saslAuth.AuthNormalize)
modconfig.Table(cfg, "auth_map", true, false, nil, &endp.saslAuth.AuthMap)
cfg.Duration("write_timeout", false, false, 1*time.Minute, &endp.serv.WriteTimeout)
cfg.Duration("read_timeout", false, false, 10*time.Minute, &endp.serv.ReadTimeout)
cfg.Duration("shutdown_timeout", false, false, 3*time.Minute, &endp.shutdownTimeout)
cfg.DataSize("max_message_size", false, false, 32*1024*1024, &endp.serv.MaxMessageBytes)
cfg.DataSize("max_header_size", false, false, 1*1024*1024, &endp.maxHeaderBytes)
cfg.Int("max_recipients", false, false, 20000, &endp.serv.MaxRecipients)
cfg.Int("max_received", false, false, 50, &endp.maxReceived)
cfg.Custom("buffer", false, false, func() (interface{}, error) {
path := filepath.Join(config.StateDirectory, "buffer")
if err := os.MkdirAll(path, 0o700); err != nil {
return nil, err
}
return autoBufferMode(1*1024*1024 /* 1 MiB */, path), nil
}, bufferModeDirective, &endp.buffer)
cfg.Custom("tls", true, endp.name != "lmtp", nil, tls2.TLSDirective, &endp.serv.TLSConfig)
cfg.Custom("proxy_protocol", false, false, nil, proxy_protocol.ProxyProtocolDirective, &endp.proxyProtocol)
cfg.Bool("insecure_auth", endp.name == "lmtp", false, &endp.serv.AllowInsecureAuth)
cfg.Int("smtp_max_line_length", false, false, 4000, &endp.serv.MaxLineLength)
cfg.Bool("io_debug", false, false, &ioDebug)
cfg.Bool("debug", true, false, &endp.log.Debug)
cfg.Bool("defer_sender_reject", false, true, &endp.deferServerReject)
cfg.Int("max_logged_rcpt_errors", false, false, 5, &endp.maxLoggedRcptErrors)
cfg.Custom("limits", false, false, func() (interface{}, error) {
return &limits.Group{}, nil
}, func(cfg *config.Map, n config.Node) (interface{}, error) {
var g *limits.Group
if err := modconfig.GroupFromNode("limits", n.Args, n, cfg.Globals, &g); err != nil {
return nil, err
}
return g, nil
}, &endp.limits)
cfg.AllowUnknown()
unknown, err := cfg.Process()
if err != nil {
return err
}
endp.saslAuth.Log.Debug = endp.log.Debug
endp.saslAuth.ErrorMap = endp.authErrorMap
// INTERNATIONALIZATION: See RFC 6531 Section 3.3.
endp.serv.Domain, err = idna.ToASCII(hostname)
if err != nil {
return fmt.Errorf("%s: cannot represent the hostname as an A-label name: %w", endp.name, err)
}
endp.pipeline, err = msgpipeline.New(cfg.Globals, unknown)
if err != nil {
return err
}
endp.pipeline.Hostname = endp.serv.Domain
endp.pipeline.Resolver = endp.resolver
endp.pipeline.Log = endp.log.Sublogger("pipeline")
endp.pipeline.FirstPipeline = true
if endp.submission {
endp.authAlwaysRequired = true
if len(endp.saslAuth.SASLMechanisms()) == 0 {
return fmt.Errorf("%s: auth. provider must be set for submission endpoint", endp.name)
}
}
if ioDebug {
endp.serv.Debug = endp.log.DebugWriter()
endp.log.Println("I/O debugging is on! It may leak passwords in logs, be careful!")
}
return nil
}
func (endp *Endpoint) Start() error {
if err := endp.setupListeners(endp.endpoints); err != nil {
if err := endp.Stop(); err != nil {
endp.log.Error("failed to Stop after setupListeners fail", err)
}
return err
}
return nil
}
func (endp *Endpoint) authErrorMap(err error) error {
if exterrors.IsTemporary(err) {
return &smtp.SMTPError{
Code: 454,
EnhancedCode: smtp.EnhancedCode{4, 7, 0},
Message: "Temporary authentication failure",
}
}
return &smtp.SMTPError{
Code: 535,
EnhancedCode: smtp.EnhancedCode{5, 7, 8},
Message: "Invalid credentials",
}
}
func (endp *Endpoint) setupListeners(addresses []config.Endpoint) error {
for _, addr := range addresses {
var l net.Listener
var err error
l, err = netresource.Listen(addr.Network(), addr.Address())
if err != nil {
return fmt.Errorf("%s: %w", endp.name, err)
}
endp.log.Printf("listening on %v", addr)
if addr.IsTLS() {
if endp.serv.TLSConfig == nil {
return fmt.Errorf("%s: can't bind on SMTPS endpoint without TLS configuration", endp.name)
}
l = tls.NewListener(l, endp.serv.TLSConfig)
}
if endp.proxyProtocol != nil {
l = proxy_protocol.NewListener(l, endp.proxyProtocol, endp.log.Sublogger("proxy"))
}
endp.listeners = append(endp.listeners, l)
endp.listenersWg.Add(1)
go func() {
if err := endp.serv.Serve(l); err != nil {
endp.log.Printf("failed to serve %s: %s", addr, err)
}
endp.listenersWg.Done()
}()
}
return nil
}
func (endp *Endpoint) NewSession(conn *smtp.Conn) (smtp.Session, error) {
sess := endp.newSession(conn)
// Executed before authentication and session initialization.
if err := endp.pipeline.RunEarlyChecks(context.TODO(), &sess.connState); err != nil {
if err := sess.Logout(); err != nil {
endp.log.Error("early checks logout failed", err)
}
return nil, endp.wrapErr("", true, "EHLO", err)
}
endp.sessionCnt.Add(1)
return sess, nil
}
func (endp *Endpoint) newSession(conn *smtp.Conn) *Session {
s := &Session{
endp: endp,
log: endp.log,
sessionCtx: context.Background(),
}
// Used in tests.
if conn == nil {
return s
}
s.connState = module.ConnState{
Hostname: conn.Hostname(),
LocalAddr: conn.Conn().LocalAddr(),
RemoteAddr: conn.Conn().RemoteAddr(),
}
if tlsState, ok := conn.TLSConnectionState(); ok {
s.connState.TLS = tlsState
}
if endp.serv.LMTP {
s.connState.Proto = "LMTP"
} else {
// Check if TLS connection conn struct is poplated.
// If it is - we are ssing TLS.
if s.connState.TLS.HandshakeComplete {
s.connState.Proto = "ESMTPS"
} else {
s.connState.Proto = "ESMTP"
}
}
if endp.resolver != nil {
rdnsCtx, cancelRDNS := context.WithCancel(s.sessionCtx)
s.connState.RDNSName = future.New()
s.cancelRDNS = cancelRDNS
go s.fetchRDNSName(rdnsCtx)
}
return s
}
func (endp *Endpoint) ConnectionCount() int {
return int(endp.sessionCnt.Load())
}
func (endp *Endpoint) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), endp.shutdownTimeout)
defer cancel()
if err := endp.serv.Shutdown(ctx); err != nil {
return err
}
endp.listenersWg.Wait()
return nil
}
func init() {
modules.RegisterEndpoint("smtp", New)
modules.RegisterEndpoint("submission", New)
modules.RegisterEndpoint("lmtp", New)
}
================================================
FILE: internal/endpoint/smtp/smtp_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"flag"
"math/rand"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/foxcpp/go-mockdns"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/auth"
"github.com/foxcpp/maddy/internal/msgpipeline"
"github.com/foxcpp/maddy/internal/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var testPort string
const testMsg = "From: \r\n" +
"Subject: Hello there!\r\n" +
"\r\n" +
"foobar\r\n"
func testEndpoint(t *testing.T, modName string, authMod module.PlainAuth, tgt module.DeliveryTarget, checks []module.Check, cfg []config.Node) *Endpoint {
t.Helper()
mod, err := New(container.New(), modName, []string{"tcp://127.0.0.1:" + testPort})
if err != nil {
t.Fatal(err)
}
endp := mod.(*Endpoint)
endp.resolver = &mockdns.Resolver{
Zones: map[string]mockdns.Zone{
"mx.example.org.": {
A: []string{"127.0.0.1"},
},
"1.0.0.127.in-addr.arpa.": {
PTR: []string{"mx.example.org"},
},
},
}
endp.log = testutils.Logger(t, "smtp")
cfg = append(cfg,
config.Node{
Name: "hostname",
Args: []string{"mx.example.com"},
},
config.Node{
Name: "tls",
Args: []string{"off"},
},
config.Node{ // To make it succeed, pipeline is actually replaced below.
Name: "deliver_to",
Args: []string{"dummy"},
},
)
if authMod != nil {
cfg = append(cfg, config.Node{
Name: "auth",
Args: []string{"dummy"},
})
}
err = endp.Configure(nil, config.NewMap(nil, config.Node{
Children: cfg,
}))
if err != nil {
t.Fatal(err)
}
endp.saslAuth = auth.SASLAuth{
Log: testutils.Logger(t, "smtp/saslauth"),
Plain: []module.PlainAuth{authMod},
}
endp.pipeline = msgpipeline.Mock(tgt, checks)
endp.pipeline.Hostname = "mx.example.com"
endp.pipeline.Resolver = endp.resolver
endp.pipeline.FirstPipeline = true
endp.pipeline.Log = testutils.Logger(t, "smtp/pipeline")
if err := endp.Start(); err != nil {
t.Fatal(err)
}
return endp
}
func submitMsg(t *testing.T, cl *smtp.Client, from string, rcpts []string, msg string) error {
return submitMsgOpts(t, cl, from, rcpts, nil, msg)
}
func submitMsgOpts(t *testing.T, cl *smtp.Client, from string, rcpts []string, opts *smtp.MailOptions, msg string) error {
t.Helper()
// Error for this one is ignored because it fails if EHLO was already sent
// and submitMsg can happen multiple times.
_ = cl.Hello("mx.example.org")
if err := cl.Mail(from, opts); err != nil {
return err
}
for _, rcpt := range rcpts {
if err := cl.Rcpt(rcpt, &smtp.RcptOptions{}); err != nil {
return err
}
}
data, err := cl.Data()
if err != nil {
return err
}
if _, err := data.Write([]byte(msg)); err != nil {
return err
}
return data.Close()
}
func TestSMTPDelivery(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
receivedPrefix := `from mx.example.org (mx.example.org [127.0.0.1]) by mx.example.com (envelope-sender ) with ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
if msg.MsgMeta.Conn.Proto != "ESMTP" {
t.Error("Wrong SrcProto:", msg.MsgMeta.Conn.Proto)
}
rdnsName, _ := msg.MsgMeta.Conn.RDNSName.Get()
if rdnsName, _ := rdnsName.(string); rdnsName != "mx.example.org" {
t.Error("Wrong rDNS name:", rdnsName)
}
}
func TestSMTPDelivery_rDNSError(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{
Err: &net.DNSError{
Name: "1.0.0.127.in-addr.arpa.",
Server: "127.0.0.1:53",
Err: "bad",
IsNotFound: false,
},
}
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
rdnsName, err := msg.MsgMeta.Conn.RDNSName.Get()
if rdnsName != nil || err == nil {
t.Errorf("Wrong rDNS result: %#+v (%v)", rdnsName, err)
}
}
func TestSMTPDelivery_EarlyCheck_Fail(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, []module.Check{
&testutils.Check{
EarlyErr: &exterrors.SMTPError{
Code: 523,
Message: "Hey",
},
},
}, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = cl.Mail("sender@example.org", nil)
if err == nil {
t.Fatal("Expected an error, got none")
}
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Fatal("Non-SMTPError returned")
}
if smtpErr.Code != 523 {
t.Fatal("Wrong SMTP code:", smtpErr.Code)
}
if smtpErr.Message != "Hey" {
t.Fatal("Wrong SMTP message:", smtpErr.Message)
}
}
func TestSMTPDeliver_CheckError(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, []module.Check{
&testutils.Check{
ConnRes: module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 523,
Message: "Hey",
},
Reject: true,
},
},
}, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = cl.Mail("sender@example.org", nil)
if err == nil {
t.Fatal("Expected an error, got none")
}
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Fatal("Non-SMTPError returned")
}
if smtpErr.Code != 523 {
t.Fatal("Wrong SMTP code:", smtpErr.Code)
}
if !strings.HasPrefix(smtpErr.Message, "Hey") {
t.Fatal("Wrong SMTP message:", smtpErr.Message)
}
}
func TestSMTPDeliver_CheckError_Deferred(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, []module.Check{
&testutils.Check{
ConnRes: module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 523,
Message: "Hey",
},
Reject: true,
},
},
}, nil)
endp.deferServerReject = true
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = cl.Mail("sender@example.org", nil)
if err != nil {
t.Fatal(err)
}
checkErr := func(err error) {
if err == nil {
t.Fatal("Expected an error, got none")
}
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Error("Non-SMTPError returned")
return
}
if smtpErr.Code != 523 {
t.Error("Wrong SMTP code:", smtpErr.Code)
}
if !strings.HasPrefix(smtpErr.Message, "Hey") {
t.Error("Wrong SMTP message:", smtpErr.Message)
}
}
checkErr(cl.Rcpt("test1@example.org", &smtp.RcptOptions{}))
checkErr(cl.Rcpt("test1@example.org", &smtp.RcptOptions{}))
checkErr(cl.Rcpt("test2@example.org", &smtp.RcptOptions{}))
}
func TestSMTPDelivery_Multi(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsg(t, cl, "sender1@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
err = submitMsg(t, cl, "sender2@example.org", []string{"rcpt3@example.com", "rcpt4@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 2 {
t.Fatal("Expected two messages, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender1@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
receivedPrefix := `from mx.example.org (mx.example.org [127.0.0.1]) by mx.example.com (envelope-sender ) with ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
msg = tgt.Messages[1]
msgID = testutils.CheckMsgID(t, &msg, "sender2@example.org", []string{"rcpt3@example.com", "rcpt4@example.com"}, "")
receivedPrefix = `from mx.example.org (mx.example.org [127.0.0.1]) by mx.example.com (envelope-sender ) with ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
}
func TestSMTPDelivery_AbortData(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
_ = cl.Close()
}()
if err := cl.Hello("mx.example.org"); err != nil {
t.Fatal(err)
}
if err := cl.Mail("sender@example.org", nil); err != nil {
t.Fatal(err)
}
if err := cl.Rcpt("test@example.com", &smtp.RcptOptions{}); err != nil {
t.Fatal(err)
}
data, err := cl.Data()
if err != nil {
t.Fatal(err)
}
if _, err := data.Write([]byte(testMsg)); err != nil {
t.Fatal(err)
}
// Then.. Suddenly, close the connection without sending the final dot.
require.NoError(t, cl.Close())
time.Sleep(250 * time.Millisecond)
if len(tgt.Messages) != 0 {
t.Fatal("Expected no messages, got", len(tgt.Messages))
}
}
func TestSMTPDelivery_EmptyMessage(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
if err := cl.Hello("mx.example.org"); err != nil {
t.Fatal(err)
}
if err := cl.Mail("sender@example.org", nil); err != nil {
t.Fatal(err)
}
if err := cl.Rcpt("test@example.com", &smtp.RcptOptions{}); err != nil {
t.Fatal(err)
}
data, err := cl.Data()
if err != nil {
t.Fatal(err)
}
if err := data.Close(); err != nil {
t.Fatal(err)
}
time.Sleep(250 * time.Millisecond)
if len(tgt.Messages) != 1 {
t.Fatal("Expected 1 message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
if len(msg.Body) != 0 {
t.Fatal("Expected an empty body, got", len(msg.Body))
}
}
func TestSMTPDelivery_AbortLogout(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
_ = cl.Close()
}()
if err := cl.Hello("mx.example.org"); err != nil {
t.Fatal(err)
}
if err := cl.Mail("sender@example.org", nil); err != nil {
t.Fatal(err)
}
if err := cl.Rcpt("test@example.com", &smtp.RcptOptions{}); err != nil {
t.Fatal(err)
}
// Then.. Suddenly, close the connection.
require.NoError(t, cl.Close())
time.Sleep(250 * time.Millisecond)
if len(tgt.Messages) != 0 {
t.Fatal("Expected no messages, got", len(tgt.Messages))
}
}
func TestSMTPDelivery_Reset(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
if err := cl.Mail("from-garbage@example.org", nil); err != nil {
t.Fatal(err)
}
if err := cl.Rcpt("to-garbage@example.org", &smtp.RcptOptions{}); err != nil {
t.Fatal(err)
}
if err := cl.Reset(); err != nil {
t.Fatal(err)
}
// then submit the message as if nothing happened.
err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
}
func TestSMTPDelivery_SubmissionAuthRequire(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "submission", &modules.Dummy{}, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
if err := cl.Mail("from-garbage@example.org", nil); err == nil {
t.Fatal("Expected an error, got none")
}
}
func TestSMTPDelivery_SubmissionAuthOK(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "submission", &modules.Dummy{}, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
if err := cl.Auth(sasl.NewPlainClient("", "user", "password")); err != nil {
t.Fatal(err)
}
if err := submitMsg(t, cl, "sender@example.org", []string{"rcpt@example.org"}, testMsg); err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt@example.org"}, "")
if msg.MsgMeta.Conn.AuthUser != "user" {
t.Error("Wrong AuthUser:", msg.MsgMeta.Conn.AuthUser)
}
if msg.MsgMeta.Conn.AuthPassword != "password" {
t.Error("Wrong AuthPassword:", msg.MsgMeta.Conn.AuthPassword)
}
receivedPrefix := `by mx.example.com (envelope-sender ) with ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
if msg.Header.Get("Message-ID") == "" {
t.Error("No submissionPrepare run")
}
}
func TestMain(m *testing.M) {
remoteSmtpPort := flag.String("test.smtpport", "random", "(maddy) SMTP port to use for connections in tests")
flag.Parse()
if *remoteSmtpPort == "random" {
*remoteSmtpPort = strconv.Itoa(rand.Intn(65536-10000) + 10000)
}
testPort = *remoteSmtpPort
os.Exit(m.Run())
}
================================================
FILE: internal/endpoint/smtp/smtputf8_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"strings"
"testing"
"github.com/emersion/go-smtp"
"github.com/foxcpp/go-mockdns"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/internal/testutils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSMTPUTF8_MangleStatusMessage(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, []module.Check{
&testutils.Check{
ConnRes: module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 523,
Message: "Hey 凱凱",
},
Reject: true,
},
},
}, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = cl.Mail("sender@example.org", nil)
if err == nil {
t.Fatal("Expected an error, got none")
}
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Fatal("Non-SMTPError returned")
}
if smtpErr.Code != 523 {
t.Fatal("Wrong SMTP code:", smtpErr.Code)
}
if !strings.HasPrefix(smtpErr.Message, "Hey ??") {
t.Fatal("Wrong SMTP message:", smtpErr.Message)
}
}
func TestSMTP_RejectNonASCIIFrom(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
require.NoError(t, cl.Close())
}()
err = submitMsg(t, cl, "ѣ@example.org", []string{"rcpt@example.com"}, testMsg)
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Fatal("Non-SMTPError returned")
}
if smtpErr.Code != 550 {
t.Fatal("Wrong SMTP code:", smtpErr.Code)
}
if smtpErr.EnhancedCode != (smtp.EnhancedCode{5, 6, 7}) {
t.Fatal("Wrong SMTP ench. code:", smtpErr.EnhancedCode)
}
}
func TestSMTPUTF8_NormalizeCaseFoldFrom(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsgOpts(t, cl, "foo@E\u0301.example.org", []string{"rcpt@example.com"}, &smtp.MailOptions{
UTF8: true,
}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
testutils.CheckMsgID(t, &msg, "foo@é.example.org", []string{"rcpt@example.com"}, "")
}
func TestSMTP_RejectNonASCIIRcpt(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsg(t, cl, "x@example.org", []string{"ѣ@example.org"}, testMsg)
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Fatal("Non-SMTPError returned")
}
if smtpErr.Code != 553 {
t.Fatal("Wrong SMTP code:", smtpErr.Code)
}
if smtpErr.EnhancedCode != (smtp.EnhancedCode{5, 6, 7}) {
t.Fatal("Wrong SMTP ench. code:", smtpErr.EnhancedCode)
}
}
func TestSMTPUTF8_NormalizeCaseFoldRcpt(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsgOpts(t, cl, "x@example.org", []string{"foo@E\u0301.example.org"}, &smtp.MailOptions{
UTF8: true,
}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
testutils.CheckMsgID(t, &msg, "x@example.org", []string{"foo@é.example.org"}, "")
}
func TestSMTPUTF8_NoMangleStatusMessage(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, []module.Check{
&testutils.Check{
ConnRes: module.CheckResult{
Reason: &exterrors.SMTPError{
Code: 523,
Message: "Hey 凱凱",
},
Reject: true,
},
},
}, nil)
endp.deferServerReject = false
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = cl.Mail("sender@example.org", &smtp.MailOptions{
UTF8: true,
})
if err == nil {
t.Fatal("Expected an error, got none")
}
smtpErr, ok := err.(*smtp.SMTPError)
if !ok {
t.Fatal("Non-SMTPError returned")
}
if smtpErr.Code != 523 {
t.Fatal("Wrong SMTP code:", smtpErr.Code)
}
if !strings.HasPrefix(smtpErr.Message, "Hey 凱凱") {
t.Fatal("Wrong SMTP message:", smtpErr.Message)
}
}
func TestSMTPUTF8_Received_EHLO_ALabel(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
if err := cl.Hello("凱凱.invalid"); err != nil {
t.Fatal(err)
}
err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
receivedPrefix := `from xn--y9qa.invalid (mx.example.org [127.0.0.1]) by mx.example.com (envelope-sender ) with ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
}
func TestSMTPUTF8_Received_rDNS_ALabel(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{
PTR: []string{"凱凱.invalid."},
}
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsg(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
receivedPrefix := `from mx.example.org (xn--y9qa.invalid [127.0.0.1]) by mx.example.com (envelope-sender ) with ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
}
func TestSMTPUTF8_Received_rDNS_ULabel(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
endp.resolver.(*mockdns.Resolver).Zones["1.0.0.127.in-addr.arpa."] = mockdns.Zone{
PTR: []string{"凱凱.invalid."},
}
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
err = submitMsgOpts(t, cl, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, &smtp.MailOptions{
UTF8: true,
}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt1@example.com", "rcpt2@example.com"}, "")
receivedPrefix := `from mx.example.org (凱凱.invalid [127.0.0.1]) by mx.example.com (envelope-sender ) with UTF8ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
}
func TestSMTPUTF8_Received_EHLO_ULabel(t *testing.T) {
tgt := testutils.Target{}
endp := testEndpoint(t, "smtp", nil, &tgt, nil, nil)
defer func() {
assert.NoError(t, endp.Stop())
}()
defer testutils.WaitForConnsClose(t, endp.serv)
cl, err := smtp.Dial("127.0.0.1:" + testPort)
if err != nil {
t.Fatal(err)
}
defer func() {
assert.NoError(t, cl.Close())
}()
if err := cl.Hello("凱凱.invalid"); err != nil {
t.Fatal(err)
}
err = submitMsgOpts(t, cl, "sender@example.org", []string{"rcpt@example.com"}, &smtp.MailOptions{
UTF8: true,
}, testMsg)
if err != nil {
t.Fatal(err)
}
if len(tgt.Messages) != 1 {
t.Fatal("Expected a message, got", len(tgt.Messages))
}
msg := tgt.Messages[0]
msgID := testutils.CheckMsgID(t, &msg, "sender@example.org", []string{"rcpt@example.com"}, "")
// Also, 'with UTF8ESMTP'.
receivedPrefix := `from 凱凱.invalid (mx.example.org [127.0.0.1]) by mx.example.com (envelope-sender ) with UTF8ESMTP id ` + msgID
if !strings.HasPrefix(msg.Header.Get("Received"), receivedPrefix) {
t.Error("Wrong Received contents:", msg.Header.Get("Received"))
}
}
================================================
FILE: internal/endpoint/smtp/submission.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"errors"
"fmt"
"net/mail"
"time"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/exterrors"
"github.com/foxcpp/maddy/framework/module"
"github.com/google/uuid"
)
var (
msgIDField = func() (string, error) {
id, err := uuid.NewRandom()
if err != nil {
return "", err
}
return id.String(), nil
}
now = time.Now
)
func (s *Session) submissionPrepare(msgMeta *module.MsgMetadata, header *textproto.Header) error {
msgMeta.DontTraceSender = true
if header.Get("Message-ID") == "" {
msgId, err := msgIDField()
if err != nil {
return errors.New("Message-ID generation failed")
}
s.log.Msg("adding missing Message-ID")
header.Set("Message-ID", "<"+msgId+"@"+s.endp.serv.Domain+">")
}
if header.Get("From") == "" {
return &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 6, 0},
Message: "Message does not contains a From header field",
Misc: map[string]interface{}{
"modifier": "submission_prepare",
},
}
}
for _, hdr := range [...]string{"Sender"} {
if value := header.Get(hdr); value != "" {
if _, err := mail.ParseAddress(value); err != nil {
return &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 6, 0},
Message: fmt.Sprintf("Invalid address in %s", hdr),
Misc: map[string]interface{}{
"modifier": "submission_prepare",
"addr": value,
},
Err: err,
}
}
}
}
for _, hdr := range [...]string{"To", "Cc", "Bcc", "Reply-To"} {
if value := header.Get(hdr); value != "" {
if _, err := mail.ParseAddressList(value); err != nil {
return &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 6, 0},
Message: fmt.Sprintf("Invalid address in %s", hdr),
Misc: map[string]interface{}{
"modifier": "submission_prepare",
"addr": value,
},
Err: err,
}
}
}
}
addrs, err := mail.ParseAddressList(header.Get("From"))
if err != nil {
return &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 6, 0},
Message: "Invalid address in From",
Misc: map[string]interface{}{
"modifier": "submission_prepare",
"addr": header.Get("From"),
},
Err: err,
}
}
// https://tools.ietf.org/html/rfc5322#section-3.6.2
// If From contains multiple addresses, Sender field must be present.
if len(addrs) > 1 && header.Get("Sender") == "" {
return &exterrors.SMTPError{
Code: 554,
EnhancedCode: exterrors.EnhancedCode{5, 6, 0},
Message: "Missing Sender header field",
Misc: map[string]interface{}{
"modifier": "submission_prepare",
"from": header.Get("From"),
},
}
}
if dateHdr := header.Get("Date"); dateHdr != "" {
_, err := parseMessageDateTime(dateHdr)
if err != nil {
return &exterrors.SMTPError{
Code: 554,
Message: "Malformed Date header",
Misc: map[string]interface{}{
"modifier": "submission_prepare",
"date": dateHdr,
},
Err: err,
}
}
} else {
s.log.Msg("adding missing Date header")
header.Set("Date", now().UTC().Format("Mon, 2 Jan 2006 15:04:05 -0700"))
}
return nil
}
================================================
FILE: internal/endpoint/smtp/submission_test.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package smtp
import (
"reflect"
"testing"
"time"
"github.com/emersion/go-message/textproto"
"github.com/emersion/go-smtp"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/stretchr/testify/assert"
)
func init() {
msgIDField = func() (string, error) {
return "A", nil
}
now = func() time.Time {
return time.Unix(0, 0)
}
}
func TestSubmissionPrepare(t *testing.T) {
test := func(hdrMap, expectedMap map[string][]string) {
t.Helper()
hdr := textproto.Header{}
for k, v := range hdrMap {
for _, field := range v {
hdr.Add(k, field)
}
}
endp := testEndpoint(t, "submission", &modules.Dummy{}, &modules.Dummy{}, nil, nil)
defer func() {
// Synchronize the endpoint initialization.
// Otherwise Close will race with Serve called by setupListeners.
cl, _ := smtp.Dial("127.0.0.1:" + testPort)
assert.NoError(t, cl.Close())
assert.NoError(t, endp.Stop())
}()
session, err := endp.NewSession(nil)
if err != nil {
t.Fatal(err)
}
err = session.(*Session).submissionPrepare(&module.MsgMetadata{}, &hdr)
if expectedMap == nil {
if err == nil {
t.Error("Expected an error, got none")
}
t.Log(err)
return
}
if expectedMap != nil && err != nil {
t.Error("Unexpected error:", err)
return
}
resMap := make(map[string][]string)
for field := hdr.Fields(); field.Next(); {
resMap[field.Key()] = append(resMap[field.Key()], field.Value())
}
if !reflect.DeepEqual(expectedMap, resMap) {
t.Errorf("wrong header result\nwant %#+v\ngot %#+v", expectedMap, resMap)
}
}
// No From field.
test(map[string][]string{}, nil)
// Malformed From field.
test(map[string][]string{
"From": {", \"\""},
}, nil)
test(map[string][]string{
"From": {" adasda"},
}, nil)
// Malformed Reply-To.
test(map[string][]string{
"From": {""},
"Reply-To": {", \"\""},
}, nil)
// Malformed CC.
test(map[string][]string{
"From": {""},
"Reply-To": {""},
"Cc": {", \"\""},
}, nil)
// Malformed Sender.
test(map[string][]string{
"From": {""},
"Reply-To": {""},
"Cc": {""},
"Sender": {" asd"},
}, nil)
// Multiple From + no Sender.
test(map[string][]string{
"From": {", "},
}, nil)
// Multiple From + valid Sender.
test(map[string][]string{
"From": {", "},
"Sender": {""},
"Date": {"Fri, 22 Nov 2019 20:51:31 +0800"},
"Message-Id": {""},
}, map[string][]string{
"From": {", "},
"Sender": {""},
"Date": {"Fri, 22 Nov 2019 20:51:31 +0800"},
"Message-Id": {""},
})
// Add missing Message-Id.
test(map[string][]string{
"From": {""},
"Date": {"Fri, 22 Nov 2019 20:51:31 +0800"},
}, map[string][]string{
"From": {""},
"Date": {"Fri, 22 Nov 2019 20:51:31 +0800"},
"Message-Id": {""},
})
// Malformed Date.
test(map[string][]string{
"From": {""},
"Date": {"not a date"},
}, nil)
// Add missing Date.
test(map[string][]string{
"From": {""},
"Message-Id": {""},
}, map[string][]string{
"From": {""},
"Message-Id": {""},
"Date": {"Thu, 1 Jan 1970 00:00:00 +0000"},
})
}
================================================
FILE: internal/imap_filter/command/command.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package command
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"regexp"
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
)
const modName = "imap.filter.command"
var placeholderRe = regexp.MustCompile(`{[a-zA-Z0-9_]+?}`)
type Check struct {
instName string
log *log.Logger
cmd string
cmdArgs []string
}
func (c *Check) IMAPFilter(accountName string, rcptTo string, msgMeta *module.MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) {
cmd, args := c.expandCommand(msgMeta, accountName, rcptTo, hdr)
var buf bytes.Buffer
_ = textproto.WriteHeader(&buf, hdr)
bR, err := body.Open()
if err != nil {
return "", nil, err
}
return c.run(cmd, args, io.MultiReader(bytes.NewReader(buf.Bytes()), bR))
}
func New(c *container.C, _, instName string) (module.Module, error) {
chk := &Check{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}
return chk, nil
}
func (c *Check) Name() string {
return modName
}
func (c *Check) InstanceName() string {
return c.instName
}
func (c *Check) Configure(inlineArgs []string, cfg *config.Map) error {
if len(inlineArgs) == 0 {
return errors.New("command: at least one argument is required (command name)")
}
c.cmd = inlineArgs[0]
c.cmdArgs = inlineArgs[1:]
// Check whether the inline argument command is usable.
if _, err := exec.LookPath(c.cmd); err != nil {
return fmt.Errorf("command: %w", err)
}
_, err := cfg.Process()
return err
}
func (c *Check) expandCommand(msgMeta *module.MsgMetadata, accountName string, rcptTo string, hdr textproto.Header) (string, []string) {
expArgs := make([]string, len(c.cmdArgs))
for i, arg := range c.cmdArgs {
expArgs[i] = placeholderRe.ReplaceAllStringFunc(arg, func(placeholder string) string {
switch placeholder {
case "{auth_user}":
if msgMeta.Conn == nil {
return ""
}
return msgMeta.Conn.AuthUser
case "{source_ip}":
if msgMeta.Conn == nil {
return ""
}
tcpAddr, _ := msgMeta.Conn.RemoteAddr.(*net.TCPAddr)
if tcpAddr == nil {
return ""
}
return tcpAddr.IP.String()
case "{source_host}":
if msgMeta.Conn == nil {
return ""
}
return msgMeta.Conn.Hostname
case "{source_rdns}":
if msgMeta.Conn == nil {
return ""
}
valI, err := msgMeta.Conn.RDNSName.Get()
if err != nil {
return ""
}
if valI == nil {
return ""
}
return valI.(string)
case "{msg_id}":
return msgMeta.ID
case "{sender}":
return msgMeta.OriginalFrom
case "{rcpt_to}":
return rcptTo
case "{original_rcpt_to}":
oldestOriginalRcpt := rcptTo
for originalRcpt, ok := rcptTo, true; ok; originalRcpt, ok = msgMeta.OriginalRcpts[originalRcpt] {
oldestOriginalRcpt = originalRcpt
}
return oldestOriginalRcpt
case "{subject}":
return hdr.Get("Subject")
case "{account_name}":
return accountName
}
return placeholder
})
}
return c.cmd, expArgs
}
func (c *Check) run(cmdName string, args []string, stdin io.Reader) (string, []string, error) {
c.log.Debugln("running", cmdName, args)
cmd := exec.Command(cmdName, args...)
cmd.Stdin = stdin
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", nil, err
}
if err := cmd.Start(); err != nil {
return "", nil, err
}
scnr := bufio.NewScanner(stdout)
var (
folder string
flags []string
)
if scnr.Scan() {
folder = scnr.Text()
}
for scnr.Scan() {
flags = append(flags, scnr.Text())
}
if err := scnr.Err(); err != nil {
return "", nil, err
}
err = cmd.Wait()
if err != nil {
if _, ok := err.(*exec.ExitError); !ok {
// If that's not ExitError, the process may still be running. We do
// not want this.
if err := cmd.Process.Signal(os.Interrupt); err != nil {
c.log.Error("failed to kill process", err)
}
}
return "", nil, err
}
c.log.Debugf("folder: %s, extra flags: %v", folder, flags)
return folder, flags, nil
}
func init() {
modules.Register(modName, New)
}
================================================
FILE: internal/imap_filter/group.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package imap_filter
import (
"github.com/emersion/go-message/textproto"
"github.com/foxcpp/maddy/framework/buffer"
"github.com/foxcpp/maddy/framework/config"
modconfig "github.com/foxcpp/maddy/framework/config/module"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
)
// Group wraps multiple modifiers and runs them serially.
//
// It is also registered as a module under 'modifiers' name and acts as a
// module group.
type Group struct {
instName string
Filters []module.IMAPFilter
log *log.Logger
}
func NewGroup(c *container.C, modName, instName string) (module.Module, error) {
return &Group{
instName: instName,
log: c.DefaultLogger.Sublogger(modName),
}, nil
}
func (g *Group) IMAPFilter(accountName string, rcptTo string, meta *module.MsgMetadata, hdr textproto.Header, body buffer.Buffer) (folder string, flags []string, err error) {
if g == nil {
return "", nil, nil
}
var (
finalFolder string
finalFlags = make([]string, 0, len(g.Filters))
)
for _, f := range g.Filters {
folder, flags, err := f.IMAPFilter(accountName, rcptTo, meta, hdr, body)
if err != nil {
g.log.Error("IMAP filter failed", err)
continue
}
if folder != "" && finalFolder == "" {
finalFolder = folder
}
finalFlags = append(finalFlags, flags...)
}
return finalFolder, finalFlags, nil
}
func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error {
for _, node := range cfg.Block.Children {
mod, err := modconfig.IMAPFilter(cfg.Globals, append([]string{node.Name}, node.Args...), node)
if err != nil {
return err
}
g.Filters = append(g.Filters, mod)
}
return nil
}
func (g *Group) Name() string {
return "modifiers"
}
func (g *Group) InstanceName() string {
return g.instName
}
func init() {
modules.Register("imap_filters", NewGroup)
}
================================================
FILE: internal/libdns/acmedns.go
================================================
//go:build libdns_acmedns || libdns_all
// +build libdns_acmedns libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/acmedns"
)
func init() {
modules.Register("libdns.acmedns", func(c *container.C, modName, instName string) (module.Module, error) {
p := acmedns.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("username", false, true, "", &p.Username)
c.String("password", false, true, "", &p.Password)
c.String("subdomain", false, true, "", &p.Subdomain)
c.String("server_url", false, true, "", &p.ServerURL)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/alidns.go
================================================
//go:build libdns_alidns || libdns_all
// +build libdns_alidns libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/alidns"
)
func init() {
modules.Register("libdns.alidns", func(c *container.C, modName, instName string) (module.Module, error) {
p := alidns.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("key_id", false, false, "", &p.AccKeyID)
c.String("key_secret", false, false, "", &p.AccKeySecret)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/cloudflare.go
================================================
//go:build libdns_cloudflare || !libdns_separate
// +build libdns_cloudflare !libdns_separate
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/cloudflare"
)
func init() {
modules.Register("libdns.cloudflare", func(c *container.C, modName, instName string) (module.Module, error) {
p := cloudflare.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("api_token", false, false, "", &p.APIToken)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/digitalocean.go
================================================
//go:build libdns_digitalocean || !libdns_separate
// +build libdns_digitalocean !libdns_separate
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/digitalocean"
)
func init() {
modules.Register("libdns.digitalocean", func(c *container.C, modName, instName string) (module.Module, error) {
p := digitalocean.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("api_token", false, false, "", &p.APIToken)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/gandi.go
================================================
//go:build libdns_gandi || !libdns_separate
// +build libdns_gandi !libdns_separate
package libdns
import (
"fmt"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/gandi"
)
func init() {
modules.Register("libdns.gandi", func(c *container.C, modName, instName string) (module.Module, error) {
p := gandi.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("api_token", false, false, "", &p.APIToken)
c.String("personal_token", false, false, "", &p.BearerToken)
},
afterConfig: func() error {
if p.APIToken != "" {
log.Println("libdns.gandi: api_token is deprecated, use personal_token instead (https://api.gandi.net/docs/authentication/)")
}
if p.APIToken == "" && p.BearerToken == "" {
return fmt.Errorf("libdns.gandi: either api_token or personal_token should be specified")
}
return nil
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/gcore.go
================================================
//go:build libdns_gcore || !libdns_separate
package libdns
import (
"fmt"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/gcore"
)
func init() {
modules.Register("libdns.gcore", func(c *container.C, modName, instName string) (module.Module, error) {
p := gcore.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("api_key", false, false, "", &p.APIKey)
},
afterConfig: func() error {
if p.APIKey == "" {
return fmt.Errorf("libdns.gcore: api_key should be specified")
}
return nil
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/googleclouddns.go
================================================
//go:build libdns_googleclouddns || libdns_all
// +build libdns_googleclouddns libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/googleclouddns"
)
func init() {
modules.Register("libdns.googleclouddns", func(c *container.C, modName, instName string) (module.Module, error) {
p := googleclouddns.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("project", false, true, "", &p.Project)
c.String("service_account_json", false, false, "", &p.ServiceAccountJSON)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/hetzner.go
================================================
//go:build libdns_hetzner || !libdns_separate
// +build libdns_hetzner !libdns_separate
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/hetzner"
)
func init() {
modules.Register("libdns.hetzner", func(c *container.C, modName, instName string) (module.Module, error) {
p := hetzner.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
log.DefaultLogger.Println("WARNING: maddy 0.10.0 will require new DNS API, see https://github.com/foxcpp/maddy/issues/807 for details")
c.String("api_token", false, false, "", &p.AuthAPIToken)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/leaseweb.go
================================================
//go:build libdns_leaseweb || libdns_all
// +build libdns_leaseweb libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/leaseweb"
)
func init() {
modules.Register("libdns.leaseweb", func(c *container.C, modName, instName string) (module.Module, error) {
p := leaseweb.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
log.DefaultLogger.Println("WARNING: maddy 0.10.0 will drop libdns.leaseweb, see https://github.com/foxcpp/maddy/issues/807 for details")
c.String("api_key", false, false, "", &p.APIKey)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/metaname.go
================================================
//go:build libdns_metaname || libdns_all
// +build libdns_metaname libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/metaname"
)
func init() {
modules.Register("libdns.metaname", func(c *container.C, modName, instName string) (module.Module, error) {
p := metaname.Provider{
Endpoint: "https://metaname.net/api/1.1",
}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("api_key", false, false, "", &p.APIKey)
c.String("account_ref", false, false, "", &p.AccountReference)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/namecheap.go
================================================
//go:build go1.16
// +build go1.16
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/namecheap"
)
func init() {
modules.Register("libdns.namecheap", func(c *container.C, modName, instName string) (module.Module, error) {
p := namecheap.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("api_key", false, true, "", &p.APIKey)
c.String("api_username", false, true, "", &p.User)
c.String("endpoint", false, false, "", &p.APIEndpoint)
c.String("client_ip", false, false, "", &p.ClientIP)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/namedotcom.go
================================================
//go:build libdns_namedotdom || libdns_all
// +build libdns_namedotdom libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/namedotcom"
)
func init() {
modules.Register("libdns.namedotcom", func(c *container.C, modName, instName string) (module.Module, error) {
p := namedotcom.Provider{
Server: "https://api.name.com",
}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
log.DefaultLogger.Println("WARNING: maddy 0.10.0 will drop libdns.namedotcom, see https://github.com/foxcpp/maddy/issues/807 for details")
c.String("user", false, false, "", &p.User)
c.String("token", false, false, "", &p.Token)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/provider_module.go
================================================
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/libdns/libdns"
)
type ProviderModule struct {
libdns.RecordDeleter
libdns.RecordAppender
setConfig func(c *config.Map)
afterConfig func() error
instName string
modName string
}
func (p *ProviderModule) Configure(inlineArgs []string, cfg *config.Map) error {
p.setConfig(cfg)
_, err := cfg.Process()
if p.afterConfig != nil {
if err := p.afterConfig(); err != nil {
return err
}
}
return err
}
func (p *ProviderModule) Name() string {
return p.modName
}
func (p *ProviderModule) InstanceName() string {
return p.instName
}
================================================
FILE: internal/libdns/rfc2136.go
================================================
//go:build libdns_rfc2136 || libdns_all
// +build libdns_rfc2136 libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/rfc2136"
)
func init() {
modules.Register("libdns.rfc2136", func(c *container.C, modName, instName string) (module.Module, error) {
p := rfc2136.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("key_name", false, true, "", &p.KeyName)
c.String("key", false, true, "", &p.Key)
c.String("key_alg", false, true, "", &p.KeyAlg)
c.String("server", false, true, "", &p.Server)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/route53.go
================================================
//go:build libdns_route53 || libdns_all
// +build libdns_route53 libdns_all
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/route53"
)
func init() {
modules.Register("libdns.route53", func(c *container.C, modName, instName string) (module.Module, error) {
p := route53.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
c.String("secret_access_key", false, false, "", &p.SecretAccessKey)
c.String("access_key_id", false, false, "", &p.AccessKeyId)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/libdns/vultr.go
================================================
//go:build libdns_vultr || !libdns_separate
// +build libdns_vultr !libdns_separate
package libdns
import (
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/log"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/libdns/vultr"
)
func init() {
modules.Register("libdns.vultr", func(c *container.C, modName, instName string) (module.Module, error) {
p := vultr.Provider{}
return &ProviderModule{
RecordDeleter: &p,
RecordAppender: &p,
setConfig: func(c *config.Map) {
log.DefaultLogger.Println("WARNING: maddy 0.10.0 will drop libdns.vultr, see https://github.com/foxcpp/maddy/issues/807 for details")
c.String("api_token", false, false, "", &p.APIToken)
},
instName: instName,
modName: modName,
}, nil
})
}
================================================
FILE: internal/limits/limiters/bucket.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package limiters
import (
"context"
"sync"
"time"
)
// BucketSet combines a group of Ls into a single key-indexed structure.
// Basically, each unique key gets its own counter. The main use case for
// BucketSet is to apply per-resource rate limiting.
//
// Amount of buckets is limited to a certain value. When the size of internal
// map is around or equal to that value, next Take call will attempt to remove
// any stale buckets from the group. If it is not possible to do so (all
// buckets are in active use), Take will return false. Alternatively, in some
// rare cases, some other (undefined) waiting Take can return false.
//
// A BucksetSet without a New function assigned is no-op: Take and TakeContext
// always succeed and Release does nothing.
type BucketSet struct {
// New function is used to construct underlying L instances.
//
// It is safe to change it only when BucketSet is not used by any
// goroutine.
New func() L
// Time after which bucket is considered stale and can be removed from the
// set. For safe use with Rate limiter, it should be at least as twice as
// big as Rate refill interval.
ReapInterval time.Duration
MaxBuckets int
mLck sync.Mutex
m map[string]*struct {
r L
lastUse time.Time
}
}
func NewBucketSet(new_ func() L, reapInterval time.Duration, maxBuckets int) *BucketSet {
return &BucketSet{
New: new_,
ReapInterval: reapInterval,
MaxBuckets: maxBuckets,
m: map[string]*struct {
r L
lastUse time.Time
}{},
}
}
func (r *BucketSet) Close() {
r.mLck.Lock()
defer r.mLck.Unlock()
for _, v := range r.m {
v.r.Close()
}
}
func (r *BucketSet) take(key string) L {
r.mLck.Lock()
defer r.mLck.Unlock()
if len(r.m) > r.MaxBuckets {
now := time.Now()
// Attempt to get rid of stale buckets.
for k, v := range r.m {
if v.lastUse.Sub(now) > r.ReapInterval {
// Drop the bucket, if there happen to be any waiting Take for it.
// It will return 'false', but this is fine for us since this
// whole 'reaping' process will run only when we are under a
// high load and dropping random requests in this case is a
// more or less reasonable thing to do.
v.r.Close()
delete(r.m, k)
}
}
// Still full? E.g. all buckets are in use.
if len(r.m) > r.MaxBuckets {
return nil
}
}
bucket, ok := r.m[key]
if !ok {
r.m[key] = &struct {
r L
lastUse time.Time
}{
r: r.New(),
lastUse: time.Now(),
}
bucket = r.m[key]
}
r.m[key].lastUse = time.Now()
return bucket.r
}
func (r *BucketSet) Take(key string) bool {
if r.New == nil {
return true
}
bucket := r.take(key)
return bucket.Take()
}
func (r *BucketSet) Release(key string) {
if r.New == nil {
return
}
r.mLck.Lock()
defer r.mLck.Unlock()
bucket, ok := r.m[key]
if !ok {
return
}
bucket.r.Release()
}
func (r *BucketSet) TakeContext(ctx context.Context, key string) error {
if r.New == nil {
return nil
}
bucket := r.take(key)
return bucket.TakeContext(ctx)
}
================================================
FILE: internal/limits/limiters/concurrency.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package limiters
import "context"
// Semaphore is a convenience wrapper for a channel that implements
// semaphore-kind synchronization.
//
// If the argument given to the NewSemaphore is negative or zero,
// all methods are no-op.
type Semaphore struct {
c chan struct{}
}
func NewSemaphore(max int) Semaphore {
return Semaphore{c: make(chan struct{}, max)}
}
func (s Semaphore) Take() bool {
if cap(s.c) <= 0 {
return true
}
s.c <- struct{}{}
return true
}
func (s Semaphore) TakeContext(ctx context.Context) error {
if cap(s.c) <= 0 {
return nil
}
select {
case s.c <- struct{}{}:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (s Semaphore) Release() {
if cap(s.c) <= 0 {
return
}
select {
case <-s.c:
default:
panic("limiters: mismatched Release call")
}
}
func (s Semaphore) Close() {
}
================================================
FILE: internal/limits/limiters/limiters.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package limiters provides a set of wrappers intended to restrict the amount
// of resources consumed by the server.
package limiters
import "context"
// The L interface represents a blocking limiter that has some upper bound of
// resource use and blocks when it is exceeded until enough resources are
// freed.
type L interface {
Take() bool
TakeContext(context.Context) error
Release()
// Close frees any resources used internally by Limiter for book-keeping.
Close()
}
================================================
FILE: internal/limits/limiters/multilimit.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package limiters
import "context"
// MultiLimit wraps multiple L implementations into a single one, locking them
// in the specified order.
//
// It does not implement any deadlock detection or avoidance algorithms.
type MultiLimit struct {
Wrapped []L
}
func (ml *MultiLimit) Take() bool {
for i := 0; i < len(ml.Wrapped); i++ {
if !ml.Wrapped[i].Take() {
// Acquire failed, undo acquire for all other resources we already
// got.
for _, l := range ml.Wrapped[:i] {
l.Release()
}
return false
}
}
return true
}
func (ml *MultiLimit) TakeContext(ctx context.Context) error {
for i := 0; i < len(ml.Wrapped); i++ {
if err := ml.Wrapped[i].TakeContext(ctx); err != nil {
// Acquire failed, undo acquire for all other resources we already
// got.
for _, l := range ml.Wrapped[:i] {
l.Release()
}
return err
}
}
return nil
}
func (ml *MultiLimit) Release() {
for _, l := range ml.Wrapped {
l.Release()
}
}
func (ml *MultiLimit) Close() {
for _, l := range ml.Wrapped {
l.Close()
}
}
================================================
FILE: internal/limits/limiters/rate.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
package limiters
import (
"context"
"errors"
"time"
)
var ErrClosed = errors.New("limiters: Rate bucket is closed")
// Rate structure implements a basic rate-limiter for requests using the token
// bucket approach.
//
// Take() is expected to be called before each request. Excessive calls will
// block. Timeouts can be implemented using the TakeContext method.
//
// Rate.Close causes all waiting Take to return false. TakeContext returns
// ErrClosed in this case.
//
// If burstSize = 0, all methods are no-op and always succeed.
type Rate struct {
bucket chan struct{}
stop chan struct{}
}
func NewRate(burstSize int, interval time.Duration) Rate {
r := Rate{
bucket: make(chan struct{}, burstSize),
stop: make(chan struct{}),
}
if burstSize == 0 {
return r
}
for i := 0; i < burstSize; i++ {
r.bucket <- struct{}{}
}
go r.fill(burstSize, interval)
return r
}
func (r Rate) fill(burstSize int, interval time.Duration) {
t := time.NewTimer(interval)
defer t.Stop()
for {
t.Reset(interval)
select {
case <-t.C:
case <-r.stop:
close(r.bucket)
return
}
fill:
for i := 0; i < burstSize; i++ {
select {
case r.bucket <- struct{}{}:
default:
// If there are no Take pending and the bucket is already
// full - don't block.
break fill
}
}
}
}
func (r Rate) Take() bool {
if cap(r.bucket) == 0 {
return true
}
_, ok := <-r.bucket
return ok
}
func (r Rate) TakeContext(ctx context.Context) error {
if cap(r.bucket) == 0 {
return nil
}
select {
case _, ok := <-r.bucket:
if !ok {
return ErrClosed
}
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func (r Rate) Release() {
}
func (r Rate) Close() {
close(r.stop)
}
================================================
FILE: internal/limits/limits.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see .
*/
// Package limit provides a module object that can be used to restrict the
// concurrency and rate of the messages flow globally or on per-source,
// per-destination basis.
//
// Note, all domain inputs are interpreted with the assumption they are already
// normalized.
//
// Low-level components are available in the limiters/ subpackage.
package limits
import (
"context"
"net"
"strconv"
"time"
"github.com/foxcpp/maddy/framework/config"
"github.com/foxcpp/maddy/framework/container"
"github.com/foxcpp/maddy/framework/module"
"github.com/foxcpp/maddy/framework/module/modules"
"github.com/foxcpp/maddy/internal/limits/limiters"
)
type Group struct {
instName string
global limiters.MultiLimit
ip *limiters.BucketSet // BucketSet of MultiLimit
source *limiters.BucketSet // BucketSet of MultiLimit
dest *limiters.BucketSet // BucketSet of MultiLimit
}
func New(c *container.C, _, instName string) (module.Module, error) {
return &Group{
instName: instName,
}, nil
}
func (g *Group) Configure(inlineArgs []string, cfg *config.Map) error {
var (
globalL []limiters.L
ipL []func() limiters.L
sourceL []func() limiters.L
destL []func() limiters.L
)
for _, child := range cfg.Block.Children {
if len(child.Args) < 1 {
return config.NodeErr(child, "at least two arguments are required")
}
var (
ctor func() limiters.L
err error
)
switch kind := child.Args[0]; kind {
case "rate":
ctor, err = rateCtor(child, child.Args[1:])
case "concurrency":
ctor, err = concurrencyCtor(child, child.Args[1:])
default:
return config.NodeErr(child, "unknown limit kind: %v", kind)
}
if err != nil {
return err
}
switch scope := child.Name; scope {
case "all":
globalL = append(globalL, ctor())
case "ip":
ipL = append(ipL, ctor)
case "source":
sourceL = append(sourceL, ctor)
case "destination":
destL = append(destL, ctor)
default:
return config.NodeErr(child, "unknown limit scope: %v", scope)
}
}
// 20010 is slightly higher than the default max. recipients count in
// endpoint/smtp.
g.global = limiters.MultiLimit{Wrapped: globalL}
if len(ipL) != 0 {
g.ip = limiters.NewBucketSet(func() limiters.L {
l := make([]limiters.L, 0, len(ipL))
for _, ctor := range ipL {
l = append(l, ctor())
}
return &limiters.MultiLimit{Wrapped: l}
}, 1*time.Minute, 20010)
}
if len(sourceL) != 0 {
g.source = limiters.NewBucketSet(func() limiters.L {
l := make([]limiters.L, 0, len(sourceL))
for _, ctor := range sourceL {
l = append(l, ctor())
}
return &limiters.MultiLimit{Wrapped: l}
}, 1*time.Minute, 20010)
}
if len(destL) != 0 {
g.dest = limiters.NewBucketSet(func() limiters.L {
l := make([]limiters.L, 0, len(destL))
for _, ctor := range destL {
l = append(l, ctor())
}
return &limiters.MultiLimit{Wrapped: l}
}, 1*time.Minute, 20010)
}
return nil
}
func rateCtor(node config.Node, args []string) (func() limiters.L, error) {
period := 1 * time.Second
burst := 0
switch len(args) {
case 2:
var err error
period, err = time.ParseDuration(args[1])
if err != nil {
return nil, config.NodeErr(node, "%v", err)
}
fallthrough
case 1:
var err error
burst, err = strconv.Atoi(args[0])
if err != nil {
return nil, config.NodeErr(node, "%v", err)
}
case 0:
return nil, config.NodeErr(node, "at least burst size is needed")
default:
return nil, config.NodeErr(node, "too many arguments")
}
return func() limiters.L {
return limiters.NewRate(burst, period)
}, nil
}
func concurrencyCtor(node config.Node, args []string) (func() limiters.L, error) {
if len(args) != 1 {
return nil, config.NodeErr(node, "max concurrency value is needed")
}
max, err := strconv.Atoi(args[0])
if err != nil {
return nil, config.NodeErr(node, "%v", err)
}
return func() limiters.L {
return limiters.NewSemaphore(max)
}, nil
}
func (g *Group) TakeMsg(ctx context.Context, addr net.IP, sourceDomain string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := g.global.TakeContext(ctx); err != nil {
return err
}
if g.ip != nil {
if err := g.ip.TakeContext(ctx, addr.String()); err != nil {
g.global.Release()
return err
}
}
if g.source != nil {
if err := g.source.TakeContext(ctx, sourceDomain); err != nil {
g.global.Release()
g.ip.Release(addr.String())
return err
}
}
return nil
}
func (g *Group) TakeDest(ctx context.Context, domain string) error {
if g.dest == nil {
return nil
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
return g.dest.TakeContext(ctx, domain)
}
func (g *Group) ReleaseMsg(addr net.IP, sourceDomain string) {
g.global.Release()
if g.ip != nil {
g.ip.Release(addr.String())
}
if g.source != nil {
g.source.Release(sourceDomain)
}
}
func (g *Group) ReleaseDest(domain string) {
if g.dest == nil {
return
}
g.dest.Release(domain)
}
func (g *Group) Name() string {
return "limits"
}
func (g *Group) InstanceName() string {
return g.instName
}
func init() {
modules.Register("limits", New)
}
================================================
FILE: internal/modify/dkim/dkim.go
================================================
/*
Maddy Mail Server - Composable all-in-one email server.
Copyright © 2019-2020 Max Mazurov , Maddy Mail Server contributors
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see