Files
nuntius/cmd/server/main.go
T
petrbalvin 3a38f00dc0
Test / test (push) Successful in 2m1s
Release / gates (push) Successful in 1m57s
Release / build (amd64, freebsd) (push) Successful in 1m26s
Release / build (amd64, linux) (push) Successful in 1m30s
Release / build (arm64, freebsd) (push) Successful in 1m28s
Release / build (arm64, linux) (push) Successful in 1m49s
Release / build (loong64, linux) (push) Successful in 1m30s
Release / build (riscv64, linux) (push) Successful in 1m29s
Release / release (push) Successful in 41s
feat: contact form backend for linux and freebsd servers
Assisted-by: GLM 5.3 Flash
2026-09-29 00:32:56 +02:00

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)
}