...
 
Commits (46)
......@@ -19,6 +19,7 @@ cmd/smtp-check/smtp-check
cmd/spf-check/spf-check
cmd/mda-lmtp/mda-lmtp
cmd/dovecot-auth-cli/dovecot-auth-cli
test/util/minidns
# Test binary, generated during coverage tests.
chasquid.test
......
......@@ -3,6 +3,7 @@ stages:
- test
- docker_image
# Integration test, using the module versions from the repository.
integration_test:
stage: test
image: docker:stable
......@@ -14,6 +15,18 @@ integration_test:
- docker run chasquid-test env
- docker run chasquid-test make test
# Integration test, using the latest module versions.
integration_test_latest:
stage: test
image: docker:stable
services:
- docker:dind
script:
- docker info
- docker build -t chasquid-test --build-arg GO_GET_ARGS="-u=patch" -f test/Dockerfile .
- docker run chasquid-test env
- docker run chasquid-test make test
image_build:
stage: docker_image
image: docker:stable
......@@ -24,3 +37,14 @@ image_build:
- docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
- docker build -t $CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME -f docker/Dockerfile .
- docker push $CI_REGISTRY_IMAGE:$CI_BUILD_REF_NAME
dockerhub_image_build:
stage: docker_image
image: docker:stable
services:
- docker:dind
script:
- docker info
- docker login -u $DOCKER_REGISTRY_USER -p $DOCKER_REGISTRY_PASSWORD docker.io
- docker build -t index.docker.io/albertito/chasquid:$CI_BUILD_REF_NAME -f docker/Dockerfile .
- docker push index.docker.io/albertito/chasquid:$CI_BUILD_REF_NAME
......@@ -30,4 +30,6 @@ nav:
- flow.md
- monitoring.md
- sec-levels.md
- tests.md
- relnotes.md
......@@ -6,9 +6,8 @@ dist: trusty
sudo: false
go:
# Normally we'd want to support the version in Debian stable, but
# golang.org/x/crypto requires math/bits which appeared in 1.9.
- 1.9
# Check against the version in Debian stable.
- 1.11
- stable
- master
......
......@@ -9,12 +9,12 @@ It is designed mainly for individuals and small groups.
It's written in [Go](https://golang.org), and distributed under the
[Apache license 2.0](http://en.wikipedia.org/wiki/Apache_License).
[![Travis-CI build status](https://travis-ci.org/albertito/chasquid.svg?branch=master)](https://travis-ci.org/albertito/chasquid)
[![pipeline status](https://gitlab.com/albertito/chasquid/badges/master/pipeline.svg)](https://gitlab.com/albertito/chasquid/commits/master)
[![Travis-CI status](https://travis-ci.org/albertito/chasquid.svg?branch=master)](https://travis-ci.org/albertito/chasquid)
[![Gitlab CI status](https://gitlab.com/albertito/chasquid/badges/master/pipeline.svg)](https://gitlab.com/albertito/chasquid/pipelines)
[![Go Report Card](https://goreportcard.com/badge/github.com/albertito/chasquid)](https://goreportcard.com/report/github.com/albertito/chasquid)
[![Coverage Status](https://coveralls.io/repos/github/albertito/chasquid/badge.svg?branch=next)](https://coveralls.io/github/albertito/chasquid?branch=next)
[![GoDoc](https://godoc.org/blitiri.com.ar/go/chasquid?status.svg)](https://godoc.org/blitiri.com.ar/go/chasquid)
[![Freenode](https://img.shields.io/badge/chat-freenode-brightgreen.svg)](https://webchat.freenode.net?channels=%23chasquid)
[![Coverage](https://img.shields.io/badge/coverage-next-brightgreen.svg)](https://blitiri.com.ar/p/chasquid/coverage.html)
[![Docs](https://img.shields.io/badge/docs-reference-blue.svg)](https://blitiri.com.ar/p/chasquid/docs/)
[![Freenode](https://img.shields.io/badge/chat-freenode-blue.svg)](https://webchat.freenode.net/#chasquid)
## Features
......@@ -23,14 +23,14 @@ It's written in [Go](https://golang.org), and distributed under the
* Easy to configure.
* Hard to mis-configure in ways that are harmful or insecure (e.g. no open
relay, or clear-text authentication).
* Monitoring HTTP server, with exported variables and tracing to help
* [Monitoring] HTTP server, with exported variables and tracing to help
debugging.
* Integrated with [Debian] and [Ubuntu].
* Supports using [Dovecot] for authentication.
* Useful
* Multiple/virtual domains, with per-domain users and aliases.
* Suffix dropping (`[email protected]``[email protected]`).
* Hooks for integration with greylisting, anti-virus, anti-spam, and
* [Hooks] for integration with greylisting, anti-virus, anti-spam, and
DKIM/DMARC.
* International usernames ([SMTPUTF8]) and domain names ([IDNA]).
* Secure
......@@ -40,22 +40,24 @@ It's written in [Go](https://golang.org), and distributed under the
* [SPF] and [MTA-STS] checking.
[SMTPUTF8]: https://en.wikipedia.org/wiki/Extended_SMTP#SMTPUTF8
[Debian]: https://debian.org
[Dovecot]: https://blitiri.com.ar/p/chasquid/docs/dovecot/
[Hooks]: https://blitiri.com.ar/p/chasquid/docs/hooks/
[IDNA]: https://en.wikipedia.org/wiki/Internationalized_domain_name
[Let's Encrypt]: https://letsencrypt.org
[Dovecot]: https://dovecot.org
[SPF]: https://en.wikipedia.org/wiki/Sender_Policy_Framework
[MTA-STS]: https://tools.ietf.org/html/rfc8461
[Debian]: https://debian.org
[Ubuntu]: https://ubuntu.com
[Monitoring]: https://blitiri.com.ar/p/chasquid/docs/monitoring/
[SMTPUTF8]: https://en.wikipedia.org/wiki/Extended_SMTP#SMTPUTF8
[SPF]: https://en.wikipedia.org/wiki/Sender_Policy_Framework
[Tracking]: https://blitiri.com.ar/p/chasquid/docs/sec-levels/
[Ubuntu]: https://ubuntu.com
## Documentation
The [how-to guide](docs/howto.md) and the [installation
guide](docs/install.md) are the best starting points on how to install,
configure and run chasquid.
The [how-to guide](https://blitiri.com.ar/p/chasquid/docs/howto/) and the
[installation guide](https://blitiri.com.ar/p/chasquid/docs/install/) are the
best starting points on how to install, configure and run chasquid.
You will find [all documentation here](https://blitiri.com.ar/p/chasquid/docs/).
......
......@@ -92,7 +92,7 @@ func main() {
s := smtpsrv.NewServer()
s.Hostname = conf.Hostname
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
s.PostDataHook = "hooks/post-data"
s.HookPath = "hooks/"
s.SetAliasesConfig(conf.SuffixSeparators, conf.DropCharacters)
......
......@@ -36,6 +36,7 @@ Usage:
chasquid-util [options] aliases-resolve <address>
chasquid-util [options] domaininfo-remove <domain>
chasquid-util [options] print-config
chasquid-util [options] aliases-add <source> <target>
Options:
-C --configdir=<path> Configuration directory
......@@ -65,6 +66,7 @@ func main() {
"aliases-resolve": aliasesResolve,
"print-config": printConfig,
"domaininfo-remove": domaininfoRemove,
"aliases-add": aliasesAdd,
}
for cmd, f := range commands {
......@@ -269,3 +271,53 @@ func domaininfoRemove() {
Fatalf("Error removing file: %v", err)
}
}
// chasquid-util aliases-add <source> <target>
func aliasesAdd() {
source := args["<source>"].(string)
target := args["<target>"].(string)
user, domain := envelope.Split(source)
if domain == "" {
Fatalf("Domain required in source address")
}
// Ensure the domain exists.
if _, err := os.Stat(filepath.Join(configDir, "domains", domain)); os.IsNotExist(err) {
Fatalf("Domain doesn't exist")
}
conf, err := config.Load(configDir + "/chasquid.conf")
if err != nil {
Fatalf("Error reading config")
}
os.Chdir(configDir)
// Setup alias resolver.
r := aliases.NewResolver()
r.SuffixSep = conf.SuffixSeparators
r.DropChars = conf.DropCharacters
r.AddDomain(domain)
aliasesFilePath := filepath.Join("domains", domain, "aliases")
if err := r.AddAliasesFile(domain, aliasesFilePath); err != nil {
Fatalf("%s: error loading %q: %v", domain, aliasesFilePath, err)
}
// Check for existing entry.
if _, ok := r.Exists(source); ok {
Fatalf("There's already an entry for %v", source)
}
// Append the new entry.
aliasesFile, err := os.OpenFile(aliasesFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
if err != nil {
Fatalf("Couldn't open %s: %v", aliasesFilePath, err)
}
_, err = fmt.Fprintf(aliasesFile, "%s: %s\n", user, target)
if err != nil {
Fatalf("Couldn't write to %s: %v", aliasesFilePath, err)
}
aliasesFile.Close()
fmt.Println("Added alias")
}
......@@ -80,4 +80,23 @@ if [ "$C" != "hostname: \"$HOSTNAME\"" ]; then
exit 1
fi
if r aliases-add [email protected] target > /dev/null; then
A=$(grep alias2 .config/domains/domain/aliases)
if [ "$A" != "alias2: target" ]; then
echo aliases-add failed
echo output: "$A"
exit 1
fi
fi
if r aliases-add [email protected] target > /dev/null; then
echo aliases-add on existing alias worked
exit 1
fi
if r aliases-add [email protected] target > /dev/null; then
echo aliases-add on non-existing domain worked
exit 1
fi
success
......@@ -8,6 +8,7 @@ import (
"context"
"crypto/tls"
"flag"
"fmt"
"log"
"net"
"net/smtp"
......@@ -54,6 +55,7 @@ func main() {
}
log.Printf("OK")
}
log.Printf("")
mxs, err := net.LookupMX(domain)
if err != nil {
......@@ -64,8 +66,9 @@ func main() {
log.Fatalf("MX lookup returned no results")
}
errs := []error{}
for _, mx := range mxs {
log.Printf("=== Testing MX: %2d %s", mx.Pref, mx.Host)
log.Printf("=== MX: %2d %s", mx.Pref, mx.Host)
ips, err := net.LookupIP(mx.Host)
if err != nil {
......@@ -73,9 +76,10 @@ func main() {
}
for _, ip := range ips {
result, err := spf.CheckHostWithSender(ip, domain, "[email protected]"+domain)
log.Printf("SPF %v for %v: %v", result, ip, err)
if result != spf.Pass {
log.Printf("SPF check != pass for IP %s: %s - %s",
ip, result, err)
errs = append(errs,
fmt.Errorf("%s: SPF failed (%v)", mx.Host, ip))
}
}
......@@ -94,19 +98,21 @@ func main() {
}
err = c.StartTLS(config)
if err != nil {
log.Fatalf("TLS error: %v", err)
log.Printf("TLS error: %v", err)
errs = append(errs, fmt.Errorf("%s: TLS failed", mx.Host))
} else {
cstate, _ := c.TLSConnectionState()
log.Printf("TLS OK: %s - %s", tlsconst.VersionName(cstate.Version),
tlsconst.CipherSuiteName(cstate.CipherSuite))
}
cstate, _ := c.TLSConnectionState()
log.Printf("TLS OK: %s - %s", tlsconst.VersionName(cstate.Version),
tlsconst.CipherSuiteName(cstate.CipherSuite))
c.Close()
}
if policy != nil {
if !policy.MXIsAllowed(mx.Host) {
log.Fatalf("NOT allowed by STS policy")
log.Printf("NOT allowed by STS policy")
errs = append(errs, fmt.Errorf("%s: STS failed", mx.Host))
}
log.Printf("Allowed by policy")
}
......@@ -114,5 +120,13 @@ func main() {
log.Printf("")
}
log.Printf("=== Success")
if len(errs) == 0 {
log.Printf("=== Success")
} else {
log.Printf("=== FAILED")
for _, err := range errs {
log.Printf("%v", err)
}
log.Fatal("")
}
}
......@@ -36,6 +36,12 @@ pepe: jose
flowers: [email protected], [email protected]
```
Destination addresses can be for a remote domain as well. In that case, the
email will be forwarded using
[sender rewriting](https://en.wikipedia.org/wiki/Sender_Rewriting_Scheme).
While the content of the message will not be changed, the envelope sender will
be the constructed from the alias user.
User names cannot contain spaces, ":" or commas, for parsing reasons. This is
a tradeoff between flexibility and keeping the file format easy to edit for
people. User names will be normalized internally to lower-case. UTF-8 is
......@@ -77,5 +83,16 @@ The `chasquid-util` command-line tool can be used to check and resolve
aliases.
## Hooks
There are two hooks that allow more sophisticated aliases resolution:
`alias-exists` and `alias-resolve`.
If they exist, they are invoked as part of the resolution process and the
results are merged with the file-based resolution results.
See the [hooks](hooks.md) documentation for more details.
[chasquid]: https://blitiri.com.ar/p/chasquid
[email aliases]: https://en.wikipedia.org/wiki/Email_alias
......@@ -51,5 +51,23 @@ If chasquid can't find them, the paths can be set with the
`dovecot_userdb_path` and `dovecot_client_path` options.
## Troubleshooting
Dovecot authentication can be tricky to troubleshoot.
If you think it is not working as it should, or chasquid isn't correctly
talking with it, the easiest way to check is to [increase dovecot auth logging
verbosity](https://doc.dovecot.org/admin_manual/logging/?highlight=logging#logging-verbosity):
```
auth_verbose = yes
auth_debug = yes
```
One common gotcha is when dovecot is set up to use `user` instead of
`[email protected]`. In that case you can try setting `auth_username_format = %n` to
make it ignore the domain if present.
[dovecot]: https://dovecot.org
[chasquid]: https://blitiri.com.ar/p/chasquid
# Post-DATA hook
# Hooks
chasquid supports some functionality via hooks, which are binaries that get
executed at specific points in time during delivery.
They are optional, and will be skipped if they don't exist.
## Post-DATA hook
After completion of DATA, but before accepting the mail for queueing, chasquid
will run the command at `$config_dir/hooks/post-data`.
......@@ -21,7 +29,7 @@ This hook can be used to block based on contents, for example to check for
spam or virus. See `etc/hooks/post-data` for an example.
## Environment
### Environment
This hook will run as the chasquid user, so be careful about permissions and
privileges.
......@@ -43,3 +51,34 @@ The environment will contain the following variables:
There is a 1 minute timeout for hook execution.
It will be run at the config directory.
## Alias resolve hook
When an alias needs to be resolved, chasquid will run the command at
`$config_dir/hooks/alias-resolve` (if the file exists).
The address to resolve will be passed as the single argument.
The output of the command will be parsed as if it was the right-hand side of
the aliases configuration file (see [Aliases](aliases.md) for more details).
Results are appended to the results of the file-based alias resolution.
If there is no alias for the address, the hook should just exit successfuly
without emitting any output.
There is a 5 second timeout for hook execution. If the hook exits with an
error, including timeout, delivery will fail.
## Alias exists hook
When chasquid needs to check whether an alias exists or not, it will run the
command at `$config_dir/hooks/alias-exists` (if the file exists).
The address to check will be passed as the single argument.
If the commands exits successfuly (exit code 0), then the alias exists; any
other exit code signals that the alias does not exist.
There is a 5 second timeout for hook execution. If the hook times out, the
alias will be assumed not to exist.
......@@ -3,16 +3,23 @@
## Installation
If you're using Debian or Ubuntu, chasquid can be installed by running
`sudo apt install chasquid`.
### Debian/Ubuntu
To get, build and install the source, you will need a working
If you're using Debian or Ubuntu, chasquid can be installed by running:
```shell
sudo apt install chasquid
```
### From source
To get, build and install from source, you will need a working
[Go](http://golang.org) environment.
```shell
# Get the code and build the binaries.
go get blitiri.com.ar/go/chasquid
cd "$GOPATH/src/blitiri.com.ar/go/chasquid"
git clone https://blitiri.com.ar/repos/chasquid
cd chasquid
make
# Install the binaries to /usr/local/bin.
......@@ -93,7 +100,7 @@ The hook should be at `/etc/chasquid/hooks/post-data`.
The one installed by default is a bash script supporting:
* greylisting using greylistd.
* anti-spam using spamassassin.
* anti-spam using spamassassin or rspamd.
* anti-virus using clamav.
To use them, they just need to be available in your system.
......
......@@ -30,11 +30,13 @@ They're accessible over the monitoring http server, at `/debug/vars` (default
endpoint for expvars).
*Note these are still subject to change, although breaking changes will be
avoided whenever possible, and will be noted in the [upgrading
notes](upgrading.md).*
avoided whenever possible, and will be noted in the [release
notes](relnotes.md).*
List of exported variables:
- **chasquid/aliases/hookResults** (hook result -> counter): count of aliases
hook results, by hook and result.
- **chasquid/queue/deliverAttempts** (recipient type -> counter): attempts to
deliver mail, by recipient type (pipe/local email/remote email).
- **chasquid/queue/dsnQueued** (counter): count of DSNs that we generated
......@@ -48,7 +50,7 @@ List of exported variables:
- **chasquid/smtpIn/hookResults** (result -> counter): count of hook
invocations, by result.
- **chasquid/smtpIn/loopsDetected** (counter): count of email loops detected.
- **chasquid/smtpIn/responseCodeCount** (result -> counter): count of response
- **chasquid/smtpIn/responseCodeCount** (code -> counter): count of response
codes returned to incoming SMTP connections, by result code.
- **chasquid/smtpIn/securityLevelChecks** (result -> counter): count of
security level checks on incoming connections, by result.
......@@ -90,3 +92,114 @@ List of exported variables:
errors as part of keeping the STS cache.
- **chasquid/version** (string): version string.
## Prometheus
To monitor chasquid using [Prometheus](https://prometheus.io), you can use the
[prometheus-expvar-exporter](https://blitiri.com.ar/git/r/prometheus-expvar-exporter/b/master/t/f=README.md.html)
with the following configuration:
```toml
# Address to listen on. Prometheus should be told to scrape this.
listen_addr = ":8000"
[chasquid]
# Replace with the address of chasquid's monitoring server.
url = "http://localhost:1099/debug/vars"
# Metrics are auto-imported, but some can't be; in particular the ones with
# labels need explicit definitions here.
m.aliases_hook_results.expvar ="chasquid/aliases/hookResults"
m.aliases_hook_results.help ="aliases hook results"
m.aliases_hook_results.label_name ="result"
m.deliver_attempts.expvar = "chasquid/queue/deliverAttempts"
m.deliver_attempts.help = "attempts to deliver mail"
m.deliver_attempts.label_name = "recipient_type"
m.dsn_queued.expvar = "chasquid/queue/dsnQueued"
m.dsn_queued.help = "DSN queued"
m.items_written.expvar = "chasquid/queue/itemsWritten"
m.items_written.help = "items written"
m.queue_puts.expvar = "chasquid/queue/putCount"
m.queue_puts.help = "chasquid/queue/putCount"
m.smtpin_commands.expvar = "chasquid/smtpIn/commandCount"
m.smtpin_commands.help = "incoming SMTP command count"
m.smtpin_commands.label_name = "command"
m.smtp_hook_results.expvar = "chasquid/smtpIn/hookResults"
m.smtp_hook_results.help = "hook invocation results"
m.smtp_hook_results.label_name = "result"
m.loops_detected.expvar = "chasquid/smtpIn/loopsDetected"
m.loops_detected.help = "loops detected"
m.smtp_response_codes.expvar = "chasquid/smtpIn/responseCodeCount"
m.smtp_response_codes.help = "response codes returned to SMTP commands"
m.smtp_response_codes.label_name = "code"
m.in_sec_level_checks.expvar = "chasquid/smtpIn/securityLevelChecks"
m.in_sec_level_checks.help = "incoming security level check results"
m.in_sec_level_checks.label_name = "result"
m.spf_results.expvar = "chasquid/smtpIn/spfResultCount"
m.spf_results.help = "SPF result count"
m.spf_results.label_name = "result"
m.in_tls_usage.expvar = "chasquid/smtpIn/tlsCount"
m.in_tls_usage.help = "count of TLS usage in incoming connections"
m.in_tls_usage.label_name = "status"
m.out_sec_level_checks.expvar = "chasquid/smtpOut/securityLevelChecks"
m.out_sec_level_checks.help = "outgoing security level check results"
m.out_sec_level_checks.label_name = "result"
m.sts_modes.expvar = "chasquid/smtpOut/sts/mode"
m.sts_modes.help = "STS checks on outgoing connections, by mode"
m.sts_modes.label_name = "mode"
m.sts_security.expvar = "chasquid/smtpOut/sts/security"
m.sts_security.help = "STS security checks on outgoing connections, by result"
m.sts_security.label_name = "result"
m.out_tls_usage.expvar = "chasquid/smtpOut/tlsCount"
m.out_tls_usage.help = "count of TLS usage in outgoing connections"
m.out_tls_usage.label_name = "status"
m.sts_cache_expired.expvar = "chasquid/sts/cache/expired"
m.sts_cache_expired.help = "expired entries in the STS cache"
m.sts_cache_failed_fetch.expvar = "chasquid/sts/cache/failedFetch"
m.sts_cache_failed_fetch.help = "failed fetches in the STS cache"
m.sts_cache_fetches.expvar = "chasquid/sts/cache/fetches"
m.sts_cache_fetches.help = "total fetches in the STS cache"
m.sts_cache_hits.expvar = "chasquid/sts/cache/hits"
m.sts_cache_hits.help = "hits in the STS cache"
m.sts_cache_invalid.expvar = "chasquid/sts/cache/invalid"
m.sts_cache_invalid.help = "invalid policies in the STS cache"
m.sts_cache_io_errors.expvar = "chasquid/sts/cache/ioErrors"
m.sts_cache_io_errors.help = "I/O errors when maintaining STS cache"
m.sts_cache_marshal_errors.expvar = "chasquid/sts/cache/marshalErrors"
m.sts_cache_marshal_errors.help = "marshalling errors when maintaining STS cache"
m.sts_cache_refresh_cycles.expvar = "chasquid/sts/cache/refreshCycles"
m.sts_cache_refresh_cycles.help = "STS cache refresh cycles"
m.sts_cache_refresh_errors.expvar = "chasquid/sts/cache/refreshErrors"
m.sts_cache_refresh_errors.help = "STS cache refresh errors"
m.sts_cache_refreshes.expvar = "chasquid/sts/cache/refreshes"
m.sts_cache_refreshes.help = "count of STS cache refreshes"
m.sts_cache_unmarshal_errors.expvar = "chasquid/sts/cache/unmarshalErrors"
m.sts_cache_unmarshal_errors.help = "unmarshalling errors in STS cache"
```
# Release notes
This file contains notes for each release, summarizing changes and explicitly
noting backward-incompatible changes or known security issues.
## 1.2 (2019-12-06)
Security fixes:
- DoS through memory exhaustion due to not limiting the line length (on both
incoming and outgoing connections). Thanks to Max Mazurov
([email protected]) for the initial report.
Release notes:
- Fix handling of excessive long lines on incoming and outgoing connections.
- Better error codes when DATA size exceeded the maximum.
- New documentation sections (monitoring, release notes).
- Many miscellaneous test improvements.
## 1.1 (2019-10-26)
- Added hooks for aliases resolution.
- Added rspamd integration in the default post-data hook.
- Added chasquid-util aliases-add subcommand.
- Expanded SPF support.
- Documentation and test improvements.
- Minor bug fixes.
## 1.0 (2019-07-15)
No backwards-incompatible changes. No more are expected within this major
version.
- Fixed a bug on early connection deadline handling.
- Make DSN tidier, especially in handling multi-line errors.
- Miscellaneous test improvements.
## 0.07 (2019-01-19)
No backwards-incompatible changes.
- Send enhanced status codes.
- Internationalized Delivery Status Notifications (DSN).
- Miscellaneous test improvements.
- DKIM integration examples and test.
## 0.06 (2018-07-22)
No backwards-incompatible changes.
- New MTA-STS (Strict Transport Security) checking.
## 0.05 (2018-06-05)
No backwards-incompatible changes.
- Lots of new tests.
- Added a how-to and manual pages.
- Periodic reload of domaininfo, support removing entries manually.
- Dovecot auth support no longer considered experimental.
## 0.04 (2018-02-10)
No backwards-incompatible changes.
- Add Dovecot authentication support (experimental).
- Miscellaneous bug fixes to mda-lmtp and tests.
## 0.03 (2017-07-15)
**Backwards-incompatible changes:**
- The default MTA binary has changed. It's now maildrop by default.
If you relied on procmail being the default, add the following to
`/etc/chasquid/chasquid.conf`: `mail_delivery_agent_bin: "procmail"`.
- chasquid now listens on a third port, submission-on-TLS.
If using systemd, copy the `etc/systemd/system/chasquid-submission_tls.socket`
file to `/etc/systemd/system/`, and start it.
Release notes:
- Support submission (directly) over TLS (submissions/smtps/port 465).
- Change the default MDA binary to `maildrop`.
- Add a very basic MDA that uses LMTP to do the mail delivery.
## 0.02 (2017-03-03)
No backwards-incompatible changes.
- Improved configuration checks and safeguards.
- Fall back through the MX list on errors.
- Experimental MTA-STS implementation (disabled by default).
## 0.01 (2016-11-03)
Initial release.
../test/README.md
\ No newline at end of file
This file contains notes for upgrading between different versions.
## 0.07 → 1.0
No backwards-incompatible changes. No more are expected within this major
version.
## 0.06 → 0.07
No backwards-incompatible changes.
## 0.05 → 0.06
No backwards-incompatible changes.
## 0.04 → 0.05
No backwards-incompatible changes.
## 0.03 → 0.04
No backwards-incompatible changes.
## 0.02 → 0.03
* The default MTA binary has changed. It's now maildrop by default.
If you relied on procmail being the default, add the following to
/etc/chasquid/chasquid.conf: `mail_delivery_agent_bin: "procmail"`.
* chasquid now listens on a third port, submission-on-TLS.
If using systemd, copy the `etc/systemd/system/chasquid-submission_tls.socket`
file to `/etc/systemd/system/`, and start it.
......@@ -5,6 +5,7 @@
#
# - greylist (from greylistd) to do greylisting.
# - spamc (from Spamassassin) to filter spam.
# - rspamc (from rspamd) to filter spam.
# - clamdscan (from ClamAV) to filter virus.
# - dkimsign (from driusan/dkim) to do DKIM signing.
#
......@@ -46,6 +47,22 @@ if command -v spamc >/dev/null; then
fi
if command -v rspamc >/dev/null; then
ACTION=$( rspamc < "$TF" 2>/dev/null | grep Action: | cut -d " " -f 2- )
case "$ACTION" in
greylist)
echo "greylisted, please try again"
exit 75 # temporary error
;;
reject)
echo "spam detected"
exit 20 # permanent error
;;
esac
echo "X-Spam-Action:" "$ACTION"
fi
if command -v clamdscan >/dev/null; then
if ! clamdscan --no-summary --infected - < "$TF" 1>&2 ; then
echo "virus detected"
......@@ -69,7 +86,7 @@ if [ "$AUTH_AS" != "" ] && command -v dkimsign; then
&& [ -f "certs/$DOMAIN/dkim_privkey.pem" ]; then
dkimsign -n -hd \
-key "certs/$DOMAIN/dkim_privkey.pem" \
-s $(cat "domains/$DOMAIN/dkim_selector") \
-s "$(cat "domains/$DOMAIN/dkim_selector")" \
-d "$DOMAIN" \
< "$TF"
fi
......
......@@ -20,6 +20,7 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e h1:FDhOuMEY4JVRztM/gsbk+IKUQ8kj74bxZrgw87eMMVc=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
......
......@@ -55,14 +55,24 @@ package aliases
import (
"bufio"
"context"
"expvar"
"fmt"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
"blitiri.com.ar/go/chasquid/internal/envelope"
"blitiri.com.ar/go/chasquid/internal/normalize"
"blitiri.com.ar/go/chasquid/internal/trace"
)
// Exported variables.
var (
hookResults = expvar.NewMap("chasquid/aliases/hookResults")
)
// Recipient represents a single recipient, after resolving aliases.
......@@ -101,6 +111,10 @@ type Resolver struct {
// Characters to drop from the user part.
DropChars string
// Path to resolve and exist hooks.
ExistsHook string
ResolveHook string
// Map of domain -> alias files for that domain.
// We keep track of them for reloading purposes.
files map[string][]string
......@@ -125,9 +139,6 @@ func NewResolver() *Resolver {
// Resolve the given address, returning the list of corresponding recipients
// (if any).
func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
v.mu.Lock()
defer v.mu.Unlock()
return v.resolve(0, addr)
}
......@@ -137,11 +148,15 @@ func (v *Resolver) Resolve(addr string) ([]Recipient, error) {
// doesn't exist.
func (v *Resolver) Exists(addr string) (string, bool) {
v.mu.Lock()
defer v.mu.Unlock()
addr = v.cleanIfLocal(addr)
_, ok := v.aliases[addr]
return addr, ok
v.mu.Unlock()
if ok {
return addr, true
}
return addr, v.runExistsHook(addr)
}
func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
......@@ -154,7 +169,18 @@ func (v *Resolver) resolve(rcount int, addr string) ([]Recipient, error) {
// match, which our callers can rely upon.
addr = v.cleanIfLocal(addr)
// Lookup in the aliases database.
v.mu.Lock()
rcpts := v.aliases[addr]
v.mu.Unlock()
// Augment with the hook results.
hr, err := v.runResolveHook(addr)
if err != nil {
return nil, err
}
rcpts = append(rcpts, hr...)
if len(rcpts) == 0 {
return []Recipient{{addr, EMAIL}}, nil
}
......@@ -305,31 +331,43 @@ func parseReader(domain string, r io.Reader) (map[string][]Recipient, error) {
addr = addr + "@" + domain
addr, _ = normalize.Addr(addr)
if rawalias[0] == '|' {
cmd := strings.TrimSpace(rawalias[1:])
aliases[addr] = []Recipient{{cmd, PIPE}}
} else {
rs := []Recipient{}
for _, a := range strings.Split(rawalias, ",") {
a = strings.TrimSpace(a)
if a == "" {
continue
}
// Addresses with no domain get the current one added, so it's
// easier to share alias files.
if !strings.Contains(a, "@") {
a = a + "@" + domain
}
a, _ = normalize.Addr(a)
rs = append(rs, Recipient{a, EMAIL})
}
aliases[addr] = rs
}
rs := parseRHS(rawalias, domain)
aliases[addr] = rs
}
return aliases, scanner.Err()
}
func parseRHS(rawalias, domain string) []Recipient {
if len(rawalias) == 0 {
return nil
}
if rawalias[0] == '|' {
cmd := strings.TrimSpace(rawalias[1:])
if cmd == "" {
// A pipe alias without a command is invalid.
return nil
}
return []Recipient{{cmd, PIPE}}
}
rs := []Recipient{}
for _, a := range strings.Split(rawalias, ",") {
a = strings.TrimSpace(a)
if a == "" {
continue
}
// Addresses with no domain get the current one added, so it's
// easier to share alias files.
if !strings.Contains(a, "@") {
a = a + "@" + domain
}
a, _ = normalize.Addr(a)
rs = append(rs, Recipient{a, EMAIL})
}
return rs
}
// removeAllAfter removes everything from s that comes after the separators,
// including them.
func removeAllAfter(s, seps string) string {
......@@ -357,3 +395,71 @@ func removeChars(s, chars string) string {
return s
}
func (v *Resolver) runResolveHook(addr string) ([]Recipient, error) {
if v.ResolveHook == "" {
hookResults.Add("resolve:notset", 1)
return nil, nil
}
// TODO: check if the file is executable.
if _, err := os.Stat(v.ResolveHook); os.IsNotExist(err) {
hookResults.Add("resolve:skip", 1)
return nil, nil
}
// TODO: this should be done via a context propagated all the way through.
tr := trace.New("Hook.Alias-Resolve", addr)
defer tr.Finish()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, v.ResolveHook, addr)
outb, err := cmd.Output()
out := string(outb)
tr.Debugf("stdout: %q", out)
if err != nil {
hookResults.Add("resolve:fail", 1)
tr.Error(err)
return nil, err
}
// Extract recipients from the output.
// Same format as the right hand side of aliases file, see parseRHS.
domain := envelope.DomainOf(addr)
raw := strings.TrimSpace(out)
rs := parseRHS(raw, domain)
tr.Debugf("recipients: %v", rs)
hookResults.Add("resolve:success", 1)
return rs, nil
}
func (v *Resolver) runExistsHook(addr string) bool {
if v.ExistsHook == "" {
hookResults.Add("exists:notset", 1)
return false
}
// TODO: check if the file is executable.
if _, err := os.Stat(v.ExistsHook); os.IsNotExist(err) {
hookResults.Add("exists:skip", 1)
return false
}
// TODO: this should be done via a context propagated all the way through.
tr := trace.New("Hook.Alias-Exists", addr)
defer tr.Finish()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, v.ExistsHook, addr)
err := cmd.Run()
if err != nil {
tr.Debugf("not exists: %v", err)
hookResults.Add("exists:false", 1)
return false
}
tr.Debugf("exists")
hookResults.Add("exists:true", 1)
return true
}
......@@ -198,6 +198,10 @@ func TestAddFile(t *testing.T) {
{"a: [email protected], [email protected], g\n",
[]Recipient{{"[email protected]", EMAIL}, {"[email protected]", EMAIL}, {"[email protected]", EMAIL}}},
// Invalid pipe aliases, should be ignored.
{"a:|\n", []Recipient{{"[email protected]", EMAIL}}},
{"a:| \n", []Recipient{{"[email protected]", EMAIL}}},
}
for _, c := range cases {
......
This diff is collapsed.
......@@ -68,13 +68,13 @@ func (s *SMTP) Deliver(from string, to string, data []byte) (error, bool) {
a.from = ""
}
mxs, err := lookupMXs(a.tr, a.toDomain)
mxs, err, perm := lookupMXs(a.tr, a.toDomain)
if err != nil || len(mxs) == 0 {
// Note this is considered a permanent error.
// This is in line with what other servers (Exim) do. However, the
// downside is that temporary DNS issues can affect delivery, so we
// have to make sure we try hard enough on the lookup above.
return a.tr.Errorf("Could not find mail server: %v", err), true
return a.tr.Errorf("Could not find mail server: %v", err), perm
}
// Issue an EHLO with a valid domain; otherwise, some servers like postfix
......@@ -245,10 +245,10 @@ func (s *SMTP) fetchSTSPolicy(tr *trace.Trace, domain string) *sts.Policy {
return policy
}
func lookupMXs(tr *trace.Trace, domain string) ([]string, error) {
func lookupMXs(tr *trace.Trace, domain string) ([]string, error, bool) {
domain, err := idna.ToASCII(domain)
if err != nil {
return nil, err
return nil, err, true
}
mxs := []string{}
......@@ -260,14 +260,14 @@ func lookupMXs(tr *trace.Trace, domain string) ([]string, error) {
// Unfortunately, go's API doesn't let us easily distinguish between
// them. For now, if the error is permanent, we assume it's because
// there was no MX and fall back, otherwise we return.
// TODO: Find a better way to do this.
// TODO: Use dnsErr.IsNotFound once we can use Go >= 1.13.
dnsErr, ok := err.(*net.DNSError)
if !ok {
tr.Debugf("MX lookup error: %v", err)
return nil, err
return nil, err, true
} else if dnsErr.Temporary() {
tr.Debugf("temporary DNS error: %v", dnsErr)
return nil, err
return nil, err, false
}
// Permanent error, we assume MX does not exist and fall back to A.
......@@ -292,5 +292,5 @@ func lookupMXs(tr *trace.Trace, domain string) ([]string, error) {
}
tr.Debugf("MXs: %v", mxs)
return mxs, nil
return mxs, nil, true
}
......@@ -213,10 +213,13 @@ func TestTooManyMX(t *testing.T) {
{Host: "h3", Pref: 30}, {Host: "h4", Pref: 40},
{Host: "h5", Pref: 50}, {Host: "h5", Pref: 60},
}
mxs, err := lookupMXs(tr, "domain")
mxs, err, perm := lookupMXs(tr, "domain")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if perm != true {
t.Fatalf("expected perm == true")
}
if len(mxs) != 5 {
t.Errorf("expected len(mxs) == 5, got: %v", mxs)
}
......@@ -230,10 +233,13 @@ func TestFallbackToA(t *testing.T) {
IsTemporary: false,
}
mxs, err := lookupMXs(tr, "domain")
mxs, err, perm := lookupMXs(tr, "domain")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if perm != true {
t.Fatalf("expected perm == true")
}
if !(len(mxs) == 1 && mxs[0] == "domain") {
t.Errorf("expected mxs == [domain], got: %v", mxs)
}
......@@ -247,10 +253,13 @@ func TestTemporaryDNSerror(t *testing.T) {
IsTemporary: true,
}
mxs, err := lookupMXs(tr, "domain")
mxs, err, perm := lookupMXs(tr, "domain")
if !(mxs == nil && err == testMXErr["domain"]) {
t.Errorf("expected mxs == nil, err == test error, got: %v, %v", mxs, err)
}
if perm != false {
t.Errorf("expected perm == false")
}
}
func TestMXLookupError(t *testing.T) {
......@@ -258,19 +267,25 @@ func TestMXLookupError(t *testing.T) {
testMX["domain"] = nil
testMXErr["domain"] = fmt.Errorf("test error")
mxs, err := lookupMXs(tr, "domain")
mxs, err, perm := lookupMXs(tr, "domain")
if !(mxs == nil && err == testMXErr["domain"]) {
t.Errorf("expected mxs == nil, err == test error, got: %v, %v", mxs, err)
}
if perm != true {
t.Fatalf("expected perm == true")
}
}
func TestLookupInvalidDomain(t *testing.T) {
tr := trace.New("test", "test")
mxs, err := lookupMXs(tr, invalidDomain)
mxs, err, perm := lookupMXs(tr, invalidDomain)
if !(mxs == nil && err != nil) {
t.Errorf("expected err != nil, got: %v, %v", mxs, err)
}
if perm != true {
t.Fatalf("expected perm == true")
}
}
// TODO: Test STARTTLS negotiation.
// Code generated by protoc-gen-go.
// Code generated by protoc-gen-go. DO NOT EDIT.
// source: domaininfo.proto
// DO NOT EDIT!
/*
Package domaininfo is a generated protocol buffer package.
It is generated from these files:
domaininfo.proto
It has these top-level messages:
Domain
*/
package domaininfo
import proto "github.com/golang/protobuf/proto"
import fmt "fmt"
import math "math"
import (
fmt "fmt"
proto "github.com/golang/protobuf/proto"
math "math"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
......@@ -26,7 +18,7 @@ var _ = math.Inf
// is compatible with the proto package it is being compiled against.
// A compilation error at this line likely means your copy of the
// proto package needs to be updated.
const _ = proto.ProtoPackageIsVersion2 // please upgrade the proto package
const _ = proto.ProtoPackageIsVersion3 // please upgrade the proto package
type SecLevel int32
......@@ -47,6 +39,7 @@ var SecLevel_name = map[int32]string{
2: "TLS_INSECURE",
3: "TLS_SECURE",
}
var SecLevel_value = map[string]int32{
"PLAIN": 0,
"TLS_CLIENT": 1,
......@@ -57,40 +50,87 @@ var SecLevel_value = map[string]int32{
func (x SecLevel) String() string {
return proto.EnumName(SecLevel_name, int32(x))
}
func (SecLevel) EnumDescriptor() ([]byte, []int) { return fileDescriptor0, []int{0} }
func (SecLevel) EnumDescriptor() ([]byte, []int) {
return fileDescriptor_622326b6f7a15daa, []int{0}
}
type Domain struct {
Name string `protobuf:"bytes,1,opt,name=name" json:"name,omitempty"`
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
// Security level for mail coming from this domain (they send to us).
IncomingSecLevel SecLevel `protobuf:"varint,2,opt,name=incoming_sec_level,json=incomingSecLevel,enum=domaininfo.SecLevel" json:"incoming_sec_level,omitempty"`
IncomingSecLevel SecLevel `protobuf:"varint,2,opt,name=incoming_sec_level,json=incomingSecLevel,proto3,enum=domaininfo.SecLevel" json:"incoming_sec_level,omitempty"`
// Security level for mail going to this domain (we send to them).
OutgoingSecLevel SecLevel `protobuf:"varint,3,opt,name=outgoing_sec_level,json=outgoingSecLevel,enum=domaininfo.SecLevel" json:"outgoing_sec_level,omitempty"`
OutgoingSecLevel SecLevel `protobuf:"varint,3,opt,name=outgoing_sec_level,json=outgoingSecLevel,proto3,enum=domaininfo.SecLevel" json:"outgoing_sec_level,omitempty"`
XXX_NoUnkeyedLiteral struct{} `json:"-"`
XXX_unrecognized []byte `json:"-"`
XXX_sizecache int32 `json:"-"`
}
func (m *Domain) Reset() { *m = Domain{} }
func (m *Domain) String() string { return proto.CompactTextString(m) }
func (*Domain) ProtoMessage() {}
func (*Domain) Descriptor() ([]byte, []int) {
return fileDescriptor_622326b6f7a15daa, []int{0}
}
func (m *Domain) XXX_Unmarshal(b []byte) error {
return xxx_messageInfo_Domain.Unmarshal(m, b)
}
func (m *Domain) XXX_Marshal(b []byte, deterministic bool) ([]byte, error) {
return xxx_messageInfo_Domain.Marshal(b, m, deterministic)
}
func (m *Domain) XXX_Merge(src proto.Message) {
xxx_messageInfo_Domain.Merge(m, src)
}
func (m *Domain) XXX_Size() int {
return xxx_messageInfo_Domain.Size(m)
}
func (m *Domain) XXX_DiscardUnknown() {
xxx_messageInfo_Domain.DiscardUnknown(m)
}
func (m *Domain) Reset() { *m = Domain{} }
func (m *Domain) String() string { return proto.CompactTextString(m) }
func (*Domain) ProtoMessage() {}
func (*Domain) Descriptor() ([]byte, []int) { return fileDescriptor0, []int{0} }
var xxx_messageInfo_Domain proto.InternalMessageInfo
func (m *Domain) GetName() string {
if m != nil {
return m.Name
}
return ""
}
func (m *Domain) GetIncomingSecLevel() SecLevel {
if m != nil {
return m.IncomingSecLevel
}
return SecLevel_PLAIN
}
func (m *Domain) GetOutgoingSecLevel() SecLevel {
if m != nil {
return m.OutgoingSecLevel
}
return SecLevel_PLAIN
}
func init() {
proto.RegisterType((*Domain)(nil), "domaininfo.Domain")
proto.RegisterEnum("domaininfo.SecLevel", SecLevel_name, SecLevel_value)
proto.RegisterType((*Domain)(nil), "domaininfo.Domain")
}
func init() { proto.RegisterFile("domaininfo.proto", fileDescriptor0) }
func init() { proto.RegisterFile("domaininfo.proto", fileDescriptor_622326b6f7a15daa) }
var fileDescriptor0 = []byte{
// 189 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xe2, 0x12, 0x48, 0xc9, 0xcf, 0x4d,
var fileDescriptor_622326b6f7a15daa = []byte{
// 190 bytes of a gzipped FileDescriptorProto
0x1f, 0x8b, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0xff, 0xe2, 0x12, 0x48, 0xc9, 0xcf, 0x4d,
0xcc, 0xcc, 0xcb, 0xcc, 0x4b, 0xcb, 0xd7, 0x2b, 0x28, 0xca, 0x2f, 0xc9, 0x17, 0xe2, 0x42, 0x88,
0x28, 0x2d, 0x61, 0xe4, 0x62, 0x73, 0x01, 0x73, 0x85, 0x84, 0xb8, 0x58, 0xf2, 0x12, 0x73, 0x53,
0x25, 0x18, 0x15, 0x18, 0x35, 0x38, 0x83, 0xc0, 0x6c, 0x21, 0x27, 0x2e, 0xa1, 0xcc, 0xbc, 0xe4,
0xfc, 0xdc, 0xcc, 0xbc, 0xf4, 0xf8, 0xe2, 0xd4, 0xe4, 0xf8, 0x9c, 0xd4, 0xb2, 0xd4, 0x1c, 0x09,
0x26, 0xa0, 0x0a, 0x3e, 0x23, 0x11, 0x3d, 0x24, 0x93, 0x83, 0x53, 0x93, 0x7d, 0x40, 0x72, 0x41,
0x02, 0x30, 0xf5, 0x30, 0x11, 0x90, 0x19, 0xf9, 0xa5, 0x25, 0xe9, 0xf9, 0xa8, 0x66, 0x30, 0xe3,
0x33, 0x03, 0xa6, 0x1e, 0x26, 0xa2, 0xe5, 0xce, 0xc5, 0x01, 0x37, 0x8f, 0x93, 0x8b, 0x35, 0xc0,
0xc7, 0xd1, 0xd3, 0x4f, 0x80, 0x41, 0x88, 0x8f, 0x8b, 0x2b, 0xc4, 0x27, 0x38, 0xde, 0xd9, 0xc7,
0xd3, 0xd5, 0x2f, 0x44, 0x80, 0x51, 0x48, 0x80, 0x8b, 0x07, 0xc4, 0xf7, 0xf4, 0x0b, 0x76, 0x75,
0x0e, 0x0d, 0x72, 0x15, 0x60, 0x82, 0xa9, 0x80, 0xf2, 0x99, 0x93, 0xd8, 0xc0, 0x41, 0x60, 0x0c,
0x08, 0x00, 0x00, 0xff, 0xff, 0x2c, 0x78, 0x65, 0x5b, 0x16, 0x01, 0x00, 0x00,
0x26, 0x05, 0x46, 0x0d, 0x3e, 0x23, 0x11, 0x3d, 0x24, 0x93, 0x83, 0x53, 0x93, 0x7d, 0x40, 0x72,
0x41, 0x02, 0x30, 0xf5, 0x30, 0x11, 0x90, 0x19, 0xf9, 0xa5, 0x25, 0xe9, 0xf9, 0xa8, 0x66, 0x30,
0xe3, 0x33, 0x03, 0xa6, 0x1e, 0x26, 0xa2, 0xe5, 0xce, 0xc5, 0x01, 0x37, 0x8f, 0x93, 0x8b, 0x35,
0xc0, 0xc7, 0xd1, 0xd3, 0x4f, 0x80, 0x41, 0x88, 0x8f, 0x8b, 0x2b, 0xc4, 0x27, 0x38, 0xde, 0xd9,
0xc7, 0xd3, 0xd5, 0x2f, 0x44, 0x80, 0x51, 0x48, 0x80, 0x8b, 0x07, 0xc4, 0xf7, 0xf4, 0x0b, 0x76,
0x75, 0x0e, 0x0d, 0x72, 0x15, 0x60, 0x82, 0xa9, 0x80, 0xf2, 0x99, 0x93, 0xd8, 0xc0, 0x41, 0x60,
0x0c, 0x08, 0x00, 0x00, 0xff, 0xff, 0x2c, 0x78, 0x65, 0x5b, 0x16, 0x01, 0x00, 0x00,
}