feat: initialize nuntius project with full server implementation
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
@@ -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` (2–100 runes), `email` (RFC 5322), `service` (allow-list), `message` (10–5,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.
|
||||
@@ -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` — 2–100 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` — 10–5,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.
|
||||
Reference in New Issue
Block a user