docs: replace nginx with Caddy across all documentation and installer

This commit is contained in:
2026-07-26 21:10:45 +02:00
parent 4dd7b6991d
commit 9457d3f8cc
8 changed files with 239 additions and 180 deletions
+166 -133
View File
@@ -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/<name>] Contact form submission` | |
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | |
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | ✓ |
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] 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