291 lines
9.9 KiB
Go
291 lines
9.9 KiB
Go
// Command mailgoserver is the Go port of PyMTA-server: an async SMTP MTA (direct-to-MX
|
|
// delivery, DKIM signing, IP/user auth) plus its admin web UI, mirroring app.py.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"mailgoserver/internal/acmecert"
|
|
"mailgoserver/internal/config"
|
|
"mailgoserver/internal/db"
|
|
"mailgoserver/internal/dkim"
|
|
"mailgoserver/internal/imapserver"
|
|
"mailgoserver/internal/mailstore"
|
|
"mailgoserver/internal/relay"
|
|
"mailgoserver/internal/smtpserver"
|
|
"mailgoserver/internal/tlsutil"
|
|
"mailgoserver/internal/toolbox"
|
|
"mailgoserver/internal/webui"
|
|
)
|
|
|
|
func main() {
|
|
smtpOnly := flag.Bool("smtp-only", false, "Run SMTP server only")
|
|
webOnly := flag.Bool("web-only", false, "Run web frontend only")
|
|
host := flag.String("host", "127.0.0.1", "Web server host")
|
|
port := flag.Int("port", 5000, "Web server port")
|
|
debug := flag.Bool("debug", false, "Enable debug mode")
|
|
initData := flag.Bool("init-data", false, "Initialize sample data and exit")
|
|
configFlag := flag.String("config", "settings.ini", "Configuration file path")
|
|
flag.Parse()
|
|
portFlagSet := false
|
|
flag.Visit(func(f *flag.Flag) {
|
|
if f.Name == "port" {
|
|
portFlagSet = true
|
|
}
|
|
})
|
|
|
|
root, err := os.Getwd()
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
configPath := *configFlag
|
|
if !filepath.IsAbs(configPath) {
|
|
configPath = filepath.Join(root, configPath)
|
|
}
|
|
|
|
cfg, err := config.Load(configPath)
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "load settings:", err)
|
|
os.Exit(1)
|
|
}
|
|
toolbox.Configure(cfg)
|
|
logger := toolbox.GetLogger("main")
|
|
|
|
if *initData {
|
|
// Mirrors app.py's --init-data: the actual seeding call is commented out
|
|
// there too, so this flag currently only logs and exits without touching
|
|
// the DB — preserved as-is rather than silently making it do more.
|
|
logger.Info("Initializing sample data...")
|
|
logger.Info("Sample data initialization complete")
|
|
return
|
|
}
|
|
|
|
dbPath := config.AbsoluteSQLitePath(cfg.Section("Database").Key("DATABASE_URL").String(), root)
|
|
if err := toolbox.EnsureFolderExists(dbPath); err != nil {
|
|
logger.Error("create database directory: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
database, err := db.Open(dbPath)
|
|
if err != nil {
|
|
logger.Error("open database: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
defer database.Close()
|
|
|
|
if err := database.SeedDefaultAdminIfEmpty(); err != nil {
|
|
logger.Error("seed default admin: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
dkimKeySize := cfg.Section("DKIM").Key("DKIM_KEY_SIZE").MustInt(2048)
|
|
dkimMgr := dkim.New(database, dkimKeySize)
|
|
relayer := relay.New(database, cfg, toolbox.GetLogger("relay"))
|
|
|
|
heloHostname := cfg.Section("Server").Key("helo_hostname").String()
|
|
if heloHostname == "" {
|
|
heloHostname = cfg.Section("Server").Key("HOSTNAME").MustString("localhost")
|
|
}
|
|
attachmentsBase := cfg.Section("Attachments").Key("attachments_path").String()
|
|
if !filepath.IsAbs(attachmentsBase) {
|
|
attachmentsBase = filepath.Join(root, attachmentsBase)
|
|
}
|
|
|
|
mailstoreBase := absPath(root, cfg.Section("Mailstore").Key("mailstore_path").MustString("server_data/mailstore"))
|
|
masterKeyPath := absPath(root, cfg.Section("Mailstore").Key("master_key_path").MustString("server_data/mailstore_master.key"))
|
|
masterKey, err := mailstore.LoadOrCreateMasterKey(masterKeyPath)
|
|
if err != nil {
|
|
logger.Error("load mailstore master key: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
mstore := mailstore.New(database, masterKey, mailstoreBase)
|
|
|
|
// Shared by both the SMTP and IMAP implicit-TLS listeners, so a single Reload()
|
|
// call (self-signed regeneration today; a Let's Encrypt renewal later) updates
|
|
// both without restarting the process.
|
|
certFile := absPath(root, cfg.Section("TLS").Key("TLS_CERT_FILE").String())
|
|
keyFile := absPath(root, cfg.Section("TLS").Key("TLS_KEY_FILE").String())
|
|
if err := tlsutil.GenerateSelfSignedCert(certFile, keyFile); err != nil {
|
|
logger.Error("generate TLS certificate: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
certReloader, err := tlsutil.NewCertReloader(certFile, keyFile)
|
|
if err != nil {
|
|
logger.Error("load TLS certificate: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
tlsConfig := tlsutil.NewReloadableTLSConfig(certReloader)
|
|
|
|
acmeDataDir := absPath(root, "server_data/acme")
|
|
acmeMgr := acmecert.New(cfg, certFile, keyFile, acmeDataDir, certReloader, toolbox.GetLogger("acme"))
|
|
|
|
backend := &smtpserver.Backend{
|
|
DB: database, DKIM: dkimMgr, Relay: relayer, Cfg: cfg, Mailstore: mstore,
|
|
Logger: toolbox.GetLogger("smtp"), HeloHostname: heloHostname, AttachmentsBasePath: attachmentsBase,
|
|
}
|
|
imapBackend := &imapserver.Backend{DB: database, Mailstore: mstore, Logger: toolbox.GetLogger("imap")}
|
|
|
|
var smtpRunning atomic.Bool
|
|
|
|
runSMTP := func() {
|
|
smtpPort := cfg.Section("Server").Key("SMTP_PORT").MustInt(4025)
|
|
smtpTLSPort := cfg.Section("Server").Key("SMTP_TLS_PORT").MustInt(40465)
|
|
banner := smtpserver.ResolveBanner(cfg, heloHostname)
|
|
|
|
// Bind IPv4-only ("0.0.0.0"), matching both listeners in server_runner.py
|
|
// exactly (they hardcode hostname='0.0.0.0' regardless of the BIND_IP
|
|
// setting) — a bare ":port" address binds dual-stack on most systems,
|
|
// which would accept IPv6 connections the Python version never did.
|
|
plainServer := smtpserver.NewPlainServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpPort), banner)
|
|
tlsServer := smtpserver.NewTLSServer(backend, fmt.Sprintf("0.0.0.0:%d", smtpTLSPort), banner, tlsConfig)
|
|
|
|
smtpRunning.Store(true)
|
|
logger.Info("Plain SMTP listening on :%d, direct-TLS SMTP listening on :%d", smtpPort, smtpTLSPort)
|
|
|
|
go func() {
|
|
if err := plainServer.ListenAndServe(); err != nil {
|
|
logger.Error("plain SMTP server: %v", err)
|
|
}
|
|
}()
|
|
if err := tlsServer.ListenAndServeTLS(); err != nil {
|
|
logger.Error("TLS SMTP server: %v", err)
|
|
}
|
|
}
|
|
|
|
runIMAP := func() {
|
|
imapPort := cfg.Section("IMAP").Key("IMAP_PORT").MustInt(1143)
|
|
imapTLSPort := cfg.Section("IMAP").Key("IMAP_TLS_PORT").MustInt(1993)
|
|
|
|
plainServer := imapserver.NewPlainServer(imapBackend)
|
|
tlsServer := imapserver.NewTLSServer(imapBackend, tlsConfig)
|
|
|
|
logger.Info("Plain IMAP listening on :%d, direct-TLS IMAP listening on :%d", imapPort, imapTLSPort)
|
|
|
|
go func() {
|
|
if err := plainServer.ListenAndServe(fmt.Sprintf("0.0.0.0:%d", imapPort)); err != nil {
|
|
logger.Error("plain IMAP server: %v", err)
|
|
}
|
|
}()
|
|
if err := tlsServer.ListenAndServeTLS(fmt.Sprintf("0.0.0.0:%d", imapTLSPort)); err != nil {
|
|
logger.Error("TLS IMAP server: %v", err)
|
|
}
|
|
}
|
|
|
|
// runCertRenewal is the first periodic/background job in this codebase — everything
|
|
// else is purely request-driven. Checks soon after boot (so enabling Let's Encrypt
|
|
// and restarting converges quickly) and every 12h thereafter. The first check each
|
|
// process run always attempts ObtainOrRenew regardless of NeedsRenewal's expiry
|
|
// check, since a fresh self-signed cert has ~1 year left and would otherwise never
|
|
// get replaced by the very first real certificate.
|
|
runCertRenewal := func() {
|
|
time.Sleep(1 * time.Minute)
|
|
checkAndRenew := func() {
|
|
if !cfg.Section("LetsEncrypt").Key("enabled").MustBool(false) {
|
|
return
|
|
}
|
|
if !acmeMgr.Status().LastAttempt.IsZero() {
|
|
if needs, err := acmeMgr.NeedsRenewal(); err != nil || !needs {
|
|
return
|
|
}
|
|
}
|
|
if err := acmeMgr.ObtainOrRenew(context.Background()); err != nil {
|
|
logger.Error("ACME obtain/renew: %v", err)
|
|
}
|
|
}
|
|
checkAndRenew()
|
|
ticker := time.NewTicker(12 * time.Hour)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
checkAndRenew()
|
|
}
|
|
}
|
|
|
|
if *smtpOnly {
|
|
go runIMAP()
|
|
go runCertRenewal()
|
|
runSMTP()
|
|
return
|
|
}
|
|
|
|
app, err := webui.New(database, dkimMgr, mstore, acmeMgr, cfg, configPath, toolbox.GetLogger("web"), smtpRunning.Load)
|
|
if err != nil {
|
|
logger.Error("init web UI: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
mux := app.Mux()
|
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
|
|
writeHealthJSON(w, database, smtpRunning.Load())
|
|
})
|
|
// Most visitors are mailbox owners, not admins — default the bare root to the
|
|
// self-service webmail login, with a "Login as Admin" button there for the admin
|
|
// dashboard's login instead of requiring the admin URL to be typed by hand.
|
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, webui.MailboxPrefix+"/login", http.StatusFound)
|
|
})
|
|
|
|
if !*webOnly {
|
|
go runSMTP()
|
|
go runIMAP()
|
|
go runCertRenewal()
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
|
|
httpsPort := cfg.Section("Server").Key("WEB_HTTPS_PORT").MustInt(5001)
|
|
httpsAddr := fmt.Sprintf("%s:%d", *host, httpsPort)
|
|
httpsServer := &http.Server{Addr: httpsAddr, Handler: mux, TLSConfig: tlsConfig}
|
|
go func() {
|
|
logger.Info("Web interface (HTTPS) starting at https://%s", httpsAddr)
|
|
// Empty cert/key paths: TLSConfig.GetCertificate (backed by certReloader) supplies
|
|
// the certificate, so ListenAndServeTLS never reads from disk itself — the same
|
|
// self-signed-by-default, Let's-Encrypt-when-enabled cert the SMTP/IMAP TLS
|
|
// listeners already use, hot-reloaded without restarting this server either.
|
|
if err := httpsServer.ListenAndServeTLS("", ""); err != nil {
|
|
logger.Error("web https server: %v", err)
|
|
}
|
|
}()
|
|
|
|
httpPort := *port
|
|
if !portFlagSet {
|
|
httpPort = cfg.Section("Server").Key("WEB_HTTP_PORT").MustInt(5000)
|
|
}
|
|
addr := fmt.Sprintf("%s:%d", *host, httpPort)
|
|
logger.Info("Web interface starting at http://%s (debug=%v)", addr, *debug)
|
|
if err := http.ListenAndServe(addr, mux); err != nil {
|
|
logger.Error("web server: %v", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func absPath(root, p string) string {
|
|
if filepath.IsAbs(p) {
|
|
return p
|
|
}
|
|
return filepath.Join(root, p)
|
|
}
|
|
|
|
func writeHealthJSON(w http.ResponseWriter, database *db.DB, smtpUp bool) {
|
|
dbStatus := "ok"
|
|
if err := database.Ping(); err != nil {
|
|
dbStatus = "error"
|
|
}
|
|
smtpStatus := "stopped"
|
|
if smtpUp {
|
|
smtpStatus = "running"
|
|
}
|
|
overall := "healthy"
|
|
if smtpStatus == "stopped" || dbStatus == "error" {
|
|
overall = "degraded"
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
fmt.Fprintf(w, `{"status":%q,"timestamp":%q,"services":{"smtp_server":%q,"web_frontend":"running","database":%q},"version":"1.0.0"}`,
|
|
overall, time.Now().Format(time.RFC3339), smtpStatus, dbStatus)
|
|
}
|