161 lines
3.4 KiB
Go
161 lines
3.4 KiB
Go
//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/http"
|
|
"os"
|
|
"os/signal"
|
|
"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")
|
|
flag.Parse()
|
|
if *showVersion {
|
|
fmt.Printf("%s version %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()
|
|
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{
|
|
Addr: ":" + itoa(cfg.Server.Port),
|
|
Handler: withRequestLog(mux, logger),
|
|
ReadHeaderTimeout: 10 * time.Second,
|
|
ReadTimeout: 15 * time.Second,
|
|
WriteTimeout: 30 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
}
|
|
|
|
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(), 15*time.Second)
|
|
defer cancel()
|
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
|
logger.Error("shutdown error", "err", err)
|
|
os.Exit(1)
|
|
}
|
|
logger.Info("nuntius stopped cleanly")
|
|
}
|
|
|
|
// withRequestLog logs each HTTP request method, path, status, and duration.
|
|
func withRequestLog(next http.Handler, logger *slog.Logger) 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", clientIP(r),
|
|
)
|
|
})
|
|
}
|
|
|
|
type statusRecorder struct {
|
|
http.ResponseWriter
|
|
status int
|
|
}
|
|
|
|
func (r *statusRecorder) WriteHeader(code int) {
|
|
r.status = code
|
|
r.ResponseWriter.WriteHeader(code)
|
|
}
|
|
|
|
func clientIP(r *http.Request) string {
|
|
host := r.RemoteAddr
|
|
if i := last(host, ':'); i >= 0 {
|
|
return host[:i]
|
|
}
|
|
return host
|
|
}
|
|
|
|
func last(s string, c byte) int {
|
|
for i := len(s) - 1; i >= 0; i-- {
|
|
if s[i] == c {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func itoa(n int) string {
|
|
if n == 0 {
|
|
return "0"
|
|
}
|
|
neg := n < 0
|
|
if neg {
|
|
n = -n
|
|
}
|
|
var buf [20]byte
|
|
i := len(buf)
|
|
for n > 0 {
|
|
i--
|
|
buf[i] = byte('0' + n%10)
|
|
n /= 10
|
|
}
|
|
if neg {
|
|
i--
|
|
buf[i] = '-'
|
|
}
|
|
return string(buf[i:])
|
|
}
|