golib/cmd/form2mail/form2mail.go
AJ ONeal ac7047c77c
feat(cmd/form2mail): production contact-form server
Wires formmailer + ipcohort + geoip + gitshallow into a long-running
HTTP server. Adds graceful SIGTERM via signal.NotifyContext +
http.Server.Shutdown so deploys complete in seconds instead of waiting
for the systemd 90s SIGKILL. Trusts RFC1918 CIDRs (loopback + 10/8 +
172.16/12 + 192.168/16) for X-Forwarded-For so the upstream TLS router
can pass through the real client IP.

Includes a rename-old-first deploy script (avoids ETXTBSY on running
ELFs), HTML success/error templates that hot-reload from disk on each
request, and a silent-drop spam trap for .ru sender addresses.
2026-09-08 15:28:36 -06:00

396 lines
13 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/netip"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"golang.org/x/term"
"github.com/joho/godotenv"
"github.com/therootcompany/golib/net/formmailer"
"github.com/therootcompany/golib/net/geoip"
"github.com/therootcompany/golib/net/gitshallow"
"github.com/therootcompany/golib/net/httpcache"
"github.com/therootcompany/golib/net/ipcohort"
"github.com/therootcompany/golib/sync/dataset"
)
const (
name = "form2email"
licenseYear = "2026"
licenseOwner = "AJ ONeal"
licenseType = "CC0-1.0"
defaultBlocklistRepo = "https://github.com/bitwire-it/ipblocklist.git"
refreshInterval = 47 * time.Minute
requestsPerMinute = 5
burstSize = 3
)
var (
version = "0.0.0-dev"
commit = "0000000"
date = "0001-01-01T00:00:00Z"
)
func printVersion(out io.Writer) {
if len(commit) > 7 {
commit = commit[:7]
}
_, _ = fmt.Fprintf(out, "%s v%s %s (%s)\n", name, version, commit, date)
_, _ = fmt.Fprintf(out, "Copyright (C) %s %s\n", licenseYear, licenseOwner)
_, _ = fmt.Fprintf(out, "Licensed under the %s license\n", licenseType)
}
type MainConfig struct {
showVersion bool
listenAddr string
smtpHost string
smtpFrom string
smtpToList string
smtpUser string
smtpPass string
smtpSubject string
successFile string
errorFile string
responseType string
blocklistRepo string
cacheDir string
geoipConfPath string
}
func main() {
home, _ := os.UserHomeDir()
_ = godotenv.Load()
_ = godotenv.Load(filepath.Join(home, ".config/form2mail/env"))
cfg := MainConfig{
listenAddr: "localhost:3081",
smtpHost: os.Getenv("SMTP_HOST"),
smtpFrom: os.Getenv("SMTP_FROM"),
smtpToList: os.Getenv("SMTP_TO"),
smtpUser: os.Getenv("SMTP_USER"),
smtpSubject: "Website contact request from {.Email}",
successFile: "success-file.html",
errorFile: "error-file.html",
responseType: "text/plain",
blocklistRepo: defaultBlocklistRepo,
}
fs := flag.NewFlagSet("", flag.ContinueOnError)
fs.BoolVar(&cfg.showVersion, "version", false, "Print version and exit")
fs.StringVar(&cfg.listenAddr, "listen", cfg.listenAddr, "Address to listen on")
fs.StringVar(&cfg.smtpHost, "smtp-host", cfg.smtpHost, "SMTP server:port e.g. smtp.gmail.com:587 (required)")
fs.StringVar(&cfg.smtpFrom, "smtp-from", cfg.smtpFrom, "Sender email e.g. you@gmail.com (required)")
fs.StringVar(&cfg.smtpToList, "smtp-to", cfg.smtpToList, "Recipient email e.g. alerts@yourdomain.com (required)")
fs.StringVar(&cfg.smtpUser, "smtp-user", cfg.smtpUser, "SMTP username (defaults to smtp-from if not set)")
fs.StringVar(&cfg.successFile, "success-file", cfg.successFile, "HTML or JSON file to reply with on success.")
fs.StringVar(&cfg.errorFile, "error-file", cfg.errorFile, "HTML or JSON file to reply with on failure.")
fs.StringVar(&cfg.blocklistRepo, "blocklist-repo", cfg.blocklistRepo, "git URL of the bitwire-it-compatible blocklist repo")
fs.StringVar(&cfg.cacheDir, "cache-dir", "", "cache parent dir (default: ~/.cache)")
fs.StringVar(&cfg.geoipConfPath, "geoip-conf", "", "path to GeoIP.conf (default: ./GeoIP.conf or ~/.config/maxmind/GeoIP.conf)")
fs.Usage = func() {
printVersion(os.Stderr)
fmt.Fprintln(os.Stderr, "\nUSAGE")
fmt.Fprintln(os.Stderr, " form2email [options]")
fs.PrintDefaults()
fmt.Fprintln(os.Stderr, "\nEnv vars (overrides flags): SMTP_HOST, SMTP_FROM, SMTP_TO, SMTP_USER, SMTP_PASS")
}
if err := fs.Parse(os.Args[1:]); err != nil {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
fmt.Fprintln(os.Stderr, err)
fs.Usage()
os.Exit(1)
}
if cfg.showVersion {
printVersion(os.Stdout)
return
}
if cfg.smtpHost == "" || cfg.smtpFrom == "" || cfg.smtpToList == "" {
fmt.Fprintf(os.Stderr, "\nError: missing required SMTP settings\n\n")
fs.Usage()
fmt.Fprintf(os.Stderr, "\nError: missing required SMTP settings\n\n")
os.Exit(1)
}
printVersion(os.Stderr)
// Verify templates are readable at startup; re-read on each request so
// operators can edit HTML without restarting (matches legacy behavior).
successFallback, err := os.ReadFile(cfg.successFile)
if err != nil {
fmt.Fprintf(os.Stderr, "\nError: couldn't read success response file %q: %v\n\n", cfg.successFile, err)
os.Exit(1)
}
errorFallback, err := os.ReadFile(cfg.errorFile)
if err != nil {
fmt.Fprintf(os.Stderr, "\nError: couldn't read error response file %q: %v\n\n", cfg.errorFile, err)
os.Exit(1)
}
successBody := hotReload(cfg.successFile, successFallback)
errorBody := hotReload(cfg.errorFile, errorFallback)
if cfg.smtpUser == "" {
cfg.smtpUser = cfg.smtpFrom
}
if cfg.smtpFrom == "" {
cfg.smtpFrom = cfg.smtpUser
}
if pass, hasPass := os.LookupEnv("SMTP_PASS"); !hasPass {
fmt.Fprintf(os.Stderr, "SMTP_PASS not set → ")
pwBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
if err != nil {
log.Fatalf("Failed to read password: %v", err)
}
fmt.Fprintln(os.Stderr)
cfg.smtpPass = strings.TrimSpace(string(pwBytes))
} else {
cfg.smtpPass = pass
}
cfg.responseType = inferContentType(cfg.successFile)
if cfg.cacheDir == "" {
cfg.cacheDir = filepath.Join(home, ".cache")
}
// GeoIP config discovery: explicit --geoip-conf wins; otherwise default paths.
if cfg.geoipConfPath == "" {
for _, p := range geoip.DefaultConfPaths() {
if _, err := os.Stat(p); err == nil {
cfg.geoipConfPath = p
break
}
}
}
if cfg.geoipConfPath == "" {
log.Fatalf("geoip-conf: not found; set --geoip-conf or place GeoIP.conf in a default location.\n"+
"GeoLite2 registration is free at https://www.maxmind.com/en/geolite2/signup\n"+
"Default search paths: %v", geoip.DefaultConfPaths())
}
confData, err := os.ReadFile(cfg.geoipConfPath)
if err != nil {
log.Fatalf("geoip-conf: %v", err)
}
conf, err := geoip.ParseConf(string(confData))
if err != nil {
log.Fatalf("geoip-conf: %v", err)
}
geoipBasicAuth := httpcache.BasicAuth(conf.AccountID, conf.LicenseKey)
// Blocklist: gitshallow-backed cohort, reloaded on each git HEAD change.
repo := gitshallow.New(cfg.blocklistRepo, filepath.Join(cfg.cacheDir, "bitwire-it"), 1, "")
repo.MaxAge = refreshInterval
// Aggressive GC every 24 fetches (~roughly daily at 47min cadence).
// bitwire-it auto-commits hourly with large blobs; without prune=now the
// 2-week default grace window lets orphaned objects accumulate into GB.
repo.GCInterval = 24
blocklistSet := dataset.NewSet(repo)
blacklist := dataset.AddInitial(blocklistSet, &ipcohort.Cohort{}, func(_ context.Context) (*ipcohort.Cohort, error) {
return ipcohort.LoadFiles(
repo.FilePath("tables/inbound/single_ips.txt"),
repo.FilePath("tables/inbound/networks.txt"),
)
})
fmt.Fprint(os.Stderr, "Syncing git repo ... ")
tBL := time.Now()
if err := blocklistSet.Load(context.Background()); err != nil {
fmt.Fprintln(os.Stderr)
fmt.Fprintf(os.Stderr, "error: ip cohort: %v\n", err)
} else {
fmt.Fprintf(os.Stderr, "%s (entries=%d)\n",
time.Since(tBL).Round(time.Millisecond), blacklist.Value().Size())
}
// GeoIP: City + ASN tarballs via httpcache conditional GETs.
maxmindDir := filepath.Join(cfg.cacheDir, "maxmind")
authHeader := http.Header{"Authorization": []string{geoipBasicAuth}}
geoSet := dataset.NewSet(
&httpcache.Cacher{
URL: geoip.DownloadBase + "/GeoLite2-City/download?suffix=tar.gz",
Path: filepath.Join(maxmindDir, geoip.TarGzName(geoip.CityEdition)),
MaxAge: 3 * 24 * time.Hour,
Header: authHeader,
},
&httpcache.Cacher{
URL: geoip.DownloadBase + "/GeoLite2-ASN/download?suffix=tar.gz",
Path: filepath.Join(maxmindDir, geoip.TarGzName(geoip.ASNEdition)),
MaxAge: 3 * 24 * time.Hour,
Header: authHeader,
},
)
geo := dataset.Add(geoSet, func(_ context.Context) (*geoip.Databases, error) {
return geoip.Open(maxmindDir)
})
fmt.Fprint(os.Stderr, "Loading geoip... ")
tGeo := time.Now()
if err := geoSet.Load(context.Background()); err != nil {
fmt.Fprintln(os.Stderr)
log.Fatalf("geoip: %v", err)
}
fmt.Fprintf(os.Stderr, "%s\n", time.Since(tGeo).Round(time.Millisecond))
fields := []formmailer.Field{
{Label: "Name", FormName: "input_1", Kind: formmailer.KindText},
{Label: "Email", FormName: "input_3", Kind: formmailer.KindEmail},
{Label: "Phone", FormName: "input_4", Kind: formmailer.KindPhone},
{Label: "Company", FormName: "input_5", Kind: formmailer.KindText},
{Label: "Message", FormName: "input_7", Kind: formmailer.KindMessage},
}
fm := &formmailer.FormMailer{
SMTPHost: cfg.smtpHost,
SMTPFrom: cfg.smtpFrom,
SMTPTo: strings.Split(cfg.smtpToList, ","),
SMTPUser: cfg.smtpUser,
SMTPPass: cfg.smtpPass,
Subject: cfg.smtpSubject,
SuccessBody: successBody,
ErrorBody: errorBody,
ContentType: cfg.responseType,
// Bot/blacklist rejections render {.SupportEmail} as "[REDACTED]"
// rather than leaking the real address. Validation errors (wrong
// format, missing field) still show the real support email so users
// know where to write in.
HiddenSupportValue: "[REDACTED]",
// Honor X-Forwarded-For from loopback (dev/ssh-tunnel) and RFC1918
// ranges (the TLS router lives on a private 10.x / 172.16.x address
// and fronts this service). Any other peer has XFF ignored, so a
// direct public request can't forge its origin IP.
TrustedProxies: []netip.Prefix{
netip.MustParsePrefix("127.0.0.0/8"),
netip.MustParsePrefix("::1/128"),
netip.MustParsePrefix("10.0.0.0/8"),
netip.MustParsePrefix("172.16.0.0/12"),
netip.MustParsePrefix("192.168.0.0/16"),
},
Blacklist: blacklist,
Geo: geo,
// North America + unknown. Unknown ("") is always allowed by formmailer.
AllowedCountries: []string{"US", "CA", "MX", "CR", "VI"},
Fields: fields,
RPM: requestsPerMinute,
Burst: burstSize,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
defer func() { _ = geoSet.Close() }()
go blocklistSet.Tick(ctx, refreshInterval, func(err error) {
log.Printf("blocklist refresh: %v", err)
})
go geoSet.Tick(ctx, refreshInterval, func(err error) {
log.Printf("geoip refresh: %v", err)
})
mux := http.NewServeMux()
emailFormName := ""
for _, f := range fields {
if f.Kind == formmailer.KindEmail {
emailFormName = f.FormName
break
}
}
contact := silentDropRU(fm, emailFormName, successBody, cfg.responseType)
mux.Handle("POST /contact", contact)
mux.Handle("POST /contact/", contact)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprintln(w, "form2email server running. POST form data to /contact")
})
srv := &http.Server{Addr: cfg.listenAddr, Handler: mux}
fmt.Printf("form2email listening on http://%s\n", cfg.listenAddr)
fmt.Printf("Forwarding submissions from %s → %s via %s\n", cfg.smtpFrom, cfg.smtpToList, cfg.smtpHost)
fmt.Printf("Rate limit: ~%d req/min per IP (burst %d)\n", requestsPerMinute, burstSize)
fmt.Println("CTRL+C to stop")
serveErr := make(chan error, 1)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
}
close(serveErr)
}()
select {
case <-ctx.Done():
log.Printf("shutdown: %v", ctx.Err())
case err := <-serveErr:
if err != nil {
log.Fatalf("listen: %v", err)
}
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown: %v", err)
}
}
// silentDropRU returns a handler that silently returns the success body for
// submissions whose email (emailFormName form input) ends with ".ru" —
// legacy spam-trap behavior from the original form2mail. All other
// submissions fall through to fm.
func silentDropRU(fm http.Handler, emailFormName string, successBody func() []byte, contentType string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// ParseMultipartForm is safe to call twice; formmailer will see the
// already-parsed form. Use a bounded reader to match formmailer's cap.
r.Body = http.MaxBytesReader(w, r.Body, 10*1024)
if err := r.ParseMultipartForm(10 * 1024); err == nil {
email := strings.ToLower(strings.TrimSpace(r.FormValue(emailFormName)))
if strings.HasSuffix(email, ".ru") {
w.Header().Set("Content-Type", contentType)
_, _ = w.Write(successBody())
return
}
}
fm.ServeHTTP(w, r)
})
}
// hotReload returns a function that re-reads path on each call, falling back
// to the provided bytes on read error (logged). Used for templates that
// operators may edit out-of-band.
func hotReload(path string, fallback []byte) func() []byte {
return func() []byte {
b, err := os.ReadFile(path)
if err != nil {
log.Printf("%s read: %v", path, err)
return fallback
}
return b
}
}
func inferContentType(path string) string {
switch strings.ToLower(filepath.Ext(path)) {
case ".html", ".htm":
return "text/html; charset=utf-8"
case ".json":
return "application/json"
default:
return "text/plain; charset=utf-8"
}
}