P5: OSS readiness — docs site, contributor onboarding, e2e harness

P5-01 — Documentation site under docs/book/ rendered with mdBook
(downloaded via Makefile, same static-binary pattern as Tailwind).
Structured chapters: getting started, concepts, operations,
security, reference. `make docs` / `make docs-watch`. Generated
output gitignored.

P5-02 — CONTRIBUTING.md rewritten from placeholder to a full
guide. CODE_OF_CONDUCT.md adapted from Contributor Covenant for a
single-maintainer project. .gitea/issue_template/{bug,feature}.md
and PULL_REQUEST_TEMPLATE.md.

P5-04 — Six README screenshots captured live from a fresh server
bootstrap (login, empty dashboard, add-host, alerts, settings,
audit log). README rewritten to centre the screenshot grid and
link out to the docs site.

P5-05 — SECURITY.md with disclosure policy (3-day ack, 30-day
default window), scope in/out, threat-model summary, operator
hardening checklist. Mirrored as a docs-site chapter.

P5-06 — End-to-end test harness. e2e/compose.e2e.yml brings up
server + sibling Linux agent (alpine + restic) + restic/rest-server.
Agent uses announce-and-approve so Playwright can drive the full
operator flow: bootstrap → login → accept pending → backup →
verify terminal status. Second spec scrapes /metrics to assert
the P6-04 endpoint surface. .gitea/workflows/e2e.yml runs on every
PR; local how-to in docs/e2e.md.
This commit is contained in:
2026-05-07 23:56:02 +01:00
parent a252b25854
commit 89537d417a
47 changed files with 2818 additions and 61 deletions
@@ -0,0 +1,113 @@
# Enrolling your first host
The control plane only knows about hosts you've explicitly
enrolled. Two paths exist:
1. **Token-based enrolment** — admin generates a token, pastes it
into an install command on the host. The host appears immediately,
already mapped to the desired repo.
2. **Announce-and-approve** — the agent runs without a token,
"announces" itself to the server, and a human in the UI accepts
the announcement.
Token-based is the default and what most operators want; the
announce flow exists for the case where you can't easily paste a
secret onto the host (auto-imaged endpoints, scripted bring-ups
from a config repo).
## Token-based enrolment
### From the UI
1. Click **+ Add host** on the dashboard.
2. Fill in the hostname, the restic repo URL, and the repo
credentials. The credentials are AEAD-encrypted at the server
immediately; what you paste is what the agent receives.
3. Optionally pick the initial source paths — these become the
first source group on the host.
4. Submit. The server mints a one-time token and shows you a copy-
pasteable install snippet.
### On the host (Linux)
```sh
curl -fsSL https://restic.example.com/install/install.sh | \
sudo RM_SERVER=https://restic.example.com \
RM_ENROL_TOKEN=<token> \
bash
```
The script:
1. Detects architecture (`amd64` or `arm64`).
2. Downloads the agent binary from `/agent/binary?os=…&arch=…`.
3. Drops the systemd unit at
`/etc/systemd/system/restic-manager-agent.service`.
4. Runs the agent in `-enrol` mode, which posts the token and
stores the persistent bearer it gets back.
5. Enables and starts the unit.
Within seconds the host should appear on the dashboard as
**online**.
### On the host (Windows)
```pwsh
$env:RM_SERVER = "https://restic.example.com"
$env:RM_ENROL_TOKEN = "<token>"
iwr -useb $env:RM_SERVER/install/install.ps1 | iex
```
Equivalent shape: registers a Windows service via the SCM
(see P2-16 for details), runs `-enrol`, starts the service.
## Recovering a lost token
Tokens are single-use and short-lived (1h). If you closed the tab
before pasting the install command, head to the **Add host** page —
outstanding tokens are listed there with a **Regenerate** button.
Regenerating revokes the old token's hash and mints a fresh raw
token while preserving the original repo credentials and initial
paths. (NS-02 in `tasks.md` if you want the design rationale.)
## Announce-and-approve
If the host can reach the server but you don't want to paste a
secret on it, run the agent in `-announce` mode:
```sh
restic-manager-agent -announce \
-server https://restic.example.com \
-hostname myhost
```
The host appears in the **Pending hosts** panel on the dashboard
with its hostname, OS, arch, and the source IP that announced it.
Click **Accept**, fill in the repo URL + credentials, and the
server pushes the bearer over the still-open WebSocket. No
back-and-forth round trip.
If you don't accept within an hour the announcement is swept.
## What happens on the agent
After enrolment, the agent:
1. Connects via WebSocket to `/ws/agent` with its bearer token.
2. Sends a `hello` envelope with its OS, arch, agent version,
restic version, and protocol version.
3. Receives a `config.update` carrying its encrypted repo
credentials and any source-group paths.
4. Sits idle, sending a heartbeat every 30s. Operator-driven
"Run now" actions arrive as `command.run` envelopes; scheduled
jobs are driven by the agent's local cron.
## Auto-init of the repository
The first time a backup runs, the agent invokes `restic init`
against the repo you configured at enrolment. If the repo already
exists (`config file already exists`) the agent treats it as a
success and proceeds. The host's repo status (`unknown`
`ready` / `init_failed`) is surfaced under the vitals strip on
the host detail page; if init fails, save fresh credentials in
the **Repo** tab to retry.
+92
View File
@@ -0,0 +1,92 @@
# Installing the server
The reference deployment is a single Docker container fronted by
your existing reverse proxy. The image bundles the server binary,
the cross-compiled agent binaries, and the install scripts.
## Prerequisites
- A Linux host with Docker and Docker Compose.
- A reverse proxy in front (Caddy, nginx, Traefik) terminating
TLS on a public hostname. The server itself is HTTP-only by
design — see [Reverse proxy](./reverse-proxy.md) for why.
- A persistent volume for the server's data directory.
## Quick start
The reference compose file lives at
[`deploy/docker-compose.yml`](https://gitea.dcglab.co.uk/steve/restic-manager/src/branch/main/deploy/docker-compose.yml):
```yaml
services:
restic-manager:
image: gitea.dcglab.co.uk/steve/restic-manager:${RM_VERSION:-latest}
restart: unless-stopped
environment:
RM_LISTEN: ":8080"
RM_DATA_DIR: "/data"
RM_BASE_URL: "https://restic.example.com"
# Trust your reverse proxy's CIDR so X-Forwarded-* are honoured.
RM_TRUSTED_PROXY: "10.0.0.0/8"
volumes:
- rm-data:/data
ports:
# Bind localhost only — your reverse proxy is the public face.
- "127.0.0.1:8080:8080"
volumes:
rm-data:
```
Bring it up:
```sh
docker compose up -d
docker compose logs -f restic-manager
```
The first run prints a one-time **bootstrap token** to the log. Use
it within an hour or it expires; if you miss the window the
container print it again on next start as long as no admin user
exists.
## First-run admin setup
Open `https://restic.example.com/bootstrap` (or whatever your
public URL is). Paste the bootstrap token, pick a username and a
password (≥ 12 characters), and submit. You'll land in the
dashboard logged in as the new admin.
If you'd rather curl it, the equivalent is:
```sh
curl -X POST https://restic.example.com/api/bootstrap \
-H 'Content-Type: application/json' \
-d '{"token":"<token-from-log>","username":"admin","password":"<≥12 chars>"}'
```
## Backing up the secret key
Inside the data volume, `secret.key` holds the AEAD key used to
encrypt every credential at rest. **Back it up separately from
the database.** Without it, encrypted credentials in the database
are unrecoverable; you'd have to re-enrol every host.
A simple working approach: copy `secret.key` to your password
manager or to a separately-backed-up secrets vault the day you
install. It doesn't change.
## Updating the server
```sh
# Pin a new version in your compose file (.env or docker-compose.yml),
# then:
docker compose pull
docker compose up -d
```
Migrations run automatically on startup; the server will refuse to
start if a migration fails (better to bail than to half-migrate).
For the agent self-update story, see
[Updating agents](../operations/updates.md).
@@ -0,0 +1,95 @@
# Running behind a reverse proxy
The restic-manager server is HTTP-only by design. TLS termination,
public hostname, ACME, HSTS, and edge-level rate limiting all
belong to a reverse proxy you already operate outside this project.
## What the proxy must forward
The server reads four headers when (and only when) the immediate
peer matches `RM_TRUSTED_PROXY`:
| Header | Value | Why |
|------------------------|----------------------------------------------------|-----|
| `X-Forwarded-For` | The original client IP | Rate-limit keys, audit log entries, OIDC redirect-URI checks. |
| `X-Forwarded-Proto` | `https` | Used for absolute URLs (e.g. OIDC redirect URIs). |
| `Host` | The public hostname clients use | Cookies are scoped to this; `RM_BASE_URL` must match. |
| `Connection` / `Upgrade` | Pass through unchanged | `/ws/agent` and `/api/jobs/{id}/stream` are WebSockets; without `Upgrade: websocket` they fail. |
Set `RM_TRUSTED_PROXY` to the CIDR (or comma-separated list of
CIDRs) the proxy connects from. Anything outside that range has
its `X-Forwarded-*` headers ignored, so a stray request that
bypasses the proxy can't spoof the client IP.
## Caddy
```caddyfile
restic.example.com {
encode zstd gzip
reverse_proxy 127.0.0.1:8080 {
header_up X-Real-IP {remote_host}
}
}
```
Caddy adds `X-Forwarded-For` / `X-Forwarded-Proto` automatically
and passes WebSocket headers through by default, so this is the
whole config.
## nginx
```nginx
server {
listen 443 ssl http2;
server_name restic.example.com;
ssl_certificate /etc/letsencrypt/live/restic.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/restic.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
# WebSocket upgrade
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
# Long-lived agent WS — disable read timeout for this surface.
proxy_read_timeout 86400s;
}
}
```
## Traefik
```yaml
http:
routers:
restic-manager:
rule: "Host(`restic.example.com`)"
entryPoints: [websecure]
tls:
certResolver: letsencrypt
service: restic-manager
services:
restic-manager:
loadBalancer:
servers:
- url: "http://restic-manager:8080"
passHostHeader: true
```
Traefik forwards WebSocket upgrades and the standard
`X-Forwarded-*` set out of the box.
## Verification
After bringing the proxy up, the audit log should show your real
client IP for an interactive login (not the proxy's local
address). If you see `127.0.0.1` or the proxy's container IP, your
`RM_TRUSTED_PROXY` is wrong or `X-Forwarded-For` isn't being
forwarded.