author | Alberto Bertogli
<albertito@blitiri.com.ar> 2017-12-09 21:35:27 UTC |
committer | Alberto Bertogli
<albertito@blitiri.com.ar> 2018-02-10 23:01:23 UTC |
parent | d4992ef8c579fccda015662976578e587ca6cca4 |
Makefile | +7 | -2 |
cmd/dovecot-auth-cli/.gitignore | +2 | -0 |
cmd/dovecot-auth-cli/dovecot-auth-cli.go | +36 | -0 |
cmd/dovecot-auth-cli/test.sh | +21 | -0 |
cmd/dovecot-auth-cli/test_auth_error.cmy | +21 | -0 |
cmd/dovecot-auth-cli/test_auth_no.cmy | +21 | -0 |
cmd/dovecot-auth-cli/test_auth_yes.cmy | +21 | -0 |
cmd/dovecot-auth-cli/test_exists_notfound.cmy | +16 | -0 |
cmd/dovecot-auth-cli/test_exists_yes.cmy | +15 | -0 |
cmd/dovecot-auth-cli/test_missing_socket.cmy | +8 | -0 |
internal/auth/auth_test.go | +2 | -0 |
internal/dovecot/dovecot.go | +276 | -0 |
internal/dovecot/dovecot_test.go | +111 | -0 |
diff --git a/Makefile b/Makefile index b1fd20e..17463aa 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ endif default: chasquid -all: chasquid chasquid-util smtp-check spf-check mda-lmtp +all: chasquid chasquid-util smtp-check spf-check mda-lmtp dovecot-auth-cli chasquid: @@ -33,11 +33,15 @@ spf-check: mda-lmtp: go build ${GOFLAGS} ./cmd/mda-lmtp/ +dovecot-auth-cli: + go build ${GOFLAGS} ./cmd/dovecot-auth-cli/ + test: go test ${GOFLAGS} ./... setsid -w ./test/run.sh setsid -w ./cmd/chasquid-util/test.sh setsid -w ./cmd/mda-lmtp/test.sh + setsid -w ./cmd/dovecot-auth-cli/test.sh install-binaries: chasquid chasquid-util smtp-check mda-lmtp @@ -54,4 +58,5 @@ install-config-skeleton: fi -.PHONY: chasquid chasquid-util smtp-check spf-check mda-lmtp test +.PHONY: chasquid test \ + chasquid-util smtp-check spf-check mda-lmtp dovecot-auth-cli diff --git a/cmd/dovecot-auth-cli/.gitignore b/cmd/dovecot-auth-cli/.gitignore new file mode 100644 index 0000000..ec06b5f --- /dev/null +++ b/cmd/dovecot-auth-cli/.gitignore @@ -0,0 +1,2 @@ +*.log +dovecot-auth-cli diff --git a/cmd/dovecot-auth-cli/dovecot-auth-cli.go b/cmd/dovecot-auth-cli/dovecot-auth-cli.go new file mode 100644 index 0000000..aee3a92 --- /dev/null +++ b/cmd/dovecot-auth-cli/dovecot-auth-cli.go @@ -0,0 +1,36 @@ +// CLI used for testing the dovecot authentication package. +// +// NOT for production use. +package main + +import ( + "fmt" + "os" + + "blitiri.com.ar/go/chasquid/internal/dovecot" +) + +func main() { + a := dovecot.NewAuth(os.Args[1]+"-userdb", os.Args[1]+"-client") + + var ok bool + var err error + + switch os.Args[2] { + case "exists": + ok, err = a.Exists(os.Args[3]) + case "auth": + ok, err = a.Authenticate(os.Args[3], os.Args[4]) + default: + fmt.Printf("unknown subcommand\n") + os.Exit(1) + } + + if ok { + fmt.Printf("yes\n") + return + } + + fmt.Printf("no: %v\n", err) + os.Exit(1) +} diff --git a/cmd/dovecot-auth-cli/test.sh b/cmd/dovecot-auth-cli/test.sh new file mode 100755 index 0000000..7a9a917 --- /dev/null +++ b/cmd/dovecot-auth-cli/test.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -e +. $(dirname ${0})/../../test/util/lib.sh + +init + +# Build the binary once, so we can use it and launch it in chamuyero scripts. +# Otherwise, we not only spend time rebuilding it over and over, but also "go +# run" masks the exit code, which is something we care about. +go build dovecot-auth-cli.go + +for i in *.cmy; do + if ! chamuyero $i > $i.log 2>&1 ; then + echo "# Test $i failed, log follows" + cat $i.log + exit 1 + fi +done + +success diff --git a/cmd/dovecot-auth-cli/test_auth_error.cmy b/cmd/dovecot-auth-cli/test_auth_error.cmy new file mode 100644 index 0000000..75bc37c --- /dev/null +++ b/cmd/dovecot-auth-cli/test_auth_error.cmy @@ -0,0 +1,21 @@ + +client unix_listen .dovecot-client + +c = ./dovecot-auth-cli .dovecot auth username password + +client -> VERSION 1 1 +client -> SPID 12345 +client -> CUID 12345 +client -> COOKIE lovelycookie +client -> MECH PLAIN +client -> MECH LOGIN +client -> DONE + +client <- VERSION 1 1 +client <~ CPID + +client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= +client -> OTHER + +c <~ no: invalid response +c wait 1 diff --git a/cmd/dovecot-auth-cli/test_auth_no.cmy b/cmd/dovecot-auth-cli/test_auth_no.cmy new file mode 100644 index 0000000..365e459 --- /dev/null +++ b/cmd/dovecot-auth-cli/test_auth_no.cmy @@ -0,0 +1,21 @@ + +client unix_listen .dovecot-client + +c = ./dovecot-auth-cli .dovecot auth username password + +client -> VERSION 1 1 +client -> SPID 12345 +client -> CUID 12345 +client -> COOKIE lovelycookie +client -> MECH PLAIN +client -> MECH LOGIN +client -> DONE + +client <- VERSION 1 1 +client <~ CPID + +client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= +client -> FAIL 1 + +c <- no: <nil> +c wait 1 diff --git a/cmd/dovecot-auth-cli/test_auth_yes.cmy b/cmd/dovecot-auth-cli/test_auth_yes.cmy new file mode 100644 index 0000000..63dbf77 --- /dev/null +++ b/cmd/dovecot-auth-cli/test_auth_yes.cmy @@ -0,0 +1,21 @@ + +client unix_listen .dovecot-client + +c = ./dovecot-auth-cli .dovecot auth username password + +client -> VERSION 1 1 +client -> SPID 12345 +client -> CUID 12345 +client -> COOKIE lovelycookie +client -> MECH PLAIN +client -> MECH LOGIN +client -> DONE + +client <- VERSION 1 1 +client <~ CPID + +client <- AUTH 1 PLAIN service=smtp secured no-penalty nologin resp=dXNlcm5hbWUAdXNlcm5hbWUAcGFzc3dvcmQ= +client -> OK 1 + +c <- yes +c wait 0 diff --git a/cmd/dovecot-auth-cli/test_exists_notfound.cmy b/cmd/dovecot-auth-cli/test_exists_notfound.cmy new file mode 100644 index 0000000..20309e6 --- /dev/null +++ b/cmd/dovecot-auth-cli/test_exists_notfound.cmy @@ -0,0 +1,16 @@ + +userdb unix_listen .dovecot-userdb + +c = ./dovecot-auth-cli .dovecot exists username + +userdb -> VERSION 1 1 +userdb -> SPID 12345 + +userdb <- VERSION 1 1 +userdb <- USER 1 username service=smtp + +userdb -> NOTFOUND 1 + +c wait 1 + +c <- no: <nil> diff --git a/cmd/dovecot-auth-cli/test_exists_yes.cmy b/cmd/dovecot-auth-cli/test_exists_yes.cmy new file mode 100644 index 0000000..6590928 --- /dev/null +++ b/cmd/dovecot-auth-cli/test_exists_yes.cmy @@ -0,0 +1,15 @@ + +userdb unix_listen .dovecot-userdb + +c = ./dovecot-auth-cli .dovecot exists username + +userdb -> VERSION 1 1 +userdb -> SPID 12345 + +userdb <- VERSION 1 1 +userdb <- USER 1 username service=smtp + +userdb -> USER 1 username system_groups_user=blah uid=10 gid=10 + +c <- yes +c wait 0 diff --git a/cmd/dovecot-auth-cli/test_missing_socket.cmy b/cmd/dovecot-auth-cli/test_missing_socket.cmy new file mode 100644 index 0000000..a677964 --- /dev/null +++ b/cmd/dovecot-auth-cli/test_missing_socket.cmy @@ -0,0 +1,8 @@ + +c = ./dovecot-auth-cli .missingsocket exists username +c <~ no: dial unix .missingsocket-userdb +c wait 1 + +c = ./dovecot-auth-cli .missingsocket auth username password +c <~ no: dial unix .missingsocket-client +c wait 1 diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 95c3eb5..8ebc70f 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + "blitiri.com.ar/go/chasquid/internal/dovecot" "blitiri.com.ar/go/chasquid/internal/userdb" ) @@ -119,6 +120,7 @@ func check(t *testing.T, a *Authenticator, user, domain, passwd string, expect b func TestInterfaces(t *testing.T) { var _ NoErrorBackend = userdb.New("/dev/null") + var _ Backend = dovecot.NewAuth("/dev/null", "/dev/null") } // Backend implementation for testing. diff --git a/internal/dovecot/dovecot.go b/internal/dovecot/dovecot.go new file mode 100644 index 0000000..9a7f26e --- /dev/null +++ b/internal/dovecot/dovecot.go @@ -0,0 +1,276 @@ +// Package dovecot implements functions to interact with Dovecot's +// authentication service. +// +// In particular, it supports doing user authorization, and checking if a user +// exists. It is a very basic implementation, with only the minimum needed to +// cover chasquid's needs. +// +// https://wiki.dovecot.org/Design/AuthProtocol +// https://wiki.dovecot.org/Services#auth +package dovecot + +import ( + "encoding/base64" + "errors" + "fmt" + "net" + "net/textproto" + "os" + "strings" + "time" + "unicode" +) + +// Default timeout to use. We expect Dovecot to be quite fast, but don't want +// to hang forever if something gets stuck. +const DefaultTimeout = 5 * time.Second + +var ( + ErrUsernameNotSafe = errors.New("username not safe (contains spaces)") +) + +var defaultUserdbPaths = []string{ + "/var/run/dovecot/auth-chasquid-userdb", + "/var/run/dovecot/auth-userdb", +} + +var defaultClientPaths = []string{ + "/var/run/dovecot/auth-chasquid-client", + "/var/run/dovecot/auth-client", +} + +// Auth represents a particular Dovecot auth service to use. +type Auth struct { + userdbAddr string + clientAddr string + + // Timeout for connection and I/O operations (applies on each call). + // Set to DefaultTimeout by NewAuth. + Timeout time.Duration +} + +// NewAuth returns a new connection against Dovecot authentication service. It +// takes the addresses of userdb and client sockets (usually paths as +// configured in dovecot). +func NewAuth(userdb, client string) *Auth { + return &Auth{ + userdbAddr: userdb, + clientAddr: client, + Timeout: DefaultTimeout, + } +} + +func (a *Auth) String() string { + return fmt.Sprintf("DovecotAuth(%q, %q)", a.userdbAddr, a.clientAddr) +} + +// Check to see if this auth is valid (but may not be working). +func (a *Auth) Check() error { + // We intentionally don't connect or complete any handshakes because + // dovecot may not be up yet, even thought it may be configured properly. + // Just check that the addresses are valid sockets. + if !isUnixSocket(a.userdbAddr) { + return fmt.Errorf("userdb is not an unix socket") + } + if !isUnixSocket(a.clientAddr) { + return fmt.Errorf("client is not an unix socket") + } + + return nil +} + +// Does user exist? +func (a *Auth) Exists(user string) (bool, error) { + if !isUsernameSafe(user) { + return false, ErrUsernameNotSafe + } + + conn, err := a.dial("unix", a.userdbAddr) + if err != nil { + return false, err + } + defer conn.Close() + + // Dovecot greets us with version and server pid. + // VERSION\t<major>\t<minor> + // SPID\t<pid> + err = expect(conn, "VERSION\t1") + if err != nil { + return false, fmt.Errorf("error receiving version: %v", err) + } + err = expect(conn, "SPID\t") + if err != nil { + return false, fmt.Errorf("error receiving SPID: %v", err) + } + + // Send our version, and then the request. + err = write(conn, "VERSION\t1\t1\n") + if err != nil { + return false, err + } + + err = write(conn, fmt.Sprintf("USER\t1\t%s\tservice=smtp\n", user)) + if err != nil { + return false, err + } + + // Get the response, and we're done. + resp, err := conn.ReadLine() + if err != nil { + return false, fmt.Errorf("error receiving response: %v", err) + } else if strings.HasPrefix(resp, "USER\t1\t"+user+"\t") { + return true, nil + } else if strings.HasPrefix(resp, "NOTFOUND\t") { + return false, nil + } + return false, fmt.Errorf("invalid response: %q", resp) +} + +// Is the password valud for the user? +func (a *Auth) Authenticate(user, passwd string) (bool, error) { + if !isUsernameSafe(user) { + return false, ErrUsernameNotSafe + } + + conn, err := a.dial("unix", a.clientAddr) + if err != nil { + return false, err + } + defer conn.Close() + + // Send our version, and then our PID. + err = write(conn, fmt.Sprintf("VERSION\t1\t1\nCPID\t%d\n", os.Getpid())) + if err != nil { + return false, err + } + + // Read the server-side handshake. We don't care about the contents + // really, so just read all lines until we see the DONE. + for { + resp, err := conn.ReadLine() + if err != nil { + return false, fmt.Errorf("error receiving handshake: %v", err) + } + if resp == "DONE" { + break + } + } + + // We only support PLAIN authentication, so construct the request. + // Note we set the "secured" option, with the assumpition that we got the + // password via a secure channel (like TLS). This is always true for + // chasquid by design, and simplifies the API. + // TODO: does dovecot handle utf8 domains well? do we need to encode them + // in IDNA first? + resp := base64.StdEncoding.EncodeToString( + []byte(fmt.Sprintf("%s\x00%s\x00%s", user, user, passwd))) + err = write(conn, fmt.Sprintf( + "AUTH\t1\tPLAIN\tservice=smtp\tsecured\tno-penalty\tnologin\tresp=%s\n", resp)) + if err != nil { + return false, err + } + + // Get the response, and we're done. + resp, err = conn.ReadLine() + if err != nil { + return false, fmt.Errorf("error receiving response: %v", err) + } else if strings.HasPrefix(resp, "OK\t1") { + return true, nil + } else if strings.HasPrefix(resp, "FAIL\t1") { + return false, nil + } + return false, fmt.Errorf("invalid response: %q", resp) +} + +func (a *Auth) Reload() error { + return nil +} + +func (a *Auth) dial(network, addr string) (*textproto.Conn, error) { + nc, err := net.DialTimeout(network, addr, a.Timeout) + if err != nil { + return nil, err + } + + nc.SetDeadline(time.Now().Add(a.Timeout)) + + return textproto.NewConn(nc), nil +} + +func expect(conn *textproto.Conn, prefix string) error { + resp, err := conn.ReadLine() + if err != nil { + return err + } + if !strings.HasPrefix(resp, prefix) { + return fmt.Errorf("got %q", resp) + } + return nil +} + +func write(conn *textproto.Conn, msg string) error { + _, err := fmt.Fprintf(conn.W, msg) + if err != nil { + return err + } + + return conn.W.Flush() +} + +// isUsernameSafe to use in the dovecot protocol? +// Unfotunately dovecot's protocol is not very robust wrt. whitespace, +// so we need to be careful. +func isUsernameSafe(user string) bool { + for _, r := range user { + if unicode.IsSpace(r) { + return false + } + } + return true +} + +// Autodetect where the dovecot authentication paths are, and return an Auth +// instance for them. If any of userdb or client are != "", they will be used +// and not autodetected. +func Autodetect(userdb, client string) *Auth { + // If both are given, no need to autodtect. + if userdb != "" && client != "" { + return NewAuth(userdb, client) + } + + var userdbs, clients []string + if userdb != "" { + userdbs = append(userdbs, userdb) + } + if client != "" { + clients = append(clients, client) + } + + if len(userdbs) == 0 { + userdbs = append(userdbs, defaultUserdbPaths...) + } + + if len(clients) == 0 { + clients = append(clients, defaultClientPaths...) + } + + // Go through each possiblity, return the first auth that works. + for _, u := range userdbs { + for _, c := range clients { + a := NewAuth(u, c) + if a.Check() == nil { + return a + } + } + } + + return nil +} + +func isUnixSocket(path string) bool { + fi, err := os.Stat(path) + if err != nil { + return false + } + return fi.Mode()&os.ModeSocket != 0 +} diff --git a/internal/dovecot/dovecot_test.go b/internal/dovecot/dovecot_test.go new file mode 100644 index 0000000..3fbba86 --- /dev/null +++ b/internal/dovecot/dovecot_test.go @@ -0,0 +1,111 @@ +package dovecot + +// The dovecot package is mainly tested via integration/external tests using +// the dovecot-auth-cli tool. See cmd/dovecot-auth-cli for more details. +// The tests here are more narrow and only test specific functionality that is +// easier to cover from Go. + +import ( + "net" + "testing" + + "blitiri.com.ar/go/chasquid/internal/testlib" +) + +func TestUsernameNotSafe(t *testing.T) { + a := NewAuth("/tmp/nothing", "/tmp/nothing") + + cases := []string{ + "a b", " ab", "ab ", "a\tb", "a\t", " ", "\t", "\t "} + for _, c := range cases { + ok, err := a.Authenticate(c, "passwd") + if ok || err != ErrUsernameNotSafe { + t.Errorf("Authenticate(%q, _): got %v, %v", c, ok, err) + } + + ok, err = a.Exists(c) + if ok || err != ErrUsernameNotSafe { + t.Errorf("Exists(%q): got %v, %v", c, ok, err) + } + } +} + +func TestAutodetect(t *testing.T) { + // If we give both parameters to autodetect, it should return a new Auth + // using them, even if they're not valid. + a := Autodetect("uDoesNotExist", "cDoesNotExist") + if a == nil { + t.Errorf("Autodetection with two params failed") + } else if *a != *NewAuth("uDoesNotExist", "cDoesNotExist") { + t.Errorf("Autodetection with two params: got %v", a) + } + + // We override the default paths, so we can point the "defaults" to our + // test environment as needed. + defaultUserdbPaths = []string{"/dev/null"} + defaultClientPaths = []string{"/dev/null"} + + // Autodetect failure: no valid sockets on the list. + a = Autodetect("", "") + if a != nil { + t.Errorf("Autodetection worked with only /dev/null, got %v", a) + } + + // Create a temporary directory, and two sockets on it. + dir := testlib.MustTempDir(t) + defer testlib.RemoveIfOk(t, dir) + + userdb := dir + "/userdb" + client := dir + "/client" + + uL := mustListen(t, userdb) + cL := mustListen(t, client) + + defaultUserdbPaths = append(defaultUserdbPaths, userdb) + defaultClientPaths = append(defaultClientPaths, client) + + // Autodetect should work fine against open sockets. + a = Autodetect("", "") + if a == nil { + t.Errorf("Autodetection failed (open sockets)") + } else if a.userdbAddr != userdb || a.clientAddr != client { + t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}", + userdb, client, a.userdbAddr, a.clientAddr) + } + + // TODO: Close the two sockets, and re-do the test from above: Autodetect + // should work fine against closed sockets. + // To implement this test, we should call SetUnlinkOnClose, but + // unfortunately that is only available in Go >= 1.8. + // We want to support Go 1.7 for a while as it is in Debian stable; once + // Debian stable moves on, we can implement this test easily. + + // Autodetect should pick the suggestions passed as parameters (if + // possible). + defaultUserdbPaths = []string{"/dev/null"} + defaultClientPaths = []string{"/dev/null", client} + a = Autodetect(userdb, "") + if a == nil { + t.Errorf("Autodetection failed (single parameter)") + } else if a.userdbAddr != userdb || a.clientAddr != client { + t.Errorf("Expected autodetect to pick {%q, %q}, but got {%q, %q}", + userdb, client, a.userdbAddr, a.clientAddr) + } + + uL.Close() + cL.Close() +} + +func mustListen(t *testing.T, path string) *net.UnixListener { + addr, err := net.ResolveUnixAddr("unix", path) + if err != nil { + t.Fatalf("failed to resolve unix addr %q: %v", path, err) + } + + l, err := net.ListenUnix("unix", addr) + if err != nil { + t.Fatalf("failed to listen on %q: %v", path, err) + } + + return l +}