feat: contact form backend for linux and freebsd servers
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
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
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
// 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)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
//go:build linux || freebsd
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStatusRecorder(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
sr := &statusRecorder{ResponseWriter: rec, status: http.StatusOK}
|
||||
|
||||
// Default status before WriteHeader.
|
||||
if sr.status != http.StatusOK {
|
||||
t.Errorf("initial status = %d, want %d", sr.status, http.StatusOK)
|
||||
}
|
||||
|
||||
sr.WriteHeader(http.StatusNotFound)
|
||||
if sr.status != http.StatusNotFound {
|
||||
t.Errorf("status after WriteHeader = %d, want %d", sr.status, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// rec should also have the status set.
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Errorf("rec.Code = %d, want %d", rec.Code, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithRequestLog(t *testing.T) {
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
wrapped := withRequestLog(handler, logger, false)
|
||||
req := httptest.NewRequest(http.MethodGet, "/test", nil)
|
||||
req.RemoteAddr = "192.168.1.1:12345"
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
wrapped.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200", rec.Code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user