git » chasquid » commit f399fe3

aliases: Implement aliases hooks

author Alberto Bertogli
2019-10-22 21:05:09 UTC
committer Alberto Bertogli
2019-10-24 20:37:09 UTC
parent dea6f73164aff9f373b9d34ceb010b8050c255e6

aliases: Implement aliases hooks

This patch implements two new hooks: alias-resolve and alias-exists.

They are called during the aliases resolution process, to allow for more
complex integration with other systems, such as storing the aliases in a
database.

See the included documentation for more details.

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