feat: initialize nuntius project with full server implementation

This commit is contained in:
2026-06-20 20:48:09 +02:00
commit 1b700f7975
33 changed files with 4313 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
# HTTP API Reference
Every form defined in `config.toml` exposes the same three HTTP verbs on its configured `path`. The server also exposes one global endpoint (`GET /health`).
## Per-Form Endpoints
For each form in the config:
| Method | Behaviour |
|---|---|
| `POST` | Validate → honeypot → email send → (newsletter only) persist to disk |
| `OPTIONS` | CORS preflight. Returns `204 No Content` if the origin is allowed; `403` otherwise. |
| _other_ | `405 Method Not Allowed` (Go 1.22+ `http.ServeMux` semantics) |
### Request shape
All four form types accept a single JSON object as the request body. Required fields depend on the form's `type`:
| Type | Required | Optional |
|---|---|---|
| `contact` | `name`, `email`, `message` | `service`, `honeypot` |
| `feedback` | `name`, `email`, `message` | `honeypot` |
| `newsletter` | `email` | `honeypot` |
| `generic` | `name`, `email`, `message` | `honeypot` |
The `honeypot` field name in the request body is configured per form (`honeypot_field` in `config.toml`, default `"website"`). The handler reads it into `contactform.Request.Honeypot` (which is `json:"-"`, so it does not appear in the public Go API) and drops the request silently if it is non-empty.
#### Example: contact
```http
POST /api/nuntius/contact HTTP/1.1
Host: example.com
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"service": "architecture",
"message": "Hello, I would like to discuss an engagement."
}
```
#### Example: feedback
```http
POST /api/nuntius/feedback HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{
"name": "Jane Doe",
"email": "jane@example.com",
"message": "I love how the site uses Vue.js with zero runtime deps."
}
```
#### Example: newsletter
```http
POST /api/nuntius/newsletter HTTP/1.1
Content-Type: application/json
Origin: https://example.com
{ "email": "jane@example.com" }
```
## Response Status Codes
| Status | Body | When |
|---|---|---|
| `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) |
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail, no log) |
| `204` | _empty_ | CORS preflight succeeded |
| `400` | `{"error": "invalid_json", "message": "..."}` | Body is not valid JSON |
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
| `405` | _empty_ | Method not allowed (e.g. `GET` on a form path) |
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
| `500` | `{"error": "send_failed", "message": "..."}` | SMTP round-trip failed |
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter` only — could not write the JSONL line |
## Response Bodies
### Success
```json
{ "ok": true }
```
### Validation error
```json
{
"error": "validation",
"details": [
{ "field": "name", "message": "name must be at least 2 characters" },
{ "field": "email", "message": "email is invalid" },
{ "field": "message", "message": "message must be at least 10 characters" }
]
}
```
`details[]` always has at least one entry when `error == "validation"`. Each entry has `field` and `message` strings. The `message` is human-readable and safe to surface in the UI.
### JSON parse error
```json
{ "error": "invalid_json", "message": "Could not parse JSON body." }
```
### CORS rejection
```json
{ "error": "origin_not_allowed" }
```
### Rate limit
```json
{ "error": "rate_limited", "message": "Too many requests, please try again later." }
```
### SMTP failure
```json
{ "error": "send_failed", "message": "Could not send email." }
```
### Storage failure (newsletter only)
```json
{ "error": "storage_failed", "message": "Could not record subscription." }
```
## CORS
CORS is enforced **per form**. The flow:
1. Browser sends a preflight `OPTIONS` request with `Origin` and `Access-Control-Request-Method`.
2. nuntius checks `Origin` against the form's `allowed_origins`.
3. If allowed, the response carries:
```http
Access-Control-Allow-Origin: <echoed origin>
Access-Control-Allow-Methods: POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Vary: Origin
```
4. If not allowed, the response is `403` with `{"error": "origin_not_allowed"}`.
For the actual `POST`:
- An absent `Origin` header is allowed (the form is treated as a non-browser client). This makes `curl` work out of the box.
- A present `Origin` that is **not** in `allowed_origins` returns `403`.
- A present `Origin` that **is** in `allowed_origins` gets the same CORS headers as the preflight.
Wildcard `*` is not supported. List every origin explicitly.
## Rate Limiting
Each form has its own in-memory token bucket. The bucket size equals `rate_limit_per_hour` and refills at `perHour / 3600` tokens per second. The first request from a new IP creates a full bucket.
To disable rate limiting on a form, set `rate_limit_per_hour: 0` explicitly. There is no global safety net — use with care.
Rate limit 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`.
The IP used for rate limiting is taken from the same precedence chain as the log line:
1. `X-Forwarded-For` (first hop, before any comma)
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.
## Health Check
### `GET /health`
```http
GET /health HTTP/1.1
```
```http
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
{ "status": "ok", "forms": 3 }
```
| Field | Type | Description |
|---|---|---|
| `status` | string | Always `"ok"` while the process is up. There is no deep health check; the endpoint is suitable for a load balancer that wants to know "is the listener alive". |
| `forms` | int | Number of forms loaded from `config.toml` at startup. |
This endpoint is not rate-limited and is not CORS-checked.
## Curl Recipes
### POST a contact form
```bash
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."
}'
```
### POST a feedback form
```bash
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."
}'
```
### POST a newsletter signup
```bash
curl -X POST http://localhost:8080/api/nuntius/newsletter \
-H "Content-Type: application/json" \
-H "Origin: https://petrbalvin.org" \
-d '{ "email": "jane@example.com" }'
```
### Trigger the honeypot (bot)
```bash
curl -X POST http://localhost:8080/api/nuntius/contact \
-H "Content-Type: application/json" \
-d '{
"name": "Bot",
"email": "bot@spam.example",
"message": "buy cheap viagra click here",
"website": "https://spam.example"
}'
# → 200 {"ok": true}, no email sent, no log line about message sent
```
### CORS preflight
```bash
curl -i -X OPTIONS http://localhost:8080/api/nuntius/contact \
-H "Origin: https://petrbalvin.org" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: Content-Type"
```
### Health check
```bash
curl -s http://localhost:8080/health | jq .
# → { "status": "ok", "forms": 3 }
```
## Notes
- nuntius does not currently expose per-form metrics, request counters, or Prometheus endpoints. A future release may add `GET /metrics`; for now, count rows in the JSONL log and grep `journalctl -u nuntius` for request lines.
- All request bodies are bounded by `ReadTimeout` (15 s) and `WriteTimeout` (30 s). The full request body is read into memory by `json.NewDecoder(r.Body).Decode(&req)`; for a 5,000-rune message this is negligible.
- Logs are emitted to stdout in JSON form via `log/slog`. One line per request (`method`, `path`, `status`, `duration_ms`, `ip`) plus one line per sent / failed message and one line per honeypot hit. There is no PII in the log line beyond the client IP.
+239
View File
@@ -0,0 +1,239 @@
# Architecture
**nuntius** is a small HTTP service that accepts JSON form submissions, validates them, sends them via SMTP, and (for `newsletter` forms) appends the email to a JSONL log on disk. This document explains the layers, the request flow, and the design decisions that keep the binary ~6 MB and the dependency graph empty.
## High-Level Overview
```mermaid
graph TD
Browser[Browser / Frontend]:::accent6
Nginx[nginx reverse proxy]:::accent6
Server[cmd/server/main.go]:::accent0
Config[internal/config/]:::accent7
Handler[internal/handler/]:::accent0
RateLimit[rateLimiter per form]:::accent7
CORS[CORS allowlist per form]:::accent7
Honeypot[honeypot_field per form]:::accent7
Validate[pkg/contactform/Validate]:::accent3
Sender[internal/email/FormSender]:::accent1
Storage[internal/storage/NewsletterStore]:::accent1
SMTP[(SMTP server)]:::accent2
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
Browser -->|POST + Origin| Nginx
Nginx -->|X-Forwarded-For| Server
Server --> Config
Server --> Handler
Handler --> RateLimit
Handler --> CORS
Handler --> Honeypot
Handler --> Validate
Handler --> Sender
Handler --> Storage
Sender -->|SMTP PLAIN over net/smtp| SMTP
Storage -->|append JSONL| JSONL
Handler -->|200 / 4xx / 5xx| Nginx
Nginx --> Browser
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
classDef accent2 fill:#F59E0B,stroke:#D97706,color:#000
classDef accent3 fill:#A855F7,stroke:#7C3AED,color:#fff
classDef accent6 fill:#6366F1,stroke:#4F46E5,color:#fff
classDef accent7 fill:#64748B,stroke:#475569,color:#fff
```
## Layers
### 1. Entry point — `cmd/server/main.go`
- Wires up a JSON `slog` handler on stdout.
- Loads the config from `config.ConfigPath()` (default `/etc/nuntius/config.toml`).
- Builds a `*handler.ContactHandler` via `handler.New(cfg)`.
- Registers one `POST` + one `OPTIONS` handler per form on a fresh `http.ServeMux`, plus `GET /health`.
- Wraps the mux in a request-logging middleware that emits a single `slog.Info` per request with method, path, status, duration, and client IP.
- Starts an `http.Server` with explicit read / write / idle timeouts.
- Installs a `signal.NotifyContext` listener for SIGINT / SIGTERM and shuts the server down gracefully within 15 s.
The main package owns **no business logic** — it only orchestrates the four internal packages.
### 2. Configuration — `internal/config/`
The config package is the gatekeeper for everything that varies per deployment.
| Concern | Where |
|---|---|
| File path | `config.ConfigPath()` returns `/etc/nuntius/config.toml` |
| Default file | `defaultConfig` is a `[]byte` constant embedded in the source; written on first start |
| Env-var expansion | `os.Expand(string(raw), os.Getenv)` runs **before** TOML is parsed by `interpres` |
| Strict decoding | `interpres.NewDecoder(...).DisallowUnknownFields()` rejects typos |
| Schema validation | `(*Config).validate()` enforces required fields, unique paths, valid form types, sensible defaults |
| Defaults | `port = 8080`, `data_dir = "./data"`, `rate_limit_per_hour = 10`, `honeypot_field = "website"`, `from = smtp.user` |
The `Form` type mirrors the TOML shape one-to-one. `ValidFormTypes` is the authoritative allow-list — adding a form type means editing one map in this package.
### 3. HTTP handler — `internal/handler/`
`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
rateLimits map[string]*rateLimiter
stores map[string]*storage.NewsletterStore
}
```
`New(cfg)` builds all four maps in one pass. `Register(mux)` then mounts `POST <path>` and `OPTIONS <path>` per form, plus `GET /health`.
The per-form handler closure (`makeHandler`) runs this pipeline in order:
```mermaid
sequenceDiagram
participant Client
participant Handler as ContactHandler
participant CORS
participant Limiter as rateLimiter
participant Body
participant Honey as Honeypot
participant Validate as contactform.Validate
participant Sender as email.FormSender
participant Store as storage.NewsletterStore
Client->>Handler: POST /api/nuntius/contact
alt preflight
Handler->>CORS: formAllowed(origin)?
CORS-->>Handler: yes / no
Handler-->>Client: 204 No Content (or 403)
else actual
Handler->>CORS: formAllowed(origin)?
CORS-->>Handler: yes / no
alt origin present and not allowed
Handler-->>Client: 403 origin_not_allowed
end
Handler->>Limiter: allow(ip)
alt over limit
Handler-->>Client: 429 rate_limited
end
Handler->>Body: json.Decode
alt invalid JSON
Handler-->>Client: 400 invalid_json
end
Handler->>Honey: honeypot field non-empty?
alt bot
Handler-->>Client: 200 ok (silent)
end
Handler->>Validate: contactform.Validate(req, form.Type)
alt errors
Handler-->>Client: 400 validation + details
end
Handler->>Sender: Send(req)
alt SMTP error
Handler-->>Client: 500 send_failed
end
opt form.Type == "newsletter"
Handler->>Store: Append(Subscriber{...})
alt write error
Handler-->>Client: 500 storage_failed
end
end
Handler-->>Client: 200 ok
end
```
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.
- **Client IP honours reverse proxies** — `clientIP` reads `X-Forwarded-For` (first hop), then `X-Real-IP`, then falls back to `RemoteAddr`. Strip the port by hand because the stdlib does not give us a `netip.AddrPort` for the remote.
- **Rate limiting is in-process** — `rateLimiter` is a per-IP token bucket. The bucket refills at `perHour/3600` tokens per second. The map is guarded by a single `sync.Mutex`; in practice the lock is held for microseconds and the access pattern is dominated by `Allow` lookups, not insert / delete.
- **Honeypot is a positive control** — A bot that fills the invisible field gets a 200 with no email sent and no log line. Humans are never blocked.
- **Validation is the same function the public library uses** — `contactform.Validate` is the only validator in the codebase; the handler just calls it with `form.Type` and forwards the returned `[]FieldError` to the client.
### 4. Email — `internal/email/`
Each form gets a `FormSender` constructed once in `handler.New`. `Send(req)` is the only public method:
1. `smtp.PlainAuth("", user, password, host)` — the empty identity means "use the supplied user as-is", which is what every modern SMTP provider expects.
2. `compose(from, to, req, formName, formType)` builds a `multipart/alternative` message (text/plain + text/html).
3. `smtp.SendMail(addr, auth, from, []string{to}, msg)`.
`compose` picks one of four HTML templates (`contact`, `feedback`, `newsletter`, `generic`) and four matching plain-text subjects. The subject for `contact` includes the `[<service>]` tag if `req.Service` is set, so inbox filters can route by service interest.
Credentials are never logged — neither the password nor the full `From` / `To` headers appear in any `slog` line. The handler logs `form`, `path`, `service` (if any), and `ip` only.
### 5. Storage — `internal/storage/`
`NewsletterStore` is a thin wrapper over an append-only JSONL file:
| Method | Semantics |
|---|---|
| `Append(Subscriber)` | Creates the parent dir if missing, opens the file with `O_APPEND \| O_CREATE \| O_WRONLY`, writes one JSON line + `\n`, closes. Sets `CreatedAt = time.Now().UTC()` if zero. |
| `Count()` | Streams the file with a `bufio.Scanner` (1 MB max line), increments on each line that unmarshals into a non-empty `Subscriber`. Malformed lines are skipped. |
| `List()` | Same scan, returns all valid subscribers in insertion order. Reads the entire log into memory — do not call on multi-million-row files. |
| `Path()` | Returns the configured file path. |
A single `sync.Mutex` guards all three methods. The store is safe for concurrent use from the handler goroutines.
There is **no rotation**. Add a `logrotate` unit if the file grows beyond what you want to `wc -l`.
## Request Flow (single form)
```mermaid
sequenceDiagram
participant Browser
participant Nginx
participant Nuntius as nuntius
participant SMTP
participant Disk
Browser->>Nginx: POST /api/nuntius/contact
Nginx->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
Nuntius->>Nuntius: CORS check
Nuntius->>Nuntius: rate-limit per IP
Nuntius->>Nuntius: parse JSON
Nuntius->>Nuntius: honeypot check
Nuntius->>Nuntius: contactform.Validate
Nuntius->>SMTP: SMTP PLAIN auth + multipart message
SMTP-->>Nuntius: 250 OK
opt newsletter form
Nuntius->>Disk: append one JSONL line
end
Nuntius-->>Nginx: 200 {"ok": true}
Nginx-->>Browser: 200 {"ok": true}
```
## Config Merging
There is no merging in nuntius — the TOML file is the only source of runtime configuration. The file itself goes through three stages at startup:
1. **Default generation** — If the file does not exist, `os.WriteFile(path, defaultConfig, 0644)` writes the embedded three-form template.
2. **Env-var expansion**`os.Expand` substitutes `${VAR}` and `$VAR` references **in the raw text** before parsing. The parsed struct never holds a literal like `"${NUNTIUS_SMTP_PASSWORD}"` — it holds the expanded value (or an empty string if the env var was unset).
3. **Strict parsing + validation**`interpres.NewDecoder(...).DisallowUnknownFields()` rejects typos; `validate()` enforces required fields, unique paths, valid form types, and applies defaults.
The order matters: expansion before parsing means env-var references work even for values that are not strings (e.g. an integer with a `${PORT}` reference would fail, but you can still reference strings).
## Error Handling
Errors are returned as a typed string in the JSON body, with the HTTP status code doing the heavy lifting:
| Status | Body shape | When |
|---|---|---|
| `200` | `{"ok": true}` | Submission accepted; mail sent (and subscriber persisted for `newsletter`) |
| `200` | `{"ok": true}` | Honeypot triggered (silent accept, no mail) |
| `400` | `{"error": "invalid_json", "message": "..."}` | Body is not valid JSON |
| `400` | `{"error": "validation", "details": [...]}` | One or more fields are invalid |
| `403` | `{"error": "origin_not_allowed"}` | `Origin` header is not in the form's `allowed_origins` |
| `429` | `{"error": "rate_limited", "message": "..."}` | Token bucket empty for this IP / form |
| `500` | `{"error": "send_failed", "message": "..."}` | SMTP round-trip failed |
| `500` | `{"error": "storage_failed", "message": "..."}` | `newsletter` only — could not write the JSONL line |
The `message` field is intentionally generic; field-level details for validation errors live in `details[]`. None of the error bodies leak SMTP credentials, file paths, or stack traces.
## Why These Choices
- **stdlib only** — `net/smtp`, `net/http`, `net/mail`, `log/slog`, `bufio`, `os/signal`, `sync` plus the first-party `interpres` TOML library are all that is needed. The build is reproducible, the binary is small, and the external supply chain is empty.
- **No global state outside the binary** — `ContactHandler` owns the maps; `slog` is the only package-level default. Everything else is constructed explicitly in `New(cfg)` so the handler is easy to instantiate from tests.
- **Append-only JSONL** — Cheaper to implement than SQLite, easier to inspect than a custom binary format, easy to back up, and resilient to partial writes.
- **Per-form isolation** — One form's SMTP credentials never travel through another form's code path. A misconfigured `contact` form cannot leak credentials from a `newsletter` form.
- **Strict config parsing** — Catching `forms[0].smtp.port = "587"` (string) or `forms[0].type = "contatc"` (typo) at startup is worth the small extra error-path code.
+191
View File
@@ -0,0 +1,191 @@
# Configuration Reference
All runtime configuration for nuntius lives in a single TOML file. The default path is `/etc/nuntius/config.toml`; if the file does not exist when the server starts, nuntius writes a three-form template there. The parser rejects unknown fields and unknown form types so typos fail loudly at startup instead of silently disabling a form.
## Quick Reference
```toml
data_dir = "./data"
[server]
port = 8080
[[forms]]
name = "contact"
type = "contact"
path = "/api/nuntius/contact"
to = "you@example.com"
from = "noreply@example.com"
rate_limit_per_hour = 10
honeypot_field = "website"
allowed_origins = ["https://example.com"]
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
```
## Top-Level Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `server` | table | | `{"port": 8080}` | Server-wide settings |
| `server.port` | int | | `8080` | HTTP listen port |
| `data_dir` | string | | `"./data"` | Directory for newsletter JSONL logs; created on first write |
| `forms` | array | ✅ | — | One or more form definitions (must contain at least one) |
> **Note:** Path resolution for `data_dir` is relative to the process's current working directory. The systemd unit installed in `docs/deployment.md` sets `WorkingDirectory=/var/lib/nuntius`, so the default `data_dir: "./data"` resolves to `/var/lib/nuntius/data/` in production.
## Form Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `name` | string | ✅ | — | Internal identifier. Appears in logs and in `data_dir/newsletter-<name>.jsonl`. |
| `path` | string | ✅ | — | HTTP path the form is served on (e.g. `/api/nuntius/contact`). Must start with `/`. Must be unique across all forms. |
| `type` | string | | `"contact"` | One of `contact`, `feedback`, `newsletter`, `generic`. See [Form types](#form-types). |
| `smtp` | table | ✅ | — | SMTP connection settings. See [SMTP fields](#smtp-fields). |
| `to` | string | ✅ | — | Recipient address (`To:` header). |
| `from` | string | | `smtp.user` | Sender address (`From:` header). |
| `rate_limit_per_hour` | int | | `10` | Submissions per IP per hour. `0` disables. |
| `honeypot_field` | string | | `"website"` | Name of the invisible form field. Empty string disables the honeypot. |
| `allowed_origins` | []string | | `[]` | CORS allowlist. One entry per origin (e.g. `https://example.com`). |
## SMTP Fields
| Field | Type | Required | Default | Description |
|---|---|:---:|---|---|
| `smtp.host` | string | ✅ | — | SMTP server hostname |
| `smtp.port` | int | ✅ | — | SMTP port; `587` for STARTTLS, `465` for implicit TLS (Go's `net/smtp` does both via `SendMail`) |
| `smtp.user` | string | ✅ | — | SMTP auth user (typically the `from` address) |
| `smtp.password` | string | ✅ | — | SMTP auth password. See [Secrets](#secrets-via-environment-variables). |
## Form Types
| Type | Required request fields | Email subject | Notes |
|---|---|---|---|
| `contact` | `name`, `email`, `message`, optional `service` | `[nuntius/<name>] Contact form submission`, or `[nuntius/<name>][<service>] ...` if `service` is set | The default. `service` is restricted to an allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`. |
| `feedback` | `name`, `email`, `message` | `[nuntius/<name>] New feedback` | Same shape as `contact` minus the `service` field. Distinct HTML template. |
| `newsletter` | `email` | `[nuntius/<name>] New newsletter subscriber` | Also writes the email to disk as a JSONL line in `data_dir/newsletter-<name>.jsonl`. No double opt-in. |
| `generic` | `name`, `email`, `message` | `[nuntius/<name>] New submission` | Escape hatch for one-off forms that do not fit the other three. |
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 mail.
### Service allow-list (for `contact`)
The `service` field on `contact` is restricted to a fixed list (defined in `pkg/contactform/validate.go`):
| Value | Use |
|---|---|
| `architecture` | Architecture / system design |
| `ai` | AI / ML engagements |
| `infrastructure` | Infrastructure / DevOps / SRE |
| `software` | Software engineering |
| `unix` | Unix / Linux tooling |
| `other` | Anything else |
An empty string is also valid (skip the field). Anything else triggers a `400 validation` error with `details[0].field = "service"`. To add a new value, edit `validServices` in `pkg/contactform/validate.go` and update this table.
## Validation Rules
The `pkg/contactform.Validate` function enforces the following limits (in runes, not bytes):
| Field | Min | Max | Validator |
|---|:---:|:---:|---|
| `name` | 2 | 100 | `utf8.RuneCountInString` after `strings.TrimSpace` |
| `email` | — | — | `net/mail.ParseAddress` (RFC 5322) after `strings.TrimSpace` |
| `service` | — | — | allow-list (see above) |
| `message` | 10 | 5,000 | `utf8.RuneCountInString` after `strings.TrimSpace` |
Whitespace is trimmed from `name`, `email`, `service`, and `message` before validation. The trimmed values are what the email body sees.
## Newsletter Subscribers
For every accepted `newsletter` submission, nuntius appends one JSON line to `data_dir/newsletter-<name>.jsonl`:
```json
{"email":"jane@example.com","ip":"203.0.113.42","form":"newsletter","created_at":"2026-06-16T12:34:56Z"}
```
| Field | Description |
|---|---|
| `email` | Trimmed, lowercased-on-output by your downstream tool — nuntius does not normalize |
| `ip` | Client IP as seen by nuntius (honours `X-Forwarded-For` / `X-Real-IP`) |
| `form` | The `name` of the form the signup hit |
| `created_at` | UTC timestamp set by nuntius at `Append` time |
The log is **append-only** and **survives restarts**. It is **not rotated automatically** — add a `logrotate` unit (or equivalent) if it grows. Malformed lines are skipped on read, so a partial write can never brick the file.
Quick shell recipes:
```bash
# Count subscribers
wc -l /var/lib/nuntius/data/newsletter-newsletter.jsonl
# Extract just the emails
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl
# Extract unique emails sorted
jq -r .email /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort -u
# Signups per day
jq -r .created_at[:10] /var/lib/nuntius/data/newsletter-newsletter.jsonl | sort | uniq -c
```
## Secrets via Environment Variables
The TOML file supports `${VAR_NAME}` and `$VAR_NAME` expansion **before parsing**, so secrets can stay out of the file. Example:
```toml
[forms.smtp]
host = "smtp.example.com"
port = 587
user = "noreply@example.com"
password = "${NUNTIUS_SMTP_PASSWORD}"
```
The `NUNTIUS_SMTP_PASSWORD` env var is read at startup and substituted into the TOML before it is parsed. If the env var is unset or empty, an empty string is substituted — so make sure the variable is set in the process environment (systemd `EnvironmentFile=`, container secrets, etc.). SMTP credentials are never logged.
Recommended production setup:
```bash
# /etc/nuntius/.env (mode 0600, owner root:nuntius)
NUNTIUS_SMTP_PASSWORD=actual-app-password
```
```ini
# /etc/systemd/system/nuntius.service
[Service]
EnvironmentFile=/etc/nuntius/.env
```
The `install.sh` helper script sets the file mode automatically.
## Defaults at a Glance
When a key is missing from the TOML, nuntius applies the following defaults:
| Key | Default | Source |
|---|---|---|
| `server.port` | `8080` | `(*Config).validate()` |
| `data_dir` | `"./data"` | `(*Config).validate()` |
| `forms[i].type` | `"contact"` | `(*Config).validate()` |
| `forms[i].from` | `forms[i].smtp.user` | `(*Config).validate()` |
| `forms[i].rate_limit_per_hour` | `10` | `(*Config).validate()` |
| `forms[i].honeypot_field` | `"website"` | `(*Config).validate()` |
Setting `rate_limit_per_hour` to `0` explicitly disables rate limiting for that form (use with care — there is no global safety net).
## Validation Errors at Startup
If the config file is malformed, nuntius exits 1 and writes a single JSON line to stderr. Examples:
```text
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"parse /etc/nuntius/config.toml: interpres: unknown field \"smtp_ost\""}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": type \"contatc\" is not supported (must be one of: contact, feedback, newsletter, generic)"}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"contact\": path must start with /"}
{"time":"...","level":"ERROR","msg":"config load failed","path":"/etc/nuntius/config.toml","err":"form \"feedback\": duplicate path \"/api/nuntius/contact\" (also used by \"contact\")"}
```
Fix the reported line and re-run; the server will not start with a broken config.
+254
View File
@@ -0,0 +1,254 @@
# 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.
## Architecture in Production
```mermaid
graph LR
Browser[Visitor's browser]:::accent6
Internet((Internet)):::accent2
Nginx[nginx :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
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.
## 1. Build and Upload (Local Machine)
```bash
cd nuntius
just build
# → ./bin/nuntius
rsync -avz \
bin/nuntius \
.env.example \
user@your-server:/tmp/nuntius/
```
> `install.sh` lives in the repository root. The systemd unit is included inline in Option A below (you do not need to rsync it from the repo). `.env.example` is a template for the secrets file. Adjust the `rsync` source list to match what you keep checked in.
## 2. Install on the Server (SSH Session)
### Option A — Manual (the README quick start)
```bash
ssh user@your-server
# Create the nuntius system user (one time)
sudo useradd --system --shell /usr/sbin/nologin --home-dir /var/lib/nuntius nuntius
sudo mkdir -p /var/lib/nuntius
sudo chown nuntius:nuntius /var/lib/nuntius
# Install the binary
sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius
# First run auto-creates /etc/nuntius/config.toml with the 3-form template
sudo /usr/local/bin/nuntius &
sleep 2
sudo kill %1
# Install the systemd service (created inline from this guide)
sudo tee /etc/systemd/system/nuntius.service > /dev/null <<'EOF'
[Unit]
Description=nuntius contact form backend
After=network.target
[Service]
Type=simple
User=nuntius
Group=nuntius
WorkingDirectory=/var/lib/nuntius
EnvironmentFile=/etc/nuntius/.env
ExecStart=/usr/local/bin/nuntius
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now nuntius
# Set up secrets
sudo cp /tmp/nuntius/.env.example /etc/nuntius/.env
sudo chmod 600 /etc/nuntius/.env
sudo chown root:nuntius /etc/nuntius/.env
sudo $EDITOR /etc/nuntius/.env # set NUNTIUS_SMTP_PASSWORD
# Edit the auto-generated config to your real SMTP + allowed origins
sudo $EDITOR /etc/nuntius/config.toml
# Restart and watch logs
sudo systemctl restart nuntius
sudo journalctl -u nuntius -f
```
The service file expects the binary at `/usr/local/bin/nuntius`, the env file at `/etc/nuntius/.env`, and the data dir at `/var/lib/nuntius` (the config's `data_dir: "./data"` resolves to `/var/lib/nuntius/data` because the unit sets `WorkingDirectory=/var/lib/nuntius`). If you'd rather use `/opt/nuntius/...` or any other layout, edit the systemd unit block above before installing.
### Option B — `install.sh` (idempotent)
Before running the script, copy the systemd unit from the block in Option A above into `/tmp/nuntius/nuntius.service` (the script reads it from the current working directory):
```bash
ssh user@your-server
cd /tmp/nuntius
# Paste the [Unit]…[Install] block from Option A into this file:
sudo $EDITOR /tmp/nuntius/nuntius.service
sudo bash install.sh
```
The script is idempotent: re-running on an already-installed host is safe. It:
1. Creates the `nuntius` system user if missing.
2. Creates `/var/lib/nuntius` with the right ownership.
3. Installs the binary to `/usr/local/bin/nuntius`.
4. Installs the systemd unit to `/etc/systemd/system/nuntius.service`.
5. Runs nuntius briefly to auto-generate `/etc/nuntius/config.toml`.
6. Copies `.env.example` to `/etc/nuntius/.env` (mode 0600) if missing.
7. Reloads systemd and `enable`s the service — but does **not** start it, so you can edit `/etc/nuntius/.env` and `/etc/nuntius/config.toml` first.
The script ends with the exact next steps you need to run by hand.
## 3. nginx Reverse Proxy
Add this location block to the same nginx server that already serves your site (most likely `/etc/nginx/sites-available/petrbalvin.org`):
```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;
}
```
Then:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
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.
## 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:
```bash
# .env (in the web project, only needed if NOT proxying through nginx)
VITE_NUNTIUS_URL=https://your-server:8080
```
For a feedback or newsletter endpoint on the same site, just call the matching path:
```js
await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, message }),
});
await fetch(`${import.meta.env.VITE_NUNTIUS_URL || ''}/api/nuntius/newsletter`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
```
CORS is enforced per form by the `allowed_origins` list in `config.toml`. Add every frontend origin that should be able to post to each form.
## 5. Updating Later
```bash
cd nuntius
just build
rsync -avz bin/nuntius user@your-server:/tmp/nuntius/
ssh user@your-server 'sudo install -m 0755 /tmp/nuntius/nuntius /usr/local/bin/nuntius && sudo systemctl restart nuntius'
```
For schema changes, also ship the new `config.toml` and a migration note in your changelog.
## 6. Updating the Config
The service does **not** auto-reload `config.toml`. After editing it, restart:
```bash
sudo $EDITOR /etc/nuntius/config.toml
sudo systemctl restart nuntius
sudo journalctl -u nuntius -f
```
The process exits 1 on a broken config (unknown field, unknown form type, duplicate path, missing SMTP host, …) and systemd will not keep it up. Fix the reported line and retry.
## Files This Guide Creates on the Server
| File | Owner | Mode | Purpose |
|---|---|---|---|
| `/usr/local/bin/nuntius` | `root` | `0755` | The static binary |
| `/var/lib/nuntius/` | `nuntius` | `0750` | Working directory + data dir |
| `/var/lib/nuntius/data/` | `nuntius` | `0750` | Newsletter JSONL logs (created at runtime) |
| `/etc/nuntius/config.toml` | `root` | `0644` | nuntius config (auto-generated, then edited) |
| `/etc/nuntius/.env` | `root:nuntius` | `0600` | Secrets (SMTP password) |
| `/etc/systemd/system/nuntius.service` | `root` | `0644` | systemd unit |
`/var/lib/nuntius/data/newsletter-*.jsonl` is created on the first `newsletter` form submission, by the `nuntius` user.
## Verifying the Deploy
After `systemctl restart nuntius`, check:
```bash
# Service is up
systemctl status nuntius
# Binary version
/usr/local/bin/nuntius --version
# Listener is bound
ss -tlnp | grep 8080
# Health endpoint responds
curl -s http://127.0.0.1:8080/health | jq .
# Recent log lines
journalctl -u nuntius -n 50 --no-pager
# nginx proxy works
curl -i https://your-domain.example/api/nuntius/health
# End-to-end POST through nginx
curl -X POST https://your-domain.example/api/nuntius/contact \
-H "Content-Type: application/json" \
-H "Origin: https://your-domain.example" \
-d '{"name":"Test","email":"test@example.com","message":"hello there, this is a test message"}'
```
If the end-to-end POST returns `{"ok": true}` and `journalctl -u nuntius -f` shows a `request` line with `status=200`, the deploy is live.
## Backing Up
- `/etc/nuntius/config.toml` and `/etc/nuntius/.env` — back up alongside your other config files.
- `/var/lib/nuntius/data/*.jsonl` — back up the JSONL log with your usual file backup job. It is append-only and trivially `tar`'able.
## Uninstalling
```bash
sudo systemctl disable --now nuntius
sudo rm /etc/systemd/system/nuntius.service /usr/local/bin/nuntius
sudo rm -rf /etc/nuntius /var/lib/nuntius
sudo userdel nuntius
```
+231
View File
@@ -0,0 +1,231 @@
# Library Usage — `pkg/contactform`
`pkg/contactform` is the public, reusable Go package that the nuntius HTTP server uses for validation. It has no third-party dependencies and no awareness of HTTP, SMTP, or the filesystem — drop it into any Go project that needs to validate a contact-style form payload.
## Installation
```bash
go get codeberg.org/petrbalvin/nuntius/pkg/contactform
```
Because nuntius ships with no `go.sum` entries from third-party modules, this import pulls in only stdlib transitively.
## Types
### `Request`
The JSON body sent to the contact endpoint.
```go
type Request struct {
Name string `json:"name"`
Email string `json:"email"`
Service string `json:"service,omitempty"`
Message string `json:"message"`
Honeypot string `json:"-"`
}
```
| Field | JSON tag | Notes |
|---|---|---|
| `Name` | `name` | Required for `contact`, `feedback`, `generic` |
| `Email` | `email` | Required for all four types |
| `Service` | `service,omitempty` | Optional. Validated against an allow-list for `contact` only. |
| `Message` | `message` | Required for `contact`, `feedback`, `generic` |
| `Honeypot` | `-` | Never marshalled to / from JSON. Read it from a separate field in your own request struct, or wire it through your favourite JSON tag. |
The handler-side mapping (used inside the nuntius server) reads the honeypot value into `Request.Honeypot` based on the form's `honeypot_field` config. If you embed `contactform.Request` directly in your own API, you are responsible for reading the honeypot field separately.
### `Response`
```go
type Response struct {
OK bool `json:"ok"`
}
```
A successful submission returns `{"ok": true}`.
### `FieldError`
```go
type FieldError struct {
Field string `json:"field"`
Message string `json:"message"`
}
```
One entry in the `details[]` array of a `validation` error.
### `ErrorResponse`
```go
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
Details []FieldError `json:"details,omitempty"`
}
```
Returned for any non-2xx response. The `Error` field is a short, machine-readable code (`validation`, `invalid_json`, `origin_not_allowed`, `rate_limited`, `send_failed`, `storage_failed`). `Message` is a human-readable summary; `Details` carries the per-field validation failures.
## Functions
### `Validate`
```go
func Validate(r *Request, formType string) []FieldError
```
Dispatches to the right per-type validator. Returns a slice of field errors; an empty (or nil) slice means the request is valid.
| `formType` | What is checked |
|---|---|
| `"contact"` | `name` (2100 runes), `email` (RFC 5322), `service` (allow-list), `message` (105,000 runes) |
| `"feedback"` | `name`, `email`, `message` (no `service`) |
| `"newsletter"` | `email` only |
| `"generic"` | `name`, `email`, `message` (no `service`) |
| `""` (empty) | Falls back to `contact` |
| anything else | Falls back to `contact` — the strict allow-list is enforced at the config layer, not here |
Whitespace is trimmed from `name`, `email`, `service`, and `message` **before** validation. The trimmed values are what the validator sees and what the email body sees.
### Constants
| Name | Value | Used by |
|---|---|---|
| `MinNameRunes` | `2` | `name` minimum length |
| `MaxNameRunes` | `100` | `name` maximum length |
| `MinMessageRunes` | `10` | `message` minimum length |
| `MaxMessageRunes` | `5000` | `message` maximum length |
## Standalone Example
The `examples/minimal/` directory contains a runnable demo. From the repo root:
```bash
go run ./examples/minimal
```
Source (`examples/minimal/main.go`):
```go
// Minimal example showing how to use the contactform package
// standalone (without the HTTP server).
package main
import (
"fmt"
"os"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
func main() {
req := &contactform.Request{
Name: "Jane Doe",
Email: "jane@example.com",
Service: "architecture",
Message: "Hi, I'd like to discuss a possible engagement.",
}
if errs := contactform.Validate(req, "contact"); len(errs) > 0 {
fmt.Fprintln(os.Stderr, "validation failed:")
for _, e := range errs {
fmt.Fprintf(os.Stderr, " - %s: %s\n", e.Field, e.Message)
}
os.Exit(1)
}
fmt.Println("Request is valid:")
fmt.Printf(" name: %s\n", req.Name)
fmt.Printf(" email: %s\n", req.Email)
fmt.Printf(" service: %s\n", req.Service)
fmt.Printf(" message: %s\n", req.Message)
}
```
## Custom Validators
If you need to extend the allow-list of `service` values, edit `validServices` in `pkg/contactform/validate.go`:
```go
var validServices = map[string]bool{
"": true,
"architecture": true,
"ai": true,
"infrastructure": true,
"software": true,
"unix": true,
"other": true,
// add yours here, e.g.:
// "security": true,
}
```
If you need different length limits, edit the four `Min*` / `Max*` constants in the same file. Or fork the package — it is intentionally small (~150 LOC) so a copy-into-your-project is also reasonable.
## Using `Validate` From Your Own HTTP Handler
```go
package main
import (
"encoding/json"
"log"
"net/http"
"codeberg.org/petrbalvin/nuntius/pkg/contactform"
)
func contactHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
defer r.Body.Close()
var req contactform.Request
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{
Error: "invalid_json",
Message: "Could not parse JSON body.",
})
return
}
if errs := contactform.Validate(&req, "contact"); len(errs) > 0 {
writeJSON(w, http.StatusBadRequest, contactform.ErrorResponse{
Error: "validation",
Details: errs,
})
return
}
// ... your SMTP / persistence / webhook code here ...
writeJSON(w, http.StatusOK, contactform.Response{OK: true})
}
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func main() {
http.HandleFunc("/api/contact", contactHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
```
The example deliberately omits CORS, rate limiting, and the honeypot — copy those from `internal/handler/contact.go` if you need them.
## Why a Separate Package?
`pkg/contactform` is the one piece of nuntius that is **not** tied to HTTP, SMTP, or the filesystem. Keeping it in a separate, dependency-free package:
- Makes it trivially embeddable in any Go service.
- Makes it cheap to unit-test (see `pkg/contactform/validate_test.go`).
- Makes the public API obvious — there is one entry point (`Validate`) and four types, all in one file pair.
- Keeps `internal/handler` focused on HTTP, `internal/email` on SMTP, and `internal/storage` on disk.
+142
View File
@@ -0,0 +1,142 @@
# Security
nuntius is a public-facing HTTP service that accepts untrusted input and turns it into outbound email and on-disk records. This document covers the threats it faces, the defenses built in, and the things you, the operator, are responsible for.
## Threat Model
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) |
| 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` |
| Process crash on disk full | Append-only JSONL opens with `O_APPEND`; partial writes cannot corrupt earlier lines |
| Partial write bricking the subscriber log | `Count` and `List` skip malformed lines |
| Config typos silently disabling a form | `interpres.NewDecoder(...).DisallowUnknownFields()` + per-type allow-list at startup |
| Long-running abusive connections | `ReadHeaderTimeout`, `ReadTimeout`, `WriteTimeout`, `IdleTimeout` on the server |
| Process hanging on shutdown | 15 s graceful shutdown via `signal.NotifyContext` + `srv.Shutdown` |
nuntius is **not** designed to defend against:
- A determined attacker who can submit a valid request from a rotating pool of IPs and bypass the per-IP rate limit. nuntius has no CAPTCHA, no proof-of-work, no IP reputation service, and no global rate limit. If you need those, put a reverse proxy with Cloudflare Turnstile / hCaptcha / reCAPTCHA in front of it.
- A compromised frontend that posts valid CORS-allowlisted traffic at high rate from a single IP. The token bucket handles that case.
- An attacker who has root on the server. nuntius reads the SMTP password from the process environment; anyone with `ps` access to the nuntius PID can read it from `/proc/<pid>/environ`. Use `systemd` `EnvironmentFile=` with mode `0600` to keep the file readable only by `root:nuntius`.
- An attacker who can MITM the SMTP connection. Use a provider that supports STARTTLS on port `587` (Go's `net/smtp.SendMail` does STARTTLS automatically when the server advertises it) or implicit TLS on port `465` via a small wrapper.
## Input Validation
All four form types are validated by `pkg/contactform.Validate`, which enforces:
- `name` — 2100 runes after `strings.TrimSpace`
- `email` — RFC 5322 via `net/mail.ParseAddress` after `strings.TrimSpace`
- `service` (contact only) — restricted to a fixed allow-list: `architecture`, `ai`, `infrastructure`, `software`, `unix`, `other`, or empty
- `message` — 105,000 runes after `strings.TrimSpace`
Validation is server-side and runs on every request, regardless of what the client claims it has already checked. Field-level errors are returned as `details[]` so the UI can highlight individual inputs.
## Honeypot
Each form has a `honeypot_field` (default `"website"`) that should be hidden from humans via CSS (`display: none`, off-screen positioning, `tabindex="-1"`, `autocomplete="off"`). The form's HTML has a real-looking input for that name; humans never fill it; bots auto-fill every input they see.
If the field is non-empty, nuntius:
1. Logs a `honeypot triggered` line at `INFO` with `form`, `path`, and `ip`.
2. Responds `200 {"ok": true}` — the same as a legitimate submission.
3. Does **not** send mail.
4. Does **not** persist the subscriber.
This makes the bot think it succeeded. There is no way for an external observer to tell a honeypot-hit response from a real success without the server log.
To disable the honeypot on a specific form, set `honeypot_field: ""`. The form's HTML must then omit the hidden input.
## CORS
Per-form allowlist, configured in `config.toml`:
```toml
[[forms]]
name = "contact"
allowed_origins = ["https://petrbalvin.org", "https://www.petrbalvin.org"]
```
Rules:
- An absent `Origin` header is allowed (treats the client as `curl` / non-browser). This makes debugging easy without weakening anything in production.
- A present `Origin` is checked against the allowlist. If not present, the request is rejected with `403 origin_not_allowed`.
- Wildcards are not supported. List every origin explicitly.
- The preflight (`OPTIONS`) and the actual request go through the same allowlist check.
The response sets `Access-Control-Allow-Origin` to the echoed origin (not `*`) and includes `Vary: Origin` so downstream caches do not mix origins.
## Rate Limiting
Each form has its own per-IP token bucket. The bucket size is `rate_limit_per_hour` and the refill rate is `perHour / 3600` tokens per second. The bucket map is guarded by a `sync.Mutex` and is in-process.
The IP used is taken from:
1. `X-Forwarded-For` (first hop, before any comma)
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.
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`.
## 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.
Things nuntius does **not** do:
- It does not log the password, the `Authorization` header, or the raw SMTP `From` / `To` of any single message (only the form-level `to` from the config).
- It does not write the password to disk. `/etc/nuntius/.env` is the only place it is persisted.
- It does not echo the password in any error response.
- It does not log the full request body. `slog.Info` carries only `method`, `path`, `status`, `duration_ms`, and `ip`.
Things you should do:
- Make sure `/etc/nuntius/.env` is `0600` and owned `root:nuntius` (the `install.sh` script does this for you).
- Use an app-specific password or OAuth token if your provider supports it. nuntius uses `PLAIN` auth because that is what every SMTP provider offers; the credential is the secret, not the auth mechanism.
- Rotate the password on the same cadence as any other production secret.
- Restrict read access to `journalctl -u nuntius` if you do not want the client IPs in your local log files.
## Disk Storage
The newsletter JSONL log is:
- Append-only — nuntius only ever writes new lines and reads them back. There is no `UPDATE` / `DELETE` path.
- Crash-safe — `os.OpenFile(path, O_APPEND | O_CREATE | O_WRONLY, 0644)` plus a single `Write(line)`. A partial write leaves a malformed trailing line; `Count` and `List` skip malformed lines, so the rest of the log is still readable.
- Bounded — there is no upper bound; add a `logrotate` unit if it grows.
The systemd unit runs nuntius as user `nuntius` and points `WorkingDirectory` at `/var/lib/nuntius`. The data dir inherits that ownership.
## Timeouts
The `http.Server` is configured with:
| Timeout | Value | Purpose |
|---|---|---|
| `ReadHeaderTimeout` | 10 s | Limit Slowloris-style header reads |
| `ReadTimeout` | 15 s | Total request body read |
| `WriteTimeout` | 30 s | Total response write |
| `IdleTimeout` | 60 s | Keep-alive idle |
The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`).
## What nuntius Does Not (Yet) Do
- **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 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.
## Reporting a Vulnerability
Email **petrbalvin@yandex.com** with a description and a reproducer. Please do not file a public issue for security-sensitive reports.