146 lines
3.9 KiB
Go
146 lines
3.9 KiB
Go
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
|||
|
|
// SPDX-License-Identifier: MIT
|
||
|
|
|
||
|
|
//go:build linux || freebsd
|
||
|
|
|
||
|
|
// Command nuntius runs the contact form backend server.
|
||
|
|
//
|
||
|
|
// Configuration is loaded from a TOML file (default: /etc/nuntius/config.toml).
|
||
|
|
// The TOML file may reference environment variables for secrets using
|
||
|
|
// ${VAR_NAME} or $VAR_NAME syntax.
|
||
|
|
package main
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"errors"
|
||
|
|
"flag"
|
||
|
|
"fmt"
|
||
|
|
"log/slog"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"os/signal"
|
||
|
|
"strconv"
|
||
|
|
"syscall"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/config"
|
||
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/handler"
|
||
|
|
"sourcedock.dev/petrbalvin/nuntius/internal/version"
|
||
|
|
)
|
||
|
|
|
||
|
|
func main() {
|
||
|
|
showVersion := flag.Bool("version", false, "Print version and exit")
|
||
|
|
checkConfig := flag.Bool("check-config", false, "Validate the configuration file and exit without listening")
|
||
|
|
flag.Parse()
|
||
|
|
if *showVersion {
|
||
|
|
fmt.Printf("%s %s\n", version.Name, version.Version())
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||
|
|
slog.SetDefault(logger)
|
||
|
|
|
||
|
|
cfgPath := config.ConfigPath()
|
||
|
|
|
||
|
|
if *checkConfig {
|
||
|
|
if _, err := config.Load(cfgPath); err != nil {
|
||
|
|
fmt.Fprintf(os.Stderr, "nuntius: configuration %s is invalid: %v\n", cfgPath, err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
fmt.Printf("configuration OK: %s\n", cfgPath)
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
cfg, err := config.Load(cfgPath)
|
||
|
|
if err != nil {
|
||
|
|
logger.Error("config load failed", "path", cfgPath, "err", err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
|
||
|
|
h := handler.New(cfg)
|
||
|
|
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
h.Register(mux)
|
||
|
|
|
||
|
|
srv := &http.Server{
|
||
|
|
// The bind host and every timeout are configuration: an omitted
|
||
|
|
// key resolves to the value this release has always used, so the
|
||
|
|
// zero-config behaviour is unchanged.
|
||
|
|
Addr: net.JoinHostPort(cfg.Server.Bind, strconv.Itoa(cfg.Server.Port)),
|
||
|
|
Handler: withRequestLog(mux, logger, cfg.Server.TrustProxyHeaders),
|
||
|
|
ReadHeaderTimeout: cfg.Server.ReadHeaderTimeout(),
|
||
|
|
ReadTimeout: cfg.Server.ReadTimeout(),
|
||
|
|
WriteTimeout: cfg.Server.WriteTimeout(),
|
||
|
|
IdleTimeout: cfg.Server.IdleTimeout(),
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.Info("nuntius starting",
|
||
|
|
"version", version.Name+" "+version.Version(),
|
||
|
|
"addr", srv.Addr,
|
||
|
|
"config", cfgPath,
|
||
|
|
"forms", len(cfg.Forms),
|
||
|
|
)
|
||
|
|
for _, f := range cfg.Forms {
|
||
|
|
logger.Info("form registered",
|
||
|
|
"name", f.Name,
|
||
|
|
"path", f.Path,
|
||
|
|
"to", f.To,
|
||
|
|
"smtp", f.SMTP.Host,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Graceful shutdown.
|
||
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||
|
|
defer stop()
|
||
|
|
|
||
|
|
go func() {
|
||
|
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||
|
|
logger.Error("server error", "err", err)
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
<-ctx.Done()
|
||
|
|
logger.Info("shutdown signal received")
|
||
|
|
|
||
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.Server.ShutdownTimeout())
|
||
|
|
defer cancel()
|
||
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||
|
|
logger.Error("shutdown error", "err", err)
|
||
|
|
h.PersistState()
|
||
|
|
os.Exit(1)
|
||
|
|
}
|
||
|
|
h.PersistState()
|
||
|
|
h.Close()
|
||
|
|
logger.Info("nuntius stopped cleanly")
|
||
|
|
}
|
||
|
|
|
||
|
|
// withRequestLog logs each HTTP request method, path, status, and duration.
|
||
|
|
// The logged IP follows the same trust rules as rate limiting: proxy
|
||
|
|
// headers only when cfg says a trusted proxy is in front.
|
||
|
|
func withRequestLog(next http.Handler, logger *slog.Logger, trustProxy bool) http.Handler {
|
||
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
start := time.Now()
|
||
|
|
rw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||
|
|
next.ServeHTTP(rw, r)
|
||
|
|
logger.Info("request",
|
||
|
|
"method", r.Method,
|
||
|
|
"path", r.URL.Path,
|
||
|
|
"status", rw.status,
|
||
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
||
|
|
"ip", handler.ClientIP(r, trustProxy),
|
||
|
|
)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
type statusRecorder struct {
|
||
|
|
http.ResponseWriter
|
||
|
|
status int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
||
|
|
r.status = code
|
||
|
|
r.ResponseWriter.WriteHeader(code)
|
||
|
|
}
|