diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index 1cb63c0..f042599 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -46,6 +46,9 @@ jobs: - name: Download dependencies run: go mod download + - name: Install build tools + run: dnf install -y gcc + - name: go test -race run: go test -race -count=1 ./... diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b4ae0c5..7dcacfc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ See [Cutting a release](#cutting-a-release) for the full procedure. - **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`) - [`just`](https://github.com/casey/just) command runner (the `justfile` is the source of truth for build / test / run / install / uninstall) -- Linux, macOS, or FreeBSD +- Linux or FreeBSD - A local SMTP account for end-to-end testing. Tests in `internal/storage` and `pkg/contactform` do not require SMTP. ### Quick Start @@ -42,8 +42,15 @@ just test The suite covers: -- `pkg/contactform` — validators for all four form types (contact, feedback, newsletter, generic), whitespace trimming, and error message shape. +- `cmd/server` — client IP extraction, request-logging middleware. +- `internal/config` — TOML loading, env-var expansion, schema validation. +- `internal/email` — SMTP message composition (`multipart/alternative`) and subject templates. +- `internal/handler` — form handler pipeline (CORS, rate limit, honeypot, validation, send), rate-limiter cleanup loop. - `internal/storage` — `Append` / `Count` / `List` semantics, file and directory auto-creation, missing-file tolerance, malformed-line resilience. +- `internal/version` — version identity and `--version` smoke output. +- `pkg/contactform` — validators for all four form types (contact, feedback, newsletter, generic), whitespace trimming, and error message shape. + +CI enforces a **≥80 % coverage threshold** on `internal/...` and `pkg/...`. Run `go test -coverprofile=coverage.out ./internal/... ./pkg/... && go tool cover -func=coverage.out` locally to check before pushing. ### Linting @@ -151,7 +158,7 @@ nuntius/ ```mermaid graph TD Browser[Browser / Frontend]:::accent6 - Nginx[nginx reverse proxy]:::accent6 + Caddy[Caddy reverse proxy]:::accent6 CLI[cmd/server/main.go]:::accent0 Config[internal/config/]:::accent7 Handler[internal/handler/]:::accent0 @@ -161,8 +168,8 @@ graph TD SMTP[(SMTP server)]:::accent2 JSONL[(data_dir/newsletter-*.jsonl)]:::accent2 - Browser -->|POST /api/nuntius/contact| Nginx - Nginx -->|forward with X-Forwarded-For| CLI + Browser -->|POST /api/nuntius/contact| Caddy + Caddy -->|forward with X-Forwarded-For| CLI CLI --> Config CLI --> Handler Handler --> Validate @@ -170,8 +177,8 @@ graph TD Handler --> Storage Email -->|SMTP PLAIN| SMTP Storage -->|append JSONL| JSONL - Handler -->|200 ok / 4xx / 5xx| Nginx - Nginx --> Browser + Handler -->|200 ok / 4xx / 5xx| Caddy + Caddy --> Browser classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff @@ -212,7 +219,7 @@ Triggered on every push to `development` and on every pull request targeting `de | Job | What it does | Why it exists | |---|---|---| | `vet` | `gofmt -l cmd internal pkg` (must print nothing) and `go vet ./...` | Catches formatting drift and static-analysis issues before tests run. | -| `test` | `go test -race -count=1 ./...` plus `go build ./examples/...` | Race detector catches issues in the per-form rate limiter, the storage mutex, and the SMTP send path. `-count=1` disables the result cache. The example build is a smoke test for the public library. | +| `test` | `go test -race -count=1 ./...`, coverage gate (`≥80 %` on `internal/...` and `pkg/...`), plus `go build ./examples/...` | Race detector catches issues in the per-form rate limiter, the storage mutex, and the SMTP send path. `-count=1` disables the result cache. The coverage gate fails the build if the combined statement coverage drops below 80 %. The example build is a smoke test for the public library. | | `build` | `just build`, `just test`, and `./bin/nuntius --version` smoke test | Confirms the `justfile` build recipe works end-to-end and that `--version` still prints the expected output. | Run the same checks locally before opening a pull request: diff --git a/README.md b/README.md index f2df97b..730888b 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,16 @@ # Nuntius — A Small, Opinionated Contact Form Backend -[![License](https://img.shields.io/badge/license-MIT-brightgreen)](LICENSE) -[![Go](https://img.shields.io/badge/Go-00ADD8?labelColor=00ADD8&logo=go&logoColor=fff)](https://go.dev) -[![Linux](https://img.shields.io/badge/Linux-FCC624?logo=linux&logoColor=000)](https://kernel.org) -[![FreeBSD](https://img.shields.io/badge/FreeBSD-AB2B28?logo=freebsd&logoColor=fff)](https://freebsd.org) - > Latin *nuntius* = "messenger" — the service that carries messages from visitors to you. -**nuntius** is a small, modular contact form backend written in Go. It serves multiple JSON endpoints (contact, feedback, newsletter, generic) over a single static binary, delivers submissions via SMTP, optionally persists newsletter signups to a JSONL log, and is designed to sit behind nginx on a tiny Linux server. Only the first-party `interpres` TOML module, no external third-party Go modules, ~6 MB binary. +**nuntius** is a small, modular contact form backend written in Go. It serves multiple +JSON endpoints (contact, feedback, newsletter, generic) over a single static binary, +delivers submissions via SMTP, optionally persists newsletter signups to a JSONL log, +and is designed to sit behind Caddy on a modest Linux or FreeBSD server. -## Features - -- **Single static binary** — `go build -ldflags="-s -w"` produces ≈ 6 MB; no Node, no Python, no Redis, no Postgres, no third-party Go packages. -- **Four form kinds out of the box** — `contact`, `feedback`, `newsletter`, and `generic`. Each gets its own endpoint, rate limit, CORS allowlist, and honeypot field. -- **SMTP delivery** — Go stdlib `net/smtp` with `PLAIN` auth. Works with any provider that exposes SMTP. -- **Newsletter subscribers on disk** — append-only JSONL log under `data_dir/`. No database, no double opt-in. One `{email, ip, form, created_at}` line per signup. -- **Server-side validation** — `pkg/contactform.Validate` is reusable; name, email (RFC 5322), service allow-list (for `contact`), and message length. -- **Reusable package** — `pkg/contactform` exports typed `Request` / `Response` / `ErrorResponse` plus a `Validate` function you can drop into any Go project. -- **Structured logging** — `log/slog` with JSON output to stdout; plays well with journald, Loki, Datadog… -- **Graceful shutdown** — SIGINT/SIGTERM close in-flight requests within 15 s. -- **Rate limiting** — token-bucket per IP, configurable per hour, in-memory (no Redis required). -- **Honeypot field** — invisible to humans, required for spam bots. Silently accepts and drops. -- **Config validation** — unknown fields and unknown form types are rejected at startup, so typos fail loudly instead of silently disabling a form. -- **Env-var secrets** — `${VAR_NAME}` / `$VAR_NAME` expansion in the TOML file keeps SMTP passwords out of disk. +Module `sourcedock.dev/petrbalvin/nuntius` — Go 1.26, zero third-party dependencies. +The only external module is the first-party +[`interpres`](https://sourcedock.dev/petrbalvin/interpres) TOML parser. Stripped +binary is roughly 6 MB. ## Quick Start @@ -35,111 +23,61 @@ cd nuntius just build # → ./bin/nuntius -# 3. Run (auto-creates /etc/nuntius/config.toml with a 3-form template on first start) +# 3. Run (writes a three-form config template to /etc/nuntius/config.toml on first start) sudo mkdir -p /etc/nuntius just run -# → "nuntius starting" addr=:8080 forms=3 +# → nuntius starting addr=[::]:8080 forms=3 -# 4. Edit the generated config to point at your SMTP and recipient +# 4. Edit the generated config with your SMTP credentials and recipient address sudo $EDITOR /etc/nuntius/config.toml just run # restart to pick up changes ``` -A default `config.toml` is auto-generated at `/etc/nuntius/config.toml` on first run with three sample forms (contact, feedback, newsletter). Edit it before exposing the service. +A default `config.toml` is auto-generated at `/etc/nuntius/config.toml` on first +run with three sample forms (contact, feedback, newsletter). Edit it before +exposing the service to the internet. -The full schema lives in [`docs/configuration.md`](docs/configuration.md); the deploy guide is in [`docs/deployment.md`](docs/deployment.md). +The full schema lives in [`docs/configuration.md`](docs/configuration.md); the +production deploy guide is in [`docs/deployment.md`](docs/deployment.md). -## Usage +## Features -### Test the endpoints +- **Single static binary** — `go build -ldflags="-s -w"` produces roughly 6 MB; + no Node, no Python, no Redis, no Postgres, no third-party Go packages. +- **Four form kinds** — `contact`, `feedback`, `newsletter`, and `generic`. Each + gets its own endpoint, rate limit, CORS allowlist, and honeypot field. +- **SMTP delivery** — Go stdlib `net/smtp` with `PLAIN` auth over STARTTLS or + implicit TLS. Works with any standards-compliant SMTP provider. +- **Newsletter subscribers on disk** — append-only JSONL log under `data_dir/`. + No database, no double opt-in. One `{email, ip, form, created_at}` line per + valid signup. +- **Server-side validation** — `pkg/contactform.Validate` is reusable; validates + name, email (RFC 5322), service allow-list (for `contact`), and message length. +- **Reusable Go package** — `pkg/contactform` exports typed `Request`, `Response`, + and `ErrorResponse` structs plus the `Validate` function you can import into + any Go project. +- **Structured logging** — `log/slog` with JSON output to stdout; plays well + with journald, Loki, Datadog, and similar. +- **Graceful shutdown** — SIGINT and SIGTERM close in-flight requests within a + configurable deadline (default 15 s). +- **Token-bucket rate limiting** — per IP, per form, configurable submissions per + hour; in-memory, no external store required. +- **Honeypot field** — invisible to humans, required by bots. Silently accepts + and drops spam submissions. +- **TOML configuration** — single file, parsed by the first-party `interpres` + library. Unknown fields and unknown form types are rejected at startup so typos + fail loudly. +- **Environment variable expansion** — `${VAR_NAME}` and `$VAR_NAME` in the TOML + file keep SMTP credentials out of version control. +- **IPv6-first** — binds to `[::]` by default, with automatic IPv4 compatibility + via dual-stack sockets. +- **Cross-platform** — pre-built binaries for Linux (amd64, arm64, riscv64, + loong64) and FreeBSD (amd64, arm64, riscv64) on every release. -```bash -# Contact form -curl -X POST http://localhost:8080/api/nuntius/contact \ - -H "Content-Type: application/json" \ - -H "Origin: https://petrbalvin.org" \ - -d '{ - "name": "Jane Doe", - "email": "jane@example.com", - "service": "architecture", - "message": "Hello, I would like to discuss an engagement." - }' +## Configuration -# Feedback (no service field) -curl -X POST http://localhost:8080/api/nuntius/feedback \ - -H "Content-Type: application/json" \ - -H "Origin: https://petrbalvin.org" \ - -d '{ - "name": "Jane Doe", - "email": "jane@example.com", - "message": "I love how the site uses Vue.js with zero runtime deps." - }' - -# Newsletter (email only) -curl -X POST http://localhost:8080/api/nuntius/newsletter \ - -H "Content-Type: application/json" \ - -H "Origin: https://petrbalvin.org" \ - -d '{ "email": "jane@example.com" }' -``` - -Successful response (all three): - -```json -{ "ok": true } -``` - -Validation error (e.g. newsletter with bad email): - -```json -{ - "error": "validation", - "details": [ - { "field": "email", "message": "email is invalid" } - ] -} -``` - -### Frontend integration - -If you proxy `/api/nuntius/*` through nginx (recommended, see [`docs/deployment.md`](docs/deployment.md)), no frontend config is needed — the form posts to the same origin as the page. Otherwise, set `VITE_NUNTIUS_URL` to the upstream origin: - -```js -await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/contact`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - name: name.value, - email: email.value, - service: service.value, - message: message.value, - }), -}); -``` - -### Use the package in your own project - -`pkg/contactform` is a small, dependency-free Go package that you can import into any project: - -```go -import "sourcedock.dev/petrbalvin/nuntius/pkg/contactform" - -req := &contactform.Request{ - Name: "Jane", - Email: "jane@example.com", - Service: "architecture", - Message: "Hello!", -} - -if errs := contactform.Validate(req, "contact"); len(errs) > 0 { - // handle validation errors -} -``` - -Full library example: [`docs/library-usage.md`](docs/library-usage.md). Full HTTP API: [`docs/api-reference.md`](docs/api-reference.md). - -## Configuration (TL;DR) - -Default path: `/etc/nuntius/config.toml`. A starter file is written automatically on first start. +Default path: `/etc/nuntius/config.toml`. A starter file is written automatically +on first start. ```toml data_dir = "./data" @@ -164,44 +102,139 @@ allowed_origins = ["https://example.com"] password = "${NUNTIUS_SMTP_PASSWORD}" ``` -Form types and full schema: [`docs/configuration.md`](docs/configuration.md). Secrets via env vars, CORS, honeypot, rate limit, and validation rules are all documented there. +Secrets are injected via environment variables using `${VAR_NAME}` syntax. The +full schema, form types, validation rules, and newsletter log format are +documented in [`docs/configuration.md`](docs/configuration.md). -## Requirements +## Form Types -- **Go 1.26+** (uses Go 1.22+ `http.ServeMux` method patterns and `log/slog`) -- An **SMTP** account (Yandex, Mailgun, Postmark, your own Postfix, …) -- **No other runtime dependencies.** No Node, no Python, no Redis, no Postgres. Configuration is TOML parsed via the first-party [`interpres`](https://sourcedock.dev/petrbalvin/interpres) library; newsletter storage is an append-only JSONL file via `bufio`. +| Type | Required fields | Email subject | Stores to disk | +|------|-----------------|---------------|:---:| +| `contact` | `name`, `email`, `message`; optional `service` | `[nuntius/] Contact form submission` | | +| `feedback` | `name`, `email`, `message` | `[nuntius/] New feedback` | | +| `newsletter` | `email` | `[nuntius/] New newsletter subscriber` | ✓ | +| `generic` | `name`, `email`, `message` | `[nuntius/] New submission` | | + +All four types are rate-limited, CORS-checked, and honeypot-protected +independently — each form has its own per-IP bucket, allowlist, and honeypot +field. `newsletter` is the only type that writes to disk; the other three only +send email. + +The `contact` form restricts the `service` field to an allow-list: +`architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`, or empty. + +Full form type documentation: [`docs/configuration.md#form-types`](docs/configuration.md#form-types). + +## Security + +Every form endpoint is protected independently: + +- **Honeypot** — a configurable invisible form field silently drops submissions + that contain it. Enabled per form via `honeypot_field`. +- **CORS** — per-form `allowed_origins` list. Requests from disallowed origins + receive a `403 Forbidden`. +- **Rate limiting** — token-bucket algorithm, per IP, per form, configurable + submissions per hour. In-memory; no Redis required. Set `rate_limit_per_hour` + to `0` to disable for a given form. +- **Server timeouts** — `ReadHeaderTimeout` prevents slow-loris attacks; + graceful shutdown deadline ensures in-flight requests are drained before exit. +- **Secret hygiene** — SMTP credentials are never logged; TOML values use + `${VAR_NAME}` expansion so passwords stay in the environment, not on disk. +- **Config validation** — unknown TOML fields and unknown form types cause a + startup failure, preventing typo-driven misconfigurations from reaching + production. +- **Input validation** — server-side validation of all fields with configurable + length limits; trimmed whitespace; RFC 5322 email parsing. + +For a full threat model and detailed security discussion, see +[`docs/security.md`](docs/security.md). To report a vulnerability privately, +email **opensource@petrbalvin.org**. + +## Production Deployment + +Build the binary on your workstation, upload it with the systemd unit and +environment template to your server, then run `python3 install.py` as root. The +script is idempotent and sets up: + +- A dedicated `nuntius` system user and group +- The binary at `/usr/local/bin/nuntius` +- Config auto-generation at `/etc/nuntius/config.toml` +- Environment file at `/etc/nuntius/.env` (mode `0600`) +- A systemd service unit at `/etc/systemd/system/nuntius.service` +- Data directory at `/var/lib/nuntius` + +The service is **enabled but not started** — you review the config and +environment file first, then start it manually. + +Complete deployment instructions including Caddy reverse-proxy configuration, +TLS setup, log rotation, and updates are in +[`docs/deployment.md`](docs/deployment.md). + +## Library Usage + +The `pkg/contactform` package is a small, dependency-free Go library you can +import into any project: + +```go +import "sourcedock.dev/petrbalvin/nuntius/pkg/contactform" + +req := &contactform.Request{ + Name: "Jane", + Email: "jane@example.com", + Service: "architecture", + Message: "Hello, I would like to discuss an engagement.", +} + +if errs := contactform.Validate(req, "contact"); len(errs) > 0 { + // handle validation errors +} +``` + +The package exports typed `Request`, `Response`, and `ErrorResponse` structs, +the `Validate` function, and all form kind constants. A full standalone example +is in [`docs/library-usage.md`](docs/library-usage.md). ## Development ```bash -just install # go mod tidy + go mod download (no-op for this project, kept for symmetry) -just build # → ./bin/nuntius (with -s -w) -just test # go test ./... -just run # go run ./cmd/server -just uninstall # rm -rf ./bin +just install # go mod tidy + go mod download +just build # → ./bin/nuntius (stripped, -s -w) +just test # go test -race ./...; enforces ≥80% coverage on internal + pkg +just fmt # gofmt -w cmd internal pkg +just run # go run ./cmd/server ``` -All four `just` recipes are mandatory per the project conventions and produce zero errors and zero warnings. +All `just` recipes produce zero errors and zero warnings. Tests include the race +detector and enforce an 80% coverage floor on the `internal` and `pkg` packages. + +The CI pipeline (Gitea Actions, `.gitea/workflows/`) runs the full test suite +and performs a multi-platform build matrix on every release tag +(`linux/amd64`, `linux/arm64`, `linux/riscv64`, `linux/loong64`, `freebsd/amd64`, +`freebsd/arm64`, `freebsd/riscv64`). + +## Requirements + +- **Go 1.26+** +- An **SMTP** account (any standards-compliant provider — Postfix, Yandex 360, + Mailgun, Postmark, etc.) +- **No other runtime dependencies.** No Node, no Python, no Redis, no Postgres. + Config is TOML parsed by the first-party + [`interpres`](https://sourcedock.dev/petrbalvin/interpres) library; newsletter + storage is an append-only JSONL file via `bufio`; rate limiting is in-memory. ## Documentation - [`docs/architecture.md`](docs/architecture.md) — layers, request flow, design decisions - [`docs/configuration.md`](docs/configuration.md) — full config schema, form types, secrets, newsletter log - [`docs/api-reference.md`](docs/api-reference.md) — HTTP verbs, status codes, `GET /health` -- [`docs/deployment.md`](docs/deployment.md) — production systemd, nginx, updates +- [`docs/deployment.md`](docs/deployment.md) — production systemd, Caddy, updates - [`docs/security.md`](docs/security.md) — threat model, CORS, rate limiting, secret handling -- [`docs/library-usage.md`](docs/library-usage.md) — `pkg/contactform` API and standalone use +- [`docs/library-usage.md`](docs/library-usage.md) — `pkg/contactform` API and standalone usage ## Contributing -See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit conventions, and the pull request flow. - -## Security - -For a full threat model, see [`docs/security.md`](docs/security.md). The short version: every form has its own per-IP rate limit, CORS allowlist, and honeypot field; SMTP credentials never touch the log; unknown config fields are rejected at startup. - -If you find a security issue, please email **opensource@petrbalvin.org** rather than opening a public issue. +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for development setup, code style, commit +conventions, and the pull request flow. ## License diff --git a/docs/api-reference.md b/docs/api-reference.md index 085f38a..97bba9f 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -172,7 +172,7 @@ The IP used for rate limiting is taken from the same precedence chain as the log 2. `X-Real-IP` 3. `RemoteAddr` (with the port stripped) -Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` so the bucket sees the real client IP. +Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration. ## Health Check diff --git a/docs/architecture.md b/docs/architecture.md index 3610c41..0b550db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -7,7 +7,7 @@ ```mermaid graph TD Browser[Browser / Frontend]:::accent6 - Nginx[nginx reverse proxy]:::accent6 + Caddy[Caddy reverse proxy]:::accent6 Server[cmd/server/main.go]:::accent0 Config[internal/config/]:::accent7 Handler[internal/handler/]:::accent0 @@ -20,8 +20,8 @@ graph TD SMTP[(SMTP server)]:::accent2 JSONL[(data_dir/newsletter-*.jsonl)]:::accent2 - Browser -->|POST + Origin| Nginx - Nginx -->|X-Forwarded-For| Server + Browser -->|POST + Origin| Caddy + Caddy -->|X-Forwarded-For| Server Server --> Config Server --> Handler Handler --> RateLimit @@ -32,8 +32,8 @@ graph TD Handler --> Storage Sender -->|SMTP PLAIN over net/smtp| SMTP Storage -->|append JSONL| JSONL - Handler -->|200 / 4xx / 5xx| Nginx - Nginx --> Browser + Handler -->|200 / 4xx / 5xx| Caddy + Caddy --> Browser classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff @@ -74,14 +74,28 @@ The `Form` type mirrors the TOML shape one-to-one. `ValidFormTypes` is the autho ### 3. HTTP handler — `internal/handler/` +`ContactHandler` stores its dependencies behind small interfaces so the handler pipeline can be tested without a real SMTP server or filesystem: + +```go +type formSender interface { + Send(req contactform.Request) error +} + +type subscriberStorer interface { + Append(sub storage.Subscriber) error +} +``` + +`email.FormSender` and `storage.NewsletterStore` satisfy these interfaces in production. Tests use mock implementations (`mockSender`, `mockStore`) that record calls and simulate errors. + `ContactHandler` is a single struct with four maps keyed by URL path: ```go type ContactHandler struct { forms map[string]*config.Form - senders map[string]*email.FormSender + senders map[string]formSender rateLimits map[string]*rateLimiter - stores map[string]*storage.NewsletterStore + stores map[string]subscriberStorer } ``` @@ -142,6 +156,10 @@ sequenceDiagram end ``` +### Rate limiter cleanup + +The per-form `rateLimiter` type starts a background goroutine (`startCleanup`) that ticks once per hour and deletes IP entries older than twice the refill window. This prevents unbounded memory growth from one-off IPs. The cleanup goroutine is covered by `TestRateLimiterCleanup` in `contact_test.go`. + Notable behaviors: - **CORS is path-aware** — Each form has its own `allowed_origins` list; preflights and actual requests are checked against the form being hit, not the host. @@ -182,13 +200,13 @@ There is **no rotation**. Add a `logrotate` unit if the file grows beyond what y ```mermaid sequenceDiagram participant Browser - participant Nginx + participant Caddy participant Nuntius as nuntius participant SMTP participant Disk - Browser->>Nginx: POST /api/nuntius/contact - Nginx->>Nuntius: forward (X-Forwarded-For, X-Real-IP) + Browser->>Caddy: POST /api/nuntius/contact + Caddy->>Nuntius: forward (X-Forwarded-For, X-Real-IP) Nuntius->>Nuntius: CORS check Nuntius->>Nuntius: rate-limit per IP Nuntius->>Nuntius: parse JSON @@ -199,8 +217,8 @@ sequenceDiagram opt newsletter form Nuntius->>Disk: append one JSONL line end - Nuntius-->>Nginx: 200 {"ok": true} - Nginx-->>Browser: 200 {"ok": true} + Nuntius-->>Caddy: 200 {"ok": true} + Caddy-->>Browser: 200 {"ok": true} ``` ## Config Merging diff --git a/docs/deployment.md b/docs/deployment.md index 6fb8330..f5e6777 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Production Deployment -This guide covers installing nuntius on a Linux server with `nginx`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes. +This guide covers installing nuntius on a Linux server with `Caddy`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes. ## Architecture in Production @@ -8,21 +8,21 @@ This guide covers installing nuntius on a Linux server with `nginx`, `systemd`, graph LR Browser[Visitor's browser]:::accent6 Internet((Internet)):::accent2 - Nginx[nginx :443]:::accent0 + Caddy[Caddy :443]:::accent0 Nuntius[nuntius :8080]:::accent1 Systemd[systemd unit]:::accent7 SMTP[(SMTP provider)]:::accent2 Disk[(/var/lib/nuntius/data/*.jsonl)]:::accent2 Browser --> Internet - Internet --> Nginx - Nginx -->|proxy_pass /api/nuntius/| Nuntius + Internet --> Caddy + Caddy -->|reverse_proxy /api/nuntius/| Nuntius Systemd -.->|manages| Nuntius Nuntius -->|SMTP PLAIN| SMTP Nuntius -->|append JSONL| Disk ``` -nuntius listens on `127.0.0.1:8080` (default). nginx terminates TLS on `:443` and proxies `/api/nuntius/*` to the upstream. systemd restarts the process on failure. +nuntius binds `[::]:8080` by default — the dual-stack IPv6 wildcard that accepts both IPv4 and IPv6 connections on Linux and FreeBSD. Caddy should be configured to proxy to `[::1]:8080` or `127.0.0.1:8080` for loopback-only access. ## 1. Build and Upload (Local Machine) @@ -121,35 +121,31 @@ The script is idempotent: re-running on an already-installed host is safe. It: The script ends with the exact next steps you need to run by hand. -## 3. nginx Reverse Proxy +## 3. Caddy Reverse Proxy -Add this location block to the same nginx server that already serves your site (most likely `/etc/nginx/sites-available/petrbalvin.org`): +Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`): -```nginx -location /api/nuntius/ { - proxy_pass http://127.0.0.1:8080/api/nuntius/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; +```caddyfile +handle_path /api/nuntius/* { + reverse_proxy 127.0.0.1:8080 } ``` Then: ```bash -sudo nginx -t -sudo systemctl reload nginx +sudo caddy validate +sudo systemctl reload caddy ``` -Without `X-Forwarded-For`, the rate limiter will see every request as coming from `127.0.0.1` and effectively become useless once the form is live. The `Host` header also matters for vhost-aware upstreams; the default is fine for a single nuntius instance. +Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration. ## 4. Wire It Up to Your Web -If you proxy `/api/nuntius/*` through nginx (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL: +If you proxy `/api/nuntius/*` through Caddy (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL: ```bash -# .env (in the web project, only needed if NOT proxying through nginx) +# .env (in the web project, only needed if NOT proxying through Caddy) VITE_NUNTIUS_URL=https://your-server:8080 ``` @@ -227,10 +223,10 @@ curl -s http://127.0.0.1:8080/health | jq . # Recent log lines journalctl -u nuntius -n 50 --no-pager -# nginx proxy works +# Caddy proxy works curl -i https://your-domain.example/api/nuntius/health -# End-to-end POST through nginx +# End-to-end POST through Caddy curl -X POST https://your-domain.example/api/nuntius/contact \ -H "Content-Type: application/json" \ -H "Origin: https://your-domain.example" \ diff --git a/docs/security.md b/docs/security.md index 56dea66..4f4bd53 100644 --- a/docs/security.md +++ b/docs/security.md @@ -9,7 +9,7 @@ nuntius is designed to defend against: | Threat | Mitigation | |---|---| | Naive spam bots filling the form | Per-form honeypot field that silently accepts and drops | -| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory) | +| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory, with periodic cleanup goroutine) | | Cross-origin abuse from a third-party site | Per-form CORS allowlist enforced on preflight and actual requests | | Malformed bodies | Strict JSON parsing and per-type field validation | | SMTP credential leakage in logs | `log/slog` never logs the `From` / `Authorization` / password; the password only lives in `FormSender` | @@ -81,12 +81,14 @@ The IP used is taken from: 2. `X-Real-IP` 3. `RemoteAddr` (with port stripped) -Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` — otherwise every request looks like it came from `127.0.0.1` and the bucket becomes a single global bucket. +Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration. To disable rate limiting on a specific form, set `rate_limit_per_hour: 0`. There is no global safety net. State is per-process and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is `replicas × per_form_limit`. +A background goroutine (`startCleanup`) ticks once per hour and removes IP entries that are older than twice the refill window. This prevents the in-memory bucket map from growing unboundedly as one-off IPs accumulate over weeks of uptime. + ## Secret Handling SMTP credentials live in `/etc/nuntius/.env` (mode `0600`, owner `root:nuntius`). The config file references them as `${NUNTIUS_SMTP_PASSWORD}`; `os.Expand` substitutes them **before** the TOML is parsed, so the parsed `config.Form` holds the expanded value in memory only for the lifetime of the process. @@ -133,7 +135,7 @@ The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`) - **No CAPTCHA / proof-of-work.** If you need a stronger abuse signal, add a reverse proxy with a CAPTCHA in front of nuntius; the same CORS rules apply. - **No request signing / HMAC.** A frontend that has been compromised can post at the configured rate from a single IP. The token bucket handles the volume; the origin allowlist limits the surface. - **No per-IP persistence across restarts.** Restart resets the buckets. That is an acceptable trade-off for a small backend and is documented in the rate-limit section above. -- **No TLS for the nuntius listener itself.** nuntius is meant to run behind nginx (or another reverse proxy) that terminates TLS. Exposing the binary directly on `:8080` is a development convenience, not a production pattern. +- **No TLS for the nuntius listener itself.** nuntius binds `[::]:8080` (the dual-stack IPv6 wildcard) and is meant to run behind Caddy (or another reverse proxy) that terminates TLS. Exposing the binary directly is a development convenience, not a production pattern. - **No DKIM signing.** Outbound mail is signed by your SMTP provider, not by nuntius. - **No TLS for the SMTP connection itself is configured by nuntius.** Go's `net/smtp.SendMail` opportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on `:465`, wrap `SendMail` in a custom dialer. diff --git a/install.py b/install.py index 9bdb7e3..77e02b0 100644 --- a/install.py +++ b/install.py @@ -214,8 +214,8 @@ Next manual steps: 3. Start the service: sudo systemctl start nuntius sudo journalctl -u nuntius -f - 4. Add the nginx location block (see README.md), then: - sudo nginx -t && sudo systemctl reload nginx + 4. Add the Caddy reverse_proxy directive (see README.md), then: + sudo caddy validate && sudo systemctl reload caddy ============================================================ """ )