docs: replace nginx with Caddy across all documentation and installer
This commit is contained in:
@@ -172,7 +172,7 @@ The IP used for rate limiting is taken from the same precedence chain as the log
|
||||
2. `X-Real-IP`
|
||||
3. `RemoteAddr` (with the port stripped)
|
||||
|
||||
Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` so the bucket sees the real client IP.
|
||||
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
|
||||
|
||||
## Health Check
|
||||
|
||||
|
||||
+30
-12
@@ -7,7 +7,7 @@
|
||||
```mermaid
|
||||
graph TD
|
||||
Browser[Browser / Frontend]:::accent6
|
||||
Nginx[nginx reverse proxy]:::accent6
|
||||
Caddy[Caddy reverse proxy]:::accent6
|
||||
Server[cmd/server/main.go]:::accent0
|
||||
Config[internal/config/]:::accent7
|
||||
Handler[internal/handler/]:::accent0
|
||||
@@ -20,8 +20,8 @@ graph TD
|
||||
SMTP[(SMTP server)]:::accent2
|
||||
JSONL[(data_dir/newsletter-*.jsonl)]:::accent2
|
||||
|
||||
Browser -->|POST + Origin| Nginx
|
||||
Nginx -->|X-Forwarded-For| Server
|
||||
Browser -->|POST + Origin| Caddy
|
||||
Caddy -->|X-Forwarded-For| Server
|
||||
Server --> Config
|
||||
Server --> Handler
|
||||
Handler --> RateLimit
|
||||
@@ -32,8 +32,8 @@ graph TD
|
||||
Handler --> Storage
|
||||
Sender -->|SMTP PLAIN over net/smtp| SMTP
|
||||
Storage -->|append JSONL| JSONL
|
||||
Handler -->|200 / 4xx / 5xx| Nginx
|
||||
Nginx --> Browser
|
||||
Handler -->|200 / 4xx / 5xx| Caddy
|
||||
Caddy --> Browser
|
||||
|
||||
classDef accent0 fill:#3B82F6,stroke:#2563EB,color:#fff
|
||||
classDef accent1 fill:#22C55E,stroke:#16A34A,color:#fff
|
||||
@@ -74,14 +74,28 @@ The `Form` type mirrors the TOML shape one-to-one. `ValidFormTypes` is the autho
|
||||
|
||||
### 3. HTTP handler — `internal/handler/`
|
||||
|
||||
`ContactHandler` stores its dependencies behind small interfaces so the handler pipeline can be tested without a real SMTP server or filesystem:
|
||||
|
||||
```go
|
||||
type formSender interface {
|
||||
Send(req contactform.Request) error
|
||||
}
|
||||
|
||||
type subscriberStorer interface {
|
||||
Append(sub storage.Subscriber) error
|
||||
}
|
||||
```
|
||||
|
||||
`email.FormSender` and `storage.NewsletterStore` satisfy these interfaces in production. Tests use mock implementations (`mockSender`, `mockStore`) that record calls and simulate errors.
|
||||
|
||||
`ContactHandler` is a single struct with four maps keyed by URL path:
|
||||
|
||||
```go
|
||||
type ContactHandler struct {
|
||||
forms map[string]*config.Form
|
||||
senders map[string]*email.FormSender
|
||||
senders map[string]formSender
|
||||
rateLimits map[string]*rateLimiter
|
||||
stores map[string]*storage.NewsletterStore
|
||||
stores map[string]subscriberStorer
|
||||
}
|
||||
```
|
||||
|
||||
@@ -142,6 +156,10 @@ sequenceDiagram
|
||||
end
|
||||
```
|
||||
|
||||
### Rate limiter cleanup
|
||||
|
||||
The per-form `rateLimiter` type starts a background goroutine (`startCleanup`) that ticks once per hour and deletes IP entries older than twice the refill window. This prevents unbounded memory growth from one-off IPs. The cleanup goroutine is covered by `TestRateLimiterCleanup` in `contact_test.go`.
|
||||
|
||||
Notable behaviors:
|
||||
|
||||
- **CORS is path-aware** — Each form has its own `allowed_origins` list; preflights and actual requests are checked against the form being hit, not the host.
|
||||
@@ -182,13 +200,13 @@ There is **no rotation**. Add a `logrotate` unit if the file grows beyond what y
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Browser
|
||||
participant Nginx
|
||||
participant Caddy
|
||||
participant Nuntius as nuntius
|
||||
participant SMTP
|
||||
participant Disk
|
||||
|
||||
Browser->>Nginx: POST /api/nuntius/contact
|
||||
Nginx->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
|
||||
Browser->>Caddy: POST /api/nuntius/contact
|
||||
Caddy->>Nuntius: forward (X-Forwarded-For, X-Real-IP)
|
||||
Nuntius->>Nuntius: CORS check
|
||||
Nuntius->>Nuntius: rate-limit per IP
|
||||
Nuntius->>Nuntius: parse JSON
|
||||
@@ -199,8 +217,8 @@ sequenceDiagram
|
||||
opt newsletter form
|
||||
Nuntius->>Disk: append one JSONL line
|
||||
end
|
||||
Nuntius-->>Nginx: 200 {"ok": true}
|
||||
Nginx-->>Browser: 200 {"ok": true}
|
||||
Nuntius-->>Caddy: 200 {"ok": true}
|
||||
Caddy-->>Browser: 200 {"ok": true}
|
||||
```
|
||||
|
||||
## Config Merging
|
||||
|
||||
+17
-21
@@ -1,6 +1,6 @@
|
||||
# Production Deployment
|
||||
|
||||
This guide covers installing nuntius on a Linux server with `nginx`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes.
|
||||
This guide covers installing nuntius on a Linux server with `Caddy`, `systemd`, and `rsync` already in place — the same setup used by [petrbalvin.org](https://www.petrbalvin.org). The same `rsync -avz` workflow that ships the static website works for nuntius with no changes.
|
||||
|
||||
## Architecture in Production
|
||||
|
||||
@@ -8,21 +8,21 @@ This guide covers installing nuntius on a Linux server with `nginx`, `systemd`,
|
||||
graph LR
|
||||
Browser[Visitor's browser]:::accent6
|
||||
Internet((Internet)):::accent2
|
||||
Nginx[nginx :443]:::accent0
|
||||
Caddy[Caddy :443]:::accent0
|
||||
Nuntius[nuntius :8080]:::accent1
|
||||
Systemd[systemd unit]:::accent7
|
||||
SMTP[(SMTP provider)]:::accent2
|
||||
Disk[(/var/lib/nuntius/data/*.jsonl)]:::accent2
|
||||
|
||||
Browser --> Internet
|
||||
Internet --> Nginx
|
||||
Nginx -->|proxy_pass /api/nuntius/| Nuntius
|
||||
Internet --> Caddy
|
||||
Caddy -->|reverse_proxy /api/nuntius/| Nuntius
|
||||
Systemd -.->|manages| Nuntius
|
||||
Nuntius -->|SMTP PLAIN| SMTP
|
||||
Nuntius -->|append JSONL| Disk
|
||||
```
|
||||
|
||||
nuntius listens on `127.0.0.1:8080` (default). nginx terminates TLS on `:443` and proxies `/api/nuntius/*` to the upstream. systemd restarts the process on failure.
|
||||
nuntius binds `[::]:8080` by default — the dual-stack IPv6 wildcard that accepts both IPv4 and IPv6 connections on Linux and FreeBSD. Caddy should be configured to proxy to `[::1]:8080` or `127.0.0.1:8080` for loopback-only access.
|
||||
|
||||
## 1. Build and Upload (Local Machine)
|
||||
|
||||
@@ -121,35 +121,31 @@ The script is idempotent: re-running on an already-installed host is safe. It:
|
||||
|
||||
The script ends with the exact next steps you need to run by hand.
|
||||
|
||||
## 3. nginx Reverse Proxy
|
||||
## 3. Caddy Reverse Proxy
|
||||
|
||||
Add this location block to the same nginx server that already serves your site (most likely `/etc/nginx/sites-available/petrbalvin.org`):
|
||||
Add this directive to your Caddyfile (most likely `/etc/caddy/Caddyfile`):
|
||||
|
||||
```nginx
|
||||
location /api/nuntius/ {
|
||||
proxy_pass http://127.0.0.1:8080/api/nuntius/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
```caddyfile
|
||||
handle_path /api/nuntius/* {
|
||||
reverse_proxy 127.0.0.1:8080
|
||||
}
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo nginx -t
|
||||
sudo systemctl reload nginx
|
||||
sudo caddy validate
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Without `X-Forwarded-For`, the rate limiter will see every request as coming from `127.0.0.1` and effectively become useless once the form is live. The `Host` header also matters for vhost-aware upstreams; the default is fine for a single nuntius instance.
|
||||
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
|
||||
|
||||
## 4. Wire It Up to Your Web
|
||||
|
||||
If you proxy `/api/nuntius/*` through nginx (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL:
|
||||
If you proxy `/api/nuntius/*` through Caddy (recommended, step 3 above), `VITE_NUNTIUS_URL` can stay empty and the form will post to the same origin as the page. Otherwise, set it to the bare upstream URL:
|
||||
|
||||
```bash
|
||||
# .env (in the web project, only needed if NOT proxying through nginx)
|
||||
# .env (in the web project, only needed if NOT proxying through Caddy)
|
||||
VITE_NUNTIUS_URL=https://your-server:8080
|
||||
```
|
||||
|
||||
@@ -227,10 +223,10 @@ curl -s http://127.0.0.1:8080/health | jq .
|
||||
# Recent log lines
|
||||
journalctl -u nuntius -n 50 --no-pager
|
||||
|
||||
# nginx proxy works
|
||||
# Caddy proxy works
|
||||
curl -i https://your-domain.example/api/nuntius/health
|
||||
|
||||
# End-to-end POST through nginx
|
||||
# End-to-end POST through Caddy
|
||||
curl -X POST https://your-domain.example/api/nuntius/contact \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Origin: https://your-domain.example" \
|
||||
|
||||
+5
-3
@@ -9,7 +9,7 @@ nuntius is designed to defend against:
|
||||
| Threat | Mitigation |
|
||||
|---|---|
|
||||
| Naive spam bots filling the form | Per-form honeypot field that silently accepts and drops |
|
||||
| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory) |
|
||||
| Casual brute-force / abuse | Per-form, per-IP token-bucket rate limit (in-memory, with periodic cleanup goroutine) |
|
||||
| Cross-origin abuse from a third-party site | Per-form CORS allowlist enforced on preflight and actual requests |
|
||||
| Malformed bodies | Strict JSON parsing and per-type field validation |
|
||||
| SMTP credential leakage in logs | `log/slog` never logs the `From` / `Authorization` / password; the password only lives in `FormSender` |
|
||||
@@ -81,12 +81,14 @@ The IP used is taken from:
|
||||
2. `X-Real-IP`
|
||||
3. `RemoteAddr` (with port stripped)
|
||||
|
||||
Behind nginx, set `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;` — otherwise every request looks like it came from `127.0.0.1` and the bucket becomes a single global bucket.
|
||||
Caddy forwards `X-Forwarded-For` automatically — so the real client IP is always passed to nuntius without any extra configuration.
|
||||
|
||||
To disable rate limiting on a specific form, set `rate_limit_per_hour: 0`. There is no global safety net.
|
||||
|
||||
State is per-process and resets on restart. If you scale nuntius horizontally, each replica has its own bucket; the effective limit is `replicas × per_form_limit`.
|
||||
|
||||
A background goroutine (`startCleanup`) ticks once per hour and removes IP entries that are older than twice the refill window. This prevents the in-memory bucket map from growing unboundedly as one-off IPs accumulate over weeks of uptime.
|
||||
|
||||
## Secret Handling
|
||||
|
||||
SMTP credentials live in `/etc/nuntius/.env` (mode `0600`, owner `root:nuntius`). The config file references them as `${NUNTIUS_SMTP_PASSWORD}`; `os.Expand` substitutes them **before** the TOML is parsed, so the parsed `config.Form` holds the expanded value in memory only for the lifetime of the process.
|
||||
@@ -133,7 +135,7 @@ The graceful shutdown budget is 15 s (`srv.Shutdown` with `context.WithTimeout`)
|
||||
- **No CAPTCHA / proof-of-work.** If you need a stronger abuse signal, add a reverse proxy with a CAPTCHA in front of nuntius; the same CORS rules apply.
|
||||
- **No request signing / HMAC.** A frontend that has been compromised can post at the configured rate from a single IP. The token bucket handles the volume; the origin allowlist limits the surface.
|
||||
- **No per-IP persistence across restarts.** Restart resets the buckets. That is an acceptable trade-off for a small backend and is documented in the rate-limit section above.
|
||||
- **No TLS for the nuntius listener itself.** nuntius is meant to run behind nginx (or another reverse proxy) that terminates TLS. Exposing the binary directly on `:8080` is a development convenience, not a production pattern.
|
||||
- **No TLS for the nuntius listener itself.** nuntius binds `[::]:8080` (the dual-stack IPv6 wildcard) and is meant to run behind Caddy (or another reverse proxy) that terminates TLS. Exposing the binary directly is a development convenience, not a production pattern.
|
||||
- **No DKIM signing.** Outbound mail is signed by your SMTP provider, not by nuntius.
|
||||
- **No TLS for the SMTP connection itself is configured by nuntius.** Go's `net/smtp.SendMail` opportunistically upgrades to TLS via STARTTLS when the server advertises it; if you need implicit TLS on `:465`, wrap `SendMail` in a custom dialer.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user