383 lines
10 KiB
Go
383 lines
10 KiB
Go
// chasquid is an SMTP (email) server, with a focus on simplicity, security,
|
|
// and ease of operation.
|
|
//
|
|
// See https://blitiri.com.ar/p/chasquid for more details.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
std_smtp "net/smtp"
|
|
"os"
|
|
"os/signal"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"blitiri.com.ar/go/chasquid/internal/config"
|
|
"blitiri.com.ar/go/chasquid/internal/courier"
|
|
"blitiri.com.ar/go/chasquid/internal/domaininfo"
|
|
"blitiri.com.ar/go/chasquid/internal/dovecot"
|
|
"blitiri.com.ar/go/chasquid/internal/envelope"
|
|
"blitiri.com.ar/go/chasquid/internal/localrpc"
|
|
"blitiri.com.ar/go/chasquid/internal/maillog"
|
|
"blitiri.com.ar/go/chasquid/internal/normalize"
|
|
"blitiri.com.ar/go/chasquid/internal/smtpsrv"
|
|
"blitiri.com.ar/go/chasquid/internal/sts"
|
|
"blitiri.com.ar/go/log"
|
|
"blitiri.com.ar/go/systemd"
|
|
)
|
|
|
|
// Command-line flags.
|
|
var (
|
|
configDir = flag.String("config_dir", "/etc/chasquid",
|
|
"configuration directory")
|
|
configOverrides = flag.String("config_overrides", "",
|
|
"override configuration values (in text protobuf format)")
|
|
showVer = flag.Bool("version", false, "show version and exit")
|
|
)
|
|
|
|
func main() {
|
|
flag.Parse()
|
|
log.Init()
|
|
|
|
parseVersionInfo()
|
|
if *showVer {
|
|
fmt.Printf("chasquid %s (source date: %s)\n", version, sourceDate)
|
|
return
|
|
}
|
|
|
|
log.Infof("chasquid starting (version %s)", version)
|
|
|
|
conf, err := config.Load(*configDir+"/chasquid.conf", *configOverrides)
|
|
if err != nil {
|
|
log.Fatalf("Error loading config: %v", err)
|
|
}
|
|
config.LogConfig(conf)
|
|
|
|
// Change to the config dir.
|
|
// This allow us to use relative paths for configuration directories.
|
|
// It also can be useful in unusual environments and for testing purposes,
|
|
// where paths inside the configuration itself could be relative, and this
|
|
// fixes the point of reference.
|
|
err = os.Chdir(*configDir)
|
|
if err != nil {
|
|
log.Fatalf("Error changing to config dir %q: %v", *configDir, err)
|
|
}
|
|
|
|
initMailLog(conf.MailLogPath)
|
|
|
|
if conf.MonitoringAddress != "" {
|
|
go launchMonitoringServer(conf)
|
|
}
|
|
|
|
s := smtpsrv.NewServer()
|
|
s.Hostname = conf.Hostname
|
|
s.MaxDataSize = conf.MaxDataSizeMb * 1024 * 1024
|
|
s.HookPath = "hooks/"
|
|
s.HAProxyEnabled = conf.HaproxyIncoming
|
|
|
|
s.SetAliasesConfig(*conf.SuffixSeparators, *conf.DropCharacters)
|
|
|
|
if conf.DovecotAuth {
|
|
loadDovecot(s, conf.DovecotUserdbPath, conf.DovecotClientPath)
|
|
}
|
|
|
|
// Load certificates from "certs/<directory>/{fullchain,privkey}.pem".
|
|
// The structure matches letsencrypt's, to make it easier for that case.
|
|
log.Infof("Loading certificates:")
|
|
for _, info := range mustReadDir("certs/") {
|
|
if info.Type().IsRegular() {
|
|
// Ignore regular files, we only care about directories.
|
|
continue
|
|
}
|
|
|
|
name := info.Name()
|
|
dir := filepath.Join("certs/", name)
|
|
loadCert(name, dir, s)
|
|
}
|
|
|
|
// Load domains from "domains/".
|
|
log.Infof("Loading domains:")
|
|
for _, info := range mustReadDir("domains/") {
|
|
domain, err := normalize.Domain(info.Name())
|
|
if err != nil {
|
|
log.Fatalf("Invalid name %+q: %v", info.Name(), err)
|
|
}
|
|
if info.Type().IsRegular() {
|
|
// Ignore regular files, we only care about directories.
|
|
continue
|
|
}
|
|
dir := filepath.Join("domains", info.Name())
|
|
loadDomain(domain, dir, s)
|
|
}
|
|
|
|
// Always include localhost as local domain.
|
|
// This can prevent potential trouble if we were to accidentally treat it
|
|
// as a remote domain (for loops, alias resolutions, etc.).
|
|
s.AddDomain("localhost")
|
|
|
|
dinfo, err := domaininfo.New(conf.DataDir + "/domaininfo")
|
|
if err != nil {
|
|
log.Fatalf("Error opening domain info database: %v", err)
|
|
}
|
|
s.SetDomainInfo(dinfo)
|
|
|
|
stsCache, err := sts.NewCache(conf.DataDir + "/sts-cache")
|
|
if err != nil {
|
|
log.Fatalf("Failed to initialize STS cache: %v", err)
|
|
}
|
|
go stsCache.PeriodicallyRefresh(context.Background())
|
|
|
|
localC := &courier.MDA{
|
|
Binary: conf.MailDeliveryAgentBin,
|
|
Args: conf.MailDeliveryAgentArgs,
|
|
Timeout: 30 * time.Second,
|
|
}
|
|
remoteC := &courier.SMTP{
|
|
HelloDomain: conf.Hostname,
|
|
Dinfo: dinfo,
|
|
STSCache: stsCache,
|
|
Relay: conf.SmtpRelay,
|
|
Auth: mapAuth(conf.Auth),
|
|
}
|
|
|
|
s.InitQueue(conf.DataDir+"/queue", localC, remoteC)
|
|
s.SetQueueLimits(conf.MaxQueueItems, conf.GiveUpSendAfterDuration())
|
|
|
|
// Load the addresses and listeners.
|
|
systemdLs, err := systemd.Listeners()
|
|
if err != nil {
|
|
log.Fatalf("Error getting systemd listeners: %v", err)
|
|
}
|
|
|
|
naddr := loadAddresses(s, conf.SmtpAddress,
|
|
systemdLs["smtp"], smtpsrv.ModeSMTP)
|
|
naddr += loadAddresses(s, conf.SubmissionAddress,
|
|
systemdLs["submission"], smtpsrv.ModeSubmission)
|
|
naddr += loadAddresses(s, conf.SubmissionOverTlsAddress,
|
|
systemdLs["submission_tls"], smtpsrv.ModeSubmissionTLS)
|
|
|
|
if naddr == 0 {
|
|
log.Fatalf("No address to listen on")
|
|
}
|
|
|
|
go localrpc.DefaultServer.ListenAndServe(conf.DataDir + "/localrpc-v1")
|
|
|
|
go signalHandler(dinfo, s)
|
|
|
|
s.ListenAndServe()
|
|
}
|
|
|
|
type mapAuth map[string]*config.Config_Auth
|
|
|
|
func (m mapAuth) Auth(from, to string, data []byte) std_smtp.Auth {
|
|
log.Infof("locating auth for %v to %v using domain %v", from, to, envelope.DomainOf(from))
|
|
if m == nil {
|
|
log.Infof("m is nil, no auth available")
|
|
return nil
|
|
}
|
|
if a, ok := m[envelope.DomainOf(from)]; ok {
|
|
log.Infof("using %v for auth", a)
|
|
return std_smtp.PlainAuth(a.Domain, a.Username, a.Password, "")
|
|
} else if a, ok := m[""]; ok {
|
|
log.Infof("no auth for %v", envelope.DomainOf(from))
|
|
log.Infof("using %v for auth", a)
|
|
return std_smtp.PlainAuth(a.Domain, a.Username, a.Password, "")
|
|
}
|
|
log.Infof("no auth for %v", envelope.DomainOf(from))
|
|
log.Infof("no default auth")
|
|
log.Infof("not using auth")
|
|
return nil
|
|
}
|
|
|
|
func loadAddresses(srv *smtpsrv.Server, addrs []string, ls []net.Listener, mode smtpsrv.SocketMode) int {
|
|
naddr := 0
|
|
for _, addr := range addrs {
|
|
if addr == "" {
|
|
// An empty address is invalid, to prevent accidental
|
|
// misconfiguration.
|
|
log.Errorf("Invalid empty listening address for %v", mode)
|
|
log.Fatalf("If you want to disable %v, remove it from the config", mode)
|
|
} else if addr == "systemd" {
|
|
// The "systemd" address indicates we get listeners via systemd.
|
|
srv.AddListeners(ls, mode)
|
|
naddr += len(ls)
|
|
} else {
|
|
srv.AddAddr(addr, mode)
|
|
naddr++
|
|
}
|
|
}
|
|
|
|
if naddr == 0 {
|
|
log.Errorf("Warning: No %v addresses/listeners", mode)
|
|
log.Errorf("If using systemd, check that you named the sockets")
|
|
}
|
|
return naddr
|
|
}
|
|
|
|
func initMailLog(path string) {
|
|
var err error
|
|
|
|
switch path {
|
|
case "<syslog>":
|
|
maillog.Default, err = maillog.NewSyslog()
|
|
case "<stdout>":
|
|
maillog.Default = maillog.New(os.Stdout)
|
|
case "<stderr>":
|
|
maillog.Default = maillog.New(os.Stderr)
|
|
default:
|
|
_ = os.MkdirAll(filepath.Dir(path), 0775)
|
|
maillog.Default, err = maillog.NewFile(path)
|
|
}
|
|
|
|
if err != nil {
|
|
log.Fatalf("Error opening mail log: %v", err)
|
|
}
|
|
}
|
|
|
|
func signalHandler(dinfo *domaininfo.DB, srv *smtpsrv.Server) {
|
|
var err error
|
|
|
|
signals := make(chan os.Signal, 1)
|
|
signal.Notify(signals, syscall.SIGHUP, syscall.SIGTERM, syscall.SIGINT)
|
|
|
|
for {
|
|
switch sig := <-signals; sig {
|
|
case syscall.SIGHUP:
|
|
log.Infof("Received SIGHUP, reloading")
|
|
|
|
// SIGHUP triggers a reopen of the log files. This is used for log
|
|
// rotation.
|
|
err = log.Default.Reopen()
|
|
if err != nil {
|
|
log.Fatalf("Error reopening log: %v", err)
|
|
}
|
|
|
|
err = maillog.Default.Reopen()
|
|
if err != nil {
|
|
log.Fatalf("Error reopening maillog: %v", err)
|
|
}
|
|
|
|
// We don't want to reload the domain info database periodically,
|
|
// as it can be expensive, and it is not expected that the user
|
|
// changes this behind chasquid's back.
|
|
err = dinfo.Reload()
|
|
if err != nil {
|
|
log.Fatalf("Error reloading domain info: %v", err)
|
|
}
|
|
|
|
// Also trigger a server reload.
|
|
srv.Reload()
|
|
case syscall.SIGTERM, syscall.SIGINT:
|
|
log.Infof("Got signal to exit: %v", sig)
|
|
// Ideally, we would shutdown the server gracefully, but for now
|
|
// we just exit. Note that in practice this is not a significant
|
|
// problem, as any in-flight transaction should be retried.
|
|
os.Exit(0)
|
|
default:
|
|
log.Errorf("Unexpected signal %v", sig)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Helper to load a single certificate configuration into the server.
|
|
func loadCert(name, dir string, s *smtpsrv.Server) {
|
|
log.Infof(" %s", name)
|
|
|
|
// Ignore directories that don't have both keys.
|
|
// We warn about this because it can be hard to debug otherwise.
|
|
certPath := filepath.Join(dir, "fullchain.pem")
|
|
if _, err := os.Stat(certPath); err != nil {
|
|
log.Infof(" skipping: %v", err)
|
|
return
|
|
}
|
|
keyPath := filepath.Join(dir, "privkey.pem")
|
|
if _, err := os.Stat(keyPath); err != nil {
|
|
log.Infof(" skipping: %v", err)
|
|
return
|
|
}
|
|
|
|
err := s.AddCerts(certPath, keyPath)
|
|
if err != nil {
|
|
log.Fatalf(" %v", err)
|
|
}
|
|
}
|
|
|
|
// Helper to load a single domain configuration into the server.
|
|
func loadDomain(name, dir string, s *smtpsrv.Server) {
|
|
s.AddDomain(name)
|
|
|
|
nusers, err := s.AddUserDB(name, dir+"/users")
|
|
if err != nil {
|
|
// If there is an error loading users, fail hard to make sure this is
|
|
// noticed and fixed as soon as it happens.
|
|
log.Fatalf(" %s: users file error: %v", name, err)
|
|
}
|
|
|
|
naliases, err := s.AddAliasesFile(name, dir+"/aliases")
|
|
if err != nil {
|
|
// If there's an error loading aliases, fail hard to make sure this is
|
|
// noticed and fixed as soon as it happens.
|
|
log.Fatalf(" %s: aliases file error: %v", name, err)
|
|
}
|
|
|
|
ndkim, err := loadDKIM(name, dir, s)
|
|
if err != nil {
|
|
// DKIM errors are fatal because if the user set DKIM up, then we
|
|
// don't want it to be failing silently, as that could cause
|
|
// deliverability issues.
|
|
log.Fatalf(" %s: DKIM loading error: %v", name, err)
|
|
}
|
|
|
|
log.Infof(" %s (%d users, %d aliases, %d DKIM keys)",
|
|
name, nusers, naliases, ndkim)
|
|
}
|
|
|
|
func loadDovecot(s *smtpsrv.Server, userdb, client string) {
|
|
a := dovecot.NewAuth(userdb, client)
|
|
s.SetAuthFallback(a)
|
|
log.Infof("Fallback authenticator: %v", a)
|
|
|
|
if err := a.Check(); err != nil {
|
|
log.Errorf("Warning: Dovecot auth is not responding: %v", err)
|
|
}
|
|
}
|
|
|
|
func loadDKIM(domain, dir string, s *smtpsrv.Server) (int, error) {
|
|
glob := path.Clean(dir + "/dkim:*.pem")
|
|
pems, err := filepath.Glob(glob)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
for _, pem := range pems {
|
|
base := filepath.Base(pem)
|
|
selector := strings.TrimPrefix(base, "dkim:")
|
|
selector = strings.TrimSuffix(selector, ".pem")
|
|
|
|
err = s.AddDKIMSigner(domain, selector, pem)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return len(pems), nil
|
|
}
|
|
|
|
// Read a directory, which must have at least some entries.
|
|
func mustReadDir(path string) []os.DirEntry {
|
|
dirs, err := os.ReadDir(path)
|
|
if err != nil {
|
|
log.Fatalf("Error reading %q directory: %v", path, err)
|
|
}
|
|
if len(dirs) == 0 {
|
|
log.Fatalf("No entries found in %q", path)
|
|
}
|
|
|
|
return dirs
|
|
}
|