mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
Currently, chasquid attempts to auto-detect dovecot sockets when starting up (if needed). If autodetection fails, chasquid emits an error, continues serving, and never tries again. This can be problematic if chasquid starts up before dovecot, and at the time the dovecot sockets are not present (e.g. after a reboot). In that case, chasquid will not use dovecot for authentication even after dovecot has started. This patch changes the autodetect logic, by doing autodetection at startup and on each request, until we find a working pair of sockets. Once we do, they're used consistently. That way, if dovecot is not ready when chasquid starts, it's not a problem and chasquid will start using dovecot once it becomes available. Thanks to Thor77 (thor77@thor77.org) for reporting and helping troubleshoot this issue.
293 lines
6.9 KiB
Go
293 lines
6.9 KiB
Go
// 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"
|
|
"sync"
|
|
"time"
|
|
"unicode"
|
|
)
|
|
|
|
// DefaultTimeout 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)")
|
|
errFailedToConnect = errors.New("failed to connect to dovecot")
|
|
errNoUserdbSocket = errors.New("unable to find userdb socket")
|
|
errNoClientSocket = errors.New("unable to find client socket")
|
|
)
|
|
|
|
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 {
|
|
addr struct {
|
|
mu *sync.Mutex
|
|
userdb string
|
|
client 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 {
|
|
a := &Auth{}
|
|
a.addr.mu = &sync.Mutex{}
|
|
a.addr.userdb = userdb
|
|
a.addr.client = client
|
|
a.Timeout = DefaultTimeout
|
|
return a
|
|
}
|
|
|
|
// String representation of this Auth, for human consumption.
|
|
func (a *Auth) String() string {
|
|
a.addr.mu.Lock()
|
|
defer a.addr.mu.Unlock()
|
|
return fmt.Sprintf("DovecotAuth(%q, %q)", a.addr.userdb, a.addr.client)
|
|
}
|
|
|
|
// Check to see if this auth is functional.
|
|
func (a *Auth) Check() error {
|
|
u, c, err := a.getAddrs()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !(a.canDial(u) && a.canDial(c)) {
|
|
return errFailedToConnect
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Exists returns true if the user exists, false otherwise.
|
|
func (a *Auth) Exists(user string) (bool, error) {
|
|
if !isUsernameSafe(user) {
|
|
return false, errUsernameNotSafe
|
|
}
|
|
|
|
userdbAddr, _, err := a.getAddrs()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
conn, err := a.dial("unix", 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") {
|
|
return true, nil
|
|
} else if strings.HasPrefix(resp, "NOTFOUND\t") {
|
|
return false, nil
|
|
}
|
|
return false, fmt.Errorf("invalid response: %q", resp)
|
|
}
|
|
|
|
// Authenticate returns true if the password is valid for the user, false
|
|
// otherwise.
|
|
func (a *Auth) Authenticate(user, passwd string) (bool, error) {
|
|
if !isUsernameSafe(user) {
|
|
return false, errUsernameNotSafe
|
|
}
|
|
|
|
_, clientAddr, err := a.getAddrs()
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
conn, err := a.dial("unix", 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)
|
|
}
|
|
|
|
// Reload the authenticator. It's a no-op for dovecot, but it is needed to
|
|
// conform with the auth.Backend interface.
|
|
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 := conn.W.Write([]byte(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
|
|
}
|
|
|
|
// getAddrs returns the addresses to the userdb and client sockets.
|
|
func (a *Auth) getAddrs() (string, string, error) {
|
|
a.addr.mu.Lock()
|
|
defer a.addr.mu.Unlock()
|
|
|
|
if a.addr.userdb == "" {
|
|
for _, u := range defaultUserdbPaths {
|
|
if a.canDial(u) {
|
|
a.addr.userdb = u
|
|
break
|
|
}
|
|
}
|
|
if a.addr.userdb == "" {
|
|
return "", "", errNoUserdbSocket
|
|
}
|
|
}
|
|
|
|
if a.addr.client == "" {
|
|
for _, c := range defaultClientPaths {
|
|
if a.canDial(c) {
|
|
a.addr.client = c
|
|
break
|
|
}
|
|
}
|
|
if a.addr.client == "" {
|
|
return "", "", errNoClientSocket
|
|
}
|
|
}
|
|
|
|
return a.addr.userdb, a.addr.client, nil
|
|
}
|
|
|
|
func (a *Auth) canDial(path string) bool {
|
|
conn, err := a.dial("unix", path)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
conn.Close()
|
|
return true
|
|
}
|