Compare commits

...

43 Commits

Author SHA1 Message Date
Andreas Wrede 3401cc0dbb version 5.3.1
Release / release (push) Successful in 6s
2026-05-10 14:03:58 -04:00
Andreas Wrede ab0132a38d fix: correct THRESHOLD_DEFAULTS metric keys and add missing defaults
- Rename memory_monitor threshold key from 'percent' to 'memory_percent'
  so it matches exactly rather than relying on suffix stripping, which was
  causing swap_percent to be evaluated against the memory threshold
- Add swap_percent default thresholds (warning: 40%, critical: 75%)
- Add zfs_monitor pool capacity default thresholds (warning: 80%, critical: 90%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 14:03:44 -04:00
andreas 9e389736f8 feat: show suffix-matched metric coverage in host info threshold table
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 09:18:49 -04:00
andreas b64a2a9313 feat: move hbc_version and hbc_type out of os_info into host info section 2026-05-10 08:33:28 -04:00
andreas a52744a448 feat: fetch and render host info section on card expand 2026-05-10 08:31:32 -04:00
andreas 5e2b04b811 feat: add fetchHostInfo and renderInfoSection JS functions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:29:53 -04:00
andreas 8e07b09d7e feat: add host info section placeholder and CSS to plugins.html 2026-05-10 08:21:17 -04:00
andreas 653e018e4f feat: add GET /api/0/hosts/{hostname}/info endpoint 2026-05-10 08:18:49 -04:00
andreas c7326da7d9 feat: add _build_host_info helper for host info endpoint
Extracts host info assembly (owner, managers, hbc version/type,
last packet timestamp, threshold configs) into a testable module-level
helper, with 10 covering tests.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:17:47 -04:00
andreas 0426a75d8c docs: add implementation plan for host overview info section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:09:47 -04:00
andreas 539f25d877 docs: add design spec for host overview info section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 08:04:38 -04:00
andreas 3e3099fc6d version 5.3.0
Release / release (push) Successful in 5s
2026-05-09 12:16:09 -04:00
andreas c9f15a3f1c fix: correct grace comment in config defaults — additional wait time, not a multiplier 2026-05-09 12:14:47 -04:00
andreas 6e396ad760 fix: correct grace field label and description — it is additional wait time, not a multiplier 2026-05-09 12:13:11 -04:00
andreas 2800de0b4a fix: preserve .hb.yaml file permissions on backup and atomic write 2026-05-09 12:04:46 -04:00
andreas 15f7e6a64d feat: profile page self-service for identity, password, and notification channels
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:57:47 -04:00
andreas 9768d13b88 feat: settings page editor with form sections, YAML editors, stage/publish/rollback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:55:10 -04:00
andreas 8640d731aa feat: add section_mode, api_section, editable flags and oauth section to settings
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:49:41 -04:00
andreas de81751e59 fix: validate password body type and coerce notification_channels to strings in PUT /api/0/users/me 2026-05-09 11:46:58 -04:00
andreas 60c692cefc feat: add PUT /api/0/users/me for user self-service profile updates
Allows any authenticated user to update their own full_name, avatar,
notification_channels, and password via the config YAML write path.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:45:09 -04:00
andreas 9a0baf3c78 fix: preserve oauth client_secret on roundtrip, harden rollback path validation, guard non-dict payload
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:43:14 -04:00
andreas 55bdb9593a feat: add config write API (POST /api/0/config, POST /api/0/config/rollback)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:35:45 -04:00
andreas 2009626fb4 fix: config read API error handling, consistent 403 messages, deduplicate key lists
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:33:18 -04:00
andreas 18769afd37 feat: add config read API (GET /api/0/config, /section/{name}, /backups)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:25:06 -04:00
andreas 31db5cf35e fix: configio thread safety, tmp cleanup, backup collision, dns empty handling
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:21:03 -04:00
andreas 326f53f23d feat: add configio module for comment-preserving YAML round-trip writes
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:11:32 -04:00
andreas 4f9bc8c868 docs: add implementation plan for config editor feature
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 11:05:05 -04:00
andreas 259b4a3594 docs: design spec for config editor UI with per-user self-service 2026-05-09 10:42:42 -04:00
andreas 8646f68957 feat: log login/logout events to event log with auth source 2026-05-09 09:25:23 -04:00
andreas a4a6c1e3d9 fix: extend fetch_user error guard; escape HTML in login page
Move field-extraction inside the try/except in fetch_user so non-dict
responses from providers with empty profile_data_path (Gitea, GitHub)
raise OAuthError instead of an uncaught AttributeError. Apply
html.escape() to provider name, label, and logo URL in the login page.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:57:25 -04:00
andreas 0e8250362e feat: multi-provider OAuth2 login page and generic routes
Replace hardcoded Gitea OAuth handlers with generic {name}-parameterized
routes and update the login page to render a button for each configured
provider via oauth_mod.get_providers().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:48:23 -04:00
andreas 2f5da9fc5e fix: coerce malformed profile JSON to OAuthError; add redirect_uri assertion
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:46:19 -04:00
andreas 87aeec5999 feat: generic build_auth_url/exchange_code/fetch_user for multi-provider OAuth2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:38:11 -04:00
andreas f24500a6b5 fix: copy field_map/profile_data_path in get_providers; improve caplog assertions 2026-05-09 08:34:48 -04:00
andreas a7bb183222 test: assert warning logged when get_providers skips invalid entries 2026-05-09 08:31:09 -04:00
andreas 8207cd7b5f feat: add PROVIDER_DEFS, ResolvedProvider, get_providers() to oauth.py
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:29:07 -04:00
andreas 11f1eefa8c docs: implementation plan for multi-provider OAuth2 2026-05-09 08:25:52 -04:00
andreas 62f496e9f8 docs: spec for multi-provider OAuth2 support
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 08:19:52 -04:00
andreas aef9e7769b fix: zfs_monitor alerts dropped on restart with wildcard pool thresholds
purge_stale_alerts used _find_threshold to validate alert state keys,
but _find_threshold has no wildcard matching. A threshold configured as
"zfs_monitor.*.status" never matched the concrete alert state key
"zfs_monitor.tank.status", so every restart silently purged active ZFS
pool alert states and reset the grace period from scratch.

Also fix _check_pending_or_renotify to set last_notification after the
grace-period notification fires, so the re-notification interval is
anchored to when the alert was actually sent rather than the next PLG cycle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 07:42:09 -04:00
andreas 58c2b9d996 version 5.2.6
Release / release (push) Successful in 5s
2026-05-09 06:56:00 -04:00
andreas 2e8bcb630d fix: show human-readable duration in re-notification messages
Replace raw seconds with d h m s format in "ongoing for ..." strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 06:53:41 -04:00
andreas 338711181b feat: alerts host-filter field with URL query param and notify URL
- Add regex filter input to the Alerts dashboard that filters displayed
  hosts on every keystroke; invalid regex turns the border red
- Initialise the filter from ?filter= in the URL query string
- Change _build_url() to produce /alerts?filter=<hostname> so
  notification links (Pushover, email, Matrix, etc.) land on the
  alerts page pre-filtered to the alerting host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 06:46:13 -04:00
andreas 43487f17e7 feat: optional logo on Gitea OAuth login button
Reads oauth.gitea.logo from config and, when set, renders an <img>
inside the button with flex alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 06:24:27 -04:00
27 changed files with 6861 additions and 445 deletions
+1
View File
@@ -12,3 +12,4 @@ dist/
ssl/ ssl/
uv.lock uv.lock
.hb.yaml .hb.yaml
.superpowers/
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,539 @@
# Host Overview Info Section — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an always-visible info section to each host card on `/plugins`, showing owner, managers, agent version/type, last packet timestamp, and effective thresholds; move hbc_version/hbc_type out of the os_info accordion.
**Architecture:** A new `_build_host_info` module-level helper in `http.py` assembles the info dict from the host object and threshold_checker. A new `GET /api/0/hosts/{hostname}/info` closure inside `serve()` calls it and returns JSON. The `plugins.html` template adds a static placeholder div per host; JS fetches the endpoint on first card expand, caches the result, and renders it.
**Tech Stack:** Python/aiohttp (backend), Jinja2 (template), vanilla JS/HTML/CSS (frontend). Tests with pytest and unittest.mock.
---
### Task 1: `_build_host_info` helper — tests first
**Files:**
- Create: `tests/test_http_host_info.py`
- Modify: `hbd/server/http.py` (add module-level helper after `_mask_config_for_api`, around line 128)
- [ ] **Step 1: Write the failing tests**
Create `tests/test_http_host_info.py`:
```python
"""Tests for _build_host_info helper in http.py."""
import pytest
from unittest.mock import MagicMock
from hbd.server.http import _build_host_info
class _FakeConn:
def __init__(self, lastbeat):
self.lastbeat = lastbeat
class _FakeHost:
def __init__(self, name="myhost", owner=None, managers=None,
connections=None, os_data=None):
self.name = name
self.owner = owner
self.managers = managers or []
self.connections = connections or {}
self._os_data = os_data
def get_latest_plugin_data(self, plugin_name):
if plugin_name == "os_info" and self._os_data is not None:
return (1234567890.0, self._os_data)
return None
def test_build_host_info_basic_fields():
host = _FakeHost(owner="alice", managers=["bob", "carol"])
result = _build_host_info(host)
assert result["owner"] == "alice"
assert result["managers"] == ["bob", "carol"]
assert result["hbc_version"] is None
assert result["hbc_type"] is None
assert result["last_packet"] is None
assert result["thresholds"] is None
def test_build_host_info_no_owner():
host = _FakeHost()
result = _build_host_info(host)
assert result["owner"] is None
assert result["managers"] == []
def test_build_host_info_reads_hbc_from_os_info():
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
result = _build_host_info(host)
assert result["hbc_version"] == "5.3.0"
assert result["hbc_type"] == "full"
def test_build_host_info_hbc_none_when_no_os_info():
host = _FakeHost(os_data=None)
result = _build_host_info(host)
assert result["hbc_version"] is None
assert result["hbc_type"] is None
def test_build_host_info_last_packet_is_max_lastbeat():
host = _FakeHost(connections={
"IPv4": _FakeConn(1000.0),
"IPv6": _FakeConn(2000.0),
})
result = _build_host_info(host)
assert result["last_packet"] == 2000.0
def test_build_host_info_last_packet_none_when_no_connections():
host = _FakeHost(connections={})
result = _build_host_info(host)
assert result["last_packet"] is None
def test_build_host_info_thresholds_none_without_checker():
host = _FakeHost()
result = _build_host_info(host, threshold_checker=None)
assert result["thresholds"] is None
def test_build_host_info_thresholds_sorted_by_metric():
from hbd.server.threshold import ThresholdConfig
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {
"memory_monitor.memory_percent": tc_mem,
"cpu_monitor.cpu_percent": tc_cpu,
}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] is not None
assert len(result["thresholds"]) == 2
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
assert result["thresholds"][0]["warning"] == 80.0
assert result["thresholds"][0]["critical"] == 95.0
assert result["thresholds"][0]["operator"] == ">"
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] == []
def test_build_host_info_threshold_null_warning_critical():
from hbd.server.threshold import ThresholdConfig
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"][0]["warning"] is None
assert result["thresholds"][0]["critical"] == 500.0
```
- [ ] **Step 2: Run tests to confirm they fail**
```bash
pytest tests/test_http_host_info.py -v
```
Expected: `ImportError` or `AttributeError``_build_host_info` does not exist yet.
- [ ] **Step 3: Implement `_build_host_info` in `hbd/server/http.py`**
Insert after `_mask_config_for_api` (around line 128, before `def serve(`):
```python
def _build_host_info(host, threshold_checker=None):
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
hbc_version = None
hbc_type = None
latest_os = host.get_latest_plugin_data("os_info")
if latest_os:
_, os_data = latest_os
hbc_version = os_data.get("hbc_version")
hbc_type = os_data.get("hbc_type")
last_packet = None
if host.connections:
last_packet = max(conn.lastbeat for conn in host.connections.values())
thresholds = None
if threshold_checker is not None:
raw = threshold_checker.get_thresholds_for_host(host.name)
thresholds = sorted(
[
{
"metric": tc.metric_path,
"warning": tc.warning,
"critical": tc.critical,
"operator": tc.operator.value,
}
for tc in raw.values()
],
key=lambda x: x["metric"],
)
return {
"owner": getattr(host, "owner", None),
"managers": list(getattr(host, "managers", [])),
"hbc_version": hbc_version,
"hbc_type": hbc_type,
"last_packet": last_packet,
"thresholds": thresholds,
}
```
- [ ] **Step 4: Run tests to confirm they pass**
```bash
pytest tests/test_http_host_info.py -v
```
Expected: all 11 tests PASS.
- [ ] **Step 5: Commit**
```bash
git add tests/test_http_host_info.py hbd/server/http.py
git commit -m "feat: add _build_host_info helper for host info endpoint"
```
---
### Task 2: `api_host_info` route handler
**Files:**
- Modify: `hbd/server/http.py`
- Add `api_host_info` closure inside `serve()` (after `api_host_access_put`, around line 829)
- Register route (around line 1271)
- [ ] **Step 1: Add `api_host_info` closure inside `serve()`**
Insert after `api_host_access_put` (after line 829, before the comment `# User profile page`):
```python
# -------------------------------------------------------------------------
# Host info endpoint
# -------------------------------------------------------------------------
async def api_host_info(request):
"""GET /api/0/hosts/{hostname}/info"""
user, err = _require_auth(request)
if err:
return err
hostname = request.match_info.get("hostname")
if hostname not in hbdclass.Host.hosts:
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
host = hbdclass.Host.hosts[hostname]
if not _can_view_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
```
- [ ] **Step 2: Register the route**
In the route list (around line 1271, after the existing `/api/0/hosts/{hostname}/access` routes):
```python
web.get("/api/0/hosts/{hostname}/info", api_host_info),
```
- [ ] **Step 3: Verify the full test suite still passes**
```bash
pytest tests/ -q
```
Expected: all tests PASS (no regressions).
- [ ] **Step 4: Smoke-test the endpoint manually** (if a dev server is running)
```bash
curl -s http://localhost:50004/api/0/hosts/<hostname>/info | python3 -m json.tool
```
Expected: JSON with `owner`, `managers`, `hbc_version`, `hbc_type`, `last_packet`, `thresholds` keys.
- [ ] **Step 5: Commit**
```bash
git add hbd/server/http.py
git commit -m "feat: add GET /api/0/hosts/{hostname}/info endpoint"
```
---
### Task 3: Info section HTML and CSS in `plugins.html`
**Files:**
- Modify: `hbd/server/templates/plugins.html`
- [ ] **Step 1: Add CSS for the info section**
In the `<style>` block (find the closing `</style>` tag around line 391 and insert before it):
```css
/* ── Host info section ──────────────────────────────────────────────────── */
.host-info-section {
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
font-size: 0.85em;
}
.info-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
margin-bottom: 10px;
}
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
.info-value { color: #222; }
.info-thresholds-title {
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.info-note { color: #888; font-style: italic; }
.info-loading { color: #bbb; font-style: italic; }
```
- [ ] **Step 2: Add info section placeholder to each host card**
Inside the host loop, at the very start of `.host-body` (before the `{% set plugin_order %}` line, around line 438):
```html
<div class="host-body">
<div class="host-info-section" id="info-{{ host.name }}">
<div class="info-loading">Loading…</div>
</div>
```
The existing `{% set plugin_order %}` line and everything after stays unchanged. Only add the two new lines between `<div class="host-body">` and `{% set plugin_order %}`.
- [ ] **Step 3: Verify the page still renders without JS errors**
Start the dev server and open `/plugins` in a browser. Expand any host card — you should see the "Loading…" italic line above the plugin accordions (it will not be replaced yet, that comes in Task 4).
- [ ] **Step 4: Commit**
```bash
git add hbd/server/templates/plugins.html
git commit -m "feat: add host info section placeholder and CSS to plugins.html"
```
---
### Task 4: JS — `infoCache`, `fetchHostInfo`, `renderInfoSection`
**Files:**
- Modify: `hbd/server/templates/plugins.html` (JS `<script>` block)
- [ ] **Step 1: Add `infoCache` constant**
After the `pluginCache` declaration (after `const pluginCache = {};`, around line 489), add:
```javascript
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
const infoCache = {};
```
- [ ] **Step 2: Add `fetchHostInfo` function**
After the existing `fetchPlugin` function (around line 522, before `fetchHostGlance`), add:
```javascript
async function fetchHostInfo(hostname) {
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
```
- [ ] **Step 3: Add `renderInfoSection` function**
After `fetchHostInfo` (before `fetchHostGlance`), add:
```javascript
function renderInfoSection(hostname, data) {
const el = document.getElementById(`info-${hostname}`);
if (!el) return;
const owner = data.owner ? escHtml(data.owner) : '—';
const managers = data.managers && data.managers.length
? data.managers.map(escHtml).join(', ') : '—';
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
const lastPkt = data.last_packet
? new Date(data.last_packet * 1000).toLocaleString() : '—';
let html = `<div class="info-meta">
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
</div>`;
if (data.thresholds === null) {
html += `<div class="info-note">Threshold alerting not configured.</div>`;
} else if (data.thresholds.length === 0) {
html += `<div class="info-note">No thresholds defined.</div>`;
} else {
html += `<div class="info-thresholds-title">Effective Thresholds</div>
<table class="data-table"><thead><tr>
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
</tr></thead><tbody>`;
for (const t of data.thresholds) {
const w = t.warning !== null && t.warning !== undefined ? t.warning : '—';
const c = t.critical !== null && t.critical !== undefined ? t.critical : '—';
html += `<tr>
<td class="key">${escHtml(t.metric)}</td>
<td>${escHtml(t.operator)}</td>
<td>${w}</td>
<td>${c}</td>
</tr>`;
}
html += `</tbody></table>`;
}
el.innerHTML = html;
}
```
- [ ] **Step 4: Commit**
```bash
git add hbd/server/templates/plugins.html
git commit -m "feat: add fetchHostInfo and renderInfoSection JS functions"
```
---
### Task 5: Wire `fetchHostInfo` into `toggleHost`
**Files:**
- Modify: `hbd/server/templates/plugins.html` (the `toggleHost` function, around line 643)
- [ ] **Step 1: Replace `toggleHost` with the updated version**
Find the existing `toggleHost` function:
```javascript
function toggleHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const wasCollapsed = card.classList.contains('collapsed');
card.classList.toggle('collapsed');
if (wasCollapsed && !pluginCache[hostname]) {
fetchHostGlance(hostname);
}
}
```
Replace with:
```javascript
function toggleHost(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const wasCollapsed = card.classList.contains('collapsed');
card.classList.toggle('collapsed');
if (wasCollapsed) {
if (!pluginCache[hostname]) {
fetchHostGlance(hostname);
}
if (!infoCache[hostname]) {
const infoEl = document.getElementById(`info-${hostname}`);
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {
const el = document.getElementById(`info-${hostname}`);
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
});
}
}
}
```
- [ ] **Step 2: Test in browser**
Open `/plugins`, expand a host card. Verify:
- The info section appears above the plugin accordions.
- Owner, managers (or "—"), agent version, agent type, last packet render correctly.
- Threshold table renders (or the appropriate "not configured" / "none defined" message).
- Collapsing and re-expanding does not re-fetch (no second network request).
- [ ] **Step 3: Commit**
```bash
git add hbd/server/templates/plugins.html
git commit -m "feat: fetch and render host info section on card expand"
```
---
### Task 6: Remove `hbc_version` and `hbc_type` from `renderOsInfoTable`
**Files:**
- Modify: `hbd/server/templates/plugins.html` (the `renderOsInfoTable` function, around line 794)
- [ ] **Step 1: Update `renderOsInfoTable`**
Find the existing function:
```javascript
function renderOsInfoTable(d) {
const ORDER = ['distro_pretty_name','system','release','version','machine',
'processor','architecture','node','python_version',
'python_implementation','hbc_version',
'distro_name','distro_version','distro_id','distro_version_id'];
const shown = new Set(ORDER);
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
```
Replace with:
```javascript
function renderOsInfoTable(d) {
const ORDER = ['distro_pretty_name','system','release','version','machine',
'processor','architecture','node','python_version',
'python_implementation',
'distro_name','distro_version','distro_id','distro_version_id'];
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
const shown = new Set(ORDER);
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
```
- [ ] **Step 2: Verify in browser**
Expand a host card, then expand the "Os Info" accordion. Confirm:
- `hbc_version` no longer appears in the os_info table.
- `hbc_type` no longer appears in the os_info table.
- Both values are shown correctly in the info section at the top.
- [ ] **Step 3: Run the full test suite**
```bash
pytest tests/ -q
```
Expected: all tests PASS.
- [ ] **Step 4: Commit**
```bash
git add hbd/server/templates/plugins.html
git commit -m "feat: move hbc_version and hbc_type out of os_info into host info section"
```
@@ -0,0 +1,210 @@
# 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": "<raw yaml text>",
"thresholds": "<raw yaml text>",
"hosts": "<raw yaml text>",
"dns": "<raw yaml text>"
}
```
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)
@@ -0,0 +1,149 @@
# Multi-Provider OAuth2 — Design Spec
**Date:** 2026-05-09
**Status:** Approved
## Goal
Allow multiple OAuth2 providers to be configured simultaneously. All enabled providers appear as login buttons on the login panel. Supported provider types: Gitea, GitHub, Nextcloud. Existing single-Gitea configs continue to work without changes.
---
## Config Format
Each entry in the `oauth` dict is a named provider instance. The dict key becomes the route slug.
```yaml
oauth:
work-gitea: # /login/oauth/work-gitea
type: gitea # optional — defaults to "gitea" when absent (backward compat)
url: https://git.example.com
client_id: xxx
client_secret: yyy
label: "Work Gitea" # optional display name; falls back to provider default
logo: https://… # optional logo URL for button
github:
type: github # no url needed — fixed SaaS endpoints
client_id: xxx
client_secret: yyy
nextcloud:
type: nextcloud
url: https://cloud.example.com
client_id: xxx
client_secret: yyy
```
**Backward compatibility:** The existing `oauth.gitea.{url,client_id,client_secret}` config (no `type` field) is treated as `type: gitea`. No migration required.
**Validation:** Entries missing `client_id`, `client_secret`, or `url` (when the provider type requires it) are skipped with a warning log. This prevents a misconfigured entry from disabling all OAuth.
---
## Provider Registry (`oauth.py`)
A `PROVIDER_DEFS` dict holds static knowledge about each supported provider type:
| | gitea | github | nextcloud |
|---|---|---|---|
| authorize URL | `{url}/login/oauth/authorize` | `https://github.com/login/oauth/authorize` | `{url}/apps/oauth2/authorize` |
| token URL | `{url}/login/oauth/access_token` | `https://github.com/login/oauth/access_token` | `{url}/apps/oauth2/api/v1/token` |
| profile URL | `{url}/api/v1/user` | `https://api.github.com/user` | `{url}/ocs/v2.php/cloud/user?format=json` |
| scope | `user:email` | `read:user` | *(empty)* |
| username field | `login` | `login` | nested: `ocs.data.id` |
| display name field | `full_name` | `name` | nested: `ocs.data.display-name` |
| avatar field | `avatar_url` | `avatar_url` | *(absent — left empty)* |
| requires `url` | yes | no | yes |
| default label | `Gitea` | `GitHub` | `Nextcloud` |
Nextcloud's profile response is nested (`ocs → data`). The registry entry includes a `profile_data_path: ["ocs", "data"]` that is navigated before field extraction.
---
## New / Changed API in `oauth.py`
### `ResolvedProvider` (new dataclass)
All endpoint URLs are pre-computed strings (no more template substitution at call time):
```python
@dataclass
class ResolvedProvider:
name: str # route slug (dict key)
type: str # "gitea" | "github" | "nextcloud"
label: str # display name for login button
logo: str # URL or ""
authorize_url: str
token_url: str
profile_url: str
scope: str
client_id: str
client_secret: str
field_map: dict # {"username": "<provider_field>", "full_name": ..., "avatar": ...}
profile_data_path: list[str] # e.g. ["ocs", "data"] or []
```
### `get_providers(config) → list[ResolvedProvider]` (new)
Iterates `config.get("oauth", {})`, resolves each valid entry against `PROVIDER_DEFS`, skips invalid entries. Returns providers in config declaration order (determines button order on login page).
### `build_auth_url(provider, state, redirect_uri)` (updated signature)
Takes a `ResolvedProvider`. Uses `provider.authorize_url`, `provider.scope`, `provider.client_id`.
### `exchange_code(provider, code, redirect_uri)` (updated signature)
Takes a `ResolvedProvider`. Sets `Accept: application/json` on all token requests (required for GitHub, harmless for others).
### `fetch_user(provider, access_token)` (updated signature)
Takes a `ResolvedProvider`. After fetching the profile JSON, navigates `provider.profile_data_path` before applying `provider.field_map`. Missing fields (e.g., Nextcloud avatar) are mapped to `""`.
### `is_enabled(config)` (updated)
Returns `True` if `get_providers(config)` returns at least one provider.
---
## Routes (`http.py`)
Replace the two hardcoded Gitea routes with generic ones:
```
GET /login/oauth/{name} initiate OAuth flow
GET /login/oauth/{name}/callback receive code, provision user, set session
```
Both handlers resolve `{name}` via `get_providers(config)`. If the name is not found, return 404. Existing `/login/oauth/gitea` URLs continue to work as long as the config has a `gitea` key.
---
## Login Page (`http.py`)
The "or" divider appears once if any providers are configured. Below it, one button per provider stacks vertically. Button appearance mirrors the current Gitea button (same CSS class, optional logo img). Button `href` is `/login/oauth/{provider.name}`.
---
## Tests (`tests/test_oauth.py`)
**Updated:** Existing tests for `build_auth_url`, `exchange_code`, `fetch_user`, `is_enabled` ported to new `ResolvedProvider`-based signatures.
**New:**
- `get_providers()` with old single-Gitea config (no `type`) → one provider, backward compat confirmed
- `get_providers()` with Gitea + GitHub + Nextcloud → correct count, types, and labels
- `get_providers()` skips entry missing `client_id` or `client_secret`
- `get_providers()` skips Gitea/Nextcloud entry missing `url`
- `get_providers()` skips entry with unknown `type` (logs warning)
- `build_auth_url` for each provider type → correct authorize URL
- `exchange_code` for GitHub → `Accept: application/json` header present
- `fetch_user` for Nextcloud → `ocs.data` navigation, missing avatar handled as `""`
- Login page HTML → one button per provider; no buttons when `oauth` is empty
---
## Out of Scope
- Generic/custom provider with user-specified endpoints
- OIDC / token introspection
- Restricting login to specific GitHub orgs or Nextcloud groups
- Automatic admin promotion from OAuth
- Token refresh
@@ -0,0 +1,135 @@
# Host Overview Info Section
**Date:** 2026-05-10
**Status:** Approved
## Summary
Add an always-visible info section to each host card on the Host Overview (`/plugins`) page. The section shows owner, managers, agent version/type, last packet timestamp, and the host's effective alert thresholds. The fields `hbc_version` and `hbc_type` are moved out of the `os_info` plugin accordion into this section.
---
## Backend: New API Endpoint
**Route:** `GET /api/0/hosts/{hostname}/info`
**Auth:** Same as other per-host endpoints (`_can_view_host`).
**Response schema:**
```json
{
"owner": "alice",
"managers": ["bob", "carol"],
"hbc_version": "5.3.0",
"hbc_type": "full",
"last_packet": 1746894000.0,
"thresholds": [
{
"metric": "cpu_monitor.cpu_percent",
"warning": 80.0,
"critical": 95.0,
"operator": ">"
}
]
}
```
**Field details:**
- `owner``host.owner`, or `null` if unset.
- `managers``host.managers` list (may be empty).
- `hbc_version` — from `host.get_latest_plugin_data("os_info")`, key `hbc_version`; `null` if no os_info data.
- `hbc_type` — same source, key `hbc_type`; `null` if unavailable.
- `last_packet``max(conn.lastbeat for conn in host.connections.values())`, or `null` if no connections.
- `thresholds` — list derived from `threshold_checker.get_thresholds_for_host(hostname)`, sorted by `metric` ascending. Each entry includes `metric`, `warning` (null if unset), `critical` (null if unset), `operator`. Returns `null` (not `[]`) if no `threshold_checker` is configured, so the frontend can distinguish "not configured" from "configured but empty".
**Location:** `hbd/server/http.py`, added alongside the other `api_host_*` functions. Registered as `web.get("/api/0/hosts/{hostname}/info", api_host_info)`.
---
## Frontend: Info Section
### HTML structure
Inserted as the first child of `.host-body`, before the plugin accordions. It is not a collapsible accordion — it is always visible when the host card is expanded.
```html
<div class="host-info-section" id="info-{hostname}">
<div class="loading">Loading…</div>
</div>
```
### Fetch lifecycle
- Fetched once per host on the first expansion of the host card (same trigger as the glance/plugin data).
- Result cached in a new per-host `infoCache` object (parallel to `pluginCache`).
- On subsequent expansions the cached data is rendered immediately without a new request.
### Rendered layout
Two logical areas rendered client-side from the JSON:
**Meta row** — a CSS-grid or simple `<dl>` showing:
| Label | Value |
|---------------|------------------------------|
| Owner | alice (or "—" if null) |
| Managers | bob, carol (or "—" if empty) |
| Agent Version | 5.3.0 (or "—") |
| Agent Type | full (or "—") |
| Last Packet | localized datetime string (or "—") |
**Threshold table** — rendered with the existing `data-table` CSS class:
| Metric | Operator | Warning | Critical |
|--------|----------|---------|----------|
| cpu_monitor.cpu_percent | > | 80 | 95 |
| … | … | … | … |
- If `thresholds` is `null`: show "Threshold alerting not configured."
- If `thresholds` is `[]`: show "No thresholds defined."
- Numeric threshold values rendered as-is (no units); `null` warning/critical shown as "—".
### CSS
New `.host-info-section` styles added in the `<style>` block of `plugins.html`. The section gets a subtle background (e.g. `#fafafa`) and a bottom border to separate it visually from the plugin accordions below. The meta row uses a two-column grid layout for compactness.
---
## Changes to `renderOsInfoTable()`
- Remove `hbc_version` from the `ORDER` array.
- Add `hbc_type` to the `SKIP_FIELDS` set (or the local `shown` set) so it is excluded from the os_info table.
Both fields will now appear only in the info section.
---
## Data Flow Summary
```
User expands host card
→ toggleHost()
→ fetchGlanceData(hostname) [existing, unchanged]
→ fetchInfoData(hostname) [new]
GET /api/0/hosts/{hostname}/info
→ renderInfoSection(hostname, data)
→ writes into #info-{hostname}
```
---
## Error Handling
- If the info fetch fails (non-200), show a one-line error message in the info section ("Could not load host info.").
- If `hbc_version`/`hbc_type` are null (host has never sent os_info), display "—".
- If `last_packet` is null (no connections recorded), display "—".
---
## Out of Scope
- Editing owner/managers from this section (covered by existing profile/access UI).
- Editing thresholds from this section.
- Monitors list (not shown — monitors are operational, not informational in this context).
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.2.5" __version__ = "5.3.1"
+10 -2
View File
@@ -27,7 +27,7 @@ SERVER_DEFAULTS = {
# Monitoring settings # Monitoring settings
"interval": 20, # Expected heartbeat interval (for server checks) "interval": 20, # Expected heartbeat interval (for server checks)
"grace": 2, # Grace multiplier (interval * grace = timeout) "grace": 2, # Grace period (extra seconds before notifying after a missed heartbeat)
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications "threshold_renotify_interval": 3600, # Seconds between threshold re-notifications
# User management # User management
@@ -79,9 +79,13 @@ THRESHOLD_DEFAULTS = {
} }
}, },
'memory_monitor': { 'memory_monitor': {
'percent': { 'memory_percent': {
'warning': 85.0, 'warning': 85.0,
'critical': 95.0 'critical': 95.0
},
'swap_percent': {
'warning': 40.0,
'critical': 75.0
} }
}, },
'disk_monitor': { 'disk_monitor': {
@@ -114,6 +118,10 @@ THRESHOLD_DEFAULTS = {
'operator': '>', 'operator': '>',
'hysteresis': 0.0, 'hysteresis': 0.0,
'display': 'ZFS pool {pool_name} is {health}' 'display': 'ZFS pool {pool_name} is {health}'
},
'capacity': {
'warning': 80.0,
'critical': 90.0,
} }
} }
} }
+114
View File
@@ -0,0 +1,114 @@
"""YAML round-trip read/write for .hb.yaml, with backup and atomic writes."""
import glob
import os
import threading
from datetime import datetime
from ruamel.yaml import YAML
_write_lock = threading.Lock()
def _make_yaml() -> YAML:
y = YAML()
y.preserve_quotes = True
return y
# Top-level keys managed by the 'server' logical section
_SERVER_KEYS = [
"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",
]
# Top-level keys managed by the 'dns' logical section
_DNS_KEYS = ["nsupdate_bin", "dyndomains", "dyndnshosts", "drophosts"]
def read_roundtrip(path: str):
"""Load .hb.yaml with ruamel.yaml, preserving comments and ordering."""
with open(path, "r", encoding="utf-8") as f:
return _make_yaml().load(f)
def write_config(path: str, data) -> 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.
"""
with _write_lock:
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = f"{path}.bak.{ts}"
n = 0
while os.path.exists(backup_path):
n += 1
backup_path = f"{path}.bak.{ts}-{n}"
orig_mode = None
if os.path.exists(path):
orig_mode = os.stat(path).st_mode
with open(path, "rb") as src, open(backup_path, "wb") as dst:
dst.write(src.read())
os.chmod(backup_path, orig_mode)
backups = sorted(glob.glob(f"{path}.bak.*"), reverse=True)
for old in backups[10:]:
os.unlink(old)
tmp = f"{path}.tmp"
try:
with open(tmp, "w", encoding="utf-8") as f:
_make_yaml().dump(data, f)
if orig_mode is not None:
os.chmod(tmp, orig_mode)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def list_backups(path: str) -> list:
"""Return backup paths sorted newest-first."""
return sorted(glob.glob(f"{path}.bak.*"), reverse=True)
def apply_structured_section(data, section: str, values: dict) -> None:
"""Merge a dict of scalar/list values into data for the named logical section.
For 'server': updates each known key individually, preserving comments on
unchanged keys. For 'users': replaces the entire users dict.
"""
if section == "server":
for key in _SERVER_KEYS:
if key in values:
data[key] = values[key]
elif section == "users":
data["users"] = values
else:
raise ValueError(f"Unknown structured section: {section!r}")
def apply_yaml_section(data, section: str, yaml_text: str) -> None:
"""Replace the named logical section by parsing yaml_text."""
parsed = _make_yaml().load(yaml_text)
if section == "notification_channels":
data["notification_channels"] = parsed
elif section == "thresholds":
data["threshold_configs"] = parsed
elif section == "hosts":
data["hosts"] = parsed
elif section == "dns":
if parsed:
for key in _DNS_KEYS:
if key in parsed:
data[key] = parsed[key]
else:
for key in _DNS_KEYS:
data.pop(key, None)
else:
raise ValueError(f"Unknown YAML section: {section!r}")
+389 -26
View File
@@ -2,6 +2,7 @@
import asyncio import asyncio
import datetime import datetime
import html as _html
import json import json
import platform import platform
import socket import socket
@@ -18,6 +19,7 @@ from . import settings as settings_mod
from . import users as users_mod from . import users as users_mod
from . import oauth as oauth_mod from . import oauth as oauth_mod
from . import ws as ws_mod from . import ws as ws_mod
from . import configio as configio_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -100,6 +102,90 @@ def _can_own_host(user, host) -> bool:
return host.is_owner(user.username) return host.is_owner(user.username)
def _mask_config_for_api(config) -> dict:
"""Return a JSON-serializable config dict with secrets masked."""
result = {}
result["server"] = {k: config.get(k) for k in configio_mod._SERVER_KEYS}
users = {}
for username, attrs in (config.get("users") or {}).items():
u = dict(attrs)
if "password" in u:
u["password"] = "•••"
users[username] = u
result["users"] = users
oauth = {}
for name, attrs in (config.get("oauth") or {}).items():
o = dict(attrs)
if "client_secret" in o:
o["client_secret"] = "•••"
oauth[name] = o
result["oauth"] = oauth
return result
def _build_host_info(host, threshold_checker=None) -> dict:
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
hbc_version = None
hbc_type = None
latest_os = host.get_latest_plugin_data("os_info")
if latest_os:
_, os_data = latest_os
hbc_version = os_data.get("hbc_version")
hbc_type = os_data.get("hbc_type")
last_packet = None
if host.connections:
last_packet = max(conn.lastbeat for conn in host.connections.values())
thresholds = None
if threshold_checker is not None:
raw = threshold_checker.get_thresholds_for_host(host.name)
# Build reverse coverage: which metric paths suffix-match to each threshold.
# Mirrors the logic in ThresholdChecker._find_threshold.
coverage: dict = {}
for plugin_name, samples in host.plugin_data.items():
if not samples:
continue
_, pdata = samples[-1]
for field_name in pdata:
full_path = f"{plugin_name}.{field_name}"
if full_path in raw:
continue # exact match — the threshold IS this metric
parts = field_name.split("_")
for i in range(1, len(parts)):
candidate = f"{plugin_name}." + "_".join(parts[i:])
if candidate in raw:
coverage.setdefault(candidate, []).append(full_path)
break
thresholds = sorted(
[
{
"metric": tc.metric_path,
"warning": tc.warning,
"critical": tc.critical,
"operator": tc.operator.value,
"covers": sorted(coverage.get(tc.metric_path, [])),
}
for tc in raw.values()
],
key=lambda x: x["metric"],
)
return {
"owner": getattr(host, "owner", None),
"managers": list(getattr(host, "managers", [])),
"hbc_version": hbc_version,
"hbc_type": hbc_type,
"last_packet": last_packet,
"thresholds": thresholds,
}
async def start( async def start(
host: str, host: str,
port: int, port: int,
@@ -588,6 +674,7 @@ async def start(
if user is None: if user is None:
return web.json_response({"error": "Invalid credentials"}, status=401) return web.json_response({"error": "Invalid credentials"}, status=401)
token = users_mod.create_session(username) token = users_mod.create_session(username)
eventlog("hbd", "INFO", f"Login: {username} via api")
resp = web.json_response({"token": token, "username": username}) resp = web.json_response({"token": token, "username": username})
resp.set_cookie( resp.set_cookie(
SESSION_COOKIE, SESSION_COOKIE,
@@ -611,6 +698,7 @@ async def start(
user = users_mod.authenticate(username, password) user = users_mod.authenticate(username, password)
if user: if user:
token = users_mod.create_session(username) token = users_mod.create_session(username)
eventlog("hbd", "INFO", f"Login: {username} via password")
redirect_to = request.rel_url.query.get("next", "/") redirect_to = request.rel_url.query.get("next", "/")
resp = web.HTTPFound(redirect_to) resp = web.HTTPFound(redirect_to)
resp.set_cookie( resp.set_cookie(
@@ -625,13 +713,18 @@ async def start(
elif request.rel_url.query.get("error"): elif request.rel_url.query.get("error"):
error = "Sign-in failed. Please try again." error = "Sign-in failed. Please try again."
gitea_button = "" oauth_buttons = ""
if oauth_mod.is_enabled(config): _providers = oauth_mod.get_providers(config)
gitea_button = f""" if _providers:
<div class="divider">or</div> buttons_html = ""
<a href="/login/oauth/gitea" class="gitea-btn"> for _p in _providers:
Sign in with Gitea _logo = f'<img src="{_html.escape(_p.logo)}" alt="" class="oauth-logo">' if _p.logo else ""
buttons_html += f"""
<a href="/login/oauth/{_html.escape(_p.name)}" class="oauth-btn">
{_logo}{_html.escape(_p.label)}
</a>""" </a>"""
oauth_buttons = f"""
<div class="divider">or</div>{buttons_html}"""
html = f"""<!DOCTYPE html> html = f"""<!DOCTYPE html>
<html> <html>
@@ -654,10 +747,12 @@ async def start(
.field {{ margin-bottom: .9em; }} .field {{ margin-bottom: .9em; }}
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999; .divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }} font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
.gitea-btn {{ display: block; width: 100%; padding: .6em; background: #609926; .oauth-btn {{ display: flex; align-items: center; justify-content: center;
gap: .5em; width: 100%; padding: .6em; background: #16191d;
color: #fff; border-radius: 4px; font-size: 1em; text-align: center; color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
text-decoration: none; box-sizing: border-box; }} text-decoration: none; box-sizing: border-box; margin-top: .5em; }}
.gitea-btn:hover {{ background: #4e7d1e; }} .oauth-btn:hover {{ background: #444; }}
.oauth-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
</style> </style>
</head> </head>
<body> <body>
@@ -668,7 +763,7 @@ async def start(
<div class="field"><label>Username</label><input name="username" autofocus></div> <div class="field"><label>Username</label><input name="username" autofocus></div>
<div class="field"><label>Password</label><input name="password" type="password"></div> <div class="field"><label>Password</label><input name="password" type="password"></div>
<button type="submit">Sign in</button> <button type="submit">Sign in</button>
</form>{gitea_button} </form>{oauth_buttons}
</div> </div>
</body> </body>
</html>""" </html>"""
@@ -677,7 +772,10 @@ async def start(
async def web_logout(request): async def web_logout(request):
"""GET /logout — clear session cookie and redirect to /login.""" """GET /logout — clear session cookie and redirect to /login."""
token = request.cookies.get(SESSION_COOKIE, "") token = request.cookies.get(SESSION_COOKIE, "")
_user = users_mod.get_session_user(token)
users_mod.delete_session(token) users_mod.delete_session(token)
if _user:
eventlog("hbd", "INFO", f"Logout: {_user.username}")
resp = web.HTTPFound("/login") resp = web.HTTPFound("/login")
resp.del_cookie(SESSION_COOKIE) resp.del_cookie(SESSION_COOKIE)
raise resp raise resp
@@ -685,7 +783,10 @@ async def start(
async def api_logout(request): async def api_logout(request):
"""POST /api/0/auth/logout""" """POST /api/0/auth/logout"""
token = _get_token(request) token = _get_token(request)
_user = users_mod.get_session_user(token)
users_mod.delete_session(token) users_mod.delete_session(token)
if _user:
eventlog("hbd", "INFO", f"Logout: {_user.username}")
resp = web.json_response({"success": True}) resp = web.json_response({"success": True})
resp.del_cookie(SESSION_COOKIE) resp.del_cookie(SESSION_COOKIE)
return resp return resp
@@ -787,6 +888,23 @@ async def start(
return web.json_response(host.access_dict()) return web.json_response(host.access_dict())
# -------------------------------------------------------------------------
# Host info endpoint
# -------------------------------------------------------------------------
async def api_host_info(request):
"""GET /api/0/hosts/{hostname}/info"""
user, err = _require_auth(request)
if err:
return err
hostname = request.match_info.get("hostname")
if hostname not in hbdclass.Host.hosts:
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
host = hbdclass.Host.hosts[hostname]
if not _can_view_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# User profile page # User profile page
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -838,6 +956,8 @@ async def start(
ch_cfg = config.get("notification_channels", {}).get(ch_name, {}) ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")}) notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
tmpl = env.get_template("profile.html") tmpl = env.get_template("profile.html")
body = tmpl.render( body = tmpl.render(
title="Profile - Heartbeat", title="Profile - Heartbeat",
@@ -847,6 +967,7 @@ async def start(
managed_hosts=managed, managed_hosts=managed,
monitored_hosts=monitored, monitored_hosts=monitored,
notification_channels=notif_channels, notification_channels=notif_channels,
all_channel_names=all_channel_names,
active_page="profile", active_page="profile",
) )
return web.Response(text=body, content_type="text/html") return web.Response(text=body, content_type="text/html")
@@ -906,29 +1027,44 @@ async def start(
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
tmpl = env.get_template("settings.html") tmpl = env.get_template("settings.html")
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
body = tmpl.render( body = tmpl.render(
title="Settings - Heartbeat", title="Settings - Heartbeat",
sections=settings_mod.get_settings_sections(config, threshold_checker=threshold_checker), sections=settings_data["sections"],
all_channel_names=settings_data["all_channel_names"],
current_user=current_user.to_dict() if current_user else None, current_user=current_user.to_dict() if current_user else None,
active_page="settings", active_page="settings",
) )
return web.Response(text=body, content_type="text/html") return web.Response(text=body, content_type="text/html")
def _oauth_redirect_uri(request) -> str: def _oauth_redirect_uri(request, provider_name: str) -> str:
base = config.get("base_url", "").rstrip("/") or str(request.url.origin()) base = config.get("base_url", "").rstrip("/") or str(request.url.origin())
return f"{base}/login/oauth/gitea/callback" return f"{base}/login/oauth/{provider_name}/callback"
async def oauth_gitea_redirect(request): def _get_oauth_provider(name: str):
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow.""" """Return the ResolvedProvider for *name*, or None if not found."""
if not oauth_mod.is_enabled(config): return next(
return web.Response(status=404, text="OAuth not configured") (p for p in oauth_mod.get_providers(config) if p.name == name),
None,
)
async def oauth_redirect(request):
"""GET /login/oauth/{name} — kick off the OAuth2 flow for the named provider."""
name = request.match_info["name"]
provider = _get_oauth_provider(name)
if provider is None:
return web.Response(status=404, text="OAuth provider not found")
state = oauth_mod.make_state() state = oauth_mod.make_state()
raise web.HTTPFound(oauth_mod.authorization_url(config, state, _oauth_redirect_uri(request))) raise web.HTTPFound(
oauth_mod.build_auth_url(provider, state, _oauth_redirect_uri(request, name))
)
async def oauth_gitea_callback(request): async def oauth_callback(request):
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back.""" """GET /login/oauth/{name}/callback — handle the provider's redirect back."""
if not oauth_mod.is_enabled(config): name = request.match_info["name"]
return web.Response(status=404, text="OAuth not configured") provider = _get_oauth_provider(name)
if provider is None:
return web.Response(status=404, text="OAuth provider not found")
code = request.rel_url.query.get("code", "") code = request.rel_url.query.get("code", "")
state = request.rel_url.query.get("state", "") state = request.rel_url.query.get("state", "")
if not code or not state: if not code or not state:
@@ -937,8 +1073,8 @@ async def start(
logger.warning("OAuth: invalid or expired state token from %s", request.remote) logger.warning("OAuth: invalid or expired state token from %s", request.remote)
raise web.HTTPFound("/login?error=1") raise web.HTTPFound("/login?error=1")
try: try:
token = await oauth_mod.exchange_code(config, code, _oauth_redirect_uri(request)) token = await oauth_mod.exchange_code(provider, code, _oauth_redirect_uri(request, name))
profile = await oauth_mod.fetch_user(config, token) profile = await oauth_mod.fetch_user(provider, token)
except oauth_mod.OAuthError as exc: except oauth_mod.OAuthError as exc:
logger.warning("OAuth error: %s", exc) logger.warning("OAuth error: %s", exc)
raise web.HTTPFound("/login?error=1") raise web.HTTPFound("/login?error=1")
@@ -948,6 +1084,7 @@ async def start(
profile["avatar_url"], profile["avatar_url"],
) )
session_token = users_mod.create_session(user.username) session_token = users_mod.create_session(user.username)
eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}")
resp = web.HTTPFound("/") resp = web.HTTPFound("/")
resp.set_cookie( resp.set_cookie(
SESSION_COOKIE, SESSION_COOKIE,
@@ -958,6 +1095,224 @@ async def start(
) )
raise resp raise resp
# -------------------------------------------------------------------------
# Config API (admin only)
# -------------------------------------------------------------------------
_config_path = getattr(config, "_config_path", "") or ""
async def api_config_get(request):
"""GET /api/0/config — full config as JSON, secrets masked. Admin only."""
user, err = _require_auth(request)
if err:
return err
if user and not user.admin:
return web.json_response({"error": "Forbidden"}, status=403)
return web.json_response(_mask_config_for_api(config))
_YAML_EXTRACTORS = {
"notification_channels": lambda d: d.get("notification_channels") or {},
"thresholds": lambda d: d.get("threshold_configs") or {},
"hosts": lambda d: d.get("hosts") or {},
"dns": lambda d: {k: d[k] for k in configio_mod._DNS_KEYS if k in d},
}
async def api_config_section_get(request):
"""GET /api/0/config/section/{name} — raw YAML text for a YAML-editor section."""
user, err = _require_auth(request)
if err:
return err
if user and not user.admin:
return web.json_response({"error": "Forbidden"}, status=403)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
name = request.match_info["name"]
if name not in _YAML_EXTRACTORS:
return web.json_response({"error": "Unknown section"}, status=404)
import io as _io
from ruamel.yaml import YAML as _YAML
try:
data = configio_mod.read_roundtrip(_config_path)
section_data = _YAML_EXTRACTORS[name](data)
_sy = _YAML()
_sy.preserve_quotes = True
buf = _io.StringIO()
_sy.dump(section_data, buf)
except Exception as exc:
logger.error("Config section read failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
return web.json_response({"yaml": buf.getvalue()})
async def api_config_backups_get(request):
"""GET /api/0/config/backups — list of backup paths, newest first."""
user, err = _require_auth(request)
if err:
return err
if user and not user.admin:
return web.json_response({"error": "Forbidden"}, status=403)
if not _config_path:
return web.json_response({"backups": []})
backups = configio_mod.list_backups(_config_path)
return web.json_response({"backups": backups})
async def api_config_post(request):
"""POST /api/0/config — publish staged changes to .hb.yaml. Admin only."""
user, err = _require_auth(request)
if err:
return err
if user and not user.admin:
return web.json_response({"error": "Forbidden"}, status=403)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
try:
payload = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
if not isinstance(payload, dict):
return web.json_response({"error": "Invalid JSON"}, status=400)
try:
data = configio_mod.read_roundtrip(_config_path)
if "server" in payload:
configio_mod.apply_structured_section(data, "server", payload["server"])
if "users" in payload:
# Hash any plaintext passwords; preserve existing hashes when omitted or "•••"
existing_users = data.get("users") or {}
users_payload = payload["users"]
for username, attrs in users_payload.items():
pw = attrs.get("password", "")
if pw and pw != "•••" and not pw.startswith("pbkdf2:"):
attrs["password"] = users_mod.hash_password(pw)
elif not pw or pw == "•••":
existing_hash = (existing_users.get(username) or {}).get("password", "")
if existing_hash:
attrs["password"] = existing_hash
else:
attrs.pop("password", None)
configio_mod.apply_structured_section(data, "users", users_payload)
if "oauth" in payload:
existing_oauth = data.get("oauth") or {}
new_oauth = payload["oauth"]
for name, attrs in new_oauth.items():
cs = attrs.get("client_secret", "")
if not cs or cs == "•••":
existing_cs = (existing_oauth.get(name) or {}).get("client_secret", "")
if existing_cs:
attrs["client_secret"] = existing_cs
else:
attrs.pop("client_secret", None)
data["oauth"] = new_oauth
for section in ("notification_channels", "thresholds", "hosts", "dns"):
if section in payload:
configio_mod.apply_yaml_section(data, section, payload[section])
configio_mod.write_config(_config_path, data)
except Exception as exc:
logger.error("Config write failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if hasattr(config, "reload"):
await config.reload()
users_mod.load_users(config)
return web.json_response({"ok": True})
async def api_config_rollback(request):
"""POST /api/0/config/rollback — restore a backup. Admin only."""
user, err = _require_auth(request)
if err:
return err
if user and not user.admin:
return web.json_response({"error": "Forbidden"}, status=403)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
backup = body.get("backup", "")
if not backup or backup not in configio_mod.list_backups(_config_path):
return web.json_response({"error": "Invalid or missing backup"}, status=400)
try:
backup_data = configio_mod.read_roundtrip(backup)
configio_mod.write_config(_config_path, backup_data)
except Exception as exc:
logger.error("Rollback failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if hasattr(config, "reload"):
await config.reload()
users_mod.load_users(config)
return web.json_response({"ok": True})
async def api_user_self_put(request):
"""PUT /api/0/users/me — update own full_name, avatar, notification_channels, password."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Authentication required"}, status=401)
if not _config_path:
return web.json_response({"error": "Config path not available"}, status=503)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
if not isinstance(body, dict):
return web.json_response({"error": "Invalid JSON"}, status=400)
username = user.username
password_change = body.get("password")
if password_change:
if not isinstance(password_change, dict):
return web.json_response({"error": "Invalid JSON"}, status=400)
current_pw = password_change.get("current", "")
new_pw = password_change.get("new", "")
if not new_pw:
return web.json_response({"error": "New password cannot be empty"}, status=400)
if not users_mod.authenticate(username, current_pw):
return web.json_response({"error": "Current password incorrect"}, status=403)
try:
data = configio_mod.read_roundtrip(_config_path)
if "users" not in data or data["users"] is None:
data["users"] = {}
user_entry = dict(data["users"].get(username) or {})
if "full_name" in body:
user_entry["full_name"] = str(body["full_name"])
if "avatar" in body:
user_entry["avatar"] = str(body["avatar"])
if "notification_channels" in body:
user_entry["notification_channels"] = [str(ch) for ch in body["notification_channels"]]
if password_change:
user_entry["password"] = users_mod.hash_password(password_change["new"])
data["users"][username] = user_entry
configio_mod.write_config(_config_path, data)
except Exception as exc:
logger.error("User self-update failed: %s", exc)
return web.json_response({"error": str(exc)}, status=500)
if hasattr(config, "reload"):
await config.reload()
users_mod.load_users(config)
return web.json_response({"ok": True})
app = web.Application() app = web.Application()
app.add_routes( app.add_routes(
[ [
@@ -969,12 +1324,19 @@ async def start(
web.get("/logout", web_logout), web.get("/logout", web_logout),
web.post("/api/0/auth/login", api_login), web.post("/api/0/auth/login", api_login),
web.post("/api/0/auth/logout", api_logout), web.post("/api/0/auth/logout", api_logout),
web.get("/login/oauth/gitea", oauth_gitea_redirect), web.get("/login/oauth/{name}", oauth_redirect),
web.get("/login/oauth/gitea/callback", oauth_gitea_callback), web.get("/login/oauth/{name}/callback", oauth_callback),
# Users # Users
web.get("/api/0/users", api_users), web.get("/api/0/users", api_users),
web.get("/api/0/users/me", api_user_self), web.get("/api/0/users/me", api_user_self),
web.put("/api/0/users/me", api_user_self_put),
web.get("/api/0/users/{username}/avatar", api_user_avatar), web.get("/api/0/users/{username}/avatar", api_user_avatar),
# Config API (admin)
web.get("/api/0/config", api_config_get),
web.get("/api/0/config/section/{name}", api_config_section_get),
web.get("/api/0/config/backups", api_config_backups_get),
web.post("/api/0/config", api_config_post),
web.post("/api/0/config/rollback", api_config_rollback),
# Hosts # Hosts
web.get("/api/0/hosts", api_hosts), web.get("/api/0/hosts", api_hosts),
web.get("/api/0/alert_summary", api_alert_summary), web.get("/api/0/alert_summary", api_alert_summary),
@@ -984,6 +1346,7 @@ async def start(
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts), web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
web.get("/api/0/hosts/{hostname}/access", api_host_access_get), web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
web.put("/api/0/hosts/{hostname}/access", api_host_access_put), web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
web.get("/api/0/hosts/{hostname}/info", api_host_info),
web.get("/api/0/alerts", api_all_alerts), web.get("/api/0/alerts", api_all_alerts),
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert), web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
web.get("/c", cmd), web.get("/c", cmd),
+1 -1
View File
@@ -401,7 +401,7 @@ def _build_url(host_name: str) -> str:
base_url = _config.get("base_url", "").rstrip("/") base_url = _config.get("base_url", "").rstrip("/")
if not base_url: if not base_url:
return "" return ""
return f"{base_url}/plugins#{host_name}" return f"{base_url}/alerts?filter={host_name}"
async def send_notification(host_name: str, notif: Notification) -> dict: async def send_notification(host_name: str, notif: Notification) -> dict:
+156 -44
View File
@@ -1,23 +1,37 @@
"""Gitea OAuth2 support. """OAuth2 provider support.
Config shape (in ~/.hb.yaml): Config shape (in ~/.hb.yaml):
oauth: oauth:
gitea: my-gitea: # route slug → /login/oauth/my-gitea
url: https://git.example.com type: gitea # "gitea" | "github" | "nextcloud"
# omit type to default to "gitea"
url: https://git.example.com # required for gitea and nextcloud
client_id: <client-id>
client_secret: <client-secret>
label: "Work Gitea" # optional display name on login button
logo: https://example.com/logo.png # optional logo URL
github:
type: github
client_id: <client-id> client_id: <client-id>
client_secret: <client-secret> client_secret: <client-secret>
Register a Gitea OAuth2 application at: nextcloud:
Gitea → Settings → Applications → OAuth2 type: nextcloud
Set the redirect URI to: url: https://cloud.example.com
https://<hbd-host>/login/oauth/gitea/callback client_id: <client-id>
client_secret: <client-secret>
Register the OAuth app with each provider and set the redirect URI to:
https://<hbd-host>/login/oauth/<name>/callback
""" """
import logging import logging
import secrets import secrets
import time import time
import urllib.parse import urllib.parse
from dataclasses import dataclass
import aiohttp import aiohttp
@@ -57,44 +71,129 @@ class OAuthError(Exception):
"""Raised when the OAuth2 flow fails for any reason.""" """Raised when the OAuth2 flow fails for any reason."""
def _gitea_cfg(config: dict) -> dict: PROVIDER_DEFS: dict = {
"""Return the gitea sub-dict or {} if absent/incomplete.""" "gitea": {
return config.get("oauth", {}).get("gitea", {}) "authorize_url_tmpl": "{url}/login/oauth/authorize",
"token_url_tmpl": "{url}/login/oauth/access_token",
"profile_url_tmpl": "{url}/api/v1/user",
"scope": "user:email",
"field_map": {"username": "login", "full_name": "full_name", "avatar": "avatar_url"},
"profile_data_path": [],
"requires_url": True,
"default_label": "Gitea",
},
"github": {
"authorize_url_tmpl": "https://github.com/login/oauth/authorize",
"token_url_tmpl": "https://github.com/login/oauth/access_token",
"profile_url_tmpl": "https://api.github.com/user",
"scope": "read:user",
"field_map": {"username": "login", "full_name": "name", "avatar": "avatar_url"},
"profile_data_path": [],
"requires_url": False,
"default_label": "GitHub",
},
"nextcloud": {
"authorize_url_tmpl": "{url}/apps/oauth2/authorize",
"token_url_tmpl": "{url}/apps/oauth2/api/v1/token",
"profile_url_tmpl": "{url}/ocs/v2.php/cloud/user?format=json",
"scope": "",
"field_map": {"username": "id", "full_name": "display-name", "avatar": None},
"profile_data_path": ["ocs", "data"],
"requires_url": True,
"default_label": "Nextcloud",
},
}
@dataclass
class ResolvedProvider:
"""A fully resolved OAuth2 provider instance, ready to use."""
name: str
type: str
label: str
logo: str
authorize_url: str
token_url: str
profile_url: str
scope: str
client_id: str
client_secret: str
field_map: dict
profile_data_path: list
def get_providers(config: dict) -> list[ResolvedProvider]:
"""Return a ResolvedProvider for every valid entry in config['oauth'].
Entries with missing required fields or unknown types are skipped with
a warning log. Order follows config declaration order.
"""
result = []
oauth_cfg = config.get("oauth", {})
if not isinstance(oauth_cfg, dict):
return result
for name, entry in oauth_cfg.items():
if not isinstance(entry, dict):
continue
provider_type = entry.get("type", "gitea")
defn = PROVIDER_DEFS.get(provider_type)
if defn is None:
logger.warning("OAuth: unknown provider type %r for %r, skipping", provider_type, name)
continue
client_id = entry.get("client_id", "")
client_secret = entry.get("client_secret", "")
if not client_id or not client_secret:
logger.warning("OAuth: %r missing client_id or client_secret, skipping", name)
continue
url = entry.get("url", "").rstrip("/")
if defn["requires_url"] and not url:
logger.warning("OAuth: %r requires url but none configured, skipping", name)
continue
label = entry.get("label") or defn["default_label"]
logo = entry.get("logo", "")
result.append(ResolvedProvider(
name=name,
type=provider_type,
label=label,
logo=logo,
authorize_url=defn["authorize_url_tmpl"].format(url=url),
token_url=defn["token_url_tmpl"].format(url=url),
profile_url=defn["profile_url_tmpl"].format(url=url),
scope=defn["scope"],
client_id=client_id,
client_secret=client_secret,
field_map=dict(defn["field_map"]),
profile_data_path=list(defn["profile_data_path"]),
))
return result
def is_enabled(config: dict) -> bool: def is_enabled(config: dict) -> bool:
"""Return True when all three required Gitea OAuth keys are present.""" """Return True when at least one OAuth provider is fully configured."""
g = _gitea_cfg(config) return bool(get_providers(config))
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
def authorization_url(config: dict, state: str, redirect_uri: str) -> str: def build_auth_url(provider: ResolvedProvider, state: str, redirect_uri: str) -> str:
"""Return the Gitea OAuth2 authorization URL to redirect the browser to.""" """Return the provider's OAuth2 authorization URL to redirect the browser to."""
g = _gitea_cfg(config) params: dict = {
if not (g.get("url") and g.get("client_id") and g.get("client_secret")): "client_id": provider.client_id,
raise OAuthError("Gitea OAuth2 is not configured")
params = urllib.parse.urlencode({
"client_id": g["client_id"],
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
"response_type": "code", "response_type": "code",
"scope": "user:email",
"state": state, "state": state,
}) }
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}" if provider.scope:
params["scope"] = provider.scope
return f"{provider.authorize_url}?{urllib.parse.urlencode(params)}"
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str: async def exchange_code(provider: ResolvedProvider, code: str, redirect_uri: str) -> str:
"""Exchange an authorization *code* for a Gitea access token. """Exchange an authorization *code* for an access token.
Returns the access token string. Raises OAuthError on any failure. Returns the access token string. Raises OAuthError on any failure.
""" """
g = _gitea_cfg(config)
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
raise OAuthError("Gitea OAuth2 is not configured")
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
payload = { payload = {
"client_id": g["client_id"], "client_id": provider.client_id,
"client_secret": g["client_secret"], "client_secret": provider.client_secret,
"code": code, "code": code,
"grant_type": "authorization_code", "grant_type": "authorization_code",
"redirect_uri": redirect_uri, "redirect_uri": redirect_uri,
@@ -102,7 +201,11 @@ async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
timeout = aiohttp.ClientTimeout(total=10) timeout = aiohttp.ClientTimeout(total=10)
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp: async with session.post(
provider.token_url,
json=payload,
headers={"Accept": "application/json"},
) as resp:
if resp.status != 200: if resp.status != 200:
text = await resp.text() text = await resp.text()
raise OAuthError(f"Token exchange failed ({resp.status}): {text}") raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
@@ -115,28 +218,37 @@ async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
return token return token
async def fetch_user(config: dict, token: str) -> dict: async def fetch_user(provider: ResolvedProvider, token: str) -> dict:
"""Fetch the authenticated user's profile from Gitea. """Fetch the authenticated user's profile from the provider.
Returns a dict with keys: login, full_name, avatar_url. Returns a dict with keys: login, full_name, avatar_url.
Raises OAuthError on any failure. Raises OAuthError on any failure.
""" """
g = _gitea_cfg(config)
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
raise OAuthError("Gitea OAuth2 is not configured")
url = f"{g['url'].rstrip('/')}/api/v1/user"
timeout = aiohttp.ClientTimeout(total=10) timeout = aiohttp.ClientTimeout(total=10)
try: try:
async with aiohttp.ClientSession(timeout=timeout) as session: async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp: async with session.get(
provider.profile_url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json",
},
) as resp:
if resp.status != 200: if resp.status != 200:
text = await resp.text() text = await resp.text()
raise OAuthError(f"User fetch failed ({resp.status}): {text}") raise OAuthError(f"User fetch failed ({resp.status}): {text}")
data = await resp.json() data = await resp.json()
except aiohttp.ClientError as exc: except aiohttp.ClientError as exc:
raise OAuthError(f"User fetch network error: {exc}") from exc raise OAuthError(f"User fetch network error: {exc}") from exc
return {
"login": data.get("login", ""), try:
"full_name": data.get("full_name", ""), for key in provider.profile_data_path:
"avatar_url": data.get("avatar_url", ""), data = data.get(key, {})
} avatar_field = provider.field_map.get("avatar")
return {
"login": data.get(provider.field_map["username"], ""),
"full_name": data.get(provider.field_map["full_name"], ""),
"avatar_url": data.get(avatar_field, "") if avatar_field else "",
}
except AttributeError:
raise OAuthError(f"Unexpected profile response structure from {provider.type}")
+74 -25
View File
@@ -232,28 +232,48 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"notification_channels": hcfg.get("notification_channels", []), "notification_channels": hcfg.get("notification_channels", []),
}) })
# ---- OAuth providers -------------------------------------------------------
oauth_providers = []
for pname, pattrs in (config.get("oauth") or {}).items():
if not isinstance(pattrs, dict):
continue
cs = pattrs.get("client_secret", "")
oauth_providers.append({
"name": pname,
"type": pattrs.get("type", "gitea"),
"url": pattrs.get("url", ""),
"client_id": pattrs.get("client_id", ""),
"client_secret": "•••" if cs else "",
"label": pattrs.get("label", ""),
"logo": pattrs.get("logo", ""),
})
return [ return [
{ {
"id": "network", "id": "network",
"title": "Network", "title": "Network",
"description": "Ports and bind addresses for all server sockets.", "description": "Ports and bind addresses for all server sockets.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("hb_port", "Heartbeat UDP port", "port", field("hb_port", "Heartbeat UDP port", "port",
"UDP port the server listens on for heartbeat datagrams."), "UDP port the server listens on for heartbeat datagrams.", editable=True),
field("hbd_host", "HTTP bind address", "text", field("hbd_host", "HTTP bind address", "text",
"Interface to bind the HTTP server to. Empty = all interfaces."), "Interface to bind the HTTP server to. Empty = all interfaces.", editable=True),
field("hbd_port", "HTTP API port", "port", field("hbd_port", "HTTP API port", "port",
"TCP port for the HTTP API and web UI."), "TCP port for the HTTP API and web UI.", editable=True),
field("ws_port", "WebSocket port", "port", field("ws_port", "WebSocket port", "port",
"TCP port for the plain WebSocket server."), "TCP port for the plain WebSocket server.", editable=True),
field("wss_port", "Secure WebSocket port", "port", field("wss_port", "Secure WebSocket port", "port",
"TCP port for WSS (TLS WebSocket). Leave empty to disable."), "TCP port for WSS (TLS WebSocket). Leave empty to disable.", editable=True),
], ],
}, },
{ {
"id": "tls", "id": "tls",
"title": "TLS / WebSocket Security", "title": "TLS / WebSocket Security",
"description": "Certificate paths used when wss_port is set.", "description": "Certificate paths used when wss_port is set.",
"section_mode": "form",
"api_section": None,
"fields": [ "fields": [
field("cert_path", "Certificate directory", "path", field("cert_path", "Certificate directory", "path",
"Directory containing the TLS certificate and key files."), "Directory containing the TLS certificate and key files."),
@@ -267,73 +287,89 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "monitoring", "id": "monitoring",
"title": "Monitoring", "title": "Monitoring",
"description": "Heartbeat timing and alert re-notification behaviour.", "description": "Heartbeat timing and alert re-notification behaviour.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("interval", "Heartbeat interval", "duration", field("interval", "Heartbeat interval", "duration",
"Expected time between heartbeat messages from each client."), "Expected time between heartbeat messages from each client.", editable=True),
field("grace", "Grace multiplier", "number", field("grace", "Grace period", "number",
"A host is marked overdue after interval × grace seconds of silence."), "Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
field("threshold_renotify_interval", "Re-notify interval", "duration", field("threshold_renotify_interval", "Re-notify interval", "duration",
"How often to re-send notifications for ongoing threshold alerts."), "How often to re-send notifications for ongoing threshold alerts.", editable=True),
field("autosave_interval", "Autosave interval", "duration", field("autosave_interval", "Autosave interval", "duration",
"How often the server saves its state to disk."), "How often the server saves its state to disk."),
field("base_url", "Base URL", "text",
"Base URL for notification links.", editable=True),
], ],
}, },
{ {
"id": "persistence", "id": "persistence",
"title": "Persistence & Logging", "title": "Persistence & Logging",
"description": "State file and event log settings.", "description": "State file and event log settings.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("pickfile", "State file", "path", field("pickfile", "State file", "path",
"Path to the pickle file used to persist host state across restarts."), "Path to the pickle file used to persist host state across restarts.", editable=True),
field("logfile", "Event log", "path", field("logfile", "Event log", "path",
"Path to the event log file."), "Path to the event log file.", editable=True),
], ],
}, },
{ {
"id": "journal", "id": "journal",
"title": "Message Journal", "title": "Message Journal",
"description": "All received heartbeat and plugin messages are journalled here.", "description": "All received heartbeat and plugin messages are journalled here.",
"section_mode": "form",
"api_section": "server",
"fields": [ "fields": [
field("journal_enabled", "Enabled", "boolean", field("journal_enabled", "Enabled", "boolean",
"Turn journalling on or off."), "Turn journalling on or off.", editable=True),
field("journal_dir", "Journal directory","path", field("journal_dir", "Journal directory","path",
"Directory where journal files are written."), "Directory where journal files are written.", editable=True),
field("journal_file", "Journal filename", "text", field("journal_file", "Journal filename", "text",
"Base filename for the journal (rotated copies get a numeric suffix)."), "Base filename for the journal (rotated copies get a numeric suffix)."),
field("journal_max_size", "Max file size", "size", field("journal_max_size", "Max file size", "size",
"Rotate the journal when it exceeds this size."), "Rotate the journal when it exceeds this size.", editable=True),
field("journal_max_backups", "Backup count", "number", field("journal_max_backups", "Backup count", "number",
"Number of rotated journal files to keep."), "Number of rotated journal files to keep.", editable=True),
], ],
}, },
{ {
"id": "dns", "id": "dns",
"title": "Dynamic DNS", "title": "Dynamic DNS",
"description": "nsupdate-based DNS registration for dynamic hosts.", "description": "nsupdate-based DNS registration — edit raw YAML.",
"fields": [ "section_mode": "yaml",
field("nsupdate_bin", "nsupdate binary", "path", "api_section": "dns",
"Full path to the nsupdate executable."), "fields": [],
field("dyndomains", "Dynamic domains", "list",
"DNS zones managed by nsupdate for dynamic hosts."),
field("drophosts", "Drop hosts", "list",
"Hostnames to silently ignore — no state, no alerts."),
],
}, },
{ {
"id": "users", "id": "users",
"title": "Users", "title": "Users",
"description": "Accounts defined in the config file. Password hashes are never shown.", "description": "Accounts defined in the config file. Password hashes are never shown.",
"section_mode": "form",
"api_section": "users",
"users": users_list, "users": users_list,
"fields": [ "fields": [
field("default_owner", "Default owner", "text", field("default_owner", "Default owner", "text",
"Username that owns hosts with no explicit owner. " "Username that owns hosts with no explicit owner. "
"Falls back to the first admin user."), "Falls back to the first admin user.", editable=True),
], ],
}, },
{
"id": "oauth",
"title": "OAuth Providers",
"description": "OAuth2 login providers. Client secrets are masked.",
"section_mode": "form",
"api_section": "oauth",
"providers": oauth_providers,
"fields": [],
},
{ {
"id": "channels", "id": "channels",
"title": "Notification Channels", "title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.", "description": "Named notification providers. Credentials are masked.",
"section_mode": "yaml",
"api_section": "notification_channels",
"channels": notif_channels, "channels": notif_channels,
"fields": [ "fields": [
field("default_notification_channels", "Default channels", "list", field("default_notification_channels", "Default channels", "list",
@@ -344,6 +380,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "hosts", "id": "hosts",
"title": "Hosts", "title": "Hosts",
"description": "Host definitions loaded from the config file.", "description": "Host definitions loaded from the config file.",
"section_mode": "yaml",
"api_section": "hosts",
"hosts": hosts_list, "hosts": hosts_list,
"fields": [], "fields": [],
}, },
@@ -351,6 +389,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "thresholds", "id": "thresholds",
"title": "Threshold Configurations", "title": "Threshold Configurations",
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.", "description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
"section_mode": "yaml",
"api_section": "thresholds",
"threshold_configs": threshold_config_list, "threshold_configs": threshold_config_list,
"fields": [ "fields": [
field("default_threshold_config", "Default config", "text", field("default_threshold_config", "Default config", "text",
@@ -361,6 +401,8 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
"id": "runtime", "id": "runtime",
"title": "Runtime", "title": "Runtime",
"description": "Flags set at startup (require restart to change).", "description": "Flags set at startup (require restart to change).",
"section_mode": "form",
"api_section": None,
"fields": [ "fields": [
field("foreground", "Foreground mode", "boolean", field("foreground", "Foreground mode", "boolean",
"Run in the foreground instead of daemonising."), "Run in the foreground instead of daemonising."),
@@ -371,3 +413,10 @@ def get_settings_sections(config: dict, threshold_checker=None) -> list:
], ],
}, },
] ]
def get_settings_data(config: dict, threshold_checker=None) -> dict:
"""Return sections list + auxiliary data for the settings template."""
sections = get_settings_sections(config, threshold_checker=threshold_checker)
all_channel_names = sorted((config.get("notification_channels") or {}).keys())
return {"sections": sections, "all_channel_names": all_channel_names}
+51 -1
View File
@@ -94,6 +94,24 @@
border-color: #2196f3; border-color: #2196f3;
} }
.filter-input {
padding: 7px 12px;
border: 2px solid #ddd;
border-radius: 20px;
font-size: 0.9em;
outline: none;
width: 200px;
transition: border-color 0.2s;
}
.filter-input:focus {
border-color: #2196f3;
}
.filter-input.invalid {
border-color: #f44336;
}
.alerts-container { .alerts-container {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
@@ -316,6 +334,7 @@
<button class="filter-button active" onclick="filterAlerts('all')">All</button> <button class="filter-button active" onclick="filterAlerts('all')">All</button>
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button> <button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button> <button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
</div> </div>
<div class="alerts-container"> <div class="alerts-container">
@@ -332,6 +351,7 @@
<script> <script>
let currentFilter = 'all'; let currentFilter = 'all';
let allAlerts = []; let allAlerts = [];
let hostFilterRe = null;
async function loadAlerts() { async function loadAlerts() {
try { try {
@@ -366,10 +386,13 @@
// Filter alerts based on current filter // Filter alerts based on current filter
let filteredAlerts = alerts; let filteredAlerts = alerts;
if (currentFilter !== 'all') { if (currentFilter !== 'all') {
filteredAlerts = alerts.filter(alert => filteredAlerts = filteredAlerts.filter(alert =>
alert.level.toLowerCase() === currentFilter alert.level.toLowerCase() === currentFilter
); );
} }
if (hostFilterRe) {
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
}
if (filteredAlerts.length === 0) { if (filteredAlerts.length === 0) {
if (currentFilter === 'all' && alerts.length === 0) { if (currentFilter === 'all' && alerts.length === 0) {
@@ -538,9 +561,36 @@
} }
} }
function onHostFilterInput(input) {
const val = input.value.trim();
if (!val) {
hostFilterRe = null;
input.classList.remove('invalid');
} else {
try {
hostFilterRe = new RegExp(val, 'i');
input.classList.remove('invalid');
} catch (_) {
hostFilterRe = null;
input.classList.add('invalid');
}
}
renderAlerts(allAlerts);
}
// Auto-refresh every 15 seconds // Auto-refresh every 15 seconds
setInterval(loadAlerts, 15000); setInterval(loadAlerts, 15000);
// Initialise filter from URL query string (?filter=...)
(function () {
const param = new URLSearchParams(window.location.search).get('filter');
if (param) {
const input = document.getElementById('host-filter');
input.value = param;
onHostFilterInput(input);
}
})();
// Initial load // Initial load
loadAlerts(); loadAlerts();
</script> </script>
+103 -4
View File
@@ -388,6 +388,30 @@
.container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; } .container::-webkit-scrollbar-track { background: #f1f1f1; border-radius: 4px; }
.container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; } .container::-webkit-scrollbar-thumb { background: #ccc; border-radius: 4px; }
.container::-webkit-scrollbar-thumb:hover { background: #999; } .container::-webkit-scrollbar-thumb:hover { background: #999; }
/* ── Host info section ──────────────────────────────────────────────────── */
.host-info-section {
padding: 12px 16px;
background: #fafafa;
border-bottom: 1px solid #e0e0e0;
font-size: 0.85em;
}
.info-meta {
display: grid;
grid-template-columns: max-content 1fr;
gap: 3px 14px;
margin-bottom: 10px;
}
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
.info-value { color: #222; }
.info-thresholds-title {
font-weight: 600;
color: #555;
margin-bottom: 6px;
}
.info-note { color: #888; font-style: italic; }
.info-loading { color: #bbb; font-style: italic; }
.threshold-covers { font-size: 0.85em; color: #777; font-style: italic; }
</style> </style>
<body> <body>
@@ -436,6 +460,9 @@
</div> </div>
<div class="host-body"> <div class="host-body">
<div class="host-info-section" id="info-{{ host.name }}">
<div class="info-loading">Loading…</div>
</div>
{% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %} {% set plugin_order = ['os_info','cpu_monitor','memory_monitor','disk_monitor','network_monitor','zfs_monitor','nagios_runner','filesystem_info'] %}
{% for plugin in plugin_order if plugin in host.plugins %} {% for plugin in plugin_order if plugin in host.plugins %}
<div class="plugin-accordion collapsed" <div class="plugin-accordion collapsed"
@@ -488,6 +515,9 @@
// pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt } // pluginCache[hostname][pluginName] = { data, timestamp, fetchedAt }
const pluginCache = {}; const pluginCache = {};
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
const infoCache = {};
function setCache(hostname, pluginName, sample) { function setCache(hostname, pluginName, sample) {
if (!pluginCache[hostname]) pluginCache[hostname] = {}; if (!pluginCache[hostname]) pluginCache[hostname] = {};
pluginCache[hostname][pluginName] = { pluginCache[hostname][pluginName] = {
@@ -521,6 +551,61 @@
return json.samples?.[0] ?? null; return json.samples?.[0] ?? null;
} }
async function fetchHostInfo(hostname) {
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.json();
}
function renderInfoSection(hostname, data) {
const el = document.getElementById(`info-${hostname}`);
if (!el) return;
const owner = data.owner ? escHtml(data.owner) : '—';
const managers = data.managers && data.managers.length
? data.managers.map(escHtml).join(', ') : '—';
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
const lastPkt = data.last_packet != null
? new Date(data.last_packet * 1000).toLocaleString() : '—';
let html = `<div class="info-meta">
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
</div>`;
if (data.thresholds === null) {
html += `<div class="info-note">Threshold alerting not configured.</div>`;
} else if (data.thresholds.length === 0) {
html += `<div class="info-note">No thresholds defined.</div>`;
} else {
html += `<div class="info-thresholds-title">Effective Thresholds</div>
<table class="data-table"><thead><tr>
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
</tr></thead><tbody>`;
for (const t of data.thresholds) {
const w = t.warning != null ? escHtml(String(t.warning)) : '—';
const c = t.critical != null ? escHtml(String(t.critical)) : '—';
let metricCell = escHtml(t.metric);
if (t.covers && t.covers.length > 0) {
metricCell += `<br><span class="threshold-covers">↳ ${t.covers.map(escHtml).join(', ')}</span>`;
}
html += `<tr>
<td class="key">${metricCell}</td>
<td>${escHtml(t.operator)}</td>
<td>${w}</td>
<td>${c}</td>
</tr>`;
}
html += `</tbody></table>`;
}
el.innerHTML = html;
}
async function fetchHostGlance(hostname) { async function fetchHostGlance(hostname) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`); const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean); const availablePlugins = (card?.dataset.plugins || '').split(',').filter(Boolean);
@@ -644,8 +729,21 @@
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`); const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
const wasCollapsed = card.classList.contains('collapsed'); const wasCollapsed = card.classList.contains('collapsed');
card.classList.toggle('collapsed'); card.classList.toggle('collapsed');
if (wasCollapsed && !pluginCache[hostname]) { if (wasCollapsed) {
fetchHostGlance(hostname); if (!pluginCache[hostname]) {
fetchHostGlance(hostname);
}
if (!infoCache[hostname]) {
const infoEl = document.getElementById(`info-${hostname}`);
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
fetchHostInfo(hostname).then(data => {
infoCache[hostname] = data;
renderInfoSection(hostname, data);
}).catch(() => {
const el = document.getElementById(`info-${hostname}`);
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
});
}
} }
} }
@@ -794,10 +892,11 @@
function renderOsInfoTable(d) { function renderOsInfoTable(d) {
const ORDER = ['distro_pretty_name','system','release','version','machine', const ORDER = ['distro_pretty_name','system','release','version','machine',
'processor','architecture','node','python_version', 'processor','architecture','node','python_version',
'python_implementation','hbc_version', 'python_implementation',
'distro_name','distro_version','distro_id','distro_version_id']; 'distro_name','distro_version','distro_id','distro_version_id'];
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
const shown = new Set(ORDER); const shown = new Set(ORDER);
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))]; const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>'; let html = '<table class="data-table"><thead><tr><th>Field</th><th>Value</th></tr></thead><tbody>';
for (const k of keys) { for (const k of keys) {
+137 -6
View File
@@ -204,6 +204,22 @@
} }
.channel-name { color: #333; } .channel-name { color: #333; }
.edit-section { margin-top: 20px; }
.edit-section h4 { font-size: .88em; font-weight: 600; color: #333; margin: 0 0 10px; text-transform: uppercase; letter-spacing: .04em; border-bottom: 1px solid #eee; padding-bottom: 6px; }
.edit-field { margin-bottom: 10px; }
.edit-field label { display: block; font-size: .82em; color: #666; margin-bottom: 3px; }
.edit-input { width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 5px 8px; font-size: .88em; box-sizing: border-box; }
.edit-input:focus { border-color: #0066cc; outline: none; }
.status-msg { font-size: .82em; margin-left: 8px; }
.save-row { display: flex; align-items: center; margin-top: 8px; }
.btn-save { background: #0066cc; color: #fff; border: none; border-radius: 4px; padding: 5px 14px; font-size: .85em; cursor: pointer; }
.btn-save:hover { background: #0055aa; }
.channel-item { display: flex; align-items: flex-start; gap: 8px; padding: 6px 0; border-bottom: 1px solid #f5f5f5; }
.channel-item:last-child { border-bottom: none; }
.channel-item label { display: flex; align-items: flex-start; gap: 8px; cursor: pointer; font-size: .88em; }
.channel-item .ch-name { font-weight: 500; color: #222; }
.channel-item .ch-meta { font-size: .8em; color: #888; }
</style> </style>
<body> <body>
@@ -266,16 +282,68 @@
</div> </div>
</div> </div>
{% if current_user %}
<!-- ---- Editable identity ---- -->
<div class="section edit-section">
<h4>Identity</h4>
<div class="edit-field">
<label for="profile-fullname">Display name</label>
<input id="profile-fullname" class="edit-input" type="text" value="{{ current_user.full_name | e }}" placeholder="Full name">
</div>
<div class="edit-field">
<label for="profile-avatar">Avatar URL or path</label>
<input id="profile-avatar" class="edit-input" type="text" value="{{ current_user.avatar | e }}" placeholder="/path/to/avatar.png or https://…">
</div>
<div class="save-row">
<button class="btn-save" onclick="saveIdentity()">Save</button>
<span id="identity-status" class="status-msg"></span>
</div>
</div>
<!-- ---- Change password ---- -->
<div class="section edit-section">
<h4>Change password</h4>
<div class="edit-field">
<label for="profile-current-pw">Current password</label>
<input id="profile-current-pw" class="edit-input" type="password" autocomplete="current-password">
</div>
<div class="edit-field">
<label for="profile-new-pw">New password</label>
<input id="profile-new-pw" class="edit-input" type="password" autocomplete="new-password">
</div>
<div class="save-row">
<button class="btn-save" onclick="changePassword()">Change password</button>
<span id="password-status" class="status-msg"></span>
</div>
</div>
{% endif %}
<!-- Notification channels --> <!-- Notification channels -->
<div class="section"> <div class="section">
<h2>Notification Channels</h2> <h2>Notification Channels</h2>
{% if notification_channels %} {% if current_user %}
{% for ch in notification_channels %} <p style="font-size:.82em;color:#888;margin:0 0 10px">Select which channels send you alerts. Channels are defined by the administrator.</p>
<div class="channel-row"> {% if all_channel_names %}
<span class="channel-type">{{ ch.type }}</span> <div id="channel-checkboxes">
<span class="channel-name">{{ ch.name }}</span> {% for ch_name in all_channel_names %}
<div class="channel-item">
<label>
<input type="checkbox" class="channel-checkbox" value="{{ ch_name | e }}"
{% if ch_name in (current_user.notification_channels or []) %}checked{% endif %}>
<div>
<div class="ch-name">{{ ch_name | e }}</div>
</div>
</label>
</div>
{% endfor %}
</div>
{% else %}
<p style="font-size:.83em;color:#bbb;font-style:italic">No notification channels configured.</p>
{% endif %}
<div class="save-row" style="margin-top:10px">
<button class="btn-save" onclick="saveChannels()">Save channels</button>
<span id="channels-status" class="status-msg"></span>
</div> </div>
{% endfor %}
{% else %} {% else %}
<span class="no-hosts">No personal notification channels configured.</span> <span class="no-hosts">No personal notification channels configured.</span>
{% endif %} {% endif %}
@@ -326,5 +394,68 @@
</div> </div>
</div> </div>
<script>
async function saveIdentity() {
const full_name = document.getElementById('profile-fullname').value;
const avatar = document.getElementById('profile-avatar').value;
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({full_name, avatar}),
});
if (resp.ok) {
showStatus('identity-status', 'Saved', '#2e7d32');
} else {
const err = await resp.json().catch(() => ({}));
showStatus('identity-status', err.error || 'Error saving', '#c62828');
}
}
async function changePassword() {
const current = document.getElementById('profile-current-pw').value;
const newpw = document.getElementById('profile-new-pw').value;
if (!current || !newpw) {
showStatus('password-status', 'Both fields are required', '#c62828');
return;
}
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({password: {current, new: newpw}}),
});
if (resp.ok) {
document.getElementById('profile-current-pw').value = '';
document.getElementById('profile-new-pw').value = '';
showStatus('password-status', 'Password changed', '#2e7d32');
} else {
const err = await resp.json().catch(() => ({}));
showStatus('password-status', err.error || 'Error', '#c62828');
}
}
async function saveChannels() {
const notification_channels = [...document.querySelectorAll('.channel-checkbox:checked')]
.map(cb => cb.value);
const resp = await fetch('/api/0/users/me', {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({notification_channels}),
});
if (resp.ok) {
showStatus('channels-status', 'Saved', '#2e7d32');
} else {
const err = await resp.json().catch(() => ({}));
showStatus('channels-status', err.error || 'Error saving', '#c62828');
}
}
function showStatus(id, msg, color) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg;
el.style.color = color;
setTimeout(() => { el.textContent = ''; }, 3000);
}
</script>
</body> </body>
</html> </html>
+504 -185
View File
@@ -265,6 +265,93 @@
.mini-table .crit { color: #b71c1c; font-weight: 600; } .mini-table .crit { color: #b71c1c; font-weight: 600; }
.mini-table .dim { color: #aaa; } .mini-table .dim { color: #aaa; }
.mini-table .metric-path { font-family: monospace; font-size: 0.88em; } .mini-table .metric-path { font-family: monospace; font-size: 0.88em; }
/* ---- Editable inputs ---- */
.field-input {
width: 100%;
max-width: 360px;
border: 1px solid #ccc;
border-radius: 4px;
padding: 4px 8px;
font-size: 0.88em;
box-sizing: border-box;
font-family: inherit;
}
.field-input:focus { border-color: #0066cc; outline: none; box-shadow: 0 0 0 2px rgba(0,102,204,.15); }
/* ---- Section footer (Stage Changes button) ---- */
.section-footer {
padding: 10px 20px;
border-top: 1px solid #f0f0f0;
display: flex;
justify-content: flex-end;
}
/* ---- Pending changes banner ---- */
.pending-banner {
position: sticky;
top: 8px;
z-index: 100;
background: #fffbe6;
border: 1px solid #e8c840;
border-radius: 6px;
padding: 10px 16px;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.87em;
margin-bottom: 16px;
box-shadow: 0 2px 8px rgba(0,0,0,.08);
}
.pending-banner .pending-msg { color: #7a6000; }
.pending-banner .pending-actions { display: flex; gap: 8px; }
/* ---- YAML editor ---- */
.yaml-editor {
width: 100%;
font-family: monospace;
font-size: 0.83em;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px;
box-sizing: border-box;
background: #fafafa;
resize: vertical;
min-height: 140px;
}
.yaml-editor:focus { border-color: #0066cc; outline: none; }
/* ---- Button styles ---- */
.btn { border: none; border-radius: 4px; padding: 5px 12px; font-size: 0.85em; cursor: pointer; }
.btn-primary { background: #0066cc; color: #fff; }
.btn-primary:hover { background: #0055aa; }
.btn-success { background: #2a7a2a; color: #fff; }
.btn-success:hover { background: #226622; }
.btn-secondary { background: #888; color: #fff; }
.btn-secondary:hover { background: #666; }
.btn-danger { background: transparent; color: #c62828; border: 1px solid #e0e0e0; border-radius: 4px; padding: 2px 7px; font-size: 0.82em; cursor: pointer; }
.btn-danger:hover { background: #fce4ec; }
/* ---- CRUD table for users / oauth ---- */
.crud-table { width: 100%; border-collapse: collapse; font-size: 0.83em; }
.crud-table th { background: #f5f5f5; padding: 6px 10px; text-align: left; font-weight: 600; color: #555; font-size: .78em; text-transform: uppercase; letter-spacing: .03em; border-bottom: 1px solid #e0e0e0; }
.crud-table td { padding: 6px 10px; border-bottom: 1px solid #f0f0f0; vertical-align: top; }
.crud-table tbody tr:last-child td { border-bottom: none; }
.crud-table .field-input { max-width: none; }
/* ---- Rollback modal ---- */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,.4);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.modal-box {
background: #fff; border-radius: 8px; padding: 24px;
min-width: 340px; max-width: 520px; width: 90%;
box-shadow: 0 8px 32px rgba(0,0,0,.18);
}
.modal-box h3 { margin: 0 0 12px; font-size: 1em; }
.backup-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; border-bottom: 1px solid #f0f0f0; font-size: .87em; }
.backup-row:last-child { border-bottom: none; }
</style> </style>
<body> <body>
@@ -272,7 +359,27 @@
<div class="container"> <div class="container">
<h1>Settings</h1> <h1>Settings</h1>
<p class="subtitle">Current server configuration — read from the config file at startup.</p> <p class="subtitle">Edit server configuration — changes are staged until you publish them to <code>.hb.yaml</code>.</p>
<!-- Pending changes banner (hidden until something is staged) -->
<div id="pending-banner" class="pending-banner" style="display:none">
<span class="pending-msg"><strong id="pending-count">0</strong> section(s) with pending changes — not yet saved to .hb.yaml</span>
<span class="pending-actions">
<button class="btn btn-secondary" onclick="discardAll()">Discard all</button>
<button class="btn btn-success" onclick="publishAll()">Publish to .hb.yaml</button>
</span>
</div>
<!-- Rollback modal -->
<div id="rollback-modal" class="modal-overlay" style="display:none" onclick="if(event.target===this)closeRollbackModal()">
<div class="modal-box">
<h3>Backups / Rollback</h3>
<div id="rollback-list" style="max-height:300px;overflow-y:auto">Loading…</div>
<div style="margin-top:14px;text-align:right">
<button class="btn btn-secondary" onclick="closeRollbackModal()">Close</button>
</div>
</div>
</div>
<div class="settings-layout"> <div class="settings-layout">
@@ -283,6 +390,8 @@
{% for section in sections %} {% for section in sections %}
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a> <a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
{% endfor %} {% endfor %}
<hr style="margin: 8px 0; border: none; border-top: 1px solid #e8e8e8;">
<a href="#" onclick="showRollbackModal(); return false;" style="color:#888;font-size:.82em">View backups / rollback</a>
</div> </div>
</nav> </nav>
@@ -295,212 +404,423 @@
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %} {% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div> </div>
{# ---- Standard field rows ---- #} {# ---- Users CRUD ---- #}
{% if section.id == 'users' %}
<div style="padding: 12px 20px 0">
{% for f in section.fields %}
{% if f.editable %}
<div class="field-row" style="border-bottom: 1px solid #eee; margin-bottom: 8px">
<div class="field-label" style="font-size:.85em;color:#555">{{ f.label }}</div>
<div class="field-body">
<input type="text" class="field-input"
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="users-editor">
<thead><tr>
<th>Username</th><th>Display name</th><th>Avatar URL</th>
<th>Admin</th><th>Channels</th><th style="min-width:110px">New password</th><th></th>
</tr></thead>
<tbody id="users-tbody">
{% for u in section.users %}
<tr data-user-row="true" data-username="{{ u.username | e }}">
<td style="font-family:monospace;font-size:.9em">{{ u.username | e }}</td>
<td><input class="field-input user-full-name" value="{{ u.full_name | e }}"></td>
<td><input class="field-input user-avatar" value="{{ u.avatar | e }}"></td>
<td style="text-align:center"><input type="checkbox" class="user-admin" {% if u.admin %}checked{% endif %}></td>
<td style="min-width:120px">
{% for ch in all_channel_names %}
<label style="display:block;font-size:.82em;white-space:nowrap">
<input type="checkbox" class="user-ch" value="{{ ch | e }}" {% if ch in u.notification_channels %}checked{% endif %}> {{ ch | e }}
</label>
{% endfor %}
</td>
<td><input type="password" class="field-input user-password" placeholder="(leave blank to keep)"></td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addUserRow()" style="margin-right:auto">+ Add user</button>
<button class="btn btn-primary" onclick="stageUsersSection()">Stage changes</button>
</div>
{# ---- OAuth CRUD ---- #}
{% elif section.id == 'oauth' %}
<div style="overflow-x:auto;padding:0 20px">
<table class="crud-table" id="oauth-editor">
<thead><tr>
<th>Name (slug)</th><th>Type</th><th>URL</th><th>Client ID</th>
<th>Client Secret</th><th>Label</th><th>Logo URL</th><th></th>
</tr></thead>
<tbody id="oauth-tbody">
{% for p in section.providers %}
<tr data-oauth-row="true" data-name="{{ p.name | e }}">
<td style="font-family:monospace;font-size:.9em">{{ p.name | e }}</td>
<td>
<select class="field-input oauth-type">
{% for t in ['gitea', 'github', 'nextcloud'] %}
<option value="{{ t }}" {% if p.type == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</td>
<td><input class="field-input oauth-url" value="{{ p.url | e }}"></td>
<td><input class="field-input oauth-client-id" value="{{ p.client_id | e }}"></td>
<td><input type="password" class="field-input oauth-secret" value="{{ p.client_secret | e }}"></td>
<td><input class="field-input oauth-label" value="{{ p.label | e }}"></td>
<td><input class="field-input oauth-logo" value="{{ p.logo | e }}"></td>
<td><button class="btn-danger" onclick="toggleDeleteRow(this)"></button></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="section-footer">
<button class="btn btn-secondary" onclick="addOAuthRow()" style="margin-right:auto">+ Add provider</button>
<button class="btn btn-primary" onclick="stageOAuthSection()">Stage changes</button>
</div>
{# ---- YAML editor section ---- #}
{% elif section.section_mode == 'yaml' %}
<div style="padding: 12px 20px">
<textarea id="yaml-{{ section.id }}" class="yaml-editor" rows="12"></textarea>
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:6px">
<button class="btn btn-secondary" onclick="loadYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Reload from file</button>
<button class="btn btn-primary" onclick="stageYamlSection('{{ section.api_section }}', 'yaml-{{ section.id }}')">Stage changes</button>
</div>
</div>
{# ---- Form section (generic fields) ---- #}
{% else %}
{% for f in section.fields %} {% for f in section.fields %}
<div class="field-row"> <div class="field-row">
<div class="field-label">{{ f.label }}</div> <div class="field-label">{{ f.label }}</div>
<div class="field-body"> <div class="field-body">
{% if f.sensitive %} {% if f.editable and section.api_section %}
{% if f.type == 'boolean' %}
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
<input type="checkbox" class="user-admin"
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
{% if f.value %}checked{% endif %}>
<span style="font-size:.88em">{{ 'Enabled' if f.value else 'Disabled' }}</span>
</label>
{% elif f.type in ('number', 'port', 'size') %}
<input type="number" class="field-input"
data-key="{{ f.key }}" data-type="{{ f.type }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% else %}
<input type="text" class="field-input"
data-key="{{ f.key }}" data-section="{{ section.api_section }}"
value="{{ f.raw if f.raw is not none else '' }}">
{% endif %}
{% if f.description %}<p class="field-desc">{{ f.description }}</p>{% endif %}
{% elif f.sensitive %}
<div class="field-value"><span class="val-masked">••••••••</span></div> <div class="field-value"><span class="val-masked">••••••••</span></div>
{% elif f.type == "boolean" %} {% elif f.type == 'boolean' %}
<div class="field-value"> <div class="field-value">
<span class="val-boolean {{ 'on' if f.value else 'off' }}"> <span class="val-boolean {{ 'on' if f.value else 'off' }}">{{ 'Enabled' if f.value else 'Disabled' }}</span>
{{ 'Enabled' if f.value else 'Disabled' }}
</span>
</div> </div>
{% elif f.type == "list" %} {% elif f.type == 'list' %}
<div class="field-value"> <div class="field-value">
{% if f.value %} {% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
<span class="val-list"> {% else %}<span class="val-empty">None</span>{% endif %}
{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}
</span>
{% else %}
<span class="val-empty">None</span>
{% endif %}
</div> </div>
{% elif f.value is none or f.value == "" %}
<div class="field-value"><span class="val-empty">Not set</span></div>
{% else %} {% else %}
<div class="field-value">{{ f.value }}</div> <div class="field-value">{{ f.value if f.value is not none else '' }}</div>
{% endif %}
{% if f.description %}
<div class="field-desc">{{ f.description }}</div>
{% endif %} {% endif %}
{% if f.description and not f.editable %}<p class="field-desc">{{ f.description }}</p>{% endif %}
</div> </div>
</div> </div>
{% endfor %} {% endfor %}
{% if section.api_section %}
{# ---- Users section ---- #} <div class="section-footer">
{% if section.id == "users" and section.users %} <button class="btn btn-primary" onclick="stageFormSection('{{ section.id }}', '{{ section.api_section }}')">Stage changes</button>
<div style="padding: 0 0 4px;">
<table class="mini-table">
<thead>
<tr>
<th>Username</th>
<th>Full Name</th>
<th>Role</th>
<th>Avatar</th>
<th>Channels</th>
</tr>
</thead>
<tbody>
{% for u in section.users %}
<tr>
<td><strong>{{ u.username }}</strong></td>
<td>{{ u.full_name or '—' }}</td>
<td>
{% if u.admin %}
<span class="badge badge-admin">Admin</span>
{% else %}
<span class="badge badge-user">User</span>
{% endif %}
</td>
<td style="font-size:0.8em; color:#888;">
{% if u.avatar %}{{ u.avatar }}{% else %}—{% endif %}
</td>
<td>
{% if u.notification_channels %}
<span class="val-list">
{% for ch in u.notification_channels %}
<span class="val-tag">{{ ch }}</span>
{% endfor %}
</span>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div> </div>
{% endif %} {% endif %}
{# ---- Notification channels section ---- #}
{% if section.id == "channels" %}
{% for ch in section.channels %}
<div class="channel-card">
<div class="channel-header">
<span class="channel-name-text">{{ ch.name }}</span>
<span class="ch-type-badge">{{ ch.type_label }}</span>
</div>
<div class="channel-fields">
{% for cf in ch.fields %}
<div class="channel-field">
<span class="channel-field-label">{{ cf.label }}</span>
<span class="channel-field-value">
{% if cf.sensitive %}
<span class="val-masked">••••••••</span>
{% elif cf.value is iterable and cf.value is not string %}
{{ cf.value | join(', ') }}
{% else %}
{{ cf.value }}
{% endif %}
</span>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
{% if not section.channels %}
<div class="field-row"><span class="val-empty">No notification channels configured.</span></div>
{% endif %}
{% endif %} {% endif %}
{# ---- Threshold configurations section ---- #} </div>
{% if section.id == "thresholds" %}
{% if section.threshold_configs %}
{% for tc in section.threshold_configs %}
<div class="thresh-config">
<div class="thresh-config-name">{{ tc.name }}</div>
{% if tc.metrics %}
<div style="overflow-x: auto;">
<table class="mini-table">
<thead>
<tr>
<th>Metric</th>
<th>Op</th>
<th>Warning</th>
<th>Critical</th>
<th>Hysteresis</th>
<th>Count</th>
</tr>
</thead>
<tbody>
{% for m in tc.metrics %}
<tr {% if not m.enabled %} style="opacity:0.45"{% endif %}>
<td class="metric-path">{{ m.metric }}</td>
<td>{{ m.operator or '>' }}</td>
<td class="warn">{{ m.warning if m.warning is not none else '—' }}</td>
<td class="crit">{{ m.critical if m.critical is not none else '—' }}</td>
<td class="dim">{{ '%.0f%%' % (m.hysteresis * 100) if m.hysteresis else '—' }}</td>
<td class="dim">{{ m.count }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<span class="val-empty">No thresholds defined.</span>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="field-row"><span class="val-empty">No threshold configurations defined.</span></div>
{% endif %}
{% endif %}
{# ---- Hosts section ---- #}
{% if section.id == "hosts" %}
{% if section.hosts %}
<div style="overflow-x: auto;">
<table class="mini-table">
<thead>
<tr>
<th>Host</th>
<th>Watch</th>
<th>DynDNS</th>
<th>Owner</th>
<th>Threshold config</th>
<th>Channels</th>
</tr>
</thead>
<tbody>
{% for h in section.hosts %}
<tr>
<td><strong>{{ h.name }}</strong></td>
<td class="host-bool">
<span class="{{ 'dot-yes' if h.watch else 'dot-no' }}"></span>
</td>
<td class="host-bool">
<span class="{{ 'dot-yes' if h.dyndns else 'dot-no' }}"></span>
</td>
<td>{{ h.owner or '—' }}</td>
<td>{{ h.threshold_config or '—' }}</td>
<td>
{% if h.notification_channels %}
<span class="val-list">
{% for ch in h.notification_channels %}
<span class="val-tag">{{ ch }}</span>
{% endfor %}
</span>
{% else %}—{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="field-row"><span class="val-empty">No hosts defined in config.</span></div>
{% endif %}
{% endif %}
</div>{# /section #}
{% endfor %} {% endfor %}
</div>{# /settings-main #} </div>{# /settings-main #}
</div>{# /settings-layout #} </div>{# /settings-layout #}
</div>{# /container #} </div>{# /container #}
<script> <script>
// ---- Channel names for add-user row ----
const _allChannels = {{ all_channel_names | tojson }};
// ---- Staged changes accumulator ----
const _staged = {};
function updatePendingBanner() {
const count = Object.keys(_staged).length;
const banner = document.getElementById('pending-banner');
if (count > 0) {
document.getElementById('pending-count').textContent = count;
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
}
function stageFormSection(sectionId, apiSection) {
const section = document.getElementById(sectionId);
if (!_staged[apiSection] || typeof _staged[apiSection] !== 'object') {
_staged[apiSection] = {};
}
section.querySelectorAll('[data-key][data-section="' + apiSection + '"]').forEach(el => {
const key = el.dataset.key;
if (el.type === 'checkbox') {
_staged[apiSection][key] = el.checked;
} else if (el.dataset.type === 'number' || el.dataset.type === 'port') {
const v = parseInt(el.value, 10);
_staged[apiSection][key] = isNaN(v) ? null : v;
} else {
_staged[apiSection][key] = el.value;
}
});
updatePendingBanner();
flashStaged(sectionId);
}
function stageYamlSection(apiSection, textareaId) {
_staged[apiSection] = document.getElementById(textareaId).value;
updatePendingBanner();
}
function stageUsersSection() {
const users = {};
document.querySelectorAll('[data-user-row]').forEach(row => {
if (row.dataset.deleted === 'true') return;
const username = row.dataset.username;
const entry = {
full_name: row.querySelector('.user-full-name').value,
avatar: row.querySelector('.user-avatar').value,
admin: row.querySelector('.user-admin').checked,
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
};
const pw = row.querySelector('.user-password').value;
if (pw) entry.password = pw;
users[username] = entry;
});
document.querySelectorAll('[data-new-user]').forEach(row => {
if (row.dataset.deleted === 'true') return;
const uname = (row.querySelector('.new-username') || {value: ''}).value.trim();
if (!uname) return;
const entry = {
full_name: row.querySelector('.user-full-name').value,
avatar: row.querySelector('.user-avatar').value,
admin: row.querySelector('.user-admin').checked,
notification_channels: [...row.querySelectorAll('.user-ch:checked')].map(cb => cb.value),
};
const pw = row.querySelector('.user-password').value;
if (pw) entry.password = pw;
users[uname] = entry;
});
const defOwner = document.querySelector('[data-key="default_owner"]');
if (defOwner) {
if (!_staged['server']) _staged['server'] = {};
_staged['server']['default_owner'] = defOwner.value;
}
_staged['users'] = users;
updatePendingBanner();
flashStaged('users');
}
function stageOAuthSection() {
const oauth = {};
document.querySelectorAll('[data-oauth-row]').forEach(row => {
if (row.dataset.deleted === 'true') return;
let name = row.dataset.name;
if (!name) {
const ni = row.querySelector('.oauth-name-input');
if (ni) name = ni.value.trim();
}
if (!name) return;
const entry = {
type: row.querySelector('.oauth-type').value,
url: row.querySelector('.oauth-url').value,
client_id: row.querySelector('.oauth-client-id').value,
};
const label = row.querySelector('.oauth-label').value;
if (label) entry.label = label;
const logo = row.querySelector('.oauth-logo').value;
if (logo) entry.logo = logo;
const secret = row.querySelector('.oauth-secret').value;
if (secret && secret !== '•••') entry.client_secret = secret;
oauth[name] = entry;
});
_staged['oauth'] = oauth;
updatePendingBanner();
flashStaged('oauth');
}
async function publishAll() {
const btn = document.querySelector('[onclick="publishAll()"]');
btn.disabled = true;
btn.textContent = 'Saving…';
try {
const resp = await fetch('/api/0/config', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(_staged),
});
if (resp.ok) {
window.location.reload();
} else {
const err = await resp.json().catch(() => ({}));
alert('Error: ' + (err.error || resp.statusText));
btn.disabled = false;
btn.textContent = 'Publish to .hb.yaml';
}
} catch (e) {
alert('Network error: ' + e.message);
btn.disabled = false;
btn.textContent = 'Publish to .hb.yaml';
}
}
function discardAll() {
Object.keys(_staged).forEach(k => delete _staged[k]);
updatePendingBanner();
window.location.reload();
}
async function loadYamlSection(apiSection, textareaId) {
const ta = document.getElementById(textareaId);
ta.value = 'Loading…';
try {
const resp = await fetch('/api/0/config/section/' + apiSection);
const data = await resp.json();
ta.value = data.yaml || '';
} catch (e) {
ta.value = '# Error loading: ' + e.message;
}
}
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('textarea[id^="yaml-"]').forEach(ta => {
const sectionId = ta.id.replace('yaml-', '');
const section = document.getElementById(sectionId);
if (section) {
const btn = section.querySelector('[onclick^="stageYamlSection"]');
if (btn) {
const m = btn.getAttribute('onclick').match(/stageYamlSection\('([^']+)'/);
if (m) loadYamlSection(m[1], ta.id);
}
}
});
});
function toggleDeleteRow(btn) {
const row = btn.closest('tr');
const deleted = row.dataset.deleted === 'true';
row.dataset.deleted = deleted ? 'false' : 'true';
row.style.opacity = deleted ? '1' : '0.4';
row.querySelectorAll('input, select').forEach(el => { el.disabled = !deleted; });
btn.textContent = deleted ? '✕' : '↩';
}
function addUserRow() {
const tbody = document.getElementById('users-tbody');
const chHtml = _allChannels.map(ch =>
`<label style="display:block;font-size:.82em;white-space:nowrap"><input type="checkbox" class="user-ch" value="${escHtml(ch)}"> ${escHtml(ch)}</label>`
).join('');
const row = document.createElement('tr');
row.setAttribute('data-new-user', 'true');
row.innerHTML = `
<td><input class="field-input new-username" placeholder="username" required></td>
<td><input class="field-input user-full-name" placeholder="Display Name"></td>
<td><input class="field-input user-avatar" placeholder="Avatar URL or path"></td>
<td style="text-align:center"><input type="checkbox" class="user-admin"></td>
<td>${chHtml}</td>
<td><input type="password" class="field-input user-password" placeholder="(required)"></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
function addOAuthRow() {
const tbody = document.getElementById('oauth-tbody');
const row = document.createElement('tr');
row.setAttribute('data-oauth-row', 'true');
row.setAttribute('data-name', '');
row.innerHTML = `
<td><input class="field-input oauth-name-input" placeholder="slug (e.g. gitea)"></td>
<td><select class="field-input oauth-type">
<option value="gitea">gitea</option>
<option value="github">github</option>
<option value="nextcloud">nextcloud</option>
</select></td>
<td><input class="field-input oauth-url" placeholder="https://…"></td>
<td><input class="field-input oauth-client-id" placeholder="client_id"></td>
<td><input type="password" class="field-input oauth-secret" placeholder="client_secret"></td>
<td><input class="field-input oauth-label" placeholder="Sign in with…"></td>
<td><input class="field-input oauth-logo" placeholder="/path/to/logo.png"></td>
<td><button class="btn-danger" onclick="this.closest('tr').remove()">✕</button></td>`;
tbody.appendChild(row);
}
async function showRollbackModal() {
document.getElementById('rollback-modal').style.display = 'flex';
const el = document.getElementById('rollback-list');
el.innerHTML = 'Loading…';
try {
const resp = await fetch('/api/0/config/backups');
const data = await resp.json();
if (!data.backups || !data.backups.length) {
el.innerHTML = '<p style="color:#888;font-size:.88em">No backups available.</p>';
return;
}
el.innerHTML = data.backups.map(b => {
const m = b.match(/\.bak\.(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/);
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}:${m[6]}` : b;
const safe = b.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
return `<div class="backup-row"><span>${label}</span><button class="btn btn-secondary" style="font-size:.8em" onclick="doRollback('${safe}')">Restore</button></div>`;
}).join('');
} catch (e) {
el.innerHTML = '<p style="color:#c62828">Error loading backups: ' + e.message + '</p>';
}
}
function closeRollbackModal() {
document.getElementById('rollback-modal').style.display = 'none';
}
async function doRollback(backupPath) {
if (!confirm('Restore this backup? The current config will be backed up first.')) return;
const resp = await fetch('/api/0/config/rollback', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({backup: backupPath}),
});
if (resp.ok) {
closeRollbackModal();
window.location.reload();
} else {
const err = await resp.json().catch(() => ({}));
alert('Rollback failed: ' + (err.error || resp.statusText));
}
}
function flashStaged(sectionId) {
const sec = document.getElementById(sectionId);
if (!sec) return;
sec.style.outline = '2px solid #e8c840';
setTimeout(() => { sec.style.outline = ''; }, 800);
}
function escHtml(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
}
// Highlight sidebar link for the section currently in view // Highlight sidebar link for the section currently in view
const sections = document.querySelectorAll('.section'); const sections = document.querySelectorAll('.section');
const navLinks = document.querySelectorAll('.sidebar-nav a'); const navLinks = document.querySelectorAll('.sidebar-nav a');
@@ -528,8 +848,7 @@
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false'); sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
}); });
} }
</script>
<script>
function closeSidebar() { function closeSidebar() {
var sidebarNav = document.getElementById('sidebar-nav'); var sidebarNav = document.getElementById('sidebar-nav');
var sidebarToggle = document.getElementById('sidebar-toggle'); var sidebarToggle = document.getElementById('sidebar-toggle');
+26 -3
View File
@@ -1389,10 +1389,24 @@ class ThresholdChecker:
host_name, lvl, message, metric_path, AlertLevel.OK, alert_state.level, value host_name, lvl, message, metric_path, AlertLevel.OK, alert_state.level, value
) )
alert_state.pending_since = None alert_state.pending_since = None
now = time.time()
alert_state.last_notification = now
alert_state.notification_count = 1
# else: still within grace window, do nothing # else: still within grace window, do nothing
else: else:
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data, check_name=check_name, metric_name=metric_name) self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data, check_name=check_name, metric_name=metric_name)
@staticmethod
def _human_duration(seconds: float) -> str:
s = int(seconds)
if s < 120:
return f"{s}s"
if s < 3600:
return f"{s // 60}m {s % 60}s"
h, rem = divmod(s, 3600)
m = rem // 60
return f"{h}h {m}m" if m else f"{h}h"
def _check_renotify( def _check_renotify(
self, self,
host_name: str, host_name: str,
@@ -1454,9 +1468,9 @@ class ThresholdChecker:
check_name=check_name, check_name=check_name,
metric_name=metric_name, metric_name=metric_name,
) )
body = f"{value} {threshold_info}, ongoing for {int(now - alert_state.since)}s" body = f"{value} {threshold_info}, ongoing for {self._human_duration(now - alert_state.since)}"
else: else:
body = f"{value} (ongoing for {int(now - alert_state.since)}s)" body = f"{value} (ongoing for {self._human_duration(now - alert_state.since)})"
message = f"REMINDER ({alert_state.level.name}): {host_name} - {short_path} = {body}" message = f"REMINDER ({alert_state.level.name}): {host_name} - {short_path} = {body}"
from . import hbdclass from . import hbdclass
@@ -1486,7 +1500,16 @@ class ThresholdChecker:
if not host.alert_states: if not host.alert_states:
continue continue
configured = self.get_thresholds_for_host(hostname) configured = self.get_thresholds_for_host(hostname)
stale = [mp for mp in host.alert_states if self._find_threshold(configured, mp)[0] is None] stale = []
for mp in host.alert_states:
if self._find_threshold(configured, mp)[0] is not None:
continue
# Also match wildcard pool/partition thresholds (e.g. "zfs_monitor.*.status"
# covers alert state "zfs_monitor.tank.status").
parts = mp.split(".")
if len(parts) == 3 and f"{parts[0]}.*.{parts[2]}" in configured:
continue
stale.append(mp)
for mp in stale: for mp in stale:
logger.info( logger.info(
"Purging stale alert state for %s / %s (no threshold configured)", "Purging stale alert state for %s / %s (no threshold configured)",
+2 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.2.5" version = "5.3.1"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)" description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -32,6 +32,7 @@ server = [
"aiohttp>=3.11", "aiohttp>=3.11",
"Jinja2>=3.1.6", "Jinja2>=3.1.6",
"matrix-nio>=0.24", "matrix-nio>=0.24",
"ruamel.yaml>=0.18",
] ]
# Minimal client — hbc_mini only, no external dependencies # Minimal client — hbc_mini only, no external dependencies
+1 -1
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh # updated by scripts/bumpminor.sh
__version__ = "5.2.5" __version__ = "5.3.1"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py) # Protocol (mirrors hbd/common/proto.py)
+162
View File
@@ -0,0 +1,162 @@
import glob
import os
import pytest
from hbd.server import configio
SAMPLE_YAML = """\
# Server configuration
hbd_port: 50004 # HTTP API port
interval: 20
users:
alice:
full_name: Alice Smith
admin: true
notification_channels:
pushover_ops:
type: pushover
token: abc123
"""
def test_read_roundtrip_loads_values(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
assert data["hbd_port"] == 50004
assert data["interval"] == 20
assert data["users"]["alice"]["full_name"] == "Alice Smith"
def test_write_config_creates_backup(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
data["interval"] = 30
configio.write_config(str(f), data)
backups = configio.list_backups(str(f))
assert len(backups) == 1
assert ".bak." in backups[0]
def test_write_config_preserves_comments(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
data["interval"] = 30
configio.write_config(str(f), data)
content = f.read_text()
assert "# Server configuration" in content
assert "# HTTP API port" in content
def test_write_config_atomically_replaces_file(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
data["interval"] = 99
configio.write_config(str(f), data)
assert not (tmp_path / ".hb.yaml.tmp").exists()
data2 = configio.read_roundtrip(str(f))
assert data2["interval"] == 99
def test_write_config_backup_rotation(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(SAMPLE_YAML)
# Pre-create 10 existing backups with old timestamps
for i in range(10):
(tmp_path / f".hb.yaml.bak.20260101-{i:06d}").write_text("old")
data = configio.read_roundtrip(str(cfg))
configio.write_config(str(cfg), data)
backups = configio.list_backups(str(cfg))
assert len(backups) == 10
assert not (tmp_path / ".hb.yaml.bak.20260101-000000").exists()
def test_list_backups_newest_first(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(SAMPLE_YAML)
for i in range(3):
(tmp_path / f".hb.yaml.bak.20260101-{i:02d}0000").write_text("b")
backups = configio.list_backups(str(cfg))
assert len(backups) == 3
assert backups == sorted(backups, reverse=True)
def test_apply_structured_section_server_updates_keys(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_structured_section(data, "server", {"interval": 60, "hbd_port": 8080})
assert data["interval"] == 60
assert data["hbd_port"] == 8080
def test_apply_structured_section_server_ignores_unknown_keys(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_structured_section(data, "server", {"interval": 60, "not_a_key": "x"})
assert "not_a_key" not in data
def test_apply_structured_section_users_replaces_dict(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
new_users = {"bob": {"full_name": "Bob Jones", "admin": False}}
configio.apply_structured_section(data, "users", new_users)
assert "alice" not in data["users"]
assert data["users"]["bob"]["full_name"] == "Bob Jones"
def test_apply_yaml_section_notification_channels(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
new_yaml = "email_ops:\n type: email\n recipients: [ops@example.com]\n"
configio.apply_yaml_section(data, "notification_channels", new_yaml)
assert "email_ops" in data["notification_channels"]
assert "pushover_ops" not in data["notification_channels"]
def test_apply_yaml_section_thresholds_maps_to_threshold_configs(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_yaml_section(data, "thresholds", "default:\n cpu: 80\n")
assert "threshold_configs" in data
assert data["threshold_configs"]["default"]["cpu"] == 80
def test_apply_yaml_section_dns_replaces_each_key(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
configio.apply_yaml_section(
data, "dns",
"nsupdate_bin: /usr/bin/nsupdate\ndyndomains: [dyn.example.com]\n"
)
assert data["nsupdate_bin"] == "/usr/bin/nsupdate"
assert data["dyndomains"] == ["dyn.example.com"]
def test_apply_yaml_section_unknown_raises(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
with pytest.raises(ValueError, match="Unknown YAML section"):
configio.apply_yaml_section(data, "nope", "x: 1\n")
def test_apply_structured_section_unknown_raises(tmp_path):
f = tmp_path / ".hb.yaml"
f.write_text(SAMPLE_YAML)
data = configio.read_roundtrip(str(f))
with pytest.raises(ValueError, match="Unknown structured section"):
configio.apply_structured_section(data, "nope", {"x": 1})
def test_read_roundtrip_missing_file_raises(tmp_path):
with pytest.raises(FileNotFoundError):
configio.read_roundtrip(str(tmp_path / "nonexistent.yaml"))
+173
View File
@@ -0,0 +1,173 @@
"""Tests for the config read/write API helpers in http.py."""
import pytest
from hbd.server import http
def test_mask_config_for_api_masks_user_passwords():
config = {
"hbd_port": 50004,
"interval": 20,
"users": {
"alice": {"full_name": "Alice", "admin": True, "password": "pbkdf2:sha256:abc"},
},
"oauth": {},
}
result = http._mask_config_for_api(config)
assert result["users"]["alice"]["password"] == "•••"
assert result["users"]["alice"]["full_name"] == "Alice"
def test_mask_config_for_api_masks_oauth_client_secret():
config = {
"hbd_port": 50004,
"interval": 20,
"users": {},
"oauth": {
"gitea": {"type": "gitea", "url": "https://git.example.com",
"client_id": "cid", "client_secret": "verysecret"},
},
}
result = http._mask_config_for_api(config)
assert result["oauth"]["gitea"]["client_secret"] == "•••"
assert result["oauth"]["gitea"]["client_id"] == "cid"
def test_mask_config_for_api_includes_server_keys():
config = {"hbd_port": 50004, "interval": 20, "users": {}, "oauth": {}}
result = http._mask_config_for_api(config)
assert result["server"]["hbd_port"] == 50004
assert result["server"]["interval"] == 20
def test_mask_config_for_api_no_password_in_users_leaves_no_key():
config = {
"hbd_port": 50004,
"users": {"bob": {"full_name": "Bob", "admin": False}},
"oauth": {},
}
result = http._mask_config_for_api(config)
assert "password" not in result["users"]["bob"]
# ---- configio integration for write path ----
def test_write_path_applies_server_section(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text("hbd_port: 50004\ninterval: 20\nusers: {}\n")
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
configio.apply_structured_section(data, "server", {"interval": 60})
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["interval"] == 60
assert data2["hbd_port"] == 50004 # unchanged
def test_write_path_applies_yaml_section(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(
"hbd_port: 50004\nnotification_channels:\n old_ch:\n type: email\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
configio.apply_yaml_section(data, "notification_channels", "new_ch:\n type: pushover\n")
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert "new_ch" in data2["notification_channels"]
assert "old_ch" not in data2["notification_channels"]
def test_write_path_hashes_plaintext_password(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text("hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: pbkdf2:sha256:old\n")
from hbd.server import configio
from hbd.server import users as users_mod
data = configio.read_roundtrip(str(cfg))
# Simulate what the POST handler does: hash plaintext password
new_users = {"alice": {"full_name": "Alice", "admin": True, "password": "newplaintext"}}
for username, attrs in new_users.items():
pw = attrs.get("password", "")
if pw and not pw.startswith("pbkdf2:"):
attrs["password"] = users_mod.hash_password(pw)
configio.apply_structured_section(data, "users", new_users)
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["users"]["alice"]["password"].startswith("pbkdf2:")
assert data2["users"]["alice"]["password"] != "newplaintext"
def test_rollback_restores_backup(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text("hbd_port: 50004\ninterval: 20\n")
from hbd.server import configio
# Make a change to create a backup
data = configio.read_roundtrip(str(cfg))
data["interval"] = 99
configio.write_config(str(cfg), data)
backups = configio.list_backups(str(cfg))
assert len(backups) == 1
# Read the backup and write it back (simulating rollback)
backup_data = configio.read_roundtrip(backups[0])
configio.write_config(str(cfg), backup_data)
restored = configio.read_roundtrip(str(cfg))
assert restored["interval"] == 20
def test_write_path_preserves_masked_password(tmp_path):
"""The "•••" sentinel must preserve the existing hash, not write "•••" to disk."""
cfg = tmp_path / ".hb.yaml"
original_hash = "pbkdf2:sha256:original_hash"
cfg.write_text(
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {original_hash}\n"
)
from hbd.server import configio
from hbd.server import users as users_mod
data = configio.read_roundtrip(str(cfg))
# Simulate what api_config_post does when client sends "•••" back
existing_users = data.get("users") or {}
users_payload = {"alice": {"full_name": "Alice", "admin": True, "password": "•••"}}
for username, attrs in users_payload.items():
pw = attrs.get("password", "")
if pw and pw != "•••" and not pw.startswith("pbkdf2:"):
attrs["password"] = users_mod.hash_password(pw)
elif not pw or pw == "•••":
existing_hash = (existing_users.get(username) or {}).get("password", "")
if existing_hash:
attrs["password"] = existing_hash
else:
attrs.pop("password", None)
configio.apply_structured_section(data, "users", users_payload)
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["users"]["alice"]["password"] == original_hash, (
f"Expected original hash preserved, got: {data2['users']['alice']['password']!r}"
)
def test_write_path_preserves_oauth_client_secret(tmp_path):
"""The "•••" sentinel for oauth client_secret must preserve the existing secret."""
cfg = tmp_path / ".hb.yaml"
original_secret = "real_client_secret_value"
cfg.write_text(
f"hbd_port: 50004\noauth:\n gitea:\n type: gitea\n url: https://git.example.com\n"
f" client_id: cid123\n client_secret: {original_secret}\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
# Simulate what api_config_post does when client sends "•••" back for client_secret
existing_oauth = data.get("oauth") or {}
new_oauth = {"gitea": {"type": "gitea", "url": "https://git.example.com", "client_id": "cid123", "client_secret": "•••"}}
for name, attrs in new_oauth.items():
cs = attrs.get("client_secret", "")
if not cs or cs == "•••":
existing_cs = (existing_oauth.get(name) or {}).get("client_secret", "")
if existing_cs:
attrs["client_secret"] = existing_cs
else:
attrs.pop("client_secret", None)
data["oauth"] = new_oauth
configio.write_config(str(cfg), data)
data2 = configio.read_roundtrip(str(cfg))
assert data2["oauth"]["gitea"]["client_secret"] == original_secret, (
f"Expected original secret preserved, got: {data2['oauth']['gitea']['client_secret']!r}"
)
+174
View File
@@ -0,0 +1,174 @@
"""Tests for _build_host_info helper in http.py."""
import pytest
from unittest.mock import MagicMock
from hbd.server.http import _build_host_info
class _FakeConn:
def __init__(self, lastbeat):
self.lastbeat = lastbeat
class _FakeHost:
def __init__(self, name="myhost", owner=None, managers=None,
connections=None, os_data=None, plugin_data=None):
self.name = name
self.owner = owner
self.managers = managers or []
self.connections = connections or {}
self._os_data = os_data
self.plugin_data = plugin_data or {}
def get_latest_plugin_data(self, plugin_name):
if plugin_name == "os_info" and self._os_data is not None:
return (1234567890.0, self._os_data)
return None
def test_build_host_info_basic_fields():
host = _FakeHost(owner="alice", managers=["bob", "carol"])
result = _build_host_info(host)
assert result["owner"] == "alice"
assert result["managers"] == ["bob", "carol"]
assert result["hbc_version"] is None
assert result["hbc_type"] is None
assert result["last_packet"] is None
assert result["thresholds"] is None
def test_build_host_info_no_owner():
host = _FakeHost()
result = _build_host_info(host)
assert result["owner"] is None
assert result["managers"] == []
def test_build_host_info_reads_hbc_from_os_info():
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
result = _build_host_info(host)
assert result["hbc_version"] == "5.3.0"
assert result["hbc_type"] == "full"
def test_build_host_info_hbc_none_when_no_os_info():
host = _FakeHost(os_data=None)
result = _build_host_info(host)
assert result["hbc_version"] is None
assert result["hbc_type"] is None
def test_build_host_info_last_packet_is_max_lastbeat():
host = _FakeHost(connections={
"IPv4": _FakeConn(1000.0),
"IPv6": _FakeConn(2000.0),
})
result = _build_host_info(host)
assert result["last_packet"] == 2000.0
def test_build_host_info_last_packet_none_when_no_connections():
host = _FakeHost(connections={})
result = _build_host_info(host)
assert result["last_packet"] is None
def test_build_host_info_thresholds_none_without_checker():
host = _FakeHost()
result = _build_host_info(host, threshold_checker=None)
assert result["thresholds"] is None
def test_build_host_info_thresholds_sorted_by_metric():
from hbd.server.threshold import ThresholdConfig
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {
"memory_monitor.memory_percent": tc_mem,
"cpu_monitor.cpu_percent": tc_cpu,
}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] is not None
assert len(result["thresholds"]) == 2
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
assert result["thresholds"][0]["warning"] == 80.0
assert result["thresholds"][0]["critical"] == 95.0
assert result["thresholds"][0]["operator"] == ">"
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] == []
def test_build_host_info_threshold_null_warning_critical():
from hbd.server.threshold import ThresholdConfig
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"][0]["warning"] is None
assert result["thresholds"][0]["critical"] == 500.0
def test_build_host_info_nagios_operator_serialized():
from hbd.server.threshold import ThresholdConfig
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
host = _FakeHost()
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"][0]["operator"] == "nagios"
def test_build_host_info_covers_suffix_matched_metrics():
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
from hbd.server.threshold import ThresholdConfig
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
host = _FakeHost(
connections={},
os_data=None,
)
# Simulate plugin_data with both percent and swap_percent fields
host.plugin_data = {
"memory_monitor": [(1234567890.0, {
"percent": 80.0,
"swap_percent": 25.0,
"available_mb": 2000,
})]
}
result = _build_host_info(host, threshold_checker=checker)
assert result["thresholds"] is not None
t = result["thresholds"][0]
assert t["metric"] == "memory_monitor.percent"
assert t["covers"] == ["memory_monitor.swap_percent"]
def test_build_host_info_covers_empty_when_exact_matches_only():
"""No covers when all plugin fields match their threshold exactly."""
from hbd.server.threshold import ThresholdConfig
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
checker = MagicMock()
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
host = _FakeHost()
host.plugin_data = {
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
}
result = _build_host_info(host, threshold_checker=checker)
t = result["thresholds"][0]
assert t["covers"] == []
+85
View File
@@ -0,0 +1,85 @@
"""Tests for PUT /api/0/users/me logic."""
import pytest
from hbd.server import users as users_mod
def test_hash_password_roundtrip():
h = users_mod.hash_password("mysecret")
assert h.startswith("pbkdf2:sha256:")
assert users_mod.authenticate.__doc__ is not None # module loaded
def test_password_change_requires_correct_current(tmp_path):
cfg = tmp_path / ".hb.yaml"
initial_hash = users_mod.hash_password("oldpass")
cfg.write_text(
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n admin: true\n password: {initial_hash}\n"
)
users_mod.load_users({"users": {"alice": {"full_name": "Alice", "admin": True, "password": initial_hash}}})
# Correct current password authenticates
assert users_mod.authenticate("alice", "oldpass") is not None
# Wrong current password does not authenticate
assert users_mod.authenticate("alice", "wrongpass") is None
def test_put_users_me_writes_new_fields(tmp_path):
"""Simulate the write path: read config, update user, write back."""
initial_hash = users_mod.hash_password("secret")
yaml_content = (
"hbd_port: 50004\n"
f"users:\n alice:\n full_name: Old Name\n admin: true\n password: {initial_hash}\n"
)
cfg = tmp_path / ".hb.yaml"
cfg.write_text(yaml_content)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
# Simulate handler updating full_name and avatar
user_entry = dict(data["users"]["alice"])
user_entry["full_name"] = "New Name"
user_entry["avatar"] = "/img/alice.png"
data["users"]["alice"] = user_entry
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
assert result["users"]["alice"]["full_name"] == "New Name"
assert result["users"]["alice"]["avatar"] == "/img/alice.png"
assert result["users"]["alice"]["password"] == initial_hash # unchanged
def test_put_users_me_changes_password(tmp_path):
initial_hash = users_mod.hash_password("oldpass")
cfg = tmp_path / ".hb.yaml"
cfg.write_text(
f"hbd_port: 50004\nusers:\n alice:\n full_name: Alice\n password: {initial_hash}\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
new_hash = users_mod.hash_password("newpass")
data["users"]["alice"]["password"] = new_hash
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
# Load users from new config and authenticate with new password
new_config = {"users": dict(result["users"])}
users_mod.load_users(new_config)
assert users_mod.authenticate("alice", "newpass") is not None
assert users_mod.authenticate("alice", "oldpass") is None
def test_put_users_me_notification_channels(tmp_path):
cfg = tmp_path / ".hb.yaml"
cfg.write_text(
"hbd_port: 50004\n"
"notification_channels:\n pushover_ops:\n type: pushover\n"
"users:\n alice:\n full_name: Alice\n notification_channels: []\n"
)
from hbd.server import configio
data = configio.read_roundtrip(str(cfg))
data["users"]["alice"]["notification_channels"] = ["pushover_ops"]
configio.write_config(str(cfg), data)
result = configio.read_roundtrip(str(cfg))
assert result["users"]["alice"]["notification_channels"] == ["pushover_ops"]
+421 -143
View File
@@ -1,3 +1,4 @@
import logging
import time as time_mod import time as time_mod
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import urlparse, parse_qs from urllib.parse import urlparse, parse_qs
@@ -36,17 +37,6 @@ def reset_users_dict():
users_mod.users = original users_mod.users = original
def test_is_enabled_when_all_keys_present():
assert oauth.is_enabled(CFG_ON) is True
def test_is_enabled_false_when_no_oauth_key():
assert oauth.is_enabled(CFG_OFF) is False
def test_is_enabled_false_when_partial_config():
assert oauth.is_enabled(CFG_PARTIAL) is False
def test_make_state_returns_unique_tokens(): def test_make_state_returns_unique_tokens():
s1 = oauth.make_state() s1 = oauth.make_state()
@@ -134,132 +124,6 @@ def test_provision_oauth_user_survives_config_reload():
assert "oauthonly" in users_mod.users assert "oauthonly" in users_mod.users
def test_authorization_url_shape():
state = "teststate"
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
url = oauth.authorization_url(CFG_ON, state, redirect_uri)
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.scheme == "https"
assert parsed.netloc == "git.example.com"
assert parsed.path == "/login/oauth/authorize"
assert qs["client_id"] == ["cid"]
assert qs["state"] == ["teststate"]
assert qs["redirect_uri"] == [redirect_uri]
assert qs["scope"] == ["user:email"]
assert qs["response_type"] == ["code"]
@pytest.mark.asyncio
async def test_exchange_code_returns_token():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
token = await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
assert token == "tok123"
@pytest.mark.asyncio
async def test_exchange_code_raises_on_error_status():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(CFG_ON, "badcode", redirect_uri)
@pytest.mark.asyncio
async def test_fetch_user_returns_profile():
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
profile = await oauth.fetch_user(CFG_ON, "tok123")
assert profile == {
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
}
@pytest.mark.asyncio
async def test_exchange_code_raises_when_no_access_token():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"error": "bad_request"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
@pytest.mark.asyncio
async def test_fetch_user_raises_on_error_status():
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.fetch_user(CFG_ON, "tok123")
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Integration-style tests: callback logic chain # Integration-style tests: callback logic chain
@@ -276,13 +140,12 @@ async def test_callback_invalid_state_rejects():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_full_oauth_flow_chain(): async def test_full_oauth_flow_chain():
"""Integration-style test: state → exchange → fetch → provision chain.""" """Integration-style test: state → exchange → fetch → provision chain."""
p = _gitea_provider()
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback" redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
# Step 1: create a state token
state = oauth.make_state() state = oauth.make_state()
assert oauth.validate_state(state) is True # consumed; replay would return False assert oauth.validate_state(state) is True
# Step 2: exchange code → token (mocked)
mock_token_response = AsyncMock() mock_token_response = AsyncMock()
mock_token_response.status = 200 mock_token_response.status = 200
mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"}) mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"})
@@ -309,16 +172,431 @@ async def test_full_oauth_flow_chain():
__aenter__=AsyncMock(return_value=mock_session), __aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False), __aexit__=AsyncMock(return_value=False),
)): )):
token = await oauth.exchange_code(CFG_ON, "authcode", redirect_uri) token = await oauth.exchange_code(p, "authcode", redirect_uri)
profile = await oauth.fetch_user(CFG_ON, token) profile = await oauth.fetch_user(p, token)
assert token == "flow_token" assert token == "flow_token"
assert profile["login"] == "flowuser" assert profile["login"] == "flowuser"
# Step 3: provision user
_reset_users() _reset_users()
user = users_mod.provision_oauth_user( user = users_mod.provision_oauth_user(
profile["login"], profile["full_name"], profile["avatar_url"] profile["login"], profile["full_name"], profile["avatar_url"]
) )
assert user.username == "flowuser" assert user.username == "flowuser"
assert user.check_password("anything") is False assert user.check_password("anything") is False
# ---------------------------------------------------------------------------
# get_providers()
# ---------------------------------------------------------------------------
CFG_GITHUB = {
"oauth": {
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
}
}
CFG_NEXTCLOUD = {
"oauth": {
"nc": {
"type": "nextcloud",
"url": "https://nc.example.com",
"client_id": "ncid",
"client_secret": "ncs",
}
}
}
CFG_MULTI = {
"oauth": {
"mygitea": {
"type": "gitea",
"url": "https://git.example.com",
"client_id": "cid",
"client_secret": "cs",
"label": "Work Gitea",
"logo": "https://example.com/logo.png",
},
"github": {"type": "github", "client_id": "ghid", "client_secret": "ghs"},
"nc": {
"type": "nextcloud",
"url": "https://nc.example.com",
"client_id": "ncid",
"client_secret": "ncs",
},
}
}
def test_get_providers_backward_compat_no_type_field():
"""Old config without 'type' defaults to gitea."""
providers = oauth.get_providers(CFG_ON)
assert len(providers) == 1
p = providers[0]
assert p.name == "gitea"
assert p.type == "gitea"
assert p.label == "Gitea"
assert p.client_id == "cid"
assert p.authorize_url == "https://git.example.com/login/oauth/authorize"
assert p.token_url == "https://git.example.com/login/oauth/access_token"
assert p.profile_url == "https://git.example.com/api/v1/user"
assert p.scope == "user:email"
assert p.profile_data_path == []
def test_get_providers_multiple():
providers = oauth.get_providers(CFG_MULTI)
assert len(providers) == 3
names = [p.name for p in providers]
assert "mygitea" in names
assert "github" in names
assert "nc" in names
def test_get_providers_custom_label_and_logo():
providers = oauth.get_providers(CFG_MULTI)
gitea = next(p for p in providers if p.name == "mygitea")
assert gitea.label == "Work Gitea"
assert gitea.logo == "https://example.com/logo.png"
def test_get_providers_github_default_label():
providers = oauth.get_providers(CFG_GITHUB)
assert providers[0].label == "GitHub"
assert providers[0].logo == ""
def test_get_providers_github_fixed_urls():
providers = oauth.get_providers(CFG_GITHUB)
p = providers[0]
assert p.authorize_url == "https://github.com/login/oauth/authorize"
assert p.token_url == "https://github.com/login/oauth/access_token"
assert p.profile_url == "https://api.github.com/user"
assert p.scope == "read:user"
def test_get_providers_nextcloud_urls_and_path():
providers = oauth.get_providers(CFG_NEXTCLOUD)
p = providers[0]
assert p.authorize_url == "https://nc.example.com/apps/oauth2/authorize"
assert p.token_url == "https://nc.example.com/apps/oauth2/api/v1/token"
assert p.profile_url == "https://nc.example.com/ocs/v2.php/cloud/user?format=json"
assert p.profile_data_path == ["ocs", "data"]
assert p.scope == ""
def test_get_providers_skips_missing_client_id(caplog):
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_secret": "cs"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "missing" in caplog.text.lower()
def test_get_providers_skips_missing_client_secret(caplog):
cfg = {"oauth": {"gitea": {"url": "https://git.example.com", "client_id": "cid"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "missing" in caplog.text.lower()
def test_get_providers_skips_missing_url_for_gitea(caplog):
cfg = {"oauth": {"gitea": {"type": "gitea", "client_id": "cid", "client_secret": "cs"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "url" in caplog.text.lower()
def test_get_providers_skips_missing_url_for_nextcloud(caplog):
cfg = {"oauth": {"nc": {"type": "nextcloud", "client_id": "cid", "client_secret": "cs"}}}
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "url" in caplog.text.lower()
def test_get_providers_github_no_url_required():
providers = oauth.get_providers(CFG_GITHUB)
assert len(providers) == 1
def test_get_providers_skips_unknown_type(caplog):
cfg = {"oauth": {"mystery": {"type": "saml", "client_id": "cid", "client_secret": "cs"}}}
import logging
with caplog.at_level(logging.WARNING, logger="hbd.server.oauth"):
result = oauth.get_providers(cfg)
assert result == []
assert "saml" in caplog.text
def test_get_providers_empty_config():
assert oauth.get_providers({}) == []
assert oauth.get_providers(CFG_OFF) == []
# ---------------------------------------------------------------------------
# build_auth_url / exchange_code / fetch_user (generic, ResolvedProvider-based)
# ---------------------------------------------------------------------------
def _gitea_provider() -> oauth.ResolvedProvider:
return oauth.get_providers(CFG_ON)[0]
def _github_provider() -> oauth.ResolvedProvider:
return oauth.get_providers(CFG_GITHUB)[0]
def _nextcloud_provider() -> oauth.ResolvedProvider:
return oauth.get_providers(CFG_NEXTCLOUD)[0]
def test_build_auth_url_gitea():
p = _gitea_provider()
url = oauth.build_auth_url(p, "teststate", "https://hbd.example.com/login/oauth/gitea/callback")
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.netloc == "git.example.com"
assert parsed.path == "/login/oauth/authorize"
assert qs["client_id"] == ["cid"]
assert qs["state"] == ["teststate"]
assert qs["scope"] == ["user:email"]
assert qs["response_type"] == ["code"]
assert qs["redirect_uri"] == ["https://hbd.example.com/login/oauth/gitea/callback"]
def test_build_auth_url_github():
p = _github_provider()
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/github/callback")
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.netloc == "github.com"
assert qs["scope"] == ["read:user"]
def test_build_auth_url_nextcloud_no_scope_param():
"""Nextcloud scope is empty — the 'scope' key must be absent from the URL."""
p = _nextcloud_provider()
url = oauth.build_auth_url(p, "st", "https://hbd.example.com/login/oauth/nc/callback")
qs = parse_qs(urlparse(url).query)
assert "scope" not in qs
@pytest.mark.asyncio
async def test_exchange_code_generic_returns_token():
p = _gitea_provider()
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
token = await oauth.exchange_code(p, "mycode", redirect_uri)
assert token == "tok123"
@pytest.mark.asyncio
async def test_exchange_code_sends_accept_json():
"""Accept: application/json must be present for all providers (required by GitHub)."""
p = _github_provider()
captured_headers = {}
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"access_token": "ghtoken"})
mock_session = MagicMock()
def capture_post(url, **kwargs):
captured_headers.update(kwargs.get("headers", {}))
return AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
)
mock_session.post = capture_post
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
await oauth.exchange_code(p, "code", "https://hbd.example.com/login/oauth/github/callback")
assert captured_headers.get("Accept") == "application/json"
@pytest.mark.asyncio
async def test_exchange_code_raises_on_error_status():
p = _gitea_provider()
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(p, "badcode", "https://hbd.example.com/login/oauth/gitea/callback")
@pytest.mark.asyncio
async def test_exchange_code_raises_when_no_access_token():
p = _gitea_provider()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"error": "bad_request"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(p, "mycode", "https://hbd.example.com/login/oauth/gitea/callback")
@pytest.mark.asyncio
async def test_fetch_user_gitea_returns_profile():
p = _gitea_provider()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
profile = await oauth.fetch_user(p, "tok123")
assert profile == {
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
}
@pytest.mark.asyncio
async def test_fetch_user_github_maps_name_field():
p = _github_provider()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"login": "bobgh",
"name": "Bob GitHub",
"avatar_url": "https://avatars.githubusercontent.com/u/1",
})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
profile = await oauth.fetch_user(p, "ghtoken")
assert profile["login"] == "bobgh"
assert profile["full_name"] == "Bob GitHub"
assert profile["avatar_url"] == "https://avatars.githubusercontent.com/u/1"
@pytest.mark.asyncio
async def test_fetch_user_nextcloud_nested_extraction():
"""Nextcloud profile is nested under ocs.data; avatar is absent."""
p = _nextcloud_provider()
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"ocs": {
"meta": {"status": "ok", "statuscode": 200},
"data": {
"id": "ncuser",
"display-name": "NC User",
"email": "nc@example.com",
},
}
})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
profile = await oauth.fetch_user(p, "nctoken")
assert profile["login"] == "ncuser"
assert profile["full_name"] == "NC User"
assert profile["avatar_url"] == "" # Nextcloud has no avatar field
@pytest.mark.asyncio
async def test_fetch_user_raises_on_error_status():
p = _gitea_provider()
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.fetch_user(p, "badtoken")
def test_is_enabled_with_valid_provider():
assert oauth.is_enabled(CFG_ON) is True
def test_is_enabled_false_when_no_providers():
assert oauth.is_enabled(CFG_OFF) is False
def test_is_enabled_false_partial_config():
assert oauth.is_enabled(CFG_PARTIAL) is False
+83
View File
@@ -0,0 +1,83 @@
import pytest
from hbd.server import settings as settings_mod
CFG = {
"hbd_port": 50004,
"interval": 20,
"grace": 2,
"users": {
"alice": {"full_name": "Alice Smith", "admin": True, "password": "pbkdf2:sha256:abc",
"notification_channels": ["pushover_ops"]},
},
"oauth": {
"gitea": {"type": "gitea", "url": "https://git.example.com",
"client_id": "cid", "client_secret": "csec", "label": "Sign in with Gitea"},
},
"notification_channels": {
"pushover_ops": {"type": "pushover", "token": "tok", "user": "usr"},
},
"hosts": {},
}
def test_sections_have_section_mode():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "section_mode" in s, f"Section {s['id']} missing section_mode"
assert s["section_mode"] in ("form", "yaml")
def test_sections_have_api_section():
sections = settings_mod.get_settings_sections(CFG)
for s in sections:
assert "api_section" in s, f"Section {s['id']} missing api_section"
def test_network_section_has_editable_fields():
sections = settings_mod.get_settings_sections(CFG)
network = next(s for s in sections if s["id"] == "network")
assert network["section_mode"] == "form"
assert network["api_section"] == "server"
editable = [f for f in network["fields"] if f["editable"]]
assert len(editable) >= 2 # hbd_port, ws_port at minimum
def test_yaml_sections_have_correct_mode():
sections = settings_mod.get_settings_sections(CFG)
yaml_sections = {s["id"]: s for s in sections if s["section_mode"] == "yaml"}
assert "channels" in yaml_sections
assert "hosts" in yaml_sections
assert "thresholds" in yaml_sections
assert "dns" in yaml_sections
assert yaml_sections["channels"]["api_section"] == "notification_channels"
assert yaml_sections["hosts"]["api_section"] == "hosts"
assert yaml_sections["thresholds"]["api_section"] == "thresholds"
assert yaml_sections["dns"]["api_section"] == "dns"
def test_oauth_section_exists():
sections = settings_mod.get_settings_sections(CFG)
oauth = next((s for s in sections if s["id"] == "oauth"), None)
assert oauth is not None
assert oauth["section_mode"] == "form"
assert oauth["api_section"] == "oauth"
assert len(oauth["providers"]) == 1
assert oauth["providers"][0]["name"] == "gitea"
assert oauth["providers"][0]["client_secret"] == "•••"
def test_all_channel_names_returned():
result = settings_mod.get_settings_data(CFG)
assert "all_channel_names" in result
assert "pushover_ops" in result["all_channel_names"]
def test_users_section_has_user_list():
sections = settings_mod.get_settings_sections(CFG)
users_sec = next(s for s in sections if s["id"] == "users")
assert users_sec["section_mode"] == "form"
assert users_sec["api_section"] == "users"
assert len(users_sec["users"]) == 1
assert users_sec["users"][0]["username"] == "alice"
# Password hash never exposed
assert "password" not in users_sec["users"][0]