mox/localserve.go

408 lines
13 KiB
Go

package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
cryptorand "crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
golog "log"
"math/big"
"net"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/mjl-/sconf"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/queue"
"github.com/mjl-/mox/smtpserver"
"github.com/mjl-/mox/store"
)
func cmdLocalserve(c *cmd) {
c.help = `Start a local SMTP/IMAP server that accepts all messages, useful when testing/developing software that sends email.
Localserve starts mox with a configuration suitable for local email-related
software development/testing. It listens for SMTP/Submission(s), IMAP(s) and
HTTP(s), on the regular port numbers + 1000.
Data is stored in the system user's configuration directory under
"mox-localserve", e.g. $HOME/.config/mox-localserve/ on linux, but can be
overridden with the -dir flag. If the directory does not yet exist, it is
automatically initialized with configuration files, an account with email
address mox@localhost and password moxmoxmox, and a newly generated self-signed
TLS certificate.
All incoming email is accepted (if checks pass), unless the recipient localpart
ends with:
- "temperror": fail with a temporary error code
- "permerror": fail with a permanent error code
- [45][0-9][0-9]: fail with the specific error code
- "timeout": no response (for an hour)
If the localpart begins with "mailfrom" or "rcptto", the error is returned
during those commands instead of during "data".
`
golog.SetFlags(0)
userConfDir, _ := os.UserConfigDir()
if userConfDir == "" {
userConfDir = "."
}
var dir string
c.flag.StringVar(&dir, "dir", filepath.Join(userConfDir, "mox-localserve"), "configuration storage directory")
args := c.Parse()
if len(args) != 0 {
c.Usage()
}
log := mlog.New("localserve")
// Load config, creating a new one if needed.
if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
err := writeLocalConfig(log, dir)
if err != nil {
log.Fatalx("creating mox localserve config", err, mlog.Field("dir", dir))
}
} else if err != nil {
log.Fatalx("stat config dir", err, mlog.Field("dir", dir))
} else if err := localLoadConfig(log, dir); err != nil {
log.Fatalx("loading mox localserve config (hint: when creating a new config with -dir, the directory must not yet exist)", err, mlog.Field("dir", dir))
}
// Initialize receivedid.
recvidbuf, err := os.ReadFile(filepath.Join(dir, "receivedid.key"))
if err == nil && len(recvidbuf) != 16+8 {
err = fmt.Errorf("bad length %d, need 16+8", len(recvidbuf))
}
if err != nil {
log.Errorx("reading receivedid.key", err)
recvidbuf = make([]byte, 16+8)
_, err := cryptorand.Read(recvidbuf)
if err != nil {
log.Fatalx("read random recvid key", err)
}
}
if err := mox.ReceivedIDInit(recvidbuf[:16], recvidbuf[16:]); err != nil {
log.Fatalx("init receivedid", err)
}
// Make smtp server accept all email and deliver to account "mox".
smtpserver.Localserve = true
// Tell queue it shouldn't be queuing/delivering.
queue.Localserve = true
mox.ListenImmediate = true
const mtastsdbRefresher = false
const skipForkExec = true
if err := start(mtastsdbRefresher, skipForkExec); err != nil {
log.Fatalx("starting mox", err)
}
golog.Printf("mox, version %s", moxvar.Version)
golog.Print("")
golog.Printf("the default user is mox@localhost, with password moxmoxmox")
golog.Printf("the default admin password is moxadmin")
golog.Printf("port numbers are those common for the services + 1000")
golog.Printf("tls uses generated self-signed certificate %s", filepath.Join(dir, "localhost.crt"))
golog.Printf("all incoming email is accepted (if checks pass), unless the recipient localpart ends with:")
golog.Print("")
golog.Printf(`- "temperror": fail with a temporary error code.`)
golog.Printf(`- "permerror": fail with a permanent error code.`)
golog.Printf(`- [45][0-9][0-9]: fail with the specific error code.`)
golog.Printf(`- "timeout": no response (for an hour).`)
golog.Print("")
golog.Printf(`if the localpart begins with "mailfrom" or "rcptto", the error is returned during those commands instead of during "data"`)
golog.Print("")
golog.Print(" smtp://localhost:1025 - receive email")
golog.Print("smtps://mox%40localhost:moxmoxmox@localhost:1465 - send email")
golog.Print(" smtp://mox%40localhost:moxmoxmox@localhost:1587 - send email (without tls)")
golog.Print("imaps://mox%40localhost:moxmoxmox@localhost:1993 - read email")
golog.Print(" imap://mox%40localhost:moxmoxmox@localhost:1143 - read email (without tls)")
golog.Print("https://mox%40localhost:moxmoxmox@localhost:1443/account/ - account https")
golog.Print(" http://mox%40localhost:moxmoxmox@localhost:1080/account/ - account http (without tls)")
golog.Print("https://admin:moxadmin@localhost:1443/admin/ - admin https")
golog.Print(" http://admin:moxadmin@localhost:1080/admin/ - admin http (without tls)")
golog.Print("")
golog.Printf("serving from %s", dir)
ctlpath := mox.DataDirPath("ctl")
_ = os.Remove(ctlpath)
ctl, err := net.Listen("unix", ctlpath)
if err != nil {
log.Fatalx("listen on ctl unix domain socket", err)
}
go func() {
for {
conn, err := ctl.Accept()
if err != nil {
log.Printx("accept for ctl", err)
continue
}
cid := mox.Cid()
ctx := context.WithValue(mox.Context, mlog.CidKey, cid)
go servectl(ctx, log.WithCid(cid), conn, func() { shutdown(log) })
}
}()
// Graceful shutdown.
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, os.Interrupt, syscall.SIGTERM)
sig := <-sigc
log.Print("shutting down, waiting max 3s for existing connections", mlog.Field("signal", sig))
shutdown(log)
if num, ok := sig.(syscall.Signal); ok {
os.Exit(int(num))
} else {
os.Exit(1)
}
}
func writeLocalConfig(log *mlog.Log, dir string) (rerr error) {
defer func() {
x := recover()
if x != nil {
if err, ok := x.(error); ok {
rerr = err
}
}
if rerr != nil {
err := os.RemoveAll(dir)
log.Check(err, "removing config directory", mlog.Field("dir", dir))
}
}()
xcheck := func(err error, msg string) {
if err != nil {
panic(fmt.Errorf("%s: %s", msg, err))
}
}
os.MkdirAll(dir, 0770)
// Generate key and self-signed certificate for use with TLS.
privKey, err := ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
xcheck(err, "generating ecdsa key for self-signed certificate")
privKeyDER, err := x509.MarshalPKCS8PrivateKey(privKey)
xcheck(err, "marshal private key to pkcs8")
privBlock := &pem.Block{
Type: "PRIVATE KEY",
Headers: map[string]string{
"Note": "ECDSA key generated by mox localserve for self-signed certificate.",
},
Bytes: privKeyDER,
}
var privPEM bytes.Buffer
err = pem.Encode(&privPEM, privBlock)
xcheck(err, "pem-encoding private key")
err = os.WriteFile(filepath.Join(dir, "localhost.key"), privPEM.Bytes(), 0660)
xcheck(err, "writing private key for self-signed certificate")
// Now the certificate.
template := &x509.Certificate{
SerialNumber: big.NewInt(time.Now().Unix()), // Required field.
DNSNames: []string{"localhost"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(4 * 365 * 24 * time.Hour),
Issuer: pkix.Name{
Organization: []string{"mox localserve"},
},
Subject: pkix.Name{
Organization: []string{"mox localserve"},
CommonName: "localhost",
},
}
certDER, err := x509.CreateCertificate(cryptorand.Reader, template, template, privKey.Public(), privKey)
xcheck(err, "making self-signed certificate")
pubBlock := &pem.Block{
Type: "CERTIFICATE",
// Comments (header) would cause failure to parse the certificate when we load the config.
Bytes: certDER,
}
var crtPEM bytes.Buffer
err = pem.Encode(&crtPEM, pubBlock)
xcheck(err, "pem-encoding self-signed certificate")
err = os.WriteFile(filepath.Join(dir, "localhost.crt"), crtPEM.Bytes(), 0660)
xcheck(err, "writing self-signed certificate")
// Write adminpasswd.
adminpw := "moxadmin"
adminpwhash, err := bcrypt.GenerateFromPassword([]byte(adminpw), bcrypt.DefaultCost)
xcheck(err, "generating hash for admin password")
err = os.WriteFile(filepath.Join(dir, "adminpasswd"), adminpwhash, 0660)
xcheck(err, "writing adminpasswd file")
// Write mox.conf.
local := config.Listener{
IPs: []string{"127.0.0.1", "::1"},
TLS: &config.TLS{
KeyCerts: []config.KeyCert{
{
CertFile: "localhost.crt",
KeyFile: "localhost.key",
},
},
},
}
local.SMTP.Enabled = true
local.SMTP.Port = 1025
local.Submission.Enabled = true
local.Submission.Port = 1587
local.Submission.NoRequireSTARTTLS = true
local.Submissions.Enabled = true
local.Submissions.Port = 1465
local.IMAP.Enabled = true
local.IMAP.Port = 1143
local.IMAP.NoRequireSTARTTLS = true
local.IMAPS.Enabled = true
local.IMAPS.Port = 1993
local.AccountHTTP.Enabled = true
local.AccountHTTP.Port = 1080
local.AccountHTTP.Path = "/account/"
local.AccountHTTPS.Enabled = true
local.AccountHTTPS.Port = 1443
local.AccountHTTPS.Path = "/account/"
local.AdminHTTP.Enabled = true
local.AdminHTTP.Port = 1080
local.AdminHTTPS.Enabled = true
local.AdminHTTPS.Port = 1443
local.MetricsHTTP.Enabled = true
local.MetricsHTTP.Port = 1081
local.WebserverHTTP.Enabled = true
local.WebserverHTTP.Port = 1080
local.WebserverHTTPS.Enabled = true
local.WebserverHTTPS.Port = 1443
static := config.Static{
DataDir: ".",
LogLevel: "traceauth",
Hostname: "localhost",
User: fmt.Sprintf("%d", os.Getuid()),
AdminPasswordFile: "adminpasswd",
Pedantic: true,
Listeners: map[string]config.Listener{
"local": local,
},
}
tlsca := struct {
AdditionalToSystem bool `sconf:"optional"`
CertFiles []string `sconf:"optional"`
}{true, []string{"localhost.crt"}}
static.TLS.CA = &tlsca
static.Postmaster.Account = "mox"
static.Postmaster.Mailbox = "Inbox"
var moxconfBuf bytes.Buffer
err = sconf.WriteDocs(&moxconfBuf, static)
xcheck(err, "making mox.conf")
err = os.WriteFile(filepath.Join(dir, "mox.conf"), moxconfBuf.Bytes(), 0660)
xcheck(err, "writing mox.conf")
// Write domains.conf.
acc := config.Account{
RejectsMailbox: "Rejects",
Destinations: map[string]config.Destination{
"mox@localhost": {},
},
}
acc.AutomaticJunkFlags.Enabled = true
acc.AutomaticJunkFlags.JunkMailboxRegexp = "^(junk|spam)"
acc.AutomaticJunkFlags.NeutralMailboxRegexp = "^(inbox|neutral|postmaster|dmarc|tlsrpt|rejects)"
acc.JunkFilter = &config.JunkFilter{
Threshold: 0.95,
Params: junk.Params{
Onegrams: true,
MaxPower: .01,
TopWords: 10,
IgnoreWords: .1,
RareWords: 2,
},
}
dynamic := config.Dynamic{
Domains: map[string]config.Domain{
"localhost": {
LocalpartCatchallSeparator: "+",
},
},
Accounts: map[string]config.Account{
"mox": acc,
},
WebHandlers: []config.WebHandler{
{
LogName: "workdir",
Domain: "localhost",
PathRegexp: "^/workdir/",
DontRedirectPlainHTTP: true,
WebStatic: &config.WebStatic{
StripPrefix: "/workdir/",
Root: ".",
ListFiles: true,
},
},
},
}
var domainsconfBuf bytes.Buffer
err = sconf.WriteDocs(&domainsconfBuf, dynamic)
xcheck(err, "making domains.conf")
err = os.WriteFile(filepath.Join(dir, "domains.conf"), domainsconfBuf.Bytes(), 0660)
xcheck(err, "writing domains.conf")
// Write receivedid.key.
recvidbuf := make([]byte, 16+8)
_, err = cryptorand.Read(recvidbuf)
xcheck(err, "reading random recvid data")
err = os.WriteFile(filepath.Join(dir, "receivedid.key"), recvidbuf, 0660)
xcheck(err, "writing receivedid.key")
// Load config, so we can access the account.
err = localLoadConfig(log, dir)
xcheck(err, "loading config")
// Set password on account.
a, _, err := store.OpenEmail("mox@localhost")
xcheck(err, "opening account to set password")
password := "moxmoxmox"
err = a.SetPassword(password)
xcheck(err, "setting password")
err = a.Close()
xcheck(err, "closing account")
golog.Printf("config created in %s", dir)
return nil
}
func localLoadConfig(log *mlog.Log, dir string) error {
mox.ConfigStaticPath = filepath.Join(dir, "mox.conf")
mox.ConfigDynamicPath = filepath.Join(dir, "domains.conf")
errs := mox.LoadConfig(context.Background(), false)
if len(errs) > 1 {
log.Error("loading config generated config file: multiple errors")
for _, err := range errs {
log.Errorx("config error", err)
}
return fmt.Errorf("stopping after multiple config errors")
} else if len(errs) == 1 {
return fmt.Errorf("loading config file: %v", errs[0])
}
return nil
}