# Config Editor — Design Spec **Date:** 2026-05-09 **Status:** Approved ## Goal Allow admins to edit the full `.hb.yaml` config through the Settings page UI, and allow regular users to manage their own notification channels and profile fields through the Profile page. The YAML file remains the single authoritative source; comments are preserved on every write. --- ## Architecture Overview ``` Browser (admin) Browser (user) staged edits (JS state) form fields │ │ │ POST /api/0/config │ PUT /api/0/users/me ▼ ▼ http.py handlers ────────────────────────┘ │ ▼ configio.py ←── ruamel.yaml (round-trip, comment-preserving) │ ├── backup .hb.yaml.bak.YYYYMMDD-HHMMSS (keep last 10) ├── write atomically (temp file → os.replace) └── ReloadableConfig.reload() ``` --- ## New Dependency Add `ruamel.yaml>=0.18` to `[project.optional-dependencies] server` in `pyproject.toml`. `PyYAML` stays (used by the client and config loader for reads); `ruamel.yaml` is used only for write-back. --- ## New Module: `hbd/server/configio.py` Single responsibility: all YAML read/write for `.hb.yaml`. ```python _write_lock = threading.Lock() def read_roundtrip(path: str) -> CommentedMap: """Load .hb.yaml with ruamel.yaml, preserving comments and ordering.""" def write_config(path: str, data: CommentedMap) -> None: """Backup current file, then atomically write data. Backup naming: {path}.bak.YYYYMMDD-HHMMSS Rotation: keep the 10 most recent backups, delete older ones. Atomic write: write to {path}.tmp, then os.replace({path}.tmp, path). Acquires _write_lock for the full backup+write sequence. """ def list_backups(path: str) -> list[str]: """Return backup paths sorted newest-first.""" def apply_structured_section(data: CommentedMap, section: str, values: dict) -> None: """Merge a dict of scalar/list values into data[section], key by key. Preserves comments on unmodified keys. """ def apply_yaml_section(data: CommentedMap, section: str, yaml_text: str) -> None: """Replace data[section] entirely by parsing yaml_text. Used for YAML-editor sections (notification_channels, thresholds, hosts, dns). """ ``` --- ## API Endpoints All endpoints require authentication. Admin-only endpoints return 403 for non-admins. | Method | Path | Auth | Purpose | |--------|------|------|---------| | GET | `/api/0/config` | admin | Full config as JSON (secrets masked) | | POST | `/api/0/config` | admin | Publish staged changes to `.hb.yaml` | | GET | `/api/0/config/section/{name}` | admin | Raw YAML text for one section (for YAML editors) | | GET | `/api/0/config/backups` | admin | List of backup timestamps, newest first | | POST | `/api/0/config/rollback` | admin | `{"backup": "…"}` → restore backup and reload | | PUT | `/api/0/users/me` | any user | Update own `full_name`, `avatar`, `notification_channels`, `password` | ### `POST /api/0/config` payload ```json { "server": { "hbd_port": 50004, "interval": 20, ... }, "users": { "alice": { "full_name": "Alice", "admin": true, ... }, ... }, "oauth": { "gitea": { "type": "gitea", "url": "...", ... }, ... }, "notification_channels": "", "thresholds": "", "hosts": "", "dns": "" } ``` Only sections present in the payload are updated; omitted sections are left unchanged in the file. **Section-to-key mapping:** Most config fields are top-level keys in `.hb.yaml` (not nested under a section key). The API uses logical section names that map to specific top-level keys: | Logical section | Top-level YAML keys covered | |---|---| | `server` | `hbd_port`, `hbd_host`, `ws_port`, `wss_port`, `hb_port`, `interval`, `grace`, `base_url`, `threshold_renotify_interval`, `logfile`, `pidfile`, `pickfile`, `journal_enabled`, `journal_dir`, `journal_max_size`, `journal_max_backups`, `default_owner` | | `users` | `users` (top-level dict) | | `oauth` | `oauth` (top-level dict) | | `notification_channels` | `notification_channels` (top-level dict, YAML text) | | `thresholds` | `threshold_configs` (top-level dict if present, YAML text) | | `hosts` | `hosts` (top-level dict, YAML text) | | `dns` | `nsupdate_bin`, `dyndomains`, `dyndnshosts`, `drophosts` (YAML text of just these keys) | `apply_structured_section` for `server` iterates the known key list and updates each present key individually, preserving comments on unchanged keys. `apply_yaml_section` for dict-valued sections (notification_channels, hosts, oauth) replaces the entire subtree. For `dns`, it replaces each of the four top-level keys listed. ### `PUT /api/0/users/me` payload ```json { "full_name": "Alice Smith", "avatar": "/avatars/alice.png", "notification_channels": ["pushover_ops", "matrix_alerts"], "password": { "current": "oldpass", "new": "newpass" } } ``` All fields are optional. `password` change requires `current` to match; server re-hashes with PBKDF2-HMAC-SHA256 before writing. Both `full_name`/`avatar`/`notification_channels` and password can be sent in one request or separately. --- ## Settings Page Changes (`/settings`) ### Section split | Section | Edit mode | Notes | |---------|-----------|-------| | Server settings | Form | Scalar fields: ports, intervals, base_url, grace, renotify interval, log/pid/pickle paths, journal settings | | Users | Form | CRUD list: add/edit/delete users; fields: username, full_name, avatar, admin toggle, notification_channels multiselect. Password field: leave blank to keep existing hash; enter a new plain-text password to replace it (server hashes before writing). New users require a password. | | OAuth providers | Form | CRUD list: add/edit/delete providers; fields: name (slug), type, url, client_id, client_secret, label, logo | | Notification channels | YAML editor | Too many provider-specific credential shapes for typed forms | | Thresholds | YAML editor | Complex nested rules | | Hosts | YAML editor | Complex per-host config | | DNS / DynDNS | YAML editor | nsupdate settings, dyndomains, drophosts | ### Publish flow 1. Each section has a **"Stage changes"** button. Clicking it stores that section's current form/editor values in browser JS state. A banner appears: *"N pending changes — not yet saved to .hb.yaml"*. 2. **"Publish to .hb.yaml"** sends `POST /api/0/config` with all staged sections. 3. On success: banner clears, page reloads to show current saved state. 4. **"Discard all"** clears JS state and reloads from server without writing. ### Rollback UI A "View backups / rollback" link at the bottom of the settings sidebar opens a modal listing available backups (timestamp + approximate age). Clicking a backup shows a confirmation prompt before calling `POST /api/0/config/rollback`. ### `settings.py` changes - Set `"editable": True` on all fields that now have form inputs. - The existing field descriptor structure (`key`, `type`, `label`, `value`, `sensitive`) is already designed for this — no structural changes needed. - Add `"section_mode": "form" | "yaml"` per section, used by the template to render the appropriate editor. --- ## Profile Page Changes (`/profile`) New editable fields alongside the existing read-only display: **Identity card** (saves via `PUT /api/0/users/me`): - Display name — text input, current `full_name` - Avatar — text input, current `avatar` URL or path - Save button → immediate write, no publish step **Change password** (saves via `PUT /api/0/users/me`): - Current password, new password inputs - Save button → validates current password server-side, re-hashes new password, writes **Notification channels** (saves via `PUT /api/0/users/me`): - Checkbox list of all globally-defined channels (from `config["notification_channels"]`) - Shows channel type and `min_level` as secondary text - Pre-checked based on user's current `notification_channels` list - Save button → writes user's channel list immediately Host access list remains read-only (existing behaviour). --- ## Write Safety - `configio._write_lock` serializes all writes (admin publish and user self-service can race if multiple requests arrive simultaneously). - All writes are atomic: temp file written in same directory as `.hb.yaml`, then `os.replace()`. A crash mid-write leaves the backup intact and the original file unchanged. - If `.hb.yaml` cannot be written (permissions, disk full), the API returns `500` with an error message; no partial write occurs. --- ## Secrets Handling - `GET /api/0/config` masks sensitive fields (passwords, tokens, API keys) with `"•••"` — same logic as the existing read-only settings page. - `GET /api/0/config/section/{name}` for YAML-editor sections returns the raw YAML text including real credential values, since the admin needs to edit them. This endpoint requires admin auth and must only be served over HTTPS in production. - Secrets in backups are unmasked (they are copies of the real file). Backup directory should have the same file permissions as `.hb.yaml` itself. --- ## Out of Scope - Conflict detection if `.hb.yaml` is modified externally between page load and publish (the last write wins; the previous state is always recoverable from a backup) - Multi-admin concurrent edit awareness - Config validation UI beyond what the server returns as errors - Diff view before publish - Audit log of who published what (beyond the event log entry already added for login/logout) - Per-host threshold editing via UI (thresholds section uses YAML editor)