Skip to content

Commit a09bec0

Browse files
committed
feat(web): left-nav design system + fix SSE XSS
Visual rework into a standard admin shell: fixed left sidebar (brand, nav with active state, sign-out) + fluid content area with a page-title header bar. base.html is now the authenticated shell (pages fill page_title/head_actions/ content); login and setup move to a standalone centered _auth_base.html. A full dependency-free design system in style.css (CSS tokens + reusable card/ table/chip/banner/button/form components; dark sidebar, light content). Also: load the vendored htmx SSE extension on the run view (the live view used hx-ext="sse" but only htmx core was loaded), and render SSE run-error fragments through an autoescaped Jinja macro instead of hand-built HTML — a real XSS vector flagged by security review, since a backup exception message can carry router-/config-derived text. doc/WEB_PLATFORM.md gains a UI/design-system section. 362 tests; ruff/mypy/bandit clean.
1 parent df5c372 commit a09bec0

15 files changed

Lines changed: 434 additions & 133 deletions

doc/WEB_PLATFORM.md

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Web Platform (`rosbackup-web`)
2+
3+
A self-hosted web UI + HTTP API for rosbackup-ng: see the fleet, run backups (whole
4+
fleet / group / tag / single target) with live progress, browse and download backup
5+
history, and (optionally) watch per-target latency. It embeds the same console-free
6+
backup engine the CLI uses — the web layer is presentation around the existing core,
7+
not a fork of the backup logic.
8+
9+
> **Scope (first iteration).** Read & run: dashboard, targets, run-now with a live
10+
> view, run history, and a backup browser/download over the **local** destination.
11+
> Schedules, API tokens, remote-destination browsing, and push-restore are later
12+
> phases (see `docs/`/the design spec). RBAC and capability toggles are future work.
13+
14+
## Install
15+
16+
The web platform is an optional extra so the core CLI keeps its lean dependency set:
17+
18+
```bash
19+
pip install "rosbackup-ng[web]"
20+
# from a checkout:
21+
pip install -e ".[web]"
22+
```
23+
24+
This adds the `rosbackup-web` console script (FastAPI + uvicorn + Jinja2; htmx is
25+
vendored in-tree — no Node, no CDN). A source checkout can also run it directly with
26+
`python rosbackup-web.py …`.
27+
28+
## First run
29+
30+
The web app reads the **same config directory** as the CLI (`-c`), and the YAML files
31+
stay the single source of truth. Runtime state (run history, sessions, audit log)
32+
lives in a SQLite database at `<config-dir>/web.db` (0600).
33+
34+
```bash
35+
# 1) set the admin password (out-of-band; never exposed over HTTP)
36+
rosbackup-web -c /etc/rosbackup --set-password
37+
38+
# 2) start the server (binds 127.0.0.1:8474 by default)
39+
rosbackup-web -c /etc/rosbackup
40+
```
41+
42+
Open <http://127.0.0.1:8474>. Until a password is set, every page returns the
43+
"set the password from the shell" notice — there is no network-exposed setup wizard.
44+
45+
### Options
46+
47+
| Flag | Default | Meaning |
48+
|---|---|---|
49+
| `-c`, `--config-dir` | `config` | Config directory (same as the CLI) |
50+
| `--host` | `127.0.0.1` | Bind address. Anything other than localhost prints a warning — terminate TLS at a reverse proxy |
51+
| `--port` | `8474` | Bind port |
52+
| `--db` | `<config-dir>/web.db` | Web state database path |
53+
| `--set-password` || Set the admin password and exit (does not start the server) |
54+
| `--latency` | off | Enable optional ICMP latency monitoring of target hosts |
55+
| `--latency-interval` | `30` | Latency poll interval in seconds (implies `--latency`) |
56+
57+
## Security model
58+
59+
- **Single admin account.** Password stored as a stdlib `scrypt` hash in `web.db`;
60+
set only via `--set-password`.
61+
- **Sessions:** HMAC-signed cookie (`HttpOnly`, `SameSite=Lax`, `Secure` behind TLS),
62+
30-minute idle / 12-hour absolute timeout.
63+
- **CSRF:** every mutating request needs the per-session token (htmx sends it as a
64+
header; plain forms include it as a hidden field).
65+
- **Login lockout:** 5 failures → 60-second lockout.
66+
- **Headers:** self-only Content-Security-Policy (htmx/CSS are vendored), `DENY`
67+
framing, `nosniff`.
68+
- **Bind localhost; let a reverse proxy own TLS** (see below).
69+
- Secrets (passwords, storage credentials) are never rendered.
70+
71+
## Behind a reverse proxy (TLS via Caddy + deSEC DNS-01)
72+
73+
For internal-only names or hosts without inbound :80/:443, the DNS-01 ACME challenge
74+
issues real certificates without exposing the box. Example with [deSEC](https://desec.io):
75+
76+
See [`examples/Caddyfile`](examples/Caddyfile). In short:
77+
78+
```
79+
rosbackup.example.com {
80+
reverse_proxy 127.0.0.1:8474
81+
tls {
82+
dns desec {env.DESEC_TOKEN}
83+
}
84+
}
85+
```
86+
87+
(Build Caddy with the deSEC DNS provider plugin: `xcaddy build --with
88+
github.com/caddy-dns/desec`, or use a Caddy build that bundles it.) Caddy sets
89+
`X-Forwarded-Proto: https`, so the session cookie is issued with `Secure`.
90+
91+
## Run as a service (systemd)
92+
93+
See [`examples/rosbackup-web.service`](examples/rosbackup-web.service). It runs the
94+
server as an unprivileged user bound to localhost, with the config directory and
95+
`web.db` on persistent storage.
96+
97+
## HTTP API (`/api/v1`)
98+
99+
Session cookie (UI) auth today; bearer tokens arrive in a later phase. Selected
100+
endpoints:
101+
102+
```
103+
GET /api/v1/health liveness (unauthenticated): version, db ok
104+
GET /api/v1/targets[?group=&tag=] inventory
105+
GET /api/v1/groups | /api/v1/tags names + device counts (the --list data)
106+
POST /api/v1/runs {scope_kind, scope_value, dry_run} -> 202 {run_id, started}
107+
GET /api/v1/runs[?limit=&offset=] run history
108+
GET /api/v1/runs/{id} run + per-target results (incl. storage destinations)
109+
GET /events/runs/{id} Server-Sent-Events live stream
110+
GET /api/v1/backups/{target} local backup sets for a target
111+
GET /api/v1/backups/{target}/download?path=<dir>/<file> download one backup file
112+
```
113+
114+
`scope_kind` is `all` | `group` | `tag` | `target`; non-`all` scopes require
115+
`scope_value`. Only **one backup run executes at a time** (a global lock); a request
116+
made while a run is active returns that run's id with `started: false`.
117+
118+
## UI & design system
119+
120+
The UI is a standard admin shell: a fixed **left sidebar** (brand + nav + sign-out)
121+
and a fluid content area with a header bar (`page_title` + optional `head_actions`).
122+
It is intentionally dependency-free — no CSS framework, no build step.
123+
124+
- **Layout:** `templates/base.html` is the authenticated shell; pages `{% extends %}`
125+
it and fill `page_title`, `head_actions`, and `content`. Pre-auth pages
126+
(`login`, `setup`) extend the standalone `_auth_base.html` (centered card).
127+
- **Tokens & components:** all styling lives in `static/style.css` via CSS custom
128+
properties (`--side-bg`, `--accent`, `--ok/--bad/--warn`, `--radius`, …). Reusable
129+
component classes: `.card` / `.card-head`, the `.cards`+`.stat` summary grid,
130+
`table` (+ `.grid`), `.chip` (+ `.ok/.bad/.running/.muted/.group`), `.banner`
131+
(`.info`/`.banner-warn`/`.banner-ok`), `.btn`/`.btn.primary`, and the `.filters`
132+
/ `.run-form` form helpers. Add new screens by composing these, not new CSS.
133+
- **Theme:** dark sidebar, light content. To restyle, change the tokens at the top of
134+
`style.css`; components follow.
135+
136+
## Latency monitoring (optional)
137+
138+
`--latency` starts a background thread that pings each target host on an interval and
139+
shows reachability + round-trip time in the Targets table. It shells out to the system
140+
`ping` (no root, no extra dependency); hosts that do not answer show as `down`. It is
141+
purely observational — it never affects backups.

doc/examples/Caddyfile

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Example Caddyfile for rosbackup-web behind TLS via the deSEC DNS-01 challenge.
2+
#
3+
# Build Caddy with the deSEC DNS provider:
4+
# xcaddy build --with github.com/caddy-dns/desec
5+
# Provide the deSEC API token in the environment (e.g. systemd EnvironmentFile):
6+
# DESEC_TOKEN=...
7+
#
8+
# DNS-01 issues real certificates WITHOUT needing inbound :80/:443 — ideal for
9+
# internal-only hostnames or hosts behind NAT.
10+
11+
rosbackup.example.com {
12+
encode zstd gzip
13+
reverse_proxy 127.0.0.1:8474 {
14+
# SSE (live run view) must not be buffered.
15+
flush_interval -1
16+
}
17+
tls {
18+
dns desec {env.DESEC_TOKEN}
19+
}
20+
}

doc/examples/rosbackup-web.service

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# systemd unit for the rosbackup-ng web platform.
2+
# Install to /etc/systemd/system/rosbackup-web.service, then:
3+
# systemctl daemon-reload && systemctl enable --now rosbackup-web
4+
#
5+
# Set the admin password once, as the service user, before first start:
6+
# sudo -u rosbackup rosbackup-web -c /etc/rosbackup --set-password
7+
8+
[Unit]
9+
Description=rosbackup-ng web platform
10+
After=network-online.target
11+
Wants=network-online.target
12+
13+
[Service]
14+
User=rosbackup
15+
Group=rosbackup
16+
# Use the venv that has the [web] extra installed.
17+
ExecStart=/opt/rosbackup/venv/bin/rosbackup-web -c /etc/rosbackup --host 127.0.0.1 --port 8474
18+
Restart=on-failure
19+
RestartSec=3
20+
21+
# Hardening — the service only needs to read the config and write web.db + backups.
22+
NoNewPrivileges=true
23+
ProtectSystem=strict
24+
ProtectHome=true
25+
PrivateTmp=true
26+
ReadWritePaths=/etc/rosbackup /var/lib/rosbackup
27+
28+
[Install]
29+
WantedBy=multi-user.target

0 commit comments

Comments
 (0)