mirror of
https://blitiri.com.ar/repos/chasquid
synced 2025-12-17 14:37:02 +00:00
When we get SIGINT or SIGTERM, today chasquid exits with code 1. This can confuse some of the supervision tools. In particular, Docker and Kubernetes expect exit 0 upon an intentional stop. And systemd, the Restart= semantics make a difference with 0 and non-0, and exiting with 1 prevents users from making that distinction. This patch changes the SIGINT/SIGTERM exit code to 0, to make it easier for users to set up things as desired in those environments. Thanks to [Guiorgy@github](https://github.com/Guiorgy) for reporting this problem in https://github.com/albertito/chasquid/pull/70.
356 lines
9.6 KiB
Go
356 lines
9.6 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"
|
|
"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/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,
|
|
}
|
|
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()
|
|
}
|
|
|
|
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
|
|
}
|