Commit f399fe3e authored by Alberto Bertogli's avatar Alberto Bertogli

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.
parent dea6f731
......@@ -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)
......
......@@ -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
# 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.
......@@ -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
}
......@@ -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,
......
#!/bin/bash
case "$1" in
"vicuñ[email protected]")
exit 0
;;
"ñandú@testserver")
exit 0
;;
"[email protected]")
exit 0
;;
esac
exit 1
#!/bin/bash
case "$1" in
"vicuñ[email protected]")
# Test one naked, one full. These exist in the static aliases file.
echo pepe, [email protected]
;;
"ñandú@testserver")
echo "| writemailto ../.data/pipe_alias_worked"
;;
"[email protected]")
exit 1
;;
esac
......@@ -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 [email protected] < 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 [email protected] < content 2> .logs/msmtp.out; then
echo "expected delivery to [email protected] to fail, but succeeded"
fi
# Test a non-existent alias.
if run_msmtp [email protected] < content 2> .logs/msmtp.out; then
echo "expected delivery to [email protected] 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
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment