docs: replace nginx with Caddy across all documentation and installer
This commit is contained in:
@@ -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 ./...
|
||||
|
||||
|
||||
+15
-8
@@ -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:
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
# Nuntius — A Small, Opinionated Contact Form Backend
|
||||
|
||||
[](LICENSE)
|
||||
[](https://go.dev)
|
||||
[](https://kernel.org)
|
||||
[](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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+30
-12
@@ -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
|
||||
|
||||
+17
-21
@@ -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" \
|
||||
|
||||
+5
-3
@@ -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.
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
============================================================
|
||||
"""
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user