first commit
This commit is contained in:
@@ -0,0 +1,382 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gomail/internal/acme"
|
||||
"gomail/internal/admin"
|
||||
"gomail/internal/config"
|
||||
"gomail/internal/crypto"
|
||||
"gomail/internal/dav"
|
||||
"gomail/internal/db"
|
||||
"gomail/internal/imap"
|
||||
"gomail/internal/jmap"
|
||||
"gomail/internal/mailstore"
|
||||
"gomail/internal/managesieve"
|
||||
"gomail/internal/oauth2"
|
||||
"gomail/internal/pipeline"
|
||||
"gomail/internal/ratelimit"
|
||||
"gomail/internal/pop3"
|
||||
"gomail/internal/queue"
|
||||
"gomail/internal/smtp"
|
||||
"gomail/internal/tlsutil"
|
||||
"gomail/internal/webmail"
|
||||
)
|
||||
|
||||
var version = "1.0.0-phase15"
|
||||
|
||||
// ipAllowlistMiddleware rejects any request whose client IP (resolved the
|
||||
// same way ratelimit.HTTPMiddleware does) isn't in allowlist. An empty
|
||||
// allowlist is a no-op (matches this project's convention elsewhere of an
|
||||
// unset/zero config value meaning "don't restrict" — see
|
||||
// ratelimit.New's rate=0 handling).
|
||||
func ipAllowlistMiddleware(allowlist []string, realIPHeader string, next http.Handler) http.Handler {
|
||||
if len(allowlist) == 0 {
|
||||
return next
|
||||
}
|
||||
allowed := make(map[string]bool, len(allowlist))
|
||||
for _, ip := range allowlist {
|
||||
allowed[ip] = true
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !allowed[ratelimit.ClientIP(r, realIPHeader)] {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
cfgPath = flag.String("config", "gomail.yaml", "path to config file")
|
||||
showVersion = flag.Bool("version", false, "print version and exit")
|
||||
genKey = flag.Bool("gen-master-key", false, "generate a new master key and exit")
|
||||
debug = flag.Bool("debug", false, "enable debug logging")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("gomail %s\n", version)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if *genKey {
|
||||
key, err := crypto.GenerateMasterKeyHex()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error generating key: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("GOMAIL_MASTER_KEY=%s\n", key)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
logLevel := slog.LevelInfo
|
||||
if *debug {
|
||||
logLevel = slog.LevelDebug
|
||||
}
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: logLevel})))
|
||||
|
||||
slog.Info("gomail starting", "version", version)
|
||||
|
||||
cfg, err := config.Load(*cfgPath)
|
||||
if err != nil {
|
||||
slog.Error("config error", "err", err)
|
||||
fmt.Fprintln(os.Stderr, "\nHint: generate a master key with: gomail -gen-master-key")
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("config loaded", "hostname", cfg.Server.Hostname, "db_driver", cfg.Database.Driver)
|
||||
|
||||
mk, err := crypto.LoadMasterKey(cfg.Security.MasterKey, cfg.Security.MasterKeyPrev)
|
||||
if err != nil {
|
||||
slog.Error("master key error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
slog.Info("master key loaded", "rotation_active", mk.Previous != nil)
|
||||
|
||||
database, err := db.Open(cfg.Database.Driver, cfg.Database.DSN)
|
||||
if err != nil {
|
||||
slog.Error("database open failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
if err := database.Migrate(); err != nil {
|
||||
slog.Error("migration failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := database.Bootstrap(cfg.Server.Hostname, cfg.Security.AdminInitPassword, cfg.Security.BcryptCost, mk); err != nil {
|
||||
slog.Error("bootstrap failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
store := mailstore.New(cfg.Storage.MaildirRoot, mk, database)
|
||||
|
||||
var tlsConf *tls.Config
|
||||
var acmeManager *tlsutil.ACMEManager
|
||||
var acmeChallengeServer *http.Server
|
||||
|
||||
if cfg.TLS.Mode == "acme" && len(cfg.TLS.ACMEDomains) > 0 {
|
||||
// Real ACME issuance — the challenge responder needs to be reachable
|
||||
// on plain :80 for the CA's HTTP-01 validator to connect to, exactly
|
||||
// as it would for a real Let's Encrypt issuance.
|
||||
responder := acme.NewChallengeResponder()
|
||||
challengeMux := http.NewServeMux()
|
||||
challengeMux.Handle("/.well-known/acme-challenge/", responder)
|
||||
acmeChallengeServer = &http.Server{Addr: ":80", Handler: challengeMux}
|
||||
go func() {
|
||||
if err := acmeChallengeServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
slog.Error("ACME challenge responder failed to start on :80 — HTTP-01 issuance will fail until this is fixed", "err", err)
|
||||
}
|
||||
}()
|
||||
|
||||
acmeManager = tlsutil.NewACMEManager(database, mk, cfg.TLS.ACMEDirectoryURL, cfg.TLS.ACMEEmail, responder)
|
||||
tlsConf = acmeManager.TLSConfig()
|
||||
slog.Info("TLS: real ACME issuance configured", "directory", cfg.TLS.ACMEDirectoryURL, "domains", cfg.TLS.ACMEDomains)
|
||||
} else {
|
||||
var err error
|
||||
tlsConf, err = tlsutil.LoadOrGenerate(
|
||||
cfg.TLS.Mode, cfg.Server.Hostname, cfg.TLS.CertFile, cfg.TLS.KeyFile,
|
||||
tlsutil.ParseMinVersion(cfg.TLS.MinVersion),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("TLS setup failed", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
orch := pipeline.NewOrchestrator(cfg, pipeline.StagesFromConfig(cfg))
|
||||
if cfg.Pipeline.ClamAVSocket != "" {
|
||||
slog.Info("pipeline: ClamAV stage enabled", "addr", cfg.Pipeline.ClamAVSocket)
|
||||
}
|
||||
if cfg.Pipeline.RspamdURL != "" {
|
||||
slog.Info("pipeline: Rspamd stage enabled", "url", cfg.Pipeline.RspamdURL)
|
||||
}
|
||||
if cfg.Pipeline.LLMURL != "" {
|
||||
slog.Info("pipeline: LLM stage enabled", "url", cfg.Pipeline.LLMURL, "model", cfg.Pipeline.LLMModel)
|
||||
}
|
||||
smtpServer := smtp.NewServer(cfg, database, store, tlsConf, orch)
|
||||
|
||||
// Outbound queue worker — MX delivery with DKIM signing per sending domain.
|
||||
// The key lookup runs fresh on every send (not cached at startup) so key
|
||||
// rotation via the admin portal (Phase 8+) takes effect without a restart.
|
||||
queueWorker := queue.NewWorker(database, store).
|
||||
WithDeliverer(&queue.MXDeliverer{Hostname: cfg.Server.Hostname}).
|
||||
WithKeyLookup(
|
||||
func(fromDomain string) ([]byte, string, bool) {
|
||||
dom, err := database.LookupDomainByName(fromDomain)
|
||||
if err != nil || dom == nil || len(dom.DKIMPrivateKeyEnc) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
plainKey, err := crypto.Decrypt(mk, dom.ID, "dkim-key", dom.DKIMPrivateKeyEnc)
|
||||
if err != nil {
|
||||
slog.Warn("failed to decrypt DKIM key, sending unsigned", "domain", fromDomain, "err", err)
|
||||
return nil, "", false
|
||||
}
|
||||
return plainKey, dom.DKIMSelector, true
|
||||
},
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go func() {
|
||||
if err := smtpServer.ListenAndServe(ctx); err != nil && ctx.Err() == nil {
|
||||
slog.Error("SMTP server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
go queueWorker.Run()
|
||||
|
||||
if acmeManager != nil {
|
||||
renewalDomains := append([]string{}, cfg.TLS.ACMEDomains...)
|
||||
go acmeManager.StartRenewalLoop(ctx, renewalDomains, 24*time.Hour)
|
||||
slog.Info("ACME renewal loop started", "check_interval", "24h", "domains", renewalDomains)
|
||||
}
|
||||
|
||||
imapServer := imap.NewServer(database, store, tlsConf, cfg.Server.Hostname, cfg.RateLimits.IMAPConnPerMin, cfg.RateLimits.IMAPAuthFailures)
|
||||
go func() {
|
||||
if err := imapServer.ListenAndServe(ctx, cfg.Server.IMAPAddr, cfg.Server.IMAPSAddr); err != nil && ctx.Err() == nil {
|
||||
slog.Error("IMAP server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
var pop3Server *pop3.Server
|
||||
if cfg.POP3.Enabled {
|
||||
pop3Server = pop3.NewServer(database, store, tlsConf, cfg.Server.Hostname, cfg.RateLimits.POP3AuthFailures)
|
||||
go func() {
|
||||
if err := pop3Server.ListenAndServe(ctx, cfg.POP3.POP3Addr, cfg.POP3.POP3SAddr); err != nil && ctx.Err() == nil {
|
||||
slog.Error("POP3 server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
slog.Info("POP3 enabled", "addr", cfg.POP3.POP3Addr, "tls_addr", cfg.POP3.POP3SAddr)
|
||||
} else {
|
||||
slog.Info("POP3 disabled (pop3.enabled: false in config)")
|
||||
}
|
||||
|
||||
sieveServer := managesieve.NewServer(database, tlsConf, cfg.Server.Hostname)
|
||||
go func() {
|
||||
if err := sieveServer.ListenAndServe(ctx, cfg.Server.ManageSieveAddr); err != nil && ctx.Err() == nil {
|
||||
slog.Error("ManageSieve server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
slog.Info("ManageSieve listening", "addr", cfg.Server.ManageSieveAddr)
|
||||
|
||||
// Shared across every HTTP-based listener (DAV, webmail, admin, JMAP) —
|
||||
// one limiter, one per-IP budget across all of them combined, since
|
||||
// they're all reachable by the same set of clients and a client hitting
|
||||
// its limit on one shouldn't get a fresh budget by switching to another.
|
||||
httpLimiter := ratelimit.New(cfg.RateLimits.HTTPReqPerMin)
|
||||
|
||||
davHandler := dav.NewHandler(database, mk)
|
||||
davMux := http.NewServeMux()
|
||||
davMux.Handle("/dav/", davHandler)
|
||||
davServer := &http.Server{Addr: cfg.Server.DAVAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, davMux), TLSConfig: tlsConf}
|
||||
go func() {
|
||||
var err error
|
||||
if tlsConf != nil {
|
||||
err = davServer.ListenAndServeTLS("", "")
|
||||
} else {
|
||||
err = davServer.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
|
||||
slog.Error("DAV server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
slog.Info("CalDAV/CardDAV listening", "addr", cfg.Server.DAVAddr)
|
||||
|
||||
oauthConfigs := buildOAuthConfigs(cfg)
|
||||
webmailHandler := webmail.NewHandler(database, store, mk, cfg.Security.JWTSecret, oauthConfigs)
|
||||
webmailMux := http.NewServeMux()
|
||||
webmailHandler.RegisterRoutes(webmailMux)
|
||||
|
||||
// JMAP is always mounted on the webmail address (internal use — the
|
||||
// webmail SPA itself still calls the Phase 8 REST API, not this, but
|
||||
// JMAP is independently available here for testing/tooling). Per
|
||||
// config, it's ADDITIONALLY exposed on its own external address when
|
||||
// jmap.external_enabled is true — same handler, just a second listener,
|
||||
// matching the plan's "toggle changes binding, not implementation".
|
||||
jmapHandler := jmap.NewHandler(database, store, cfg.Server.Hostname)
|
||||
jmapHandler.RegisterRoutes(webmailMux)
|
||||
|
||||
staticFS, _ := fs.Sub(webmail.StaticFS, "static")
|
||||
webmailMux.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
webmailServer := &http.Server{Addr: cfg.Server.WebmailAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, webmailMux)}
|
||||
go func() {
|
||||
if err := webmailServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
|
||||
slog.Error("webmail server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
slog.Info("webmail listening", "addr", cfg.Server.WebmailAddr)
|
||||
slog.Info("JMAP listening (internal, mounted on webmail address)", "session_url", cfg.Server.WebmailAddr+"/.well-known/jmap")
|
||||
|
||||
var jmapExternalServer *http.Server
|
||||
if cfg.JMAP.ExternalEnabled {
|
||||
jmapMux := http.NewServeMux()
|
||||
jmapHandler.RegisterRoutes(jmapMux)
|
||||
jmapExternalServer = &http.Server{Addr: cfg.JMAP.ExternalAddr, Handler: httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, jmapMux)}
|
||||
go func() {
|
||||
if err := jmapExternalServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
|
||||
slog.Error("external JMAP server error", "err", err)
|
||||
}
|
||||
}()
|
||||
slog.Info("JMAP externally exposed", "addr", cfg.JMAP.ExternalAddr)
|
||||
} else {
|
||||
slog.Info("JMAP external exposure disabled (jmap.external_enabled: false in config)")
|
||||
}
|
||||
|
||||
adminHandler := admin.NewHandler(database, mk, cfg.Security.JWTSecret)
|
||||
adminMux := http.NewServeMux()
|
||||
adminHandler.RegisterRoutes(adminMux)
|
||||
adminStaticFS, _ := fs.Sub(admin.StaticFS, "static")
|
||||
adminMux.Handle("/", http.FileServer(http.FS(adminStaticFS)))
|
||||
adminHTTPHandler := httpLimiter.HTTPMiddleware(cfg.Server.RealIPHeader, adminMux)
|
||||
adminHTTPHandler = ipAllowlistMiddleware(cfg.Server.AdminIPAllowlist, cfg.Server.RealIPHeader, adminHTTPHandler)
|
||||
adminServer := &http.Server{Addr: cfg.Server.AdminAddr, Handler: adminHTTPHandler}
|
||||
go func() {
|
||||
if err := adminServer.ListenAndServe(); err != nil && err != http.ErrServerClosed && ctx.Err() == nil {
|
||||
slog.Error("admin server error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
slog.Info("admin portal listening", "addr", cfg.Server.AdminAddr)
|
||||
|
||||
slog.Info("gomail is running",
|
||||
"smtp", cfg.Server.SMTPAddr,
|
||||
"submission", cfg.Server.SubmissionAddr,
|
||||
"smtps", cfg.Server.SMTPSAddr,
|
||||
"imap", cfg.Server.IMAPAddr,
|
||||
"imaps", cfg.Server.IMAPSAddr)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-quit
|
||||
slog.Info("shutdown signal received", "signal", sig)
|
||||
|
||||
cancel()
|
||||
queueWorker.Stop()
|
||||
smtpServer.Shutdown(30 * time.Second)
|
||||
imapServer.Shutdown(30 * time.Second)
|
||||
if pop3Server != nil {
|
||||
pop3Server.Shutdown(30 * time.Second)
|
||||
}
|
||||
davServer.Close()
|
||||
webmailServer.Close()
|
||||
if jmapExternalServer != nil {
|
||||
jmapExternalServer.Close()
|
||||
}
|
||||
sieveServer.Shutdown(30 * time.Second)
|
||||
adminServer.Close()
|
||||
if acmeChallengeServer != nil {
|
||||
acmeChallengeServer.Close()
|
||||
}
|
||||
slog.Info("shutdown complete")
|
||||
}
|
||||
|
||||
// buildOAuthConfigs turns the operator's configured Client ID/Secret pairs
|
||||
// into ready-to-use oauth2.Config values, keyed by provider name. A
|
||||
// provider absent from the map (or with Enabled: false) means webmail's
|
||||
// "Add Google/Microsoft Account" simply isn't offered — matching the
|
||||
// plan's "self-hosted operators register their own app" decision; there is
|
||||
// no GoMail-shared fallback.
|
||||
func buildOAuthConfigs(cfg *config.Config) map[string]*oauth2.Config {
|
||||
configs := map[string]*oauth2.Config{}
|
||||
|
||||
if cfg.OAuth.Google.Enabled {
|
||||
authURL, tokenURL, err := oauth2.WellKnownEndpoints("google")
|
||||
if err == nil {
|
||||
configs["google"] = &oauth2.Config{
|
||||
ClientID: cfg.OAuth.Google.ClientID, ClientSecret: cfg.OAuth.Google.ClientSecret,
|
||||
RedirectURI: cfg.OAuth.Google.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
|
||||
Scopes: []string{"https://mail.google.com/", "email"},
|
||||
}
|
||||
}
|
||||
}
|
||||
if cfg.OAuth.Microsoft.Enabled {
|
||||
authURL, tokenURL, err := oauth2.WellKnownEndpoints("microsoft")
|
||||
if err == nil {
|
||||
configs["microsoft"] = &oauth2.Config{
|
||||
ClientID: cfg.OAuth.Microsoft.ClientID, ClientSecret: cfg.OAuth.Microsoft.ClientSecret,
|
||||
RedirectURI: cfg.OAuth.Microsoft.RedirectURI, AuthURL: authURL, TokenURL: tokenURL,
|
||||
Scopes: []string{"https://outlook.office.com/IMAP.AccessAsUser.All", "offline_access", "email"},
|
||||
}
|
||||
}
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user