author | Alberto Bertogli
<albertito@blitiri.com.ar> 2019-10-22 21:05:09 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2019-10-24 20:37:09 UTC |
parent | dea6f73164aff9f373b9d34ceb010b8050c255e6 |
chasquid.go | +1 | -1 |
docs/aliases.md | +11 | -0 |
docs/hooks.md | +41 | -2 |
internal/aliases/aliases.go | +132 | -30 |
internal/smtpsrv/server.go | +8 | -3 |
test/t-04-aliases/alias-exists-hook | +15 | -0 |
test/t-04-aliases/alias-resolve-hook | +14 | -0 |
test/t-04-aliases/run.sh | +31 | -0 |
diff --git a/chasquid.go b/chasquid.go index fbd1313..1f35af8 100644 --- a/chasquid.go +++ b/chasquid.go @@ -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) diff --git a/docs/aliases.md b/docs/aliases.md index 63b5944..b2bdf8a 100644 --- a/docs/aliases.md +++ b/docs/aliases.md @@ -77,5 +77,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 diff --git a/docs/hooks.md b/docs/hooks.md index d84442b..c073a91 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -1,5 +1,13 @@ -# 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. diff --git a/internal/aliases/aliases.go b/internal/aliases/aliases.go index 156e1d0..df2e1f7 100644 --- a/internal/aliases/aliases.go +++ b/internal/aliases/aliases.go @@ -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,35 +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:]) - if cmd == "" { - // A pipe alias without a command is invalid. - continue - } - 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 { @@ -361,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 +} diff --git a/internal/smtpsrv/server.go b/internal/smtpsrv/server.go index dec8e13..882f9e3 100644 --- a/internal/smtpsrv/server.go +++ b/internal/smtpsrv/server.go @@ -7,6 +7,7 @@ import ( "net" "net/http" "net/textproto" + "path" "time" "blitiri.com.ar/go/chasquid/internal/aliases" @@ -67,8 +68,8 @@ type Server struct { // Queue where we put incoming mail. queue *queue.Queue - // Path to the Post-DATA hook. - PostDataHook string + // Path to the hooks. + HookPath string } // NewServer returns a new empty Server. @@ -130,6 +131,8 @@ func (s *Server) SetAuthFallback(be auth.Backend) { func (s *Server) SetAliasesConfig(suffixSep, dropChars string) { s.aliasesR.SuffixSep = suffixSep s.aliasesR.DropChars = dropChars + s.aliasesR.ResolveHook = path.Join(s.HookPath, "alias-resolve") + s.aliasesR.ExistsHook = path.Join(s.HookPath, "alias-exists") } // InitDomainInfo initializes the domain info database. @@ -231,6 +234,8 @@ func (s *Server) serve(l net.Listener, mode SocketMode) { l = tls.NewListener(l, s.tlsConfig) } + pdhook := path.Join(s.HookPath, "post-data") + for { conn, err := l.Accept() if err != nil { @@ -240,7 +245,7 @@ func (s *Server) serve(l net.Listener, mode SocketMode) { sc := &Conn{ hostname: s.Hostname, maxDataSize: s.MaxDataSize, - postDataHook: s.PostDataHook, + postDataHook: pdhook, conn: conn, tc: textproto.NewConn(conn), mode: mode, diff --git a/test/t-04-aliases/alias-exists-hook b/test/t-04-aliases/alias-exists-hook new file mode 100755 index 0000000..770937c --- /dev/null +++ b/test/t-04-aliases/alias-exists-hook @@ -0,0 +1,15 @@ +#!/bin/bash + +case "$1" in +"vicuña@testserver") + exit 0 + ;; +"ñandú@testserver") + exit 0 + ;; +"roto@testserver") + exit 0 + ;; +esac + +exit 1 diff --git a/test/t-04-aliases/alias-resolve-hook b/test/t-04-aliases/alias-resolve-hook new file mode 100755 index 0000000..c73df5a --- /dev/null +++ b/test/t-04-aliases/alias-resolve-hook @@ -0,0 +1,14 @@ +#!/bin/bash + +case "$1" in +"vicuña@testserver") + # Test one naked, one full. These exist in the static aliases file. + echo pepe, joan@testserver + ;; +"ñandú@testserver") + echo "| writemailto ../.data/pipe_alias_worked" + ;; +"roto@testserver") + exit 1 + ;; +esac diff --git a/test/t-04-aliases/run.sh b/test/t-04-aliases/run.sh index b8c8227..52c5aba 100755 --- a/test/t-04-aliases/run.sh +++ b/test/t-04-aliases/run.sh @@ -22,6 +22,10 @@ function send_and_check() { done } +# Remove the hooks that could be left over from previous failed tests. +rm -f config/hooks/alias-resolve +rm -f config/hooks/alias-exists + # Test email aliases. send_and_check pepe jose send_and_check joan juan @@ -39,5 +43,32 @@ run_msmtp tubo@testserver < content wait_for_file .data/pipe_alias_worked mail_diff content .data/pipe_alias_worked +# Set up the hooks. +mkdir -p config/hooks/ +cp alias-exists-hook config/hooks/alias-exists +cp alias-resolve-hook config/hooks/alias-resolve + +# Test email aliases. +send_and_check vicuña juan jose + +# Test the pipe alias separately. +rm -f .data/pipe_alias_worked +run_msmtp ñandú@testserver < content +wait_for_file .data/pipe_alias_worked +mail_diff content .data/pipe_alias_worked + +# Test when alias-resolve exits with an error +if run_msmtp roto@testserver < content 2> .logs/msmtp.out; then + echo "expected delivery to roto@ to fail, but succeeded" +fi + +# Test a non-existent alias. +if run_msmtp nono@testserver < content 2> .logs/msmtp.out; then + echo "expected delivery to nono@ to fail, but succeeded" +fi + +# Remove the hooks, leave a clean state. +rm -f config/hooks/alias-resolve +rm -f config/hooks/alias-exists success