Compare commits
107 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e3099fc6d | |||
| c9f15a3f1c | |||
| 6e396ad760 | |||
| 2800de0b4a | |||
| 15f7e6a64d | |||
| 9768d13b88 | |||
| 8640d731aa | |||
| de81751e59 | |||
| 60c692cefc | |||
| 9a0baf3c78 | |||
| 55bdb9593a | |||
| 2009626fb4 | |||
| 18769afd37 | |||
| 31db5cf35e | |||
| 326f53f23d | |||
| 4f9bc8c868 | |||
| 259b4a3594 | |||
| 8646f68957 | |||
| a4a6c1e3d9 | |||
| 0e8250362e | |||
| 2f5da9fc5e | |||
| 87aeec5999 | |||
| f24500a6b5 | |||
| a7bb183222 | |||
| 8207cd7b5f | |||
| 11f1eefa8c | |||
| 62f496e9f8 | |||
| aef9e7769b | |||
| 58c2b9d996 | |||
| 2e8bcb630d | |||
| 338711181b | |||
| 43487f17e7 | |||
| 40205bf5c7 | |||
| b95f1a5bb7 | |||
| 12f7eb722b | |||
| 217bba1b76 | |||
| 967e05ed74 | |||
| c20245b0ab | |||
| b9db0c552e | |||
| 05045bafa2 | |||
| 39f1b5de30 | |||
| b06de6fdd3 | |||
| 940d0af35e | |||
| d6d31aa2e3 | |||
| 76edfe7577 | |||
| d190029728 | |||
| b8307e7a9d | |||
| a2fdf091f5 | |||
| 1914e6f28e | |||
| 82cbce9615 | |||
| dbb779b013 | |||
| ca908ee967 | |||
| 73c697b6c5 | |||
| 3e2357380b | |||
| cc4a103bae | |||
| 53fb10fdf5 | |||
| 2df2ad18c9 | |||
| b81a0d2a6c | |||
| 1a19088cfe | |||
| 172f6e950f | |||
| 4349ae217a | |||
| b3aa7b585f | |||
| 88a3c09b51 | |||
| 0504402a8a | |||
| ca58c18802 | |||
| 1ddc4b8132 | |||
| 5e1720ed32 | |||
| 77f127fe60 | |||
| 54fbd8d73d | |||
| 7ab17e26e2 | |||
| 28f5fa951c | |||
| 37f1c58969 | |||
| f006077a71 | |||
| d9fc8d632f | |||
| f640574e4f | |||
| 9a19424279 | |||
| ca8ba84e65 | |||
| f3d08d1c9e | |||
| 1e4263b793 | |||
| e931acb9f5 | |||
| 018409e71d | |||
| 1824f637b4 | |||
| a534c06b26 | |||
| d7b5c97a4e | |||
| ae447ac4a6 | |||
| d44ce3d124 | |||
| b1985d0eb2 | |||
| de778f680f | |||
| d7b368c7c6 | |||
| e790663f9f | |||
| 475319e248 | |||
| ca5ef384a8 | |||
| c93dbdc0f4 | |||
| 3a546a1e5c | |||
| 74c89d098c | |||
| 3301dbfe34 | |||
| d00d903e7d | |||
| babb5d61aa | |||
| 11d1c718b3 | |||
| a99b6b54c7 | |||
| 8da3d550eb | |||
| a76d0fc840 | |||
| 94cbb31c48 | |||
| ae60844a8a | |||
| 49fa310361 | |||
| 28e2180f7b | |||
| ce0590f015 |
@@ -12,3 +12,4 @@ dist/
|
||||
ssl/
|
||||
uv.lock
|
||||
.hb.yaml
|
||||
.superpowers/
|
||||
|
||||
@@ -27,6 +27,7 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
||||
- Configurable retention and backup management
|
||||
- **Plugin system for extensible monitoring** ✅
|
||||
- Collect system metrics (CPU, memory, disk, network)
|
||||
- Monitor ZFS pool health, capacity, and I/O via `zpool(8)`
|
||||
- Execute existing Nagios monitoring plugins
|
||||
- Create custom plugins with simple Python classes
|
||||
- **Threshold alerting system** ✅
|
||||
@@ -34,6 +35,8 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
|
||||
- Hysteresis to prevent alert flapping
|
||||
- Automatic notifications on state changes
|
||||
- Re-notification for ongoing alerts
|
||||
- **Per-host watch flag** — set `watch: false` on any host to silence all notifications for that host without removing its configuration ✅
|
||||
- **Role-filtered dashboards** — Live Dashboard and Host Overview show only hosts where the logged-in user is owner or manager (admins see all) ✅
|
||||
- Modular codebase suitable for unit testing and CI ✅
|
||||
|
||||
---
|
||||
@@ -55,21 +58,26 @@ Heartbeat includes a comprehensive plugin architecture that extends monitoring b
|
||||
### Built-in Plugins
|
||||
|
||||
- `os_info`: Collects OS, kernel, distribution, and architecture information
|
||||
- `cpu_monitor`: Monitors CPU usage, load average, frequency, and process counts
|
||||
- `memory_monitor`: Monitors RAM and swap usage, available memory
|
||||
- `cpu_monitor`: Monitors CPU usage, load average, frequency, process counts, and uptime
|
||||
- `memory_monitor`: Monitors RAM and swap usage, available memory (ZFS ARC-aware)
|
||||
- `disk_monitor`: Monitors disk usage, I/O statistics, and filesystem metrics
|
||||
- `network_monitor`: Monitors network interface statistics, bandwidth, and connections
|
||||
- `ping_monitor`: Measures round-trip latency to configured hosts
|
||||
- `filesystem_info`: Collects mounted filesystem information (physical filesystems only by default)
|
||||
- `nagios_runner`: Executes Nagios monitoring plugins (check_disk, check_load, check_http, etc.)
|
||||
- `zfs_monitor`: Monitors ZFS pool health, capacity, fragmentation, dedup ratio, and cumulative I/O via `zpool(8)`
|
||||
|
||||
### Nagios Integration
|
||||
|
||||
The `nagios_runner` plugin provides seamless integration with the vast Nagios plugin ecosystem. You can run any Nagios-compatible plugin and have the results automatically parsed and stored:
|
||||
|
||||
- Executes plugins via subprocess with timeout protection
|
||||
- Executes plugins asynchronously (non-blocking) with timeout protection
|
||||
- Captures both stdout and stderr; if stdout is empty, stderr is used as the status message
|
||||
- Handles signal-killed processes (negative exit code → UNKNOWN status)
|
||||
- Validates absolute command paths at startup and warns on missing or non-executable files
|
||||
- Parses exit codes (OK/WARNING/CRITICAL/UNKNOWN)
|
||||
- Extracts performance data with thresholds
|
||||
- Reports aggregated status across all configured checks
|
||||
- Reports per-check status, exit code, and output; no aggregate rollup field
|
||||
|
||||
See [docs/NAGIOS_INTEGRATION.md](docs/NAGIOS_INTEGRATION.md) for complete integration guide including configuration examples and custom plugin development.
|
||||
|
||||
@@ -147,9 +155,11 @@ Heartbeat includes a sophisticated threshold alerting system that monitors plugi
|
||||
- **Multi-level alerts**: WARNING and CRITICAL severity levels
|
||||
- **Flexible operators**: Support for >, >=, <, <=, ==, != comparisons
|
||||
- **Hysteresis**: Prevents alert flapping with configurable recovery thresholds
|
||||
- **Smart notifications**: Alerts only on state changes, not every check
|
||||
- **Smart notifications**: Alerts only on state changes, not every check; de-escalations (e.g. CRITICAL → WARNING) do not generate a notification
|
||||
- **Re-notifications**: Periodic reminders for ongoing alerts
|
||||
- **Short-duration suppression**: Recovery notifications are suppressed for down events under 4 seconds (avoids noise from transient blips)
|
||||
- **Journal integration**: All threshold events logged for audit trail
|
||||
- **`ping_monitor` thresholds**: Latency and packet-loss thresholds use the same format as all other plugin metrics
|
||||
|
||||
### Configuration
|
||||
|
||||
@@ -172,7 +182,8 @@ thresholds:
|
||||
warning: 80.0 # Warn when CPU > 80%
|
||||
critical: 90.0 # Critical when CPU > 90%
|
||||
operator: ">"
|
||||
hysteresis: 0.1 # 10% hysteresis to prevent flapping
|
||||
hysteresis: 0.02 # 2% hysteresis to prevent flapping
|
||||
display: "(threshold: {op_symbol} {threshold_value}%)" # optional
|
||||
|
||||
memory_monitor:
|
||||
percent:
|
||||
@@ -214,7 +225,7 @@ thresholds:
|
||||
<hostname>:
|
||||
warning: <milliseconds> # Warn when RTT > this value
|
||||
critical: <milliseconds> # Critical when RTT > this value
|
||||
hysteresis: 0.1 # Optional: 10% hysteresis (default)
|
||||
hysteresis: 0.02 # Optional: 2% hysteresis (default)
|
||||
```
|
||||
|
||||
**Example alerts:**
|
||||
@@ -265,7 +276,59 @@ All plugin metrics can be thresholded:
|
||||
- **Memory**: percent, available_mb, swap_percent
|
||||
- **Disk**: Per-partition percent, free_gb, free_mb
|
||||
- **Network**: errors_total, dropped packets, connection counts
|
||||
- **Nagios**: exit_code mapping (0=OK, 1=WARNING, 2=CRITICAL)
|
||||
- **Nagios**: Any field emitted by `nagios_runner` (`<name>_status_code`, `<name>_status`, `<name>_output`, performance data fields)
|
||||
|
||||
### Display Format Templates
|
||||
|
||||
Each threshold entry accepts an optional `display` field — a Python format string shown in notifications and on the Alerts dashboard:
|
||||
|
||||
```yaml
|
||||
nagios_runner:
|
||||
status_code:
|
||||
warning: 1
|
||||
critical: 2
|
||||
operator: ">="
|
||||
display: "{check_name}: exit {value} (expected < {threshold_value})"
|
||||
```
|
||||
|
||||
Available variables:
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `{value}` | Current metric value |
|
||||
| `{threshold_value}` | Threshold that was crossed |
|
||||
| `{op_symbol}` | Comparison operator (`>`, `<`, `>=`, …); `"nagios"` for the nagios operator |
|
||||
| `{check_name}` | Prefix stripped by generic matching (see below) |
|
||||
| `{metric_name}` | Full field name within the plugin data |
|
||||
| `{output}` | For `nagios_runner` generic matches: the matched check's status text (alias for `{check_name}_output`) |
|
||||
| `{status}` | For `nagios_runner` generic matches: the matched check's status name — OK/WARNING/CRITICAL/UNKNOWN (alias for `{check_name}_status`) |
|
||||
| any plugin field | Any other field present in the plugin's data |
|
||||
|
||||
### Generic Threshold Matching
|
||||
|
||||
When a metric name has no exact threshold entry, the server progressively strips leading underscore-separated segments and re-tries the lookup. This lets a single generic entry cover an entire family of metrics.
|
||||
|
||||
The classic use case is `nagios_runner`, which names each metric after the command that produced it:
|
||||
|
||||
```
|
||||
nagios_runner.check_disk_root_status_code → no exact match
|
||||
nagios_runner.disk_root_status_code → no match
|
||||
nagios_runner.root_status_code → no match
|
||||
nagios_runner.status_code → matched ✓
|
||||
```
|
||||
|
||||
Configure the generic threshold once using the `nagios` operator, which maps exit codes directly to alert severity without requiring numeric warning/critical values:
|
||||
|
||||
```yaml
|
||||
nagios_runner:
|
||||
status_code:
|
||||
operator: "nagios" # 0=OK 1=WARNING 2=CRITICAL 3=UNKNOWN
|
||||
display: "{check_name}: {output}"
|
||||
```
|
||||
|
||||
The stripped prefix (`check_disk_root` in the example above) is available as `{check_name}` in the display template, so you can identify which check triggered the alert without writing a separate threshold entry per command.
|
||||
|
||||
Exact matches always take priority. A generic entry only applies when no specific one is defined.
|
||||
|
||||
### Per-Host Threshold Profiles
|
||||
|
||||
@@ -363,9 +426,10 @@ Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST AP
|
||||
### Web Dashboards
|
||||
|
||||
- **Login** (`/login`): Browser login form (shown automatically when auth is configured)
|
||||
- **Live View** (`/live`): Real-time host connectivity, latency, and messages
|
||||
- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins
|
||||
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering
|
||||
- **Live View** (`/live`): Real-time host connectivity, latency, and messages; hostnames link directly to the Host Overview page
|
||||
- **Host Overview** (`/plugins/<host>`): Per-host plugin metrics with ZFS pool visualization; filtered to hosts where the logged-in user is owner or manager (admins see all)
|
||||
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering; alert count pie chart shown in the navigation bar
|
||||
- **Settings** (`/settings`): Server configuration, user management, and threshold configuration viewer
|
||||
|
||||
### API Endpoints
|
||||
|
||||
@@ -443,6 +507,9 @@ hbc --boot your-server.example.com
|
||||
|
||||
# Verbose output
|
||||
hbc -v your-server.example.com
|
||||
|
||||
# Send 'boot' and 'shutdown' messages on start and exit
|
||||
hbc -b your-server.example.com
|
||||
```
|
||||
|
||||
You can also run it via the module entrypoint:
|
||||
@@ -451,12 +518,11 @@ You can also run it via the module entrypoint:
|
||||
python -m hbd.client.main your-server.example.com
|
||||
```
|
||||
|
||||
Client configuration can also be specified in YAML:
|
||||
Client configuration can also be specified in YAML (`~/.hbc.yaml`):
|
||||
|
||||
```yaml
|
||||
server: hbd.example.com
|
||||
port: 50003
|
||||
interval: 30
|
||||
hb_port: 50003 # Server port (default: 50003)
|
||||
interval: 30 # Heartbeat interval in seconds
|
||||
plugins:
|
||||
cpu_monitor:
|
||||
interval: 300 # Check every 5 minutes (default)
|
||||
@@ -470,12 +536,20 @@ plugins:
|
||||
nagios_runner:
|
||||
interval: 300 # Check every 5 minutes (default)
|
||||
commands:
|
||||
- /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
|
||||
- /usr/lib/nagios/plugins/check_disk -w 20% -c 10% -p /
|
||||
- name: check_load
|
||||
command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
|
||||
- name: check_disk
|
||||
command: /usr/lib/nagios/plugins/check_disk -w 20% -c 10% -p /
|
||||
```
|
||||
|
||||
The server hostname is always passed as a positional command-line argument; there is no `server:` config key.
|
||||
|
||||
All monitoring plugins default to 5-minute (300 second) intervals, but can be customized as needed.
|
||||
|
||||
**Connection retry:** If a server is temporarily unreachable, `hbc` retries `open()` indefinitely on every heartbeat interval. IPv6 connections that never succeeded during early startup are dropped after 3 consecutive failures (to handle hosts without IPv6 routing), while IPv4 connections always retry.
|
||||
|
||||
**Daemon logging:** When running with `-d`, `hbc` routes all log output to syslog (`LOG_DAEMON` facility) after daemonizing. Without `-d`, logs go to stderr as usual.
|
||||
|
||||
### hbc_mini — single-file client (no external dependencies)
|
||||
|
||||
`scripts/hbc_mini.py` is a self-contained version of the heartbeat client that requires only Python 3.8+ and no external packages. Copy it to any host and run it directly — no virtualenv, no `pip install`.
|
||||
@@ -531,8 +605,10 @@ python3 hbc_mini.py -m "maintenance starting" your-server.example.com
|
||||
|
||||
- No YAML config (use JSON instead)
|
||||
- No `filesystem_info` plugin
|
||||
- No `zfs_monitor` plugin (requires `zpool(8)` and the full plugin loader)
|
||||
- `cpu_monitor` does not report per-core usage or CPU frequency (no psutil)
|
||||
- Plugins cannot be loaded from external `.py` files — all plugins are compiled in
|
||||
- No IPv6 early-fail protection — connections that fail to open at startup are silently skipped rather than retried
|
||||
|
||||
Everything else — heartbeat protocol, ACK/CMD/UPD handling, `hb_install.sh`-based self-update, daemonize, syslog — is identical to the full client.
|
||||
|
||||
|
||||
@@ -104,11 +104,6 @@ The `nagios_runner` plugin collects:
|
||||
- `{name}_{metric}_min` - Minimum value (if present)
|
||||
- `{name}_{metric}_max` - Maximum value (if present)
|
||||
|
||||
**Overall:**
|
||||
- `overall_status` - Worst status from all commands
|
||||
- `overall_status_code` - Worst status code
|
||||
- `plugin_count` - Number of Nagios plugins executed
|
||||
|
||||
## Configuration Options
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -8,6 +8,7 @@ This guide explains how to create custom plugins for the Heartbeat monitoring sy
|
||||
- [Plugin Types](#plugin-types)
|
||||
- [Creating a Plugin](#creating-a-plugin)
|
||||
- [Plugin Lifecycle](#plugin-lifecycle)
|
||||
- [Server-initiated InfoPlugin refresh](#server-initiated-infoplugin-refresh)
|
||||
- [Configuration](#configuration)
|
||||
- [Best Practices](#best-practices)
|
||||
- [Examples](#examples)
|
||||
@@ -250,6 +251,28 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
|
||||
└─> Plugin releases resources, closes connections
|
||||
```
|
||||
|
||||
## Server-initiated InfoPlugin refresh
|
||||
|
||||
When a heartbeat packet arrives from a host the server has no plugin data for (e.g. after a server restart), the server sets `request_update = 1` in the ACK reply. The client detects this flag and immediately re-runs all InfoPlugins — clearing their cached results first — then resends the data as PLG messages.
|
||||
|
||||
This means InfoPlugin data will always reach the server as soon as possible without requiring a client restart. No action is needed from plugin authors: the framework handles cache invalidation and re-collection automatically.
|
||||
|
||||
The lifecycle for this case looks like:
|
||||
|
||||
```
|
||||
Server restarts, host reconnects
|
||||
└─> hbd receives HTB with no existing plugin_data for host
|
||||
└─> hbd sets request_update=1 in ACK
|
||||
|
||||
Client receives ACK
|
||||
└─> Detects request_update flag
|
||||
└─> Clears _cache on every registered InfoPlugin
|
||||
└─> Calls collect() on each InfoPlugin
|
||||
└─> Sends fresh PLG messages to server
|
||||
```
|
||||
|
||||
If you write an `InfoPlugin` with side effects in `_collect_info()` (opening connections, writing files, etc.), be aware it may be called more than once per client session when this mechanism triggers.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Plugin-Specific Configuration
|
||||
|
||||
+50
-27
@@ -256,6 +256,56 @@ disk_monitor:
|
||||
operator: "<"
|
||||
```
|
||||
|
||||
### ZFS Monitor
|
||||
|
||||
ZFS pool health is checked automatically for every pool. A pool in any state
|
||||
other than `ONLINE` (e.g. `DEGRADED`, `SUSPENDED`, `FAULTED`, `UNAVAIL`) raises
|
||||
a **CRITICAL** alert by default — no configuration required.
|
||||
|
||||
The default threshold is equivalent to:
|
||||
|
||||
```yaml
|
||||
zfs_monitor:
|
||||
pools:
|
||||
'*':
|
||||
status:
|
||||
warning: 1
|
||||
critical: 2
|
||||
operator: ">"
|
||||
hysteresis: 0.0
|
||||
display: "ZFS pool {pool_name} is {health}"
|
||||
```
|
||||
|
||||
`'*'` matches every pool on the host. The notification message includes the pool
|
||||
name and its current health string, e.g. `ZFS pool tank is DEGRADED`.
|
||||
|
||||
**Override for specific pools** — named pool entries take priority over `'*'`:
|
||||
|
||||
```yaml
|
||||
zfs_monitor:
|
||||
pools:
|
||||
# Suppress health alerts for a scratch pool (not mission-critical)
|
||||
scratch:
|
||||
status:
|
||||
enabled: false
|
||||
|
||||
# Capacity threshold for a specific pool
|
||||
tank:
|
||||
capacity:
|
||||
warning: 75.0
|
||||
critical: 90.0
|
||||
operator: ">"
|
||||
hysteresis: 0.05
|
||||
```
|
||||
|
||||
**Alert state paths** follow the pattern `zfs_monitor.<pool_name>.status`,
|
||||
so acknowledgements and silences target individual pools:
|
||||
|
||||
```
|
||||
zfs_monitor.tank.status
|
||||
zfs_monitor.backup.status
|
||||
```
|
||||
|
||||
### Network Monitor
|
||||
|
||||
```yaml
|
||||
@@ -1110,33 +1160,6 @@ hosts:
|
||||
db-02:
|
||||
threshold_config: [tight_memory, db_disk]
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
The legacy single threshold configuration is fully supported:
|
||||
|
||||
```yaml
|
||||
# Old format - still works
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
```
|
||||
|
||||
This is equivalent to:
|
||||
|
||||
```yaml
|
||||
# New format
|
||||
threshold_configs:
|
||||
default:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
```
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults
|
||||
|
||||
@@ -46,6 +46,24 @@ default_owner: andreas # owns hosts with no explicit owner
|
||||
# falls back to the first admin user if omitted
|
||||
```
|
||||
|
||||
### Client-declared host ownership
|
||||
|
||||
A host can declare its own owner directly in the hbc or hbc_mini client configuration. This is useful for hosts that are not listed in the server config, or during initial setup before a server-side config entry has been created.
|
||||
|
||||
**`~/.hbc.yaml`** (hbc):
|
||||
```yaml
|
||||
owner: andreas
|
||||
```
|
||||
|
||||
**`~/.hbc.json`** (hbc_mini):
|
||||
```json
|
||||
{ "owner": "andreas" }
|
||||
```
|
||||
|
||||
When set, the value is included in the `os_info` plugin data sent to the server. The server applies it as `host.owner` the first time `os_info` arrives, provided no owner has been configured server-side for that host. Server-configured ownership always takes precedence.
|
||||
|
||||
---
|
||||
|
||||
### Assigning roles to hosts
|
||||
|
||||
```yaml
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
# Gitea OAuth2 Authentication 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 Gitea as an OAuth2 login provider that coexists with password auth, auto-provisioning new users on first login.
|
||||
|
||||
**Architecture:** A new `oauth.py` module owns all Gitea-specific logic (CSRF state, URL building, token exchange, user-info fetch). `users.py` gains one function to upsert an OAuth-sourced user. `http.py` gets two new route handlers and a small login-page change. No new dependencies — `aiohttp.ClientSession` is already used in the codebase.
|
||||
|
||||
**Tech Stack:** Python 3.12, aiohttp 3.x, pytest, pytest-asyncio
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path | Responsibility |
|
||||
|--------|------|----------------|
|
||||
| Modify | `hbd/server/config.py` | Add `"oauth": {}` default |
|
||||
| Create | `hbd/server/oauth.py` | CSRF state, URL builder, token exchange, user-info fetch |
|
||||
| Modify | `hbd/server/users.py` | Add `provision_oauth_user()` |
|
||||
| Modify | `hbd/server/http.py` | Import oauth, two new routes, login page button |
|
||||
| Create | `tests/test_oauth.py` | All new unit tests |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add config default and `is_enabled()`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/config.py:34` (after the `"users"` line)
|
||||
- Create: `hbd/server/oauth.py`
|
||||
- Create: `tests/test_oauth.py`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/test_oauth.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from hbd.server import oauth
|
||||
|
||||
|
||||
CFG_OFF = {}
|
||||
CFG_ON = {
|
||||
"oauth": {
|
||||
"gitea": {
|
||||
"url": "https://git.example.com",
|
||||
"client_id": "cid",
|
||||
"client_secret": "csec",
|
||||
}
|
||||
}
|
||||
}
|
||||
CFG_PARTIAL = {"oauth": {"gitea": {"url": "https://git.example.com"}}}
|
||||
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm failure**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v
|
||||
```
|
||||
|
||||
Expected: `ModuleNotFoundError: No module named 'hbd.server.oauth'`
|
||||
|
||||
- [ ] **Step 3: Add config default**
|
||||
|
||||
In `hbd/server/config.py`, add after the `"default_owner"` line (currently line 35):
|
||||
|
||||
```python
|
||||
# OAuth2 providers
|
||||
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create `hbd/server/oauth.py` with `is_enabled`**
|
||||
|
||||
```python
|
||||
"""Gitea OAuth2 support.
|
||||
|
||||
Config shape (in ~/.hb.yaml):
|
||||
|
||||
oauth:
|
||||
gitea:
|
||||
url: https://git.example.com
|
||||
client_id: <client-id>
|
||||
client_secret: <client-secret>
|
||||
|
||||
Register a Gitea OAuth2 application at:
|
||||
Gitea → Settings → Applications → OAuth2
|
||||
Set the redirect URI to:
|
||||
https://<hbd-host>/login/oauth/gitea/callback
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL = 600 # 10 minutes
|
||||
|
||||
# state_token -> expiry timestamp
|
||||
_states: dict[str, float] = {}
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
"""Raised when the OAuth2 flow fails for any reason."""
|
||||
|
||||
|
||||
def _gitea_cfg(config: dict) -> dict:
|
||||
"""Return the gitea sub-dict or {} if absent/incomplete."""
|
||||
return config.get("oauth", {}).get("gitea", {})
|
||||
|
||||
|
||||
def is_enabled(config: dict) -> bool:
|
||||
"""Return True when all three required Gitea OAuth keys are present."""
|
||||
g = _gitea_cfg(config)
|
||||
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run to confirm tests pass**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v
|
||||
```
|
||||
|
||||
Expected: 3 passed
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/config.py hbd/server/oauth.py tests/test_oauth.py
|
||||
git commit -m "feat: add oauth module skeleton and is_enabled()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: CSRF state management
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/oauth.py` (add `make_state`, `validate_state`)
|
||||
- Modify: `tests/test_oauth.py` (add state tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_oauth.py`:
|
||||
|
||||
```python
|
||||
import time as time_mod
|
||||
|
||||
|
||||
def test_make_state_returns_unique_tokens():
|
||||
s1 = oauth.make_state()
|
||||
s2 = oauth.make_state()
|
||||
assert s1 != s2
|
||||
assert len(s1) == 64 # 32 bytes hex
|
||||
|
||||
|
||||
def test_validate_state_valid():
|
||||
state = oauth.make_state()
|
||||
assert oauth.validate_state(state) is True
|
||||
|
||||
|
||||
def test_validate_state_consumed_on_use():
|
||||
state = oauth.make_state()
|
||||
oauth.validate_state(state)
|
||||
assert oauth.validate_state(state) is False # replay rejected
|
||||
|
||||
|
||||
def test_validate_state_unknown():
|
||||
assert oauth.validate_state("notastate") is False
|
||||
|
||||
|
||||
def test_validate_state_expired(monkeypatch):
|
||||
state = oauth.make_state()
|
||||
# Wind expiry into the past
|
||||
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1)
|
||||
assert oauth.validate_state(state) is False
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm failure**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v -k "state"
|
||||
```
|
||||
|
||||
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'make_state'`
|
||||
|
||||
- [ ] **Step 3: Implement state functions**
|
||||
|
||||
Add to `hbd/server/oauth.py` after the `_states` dict definition:
|
||||
|
||||
```python
|
||||
def make_state() -> str:
|
||||
"""Generate a CSRF state token, store it with TTL, and return it."""
|
||||
_purge_states()
|
||||
token = secrets.token_hex(32)
|
||||
_states[token] = time.time() + STATE_TTL
|
||||
return token
|
||||
|
||||
|
||||
def validate_state(state: str) -> bool:
|
||||
"""Return True if *state* is known and unexpired; always removes it."""
|
||||
expiry = _states.pop(state, None)
|
||||
if expiry is None:
|
||||
return False
|
||||
return time.time() < expiry
|
||||
|
||||
|
||||
def _purge_states() -> None:
|
||||
now = time.time()
|
||||
expired = [k for k, exp in list(_states.items()) if exp < now]
|
||||
for k in expired:
|
||||
del _states[k]
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to confirm tests pass**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v
|
||||
```
|
||||
|
||||
Expected: 8 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/oauth.py tests/test_oauth.py
|
||||
git commit -m "feat: add OAuth2 CSRF state management"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `provision_oauth_user` in users.py
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/users.py` (add `provision_oauth_user`)
|
||||
- Modify: `tests/test_oauth.py` (add provisioning tests)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_oauth.py`:
|
||||
|
||||
```python
|
||||
from hbd.server import users as users_mod
|
||||
from hbd.server.users import User
|
||||
|
||||
|
||||
def _reset_users(entries=None):
|
||||
users_mod.users = entries or {}
|
||||
|
||||
|
||||
def test_provision_oauth_user_new():
|
||||
_reset_users()
|
||||
user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png")
|
||||
assert user.username == "gituser"
|
||||
assert user.full_name == "Git User"
|
||||
assert user.avatar == "https://example.com/avatar.png"
|
||||
assert user.admin is False
|
||||
assert user.password_hash == ""
|
||||
assert "gituser" in users_mod.users
|
||||
|
||||
|
||||
def test_provision_oauth_user_no_password_login():
|
||||
_reset_users()
|
||||
user = users_mod.provision_oauth_user("gituser", "Git User", "")
|
||||
assert user.check_password("anything") is False
|
||||
|
||||
|
||||
def test_provision_oauth_user_existing_updates_profile():
|
||||
existing = User(
|
||||
username="alice",
|
||||
full_name="Old Name",
|
||||
avatar="old.png",
|
||||
password_hash="pbkdf2:sha256:1:salt:abc",
|
||||
admin=True,
|
||||
notification_channels=["chan1"],
|
||||
)
|
||||
_reset_users({"alice": existing})
|
||||
user = users_mod.provision_oauth_user("alice", "New Name", "new.png")
|
||||
assert user.full_name == "New Name"
|
||||
assert user.avatar == "new.png"
|
||||
# Preserved
|
||||
assert user.admin is True
|
||||
assert user.password_hash == "pbkdf2:sha256:1:salt:abc"
|
||||
assert user.notification_channels == ["chan1"]
|
||||
|
||||
|
||||
def test_provision_oauth_user_does_not_overwrite_with_empty():
|
||||
existing = User(username="bob", full_name="Bob", avatar="bob.png")
|
||||
_reset_users({"bob": existing})
|
||||
user = users_mod.provision_oauth_user("bob", "", "")
|
||||
assert user.full_name == "Bob"
|
||||
assert user.avatar == "bob.png"
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm failure**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v -k "provision"
|
||||
```
|
||||
|
||||
Expected: `AttributeError: module 'hbd.server.users' has no attribute 'provision_oauth_user'`
|
||||
|
||||
- [ ] **Step 3: Implement `provision_oauth_user`**
|
||||
|
||||
Add to `hbd/server/users.py` after the `authenticate()` function (after line 187):
|
||||
|
||||
```python
|
||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
|
||||
"""Create or update a user sourced from an OAuth2 provider.
|
||||
|
||||
New users are inserted with no password_hash — they can only authenticate
|
||||
via OAuth. Existing users (e.g. defined in config with a password) have
|
||||
their display name and avatar refreshed; all other attributes are preserved.
|
||||
"""
|
||||
user = users.get(username)
|
||||
if user is None:
|
||||
user = User(username=username, full_name=full_name, avatar=avatar)
|
||||
users[username] = user
|
||||
logger.info("Provisioned OAuth user %r", username)
|
||||
else:
|
||||
if full_name:
|
||||
user.full_name = full_name
|
||||
if avatar:
|
||||
user.avatar = avatar
|
||||
return user
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to confirm tests pass**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v
|
||||
```
|
||||
|
||||
Expected: 12 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/users.py tests/test_oauth.py
|
||||
git commit -m "feat: add provision_oauth_user() to users module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: URL builder, token exchange, and user-info fetch
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/oauth.py` (add `authorization_url`, `exchange_code`, `fetch_user`)
|
||||
- Modify: `tests/test_oauth.py` (add async tests with mocked HTTP)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Append to `tests/test_oauth.py`:
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
|
||||
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",
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to confirm failure**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v -k "url or exchange or fetch"
|
||||
```
|
||||
|
||||
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'authorization_url'`
|
||||
|
||||
- [ ] **Step 3: Implement the three functions**
|
||||
|
||||
Add to `hbd/server/oauth.py`:
|
||||
|
||||
```python
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def authorization_url(config: dict, state: str, redirect_uri: str) -> str:
|
||||
"""Return the Gitea OAuth2 authorization URL to redirect the browser to."""
|
||||
g = _gitea_cfg(config)
|
||||
params = urllib.parse.urlencode({
|
||||
"client_id": g["client_id"],
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "user:email",
|
||||
"state": state,
|
||||
})
|
||||
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}"
|
||||
|
||||
|
||||
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
|
||||
"""Exchange an authorization *code* for a Gitea access token.
|
||||
|
||||
Returns the access token string. Raises OAuthError on any failure.
|
||||
"""
|
||||
g = _gitea_cfg(config)
|
||||
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
|
||||
payload = {
|
||||
"client_id": g["client_id"],
|
||||
"client_secret": g["client_secret"],
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
raise OAuthError(f"Token exchange network error: {exc}") from exc
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise OAuthError(f"No access_token in response: {data}")
|
||||
return token
|
||||
|
||||
|
||||
async def fetch_user(config: dict, token: str) -> dict:
|
||||
"""Fetch the authenticated user's profile from Gitea.
|
||||
|
||||
Returns a dict with keys: login, full_name, avatar_url.
|
||||
Raises OAuthError on any failure.
|
||||
"""
|
||||
g = _gitea_cfg(config)
|
||||
url = f"{g['url'].rstrip('/')}/api/v1/user"
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
raise OAuthError(f"User fetch network error: {exc}") from exc
|
||||
return {
|
||||
"login": data.get("login", ""),
|
||||
"full_name": data.get("full_name", ""),
|
||||
"avatar_url": data.get("avatar_url", ""),
|
||||
}
|
||||
```
|
||||
|
||||
Also add `import urllib.parse` at the top of `oauth.py` (alongside the existing imports).
|
||||
|
||||
- [ ] **Step 4: Run to confirm tests pass**
|
||||
|
||||
```
|
||||
pytest tests/test_oauth.py -v
|
||||
```
|
||||
|
||||
Expected: 17 passed
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/oauth.py tests/test_oauth.py
|
||||
git commit -m "feat: add authorization_url, exchange_code, fetch_user to oauth module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: HTTP routes — redirect and callback
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/http.py`
|
||||
|
||||
`http.py` defines all handlers inside `async def start(...)`. The two new handlers go in the same block, just before the `app = web.Application()` line (~line 900). The import goes at the top of the file.
|
||||
|
||||
- [ ] **Step 1: Add the import**
|
||||
|
||||
In `hbd/server/http.py`, add after the existing local imports (after `from . import users as users_mod`):
|
||||
|
||||
```python
|
||||
from . import oauth as oauth_mod
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the two route handlers**
|
||||
|
||||
In `hbd/server/http.py`, add the two handlers immediately before the `app = web.Application()` line:
|
||||
|
||||
```python
|
||||
async def oauth_gitea_redirect(request):
|
||||
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
|
||||
if not oauth_mod.is_enabled(config):
|
||||
return web.Response(status=404, text="OAuth not configured")
|
||||
state = oauth_mod.make_state()
|
||||
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
|
||||
raise web.HTTPFound(oauth_mod.authorization_url(config, state, redirect_uri))
|
||||
|
||||
async def oauth_gitea_callback(request):
|
||||
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
|
||||
if not oauth_mod.is_enabled(config):
|
||||
return web.Response(status=404, text="OAuth not configured")
|
||||
code = request.rel_url.query.get("code", "")
|
||||
state = request.rel_url.query.get("state", "")
|
||||
if not code or not state:
|
||||
return web.Response(status=400, text="Missing code or state")
|
||||
if not oauth_mod.validate_state(state):
|
||||
raise web.HTTPFound("/login?error=1")
|
||||
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
|
||||
try:
|
||||
token = await oauth_mod.exchange_code(config, code, redirect_uri)
|
||||
profile = await oauth_mod.fetch_user(config, token)
|
||||
except oauth_mod.OAuthError as exc:
|
||||
logger.warning("OAuth error: %s", exc)
|
||||
raise web.HTTPFound("/login?error=1")
|
||||
user = users_mod.provision_oauth_user(
|
||||
profile["login"],
|
||||
profile["full_name"],
|
||||
profile["avatar_url"],
|
||||
)
|
||||
session_token = users_mod.create_session(user.username)
|
||||
resp = web.HTTPFound("/")
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
session_token,
|
||||
max_age=users_mod.SESSION_TTL,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
raise resp
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Register the routes**
|
||||
|
||||
In `hbd/server/http.py`, add to the route list after the existing auth routes (after `web.post("/api/0/auth/logout", api_logout)`):
|
||||
|
||||
```python
|
||||
web.get("/login/oauth/gitea", oauth_gitea_redirect),
|
||||
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Manual smoke test**
|
||||
|
||||
Start the server locally with OAuth configured in `~/.hb.yaml`:
|
||||
|
||||
```yaml
|
||||
oauth:
|
||||
gitea:
|
||||
url: https://your-gitea-instance.example.com
|
||||
client_id: your-client-id
|
||||
client_secret: your-client-secret
|
||||
```
|
||||
|
||||
Visit `http://localhost:50004/login/oauth/gitea` — confirm you are redirected to Gitea's authorization page.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/http.py
|
||||
git commit -m "feat: add Gitea OAuth2 redirect and callback routes"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Login page — "Sign in with Gitea" button
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/http.py` (update `login_page` handler, ~line 625)
|
||||
|
||||
- [ ] **Step 1: Replace the login page HTML**
|
||||
|
||||
In `hbd/server/http.py`, find the `html = f"""` block inside `login_page` and replace it with:
|
||||
|
||||
```python
|
||||
gitea_button = ""
|
||||
if oauth_mod.is_enabled(config):
|
||||
gitea_url = _gitea_cfg_url(config)
|
||||
gitea_button = f"""
|
||||
<div class="divider">or</div>
|
||||
<a href="/login/oauth/gitea" class="gitea-btn">
|
||||
Sign in with Gitea
|
||||
</a>"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Heartbeat — Login</title>
|
||||
<style>
|
||||
body {{ font-family: sans-serif; background: #f5f5f5; display: flex;
|
||||
justify-content: center; align-items: center; height: 100vh; margin: 0; }}
|
||||
.box {{ background: #fff; padding: 2em 2.5em; border-radius: 8px;
|
||||
box-shadow: 0 2px 12px rgba(0,0,0,.15); min-width: 300px; }}
|
||||
h2 {{ margin: 0 0 1.2em; color: #333; font-size: 1.4em; }}
|
||||
label {{ display: block; margin-bottom: .3em; font-size: .9em; color: #555; }}
|
||||
input {{ width: 100%; padding: .5em .7em; border: 1px solid #ccc;
|
||||
border-radius: 4px; font-size: 1em; box-sizing: border-box; }}
|
||||
button {{ margin-top: 1.2em; width: 100%; padding: .6em; background: #0066cc;
|
||||
color: #fff; border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }}
|
||||
button:hover {{ background: #0055aa; }}
|
||||
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
|
||||
.field {{ margin-bottom: .9em; }}
|
||||
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
|
||||
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
|
||||
.gitea-btn {{ display: block; width: 100%; padding: .6em; background: #609926;
|
||||
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
|
||||
text-decoration: none; box-sizing: border-box; }}
|
||||
.gitea-btn:hover {{ background: #4e7d1e; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<h2>Heartbeat</h2>
|
||||
{'<p class="error">Invalid username, password, or OAuth error.</p>' if error else ''}
|
||||
<form method="post">
|
||||
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
||||
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>{gitea_button}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the `_gitea_cfg_url` helper**
|
||||
|
||||
Add this small helper in `hbd/server/http.py` just before the `login_page` handler (around line 600) so the template can read the Gitea display URL without importing internal oauth details:
|
||||
|
||||
```python
|
||||
def _gitea_cfg_url(config: dict) -> str:
|
||||
return config.get("oauth", {}).get("gitea", {}).get("url", "")
|
||||
```
|
||||
|
||||
Also update the `login_page` handler's `error` logic to show the error when the `?error=1` query param is present (set by the callback on OAuth failure):
|
||||
|
||||
```python
|
||||
async def login_page(request):
|
||||
"""GET /login — show login form; POST /login — process and redirect."""
|
||||
if not users_mod.users_enabled():
|
||||
raise web.HTTPFound("/")
|
||||
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
form = await request.post()
|
||||
username = form.get("username", "")
|
||||
password = form.get("password", "")
|
||||
user = users_mod.authenticate(username, password)
|
||||
if user:
|
||||
token = users_mod.create_session(username)
|
||||
redirect_to = request.rel_url.query.get("next", "/")
|
||||
resp = web.HTTPFound(redirect_to)
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
token,
|
||||
max_age=users_mod.SESSION_TTL,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
raise resp
|
||||
error = "Invalid username or password."
|
||||
elif request.rel_url.query.get("error"):
|
||||
error = "Sign-in failed. Please try again."
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Manual verification**
|
||||
|
||||
Start the server with OAuth configured. Visit `/login`. Confirm:
|
||||
- The "Sign in with Gitea" button appears (green, below a divider)
|
||||
- Clicking it redirects to Gitea
|
||||
- After authorising on Gitea, you are redirected back and land on `/` with a valid session cookie
|
||||
|
||||
Without OAuth configured, confirm the button does not appear.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/http.py
|
||||
git commit -m "feat: add Sign in with Gitea button to login page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes
|
||||
|
||||
- All 5 spec requirements covered: coexist ✓, auto-provision ✓, regular user ✓, any Gitea user ✓, config-driven ✓
|
||||
- `exchange_code` signature in Task 4 matches usage in Task 5 (`config, code, redirect_uri`) ✓
|
||||
- `fetch_user` returns `{login, full_name, avatar_url}` — matched in callback handler ✓
|
||||
- `validate_state` removes state on use (replay protection) ✓
|
||||
- `provision_oauth_user` skips empty strings so existing avatar/name aren't erased ✓
|
||||
- `_gitea_cfg_url` is a plain `def`, not `async` — safe to call in template prep ✓
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,184 @@
|
||||
# Gitea OAuth2 Authentication — Design Spec
|
||||
|
||||
Date: 2026-05-08
|
||||
|
||||
## Overview
|
||||
|
||||
Add Gitea as an OAuth2 login provider alongside the existing username/password
|
||||
authentication. Any user on the configured Gitea instance can sign in; their
|
||||
local account is auto-provisioned on first login as a regular (non-admin) user.
|
||||
Password login continues to work unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Config
|
||||
|
||||
A new optional `oauth.gitea` block in `~/.hb.yaml`. OAuth is disabled when the
|
||||
block is absent or any of the three required keys is missing.
|
||||
|
||||
```yaml
|
||||
oauth:
|
||||
gitea:
|
||||
url: https://git.example.com # Gitea base URL, no trailing slash
|
||||
client_id: <gitea-app-client-id>
|
||||
client_secret: <gitea-app-client-secret>
|
||||
```
|
||||
|
||||
**Gitea setup:** Create an OAuth2 application in Gitea under
|
||||
*Settings → Applications → OAuth2*. Set the redirect URI to
|
||||
`https://<hbd-host>/login/oauth/gitea/callback`.
|
||||
|
||||
`config.py` default:
|
||||
|
||||
```python
|
||||
"oauth": {},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New module: `hbd/server/oauth.py`
|
||||
|
||||
Owns all OAuth2 logic. No new dependencies — uses `aiohttp.ClientSession`
|
||||
already present in the codebase.
|
||||
|
||||
### CSRF state store
|
||||
|
||||
```python
|
||||
# state -> expires (float)
|
||||
_states: dict[str, float] = {}
|
||||
STATE_TTL = 600 # 10 minutes
|
||||
```
|
||||
|
||||
`_states` is an in-memory dict. Entries are created on redirect and deleted on
|
||||
use or expiry. A purge runs on every new state generation.
|
||||
|
||||
### Public API
|
||||
|
||||
| Function | Description |
|
||||
|---|---|
|
||||
| `is_enabled(config)` | Returns `True` when url, client_id, and client_secret are all set |
|
||||
| `make_state()` | Generates a random state token, stores it with TTL, returns it |
|
||||
| `validate_state(state)` | Returns `True` and removes the state if valid and unexpired |
|
||||
| `authorization_url(config, state, redirect_uri)` | Builds the Gitea `/login/oauth/authorize` redirect URL with `client_id`, `redirect_uri`, `scope=user:email`, `state` |
|
||||
| `exchange_code(config, code, redirect_uri)` async | POSTs to Gitea `/login/oauth/access_token` with code and redirect_uri, returns the access token string or raises `OAuthError` |
|
||||
| `fetch_user(config, token)` async | GETs Gitea `/api/v1/user` with Bearer token, returns `{"login", "full_name", "avatar_url"}` or raises `OAuthError` |
|
||||
|
||||
### Error handling
|
||||
|
||||
`OAuthError(message)` is a module-level exception. The callback route catches it
|
||||
and renders the login page with an error message — identical to an invalid
|
||||
password error in UX terms.
|
||||
|
||||
Network timeouts use a 10-second `aiohttp` timeout. Any non-2xx response from
|
||||
Gitea raises `OAuthError`.
|
||||
|
||||
---
|
||||
|
||||
## Change: `hbd/server/users.py`
|
||||
|
||||
One new function added to the public API:
|
||||
|
||||
```python
|
||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> User:
|
||||
```
|
||||
|
||||
- If the username does not exist in the live `users` dict, creates a `User`
|
||||
with no `password_hash` (so password login is impossible for this account)
|
||||
and inserts it.
|
||||
- If the username already exists (e.g. was defined in config with a password),
|
||||
updates `full_name` and `avatar` from the OAuth profile and returns the
|
||||
existing user unchanged in all other respects (preserving admin flag,
|
||||
notification channels, etc.).
|
||||
- Logs a one-line INFO message on first provision.
|
||||
|
||||
---
|
||||
|
||||
## Changes: `hbd/server/http.py`
|
||||
|
||||
### Two new route handlers
|
||||
|
||||
**`GET /login/oauth/gitea`**
|
||||
|
||||
1. Checks `oauth.is_enabled(config)` — returns 404 if not.
|
||||
2. Calls `oauth.make_state()`.
|
||||
3. Constructs `redirect_uri` as `{request.url.origin()}/login/oauth/gitea/callback` using aiohttp's `request.url.origin()`.
|
||||
4. Redirects the browser to `oauth.authorization_url(config, state, redirect_uri)`.
|
||||
|
||||
**`GET /login/oauth/gitea/callback`**
|
||||
|
||||
1. Reads `code` and `state` query params; returns 400 if either is missing.
|
||||
2. Calls `oauth.validate_state(state)` — redirects to `/login` with error if
|
||||
invalid (CSRF or replay protection).
|
||||
3. Reconstructs the same `redirect_uri` as the redirect handler (required by OAuth2 spec for token exchange).
|
||||
4. Calls `await oauth.exchange_code(config, code, redirect_uri)` to get the access token.
|
||||
4. Calls `await oauth.fetch_user(config, token)` to get the Gitea user profile.
|
||||
5. Calls `users_mod.provision_oauth_user(login, full_name, avatar_url)`.
|
||||
6. Calls `users_mod.create_session(username)` to get a session token.
|
||||
7. Sets `hbd_session` cookie (same flags as password login: httponly, Lax,
|
||||
24h TTL).
|
||||
8. Redirects to `/`.
|
||||
9. Any `OAuthError` re-renders the login page with a generic error message.
|
||||
|
||||
### Login page change
|
||||
|
||||
When `oauth.is_enabled(config)` is `True`, the existing login form gains a
|
||||
separator and a "Sign in with Gitea" link button pointing to
|
||||
`/login/oauth/gitea`. The password form is always rendered regardless.
|
||||
|
||||
### Route registration
|
||||
|
||||
```python
|
||||
web.get("/login/oauth/gitea", oauth_redirect),
|
||||
web.get("/login/oauth/gitea/callback", oauth_callback),
|
||||
```
|
||||
|
||||
Added alongside the existing `/login` and `/logout` routes.
|
||||
|
||||
---
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
Browser hbd Gitea
|
||||
| | |
|
||||
|-- GET /login ----------->| |
|
||||
|<- login page (+ button) -| |
|
||||
| | |
|
||||
|-- GET /login/oauth/gitea>| |
|
||||
|<- 302 Gitea /authorize --| |
|
||||
| | |
|
||||
|-- GET /login/oauth/authorize ----------------------->|
|
||||
|<- 302 /login/oauth/gitea/callback?code=..&state=.. --|
|
||||
| | |
|
||||
|-- GET /callback -------->| |
|
||||
| |-- POST /access_token ---->|
|
||||
| |<- {access_token} ---------|
|
||||
| |-- GET /api/v1/user ------>|
|
||||
| |<- {login, name, avatar} --|
|
||||
| | provision_oauth_user() |
|
||||
| | create_session() |
|
||||
|<- 302 / (set cookie) ----| |
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
- `test_oauth_state`: `make_state` + `validate_state` happy path; expired state
|
||||
returns False; replay (double-use) returns False.
|
||||
- `test_provision_oauth_user_new`: new username creates User with no password.
|
||||
- `test_provision_oauth_user_existing`: existing config user updates name/avatar,
|
||||
preserves admin flag and notification_channels.
|
||||
- `test_oauth_callback_invalid_state`: callback with bad state redirects to login.
|
||||
- Integration: mock Gitea endpoints with `aiohttp_client` fixture; full
|
||||
redirect → callback → session cookie flow.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Restricting login to specific Gitea organisations or teams.
|
||||
- Making OAuth users admin automatically.
|
||||
- Multiple OAuth providers.
|
||||
- Token refresh (Gitea access tokens are long-lived; the hbd session TTL governs
|
||||
re-authentication).
|
||||
@@ -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
|
||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
||||
"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "5.1.14"
|
||||
__version__ = "5.3.0"
|
||||
|
||||
@@ -15,12 +15,15 @@ CLIENT_DEFAULTS = {
|
||||
# Network settings
|
||||
"hb_port": 50003, # Port where hbd servers listen
|
||||
"interval": 10, # Heartbeat interval in seconds
|
||||
|
||||
|
||||
# Host identity
|
||||
"owner": None, # Optional username to set as this host's owner on the server
|
||||
|
||||
# Runtime flags
|
||||
"foreground": False,
|
||||
"verbose": False,
|
||||
"debug": 0,
|
||||
|
||||
|
||||
# Plugin configuration
|
||||
"plugins": {}, # Per-plugin configuration
|
||||
"thresholds": {}, # Threshold configuration for monitoring
|
||||
|
||||
+115
-58
@@ -21,6 +21,7 @@ from typing import Dict, List, Optional
|
||||
# Import protocol and config
|
||||
from .config import load_config
|
||||
from ..common.proto import dicttos, stodict
|
||||
from .. import __version__
|
||||
|
||||
# Import plugin system
|
||||
from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin
|
||||
@@ -56,23 +57,27 @@ class AsyncConnection:
|
||||
self.transport: Optional[asyncio.DatagramTransport] = None
|
||||
self.protocol: Optional[asyncio.DatagramProtocol] = None
|
||||
self._dead = False
|
||||
self._ever_opened = False
|
||||
self._open_fail_count = 0 # consecutive failures before first success
|
||||
self.request_info_event: asyncio.Event = asyncio.Event()
|
||||
|
||||
self.logger = logging.getLogger(f"hbc.conn.{addr}")
|
||||
|
||||
|
||||
async def open(self) -> bool:
|
||||
"""Open the UDP connection.
|
||||
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
|
||||
# Create datagram endpoint
|
||||
self.transport, self.protocol = await loop.create_datagram_endpoint(
|
||||
lambda: HeartbeatProtocol(self),
|
||||
family=self.af
|
||||
)
|
||||
self._ever_opened = True
|
||||
self.logger.debug(f"Opened connection to {self.addr}:{self.port}")
|
||||
return True
|
||||
except Exception as e:
|
||||
@@ -134,6 +139,9 @@ class AsyncConnection:
|
||||
|
||||
self.ackcount += 1
|
||||
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms")
|
||||
if msg.get("request_update"):
|
||||
self.logger.info("server requested plugin info refresh")
|
||||
self.request_info_event.set()
|
||||
|
||||
|
||||
class HeartbeatProtocol(asyncio.DatagramProtocol):
|
||||
@@ -169,9 +177,8 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
|
||||
self.logger.error(f"Error processing datagram: {e}", exc_info=True)
|
||||
|
||||
def error_received(self, exc):
|
||||
"""Handle protocol errors."""
|
||||
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — dropping connection")
|
||||
self.connection._dead = True
|
||||
"""Handle protocol errors — close transport so the heartbeat sender retries."""
|
||||
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc} — will retry")
|
||||
self.connection.close()
|
||||
|
||||
|
||||
@@ -262,15 +269,51 @@ async def handle_update(conn: AsyncConnection, _msg: dict): # pyright: ignore[r
|
||||
|
||||
|
||||
async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
||||
"""Send periodic heartbeats.
|
||||
|
||||
"""Send periodic heartbeats, retrying the connection if it is not open.
|
||||
|
||||
IPv6 connections that fail to open before their first successful send are
|
||||
dropped after IPV6_EARLY_FAIL_LIMIT attempts so that a network without IPv6
|
||||
does not keep a dead sender alive. IPv4 connections are retried indefinitely.
|
||||
|
||||
Args:
|
||||
conn: Connection to send on
|
||||
interval: Heartbeat interval in seconds
|
||||
"""
|
||||
logger = logging.getLogger("hbc.heartbeat")
|
||||
|
||||
while running:
|
||||
IPV6_EARLY_FAIL_LIMIT = 3
|
||||
|
||||
while running and not conn._dead:
|
||||
# Ensure transport is open before attempting to send.
|
||||
if not conn.transport:
|
||||
opened = await conn.open()
|
||||
if opened:
|
||||
conn._open_fail_count = 0
|
||||
else:
|
||||
conn._open_fail_count += 1
|
||||
# Drop an IPv6 connection that has never come up within the
|
||||
# first few attempts — it is likely unavailable on this network.
|
||||
if (not conn._ever_opened
|
||||
and conn.af == socket.AF_INET6
|
||||
and conn._open_fail_count >= IPV6_EARLY_FAIL_LIMIT):
|
||||
logger.warning(
|
||||
f"IPv6 connection to {conn.addr} unreachable after "
|
||||
f"{conn._open_fail_count} attempts, disabling"
|
||||
)
|
||||
conn._dead = True
|
||||
break
|
||||
# Retry after the normal interval; IPv4 retries forever.
|
||||
try:
|
||||
if shutdown_event:
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(interval)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
continue
|
||||
|
||||
try:
|
||||
msg = {
|
||||
"acks": conn.ackcount,
|
||||
@@ -278,20 +321,17 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
||||
"interval": interval
|
||||
}
|
||||
await conn.sendto(msg, "HTB")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending heartbeat: {e}", exc_info=True)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Heartbeat sender cancelled")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending heartbeat: {e}", exc_info=True)
|
||||
|
||||
# Wait for next interval or shutdown event
|
||||
try:
|
||||
if shutdown_event:
|
||||
await asyncio.wait_for(
|
||||
shutdown_event.wait(),
|
||||
timeout=interval
|
||||
)
|
||||
await asyncio.wait_for(shutdown_event.wait(), timeout=interval)
|
||||
break
|
||||
else:
|
||||
await asyncio.sleep(interval)
|
||||
@@ -302,15 +342,35 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
|
||||
raise
|
||||
|
||||
|
||||
async def _info_plugin_refresh_loop(conn: AsyncConnection, info_plugins: List):
|
||||
"""Wait for server requests to re-send InfoPlugin data."""
|
||||
logger = logging.getLogger("hbc.plugins")
|
||||
while running:
|
||||
await conn.request_info_event.wait()
|
||||
if not running:
|
||||
break
|
||||
conn.request_info_event.clear()
|
||||
logger.info("refreshing InfoPlugins on server request")
|
||||
for plugin in info_plugins:
|
||||
plugin._cache = None
|
||||
try:
|
||||
data = await plugin.collect()
|
||||
if data:
|
||||
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
|
||||
logger.info(f"Resent {plugin.name} data")
|
||||
except Exception as e:
|
||||
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
|
||||
|
||||
|
||||
async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
||||
"""Collect and send plugin data.
|
||||
|
||||
|
||||
Args:
|
||||
conn: Connection to send on
|
||||
registry: Plugin registry
|
||||
"""
|
||||
logger = logging.getLogger("hbc.plugins")
|
||||
|
||||
|
||||
# Collect InfoPlugins once at startup
|
||||
info_plugins = registry.get_by_type(InfoPlugin)
|
||||
for plugin in info_plugins:
|
||||
@@ -323,34 +383,31 @@ async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
|
||||
logger.info(f"Sent {plugin.name} data")
|
||||
except Exception as e:
|
||||
logger.error(f"Error collecting {plugin.name}: {e}", exc_info=True)
|
||||
|
||||
|
||||
# Schedule MonitorPlugins
|
||||
# Group plugins by interval
|
||||
from collections import defaultdict
|
||||
by_interval = defaultdict(list)
|
||||
|
||||
|
||||
monitor_plugins = registry.get_by_type(MonitorPlugin)
|
||||
for plugin in monitor_plugins:
|
||||
by_interval[plugin.interval].append(plugin)
|
||||
|
||||
# Create tasks for each interval
|
||||
tasks = []
|
||||
|
||||
# Create tasks for each interval; always include the info-refresh watcher
|
||||
tasks = [asyncio.create_task(_info_plugin_refresh_loop(conn, info_plugins))]
|
||||
for interval, plugins in by_interval.items():
|
||||
task = asyncio.create_task(
|
||||
tasks.append(asyncio.create_task(
|
||||
plugin_collector_interval(conn, plugins, interval)
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
# Wait for all tasks
|
||||
if tasks:
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Plugin collector cancelled, cancelling sub-tasks")
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
raise
|
||||
))
|
||||
|
||||
try:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
except asyncio.CancelledError:
|
||||
logger.debug("Plugin collector cancelled, cancelling sub-tasks")
|
||||
for task in tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
raise
|
||||
|
||||
|
||||
async def plugin_collector_interval(
|
||||
@@ -427,16 +484,13 @@ async def cleanup(connections: List[AsyncConnection]):
|
||||
logger = logging.getLogger("hbc.cleanup")
|
||||
logger.info("Cleaning up connections")
|
||||
|
||||
for conn in connections:
|
||||
target = next((c for c in connections if c.transport), connections[0] if connections else None)
|
||||
if target and send_shutdown:
|
||||
try:
|
||||
msg = {
|
||||
"shutdown": 1,
|
||||
"acks": conn.ackcount
|
||||
}
|
||||
await conn.sendto(msg)
|
||||
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending shutdown: {e}")
|
||||
|
||||
for conn in connections:
|
||||
conn.close()
|
||||
|
||||
# Give messages time to send
|
||||
@@ -445,7 +499,7 @@ async def cleanup(connections: List[AsyncConnection]):
|
||||
|
||||
async def async_main(args, config):
|
||||
"""Async main function."""
|
||||
global running, shutdown_event, active_tasks
|
||||
global running, shutdown_event, active_tasks, send_shutdown
|
||||
|
||||
# Create shutdown event
|
||||
shutdown_event = asyncio.Event()
|
||||
@@ -462,8 +516,7 @@ async def async_main(args, config):
|
||||
hb_port = config.get("hb_port", PORT)
|
||||
interval = config.get("interval", INTERVAL)
|
||||
|
||||
logger.info(f"Starting hbc for {iam} -> {hb_hosts}")
|
||||
logger.info(f"Port: {hb_port}, Interval: {interval}s")
|
||||
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
|
||||
|
||||
# Create connections
|
||||
connections = []
|
||||
@@ -479,30 +532,34 @@ async def async_main(args, config):
|
||||
for addr_info in addrs:
|
||||
af = addr_info[0]
|
||||
addr = addr_info[4][0]
|
||||
|
||||
|
||||
conn = AsyncConnection(conn_id, addr, hb_port, af, iam)
|
||||
if await conn.open():
|
||||
connections.append(conn)
|
||||
conn_id += 1
|
||||
|
||||
if not await conn.open():
|
||||
logger.warning(f"Initial open to {addr} failed, heartbeat sender will retry")
|
||||
connections.append(conn)
|
||||
conn_id += 1
|
||||
|
||||
if not connections:
|
||||
logger.error("No connections established")
|
||||
logger.error("No connections established (DNS resolution failed for all hosts)")
|
||||
return 1
|
||||
|
||||
logger.info(f"Created {len(connections)} connections")
|
||||
|
||||
# Send boot/message if requested
|
||||
send_shutdown = False
|
||||
if args.boot or args.message:
|
||||
boot_msg = {}
|
||||
if args.boot:
|
||||
boot_msg["boot"] = 1
|
||||
args.boot = False # Clear boot flag so we don't send it again in main loop
|
||||
send_shutdown = True
|
||||
if args.message:
|
||||
boot_msg["service"] = "service"
|
||||
boot_msg["msg"] = args.message
|
||||
|
||||
boot_msg["acks"] = 0
|
||||
for conn in connections:
|
||||
await conn.sendto(boot_msg)
|
||||
target = next((c for c in connections if c.transport), connections[0])
|
||||
await target.sendto(boot_msg)
|
||||
|
||||
if args.message and not args.daemon:
|
||||
# Message-only mode
|
||||
@@ -702,7 +759,7 @@ def main(argv=None):
|
||||
|
||||
# Daemonize if requested
|
||||
if args.daemon:
|
||||
print("Daemonizing...")
|
||||
logging.info("Daemonizing...")
|
||||
daemonize()
|
||||
_reconfigure_logging_for_daemon(log_level)
|
||||
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
||||
|
||||
@@ -364,7 +364,10 @@ class PluginLoader:
|
||||
|
||||
# Instantiate plugin with config — check plugins subdict first,
|
||||
# then top-level keys (e.g. nagios_runner: ... at root of config).
|
||||
plugin_instance_config = plugins_subconfig.get(obj.name) or raw_config.get(obj.name, {})
|
||||
plugin_instance_config = dict(plugins_subconfig.get(obj.name) or raw_config.get(obj.name) or {})
|
||||
# Propagate top-level owner so os_info (and any future plugin) can report it.
|
||||
if "owner" in raw_config and "owner" not in plugin_instance_config:
|
||||
plugin_instance_config["owner"] = raw_config["owner"]
|
||||
plugin = obj(config=plugin_instance_config)
|
||||
|
||||
# Initialize plugin
|
||||
|
||||
@@ -118,6 +118,13 @@ class CPUMonitorPlugin(MonitorPlugin):
|
||||
data["cpu_iowait"] = round(cpu_times.iowait, 1)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not get CPU times: {e}")
|
||||
|
||||
# Uptime in seconds
|
||||
try:
|
||||
import time
|
||||
data["uptime_seconds"] = int(time.time() - self.psutil.boot_time())
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Could not get uptime: {e}")
|
||||
|
||||
self.logger.debug(
|
||||
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
|
||||
|
||||
@@ -14,6 +14,24 @@ except ImportError:
|
||||
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
|
||||
def _zfs_arc_bytes() -> int:
|
||||
"""Return current ZFS ARC size in bytes, or 0 if ZFS is not present.
|
||||
|
||||
ZFS ARC is reclaimable but is not included in MemAvailable by the Linux
|
||||
kernel (it is not in SReclaimable), so it would otherwise be counted as
|
||||
used memory.
|
||||
"""
|
||||
try:
|
||||
with open("/proc/spl/kstat/zfs/arcstats") as fh:
|
||||
for line in fh:
|
||||
parts = line.split()
|
||||
if len(parts) >= 3 and parts[0] == "size":
|
||||
return int(parts[2])
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return 0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -101,11 +119,21 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
||||
|
||||
# Virtual (physical) memory statistics
|
||||
vmem = psutil.virtual_memory()
|
||||
|
||||
# psutil's available already excludes page cache / file buffers
|
||||
# (uses MemAvailable on Linux). Add ZFS ARC on top because the kernel
|
||||
# does not include it in SReclaimable / MemAvailable even though it is
|
||||
# reclaimable.
|
||||
arc_bytes = _zfs_arc_bytes()
|
||||
available = min(vmem.available + arc_bytes, vmem.total)
|
||||
used = vmem.total - available
|
||||
percent = round(used / vmem.total * 100, 1) if vmem.total else 0.0
|
||||
|
||||
metrics['memory_total'] = vmem.total
|
||||
metrics['memory_available'] = vmem.available
|
||||
metrics['memory_used'] = vmem.used
|
||||
metrics['memory_available'] = available
|
||||
metrics['memory_used'] = used
|
||||
metrics['memory_free'] = vmem.free
|
||||
metrics['memory_percent'] = vmem.percent
|
||||
metrics['memory_percent'] = percent
|
||||
|
||||
# Platform-specific memory details
|
||||
if hasattr(vmem, 'active'):
|
||||
|
||||
@@ -31,16 +31,13 @@ from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
|
||||
# Nagios exit codes
|
||||
NAGIOS_OK = 0
|
||||
NAGIOS_WARNING = 1
|
||||
NAGIOS_CRITICAL = 2
|
||||
NAGIOS_UNKNOWN = 3
|
||||
|
||||
STATUS_NAMES = {
|
||||
NAGIOS_OK: "OK",
|
||||
NAGIOS_WARNING: "WARNING",
|
||||
NAGIOS_CRITICAL: "CRITICAL",
|
||||
NAGIOS_UNKNOWN: "UNKNOWN"
|
||||
0: "OK",
|
||||
1: "WARNING",
|
||||
2: "CRITICAL",
|
||||
3: "UNKNOWN",
|
||||
}
|
||||
|
||||
|
||||
@@ -128,52 +125,39 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
||||
Dictionary with results from all plugins
|
||||
"""
|
||||
results = {}
|
||||
|
||||
# Track overall status (worst status wins)
|
||||
worst_status = NAGIOS_OK
|
||||
|
||||
|
||||
for cmd_config in self.commands:
|
||||
name = cmd_config.get("name")
|
||||
command = cmd_config.get("command")
|
||||
|
||||
|
||||
if not name or not command:
|
||||
self.logger.warning("Skipping command with missing name or command")
|
||||
continue
|
||||
|
||||
|
||||
# Execute plugin
|
||||
try:
|
||||
status_code, output, perfdata = await self._run_nagios_plugin(command)
|
||||
|
||||
|
||||
# Store results
|
||||
results[f"{name}_status"] = STATUS_NAMES.get(status_code, "UNKNOWN")
|
||||
results[f"{name}_status_code"] = status_code
|
||||
results[f"{name}_output"] = output
|
||||
|
||||
# Track worst status
|
||||
if status_code > worst_status:
|
||||
worst_status = status_code
|
||||
|
||||
|
||||
# Parse and add performance data
|
||||
if perfdata:
|
||||
for metric_name, metric_value in perfdata.items():
|
||||
results[f"{name}_{metric_name}"] = metric_value
|
||||
|
||||
|
||||
self.logger.info(
|
||||
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error running {name}: {e}", exc_info=True)
|
||||
results[f"{name}_status"] = "ERROR"
|
||||
results[f"{name}_status_code"] = NAGIOS_UNKNOWN
|
||||
results[f"{name}_output"] = str(e)
|
||||
worst_status = NAGIOS_UNKNOWN
|
||||
|
||||
# Add overall status
|
||||
results["overall_status"] = STATUS_NAMES.get(worst_status, "UNKNOWN")
|
||||
results["overall_status_code"] = worst_status
|
||||
results["plugin_count"] = len(self.commands)
|
||||
|
||||
|
||||
return results
|
||||
|
||||
async def _run_nagios_plugin(
|
||||
|
||||
@@ -62,6 +62,9 @@ class OSInfoPlugin(InfoPlugin):
|
||||
"hbc_version": hbc_version,
|
||||
"hbc_type": "full",
|
||||
}
|
||||
if self.config.get("owner"):
|
||||
self.logger.debug(f"Adding owner from config: {self.config['owner']}")
|
||||
data["owner"] = self.config["owner"]
|
||||
|
||||
# Add Linux-specific distribution info
|
||||
if platform.system() == "Linux":
|
||||
|
||||
@@ -13,12 +13,8 @@ plugins:
|
||||
count: 3 # ICMP packets per ping run (default 3)
|
||||
timeout: 5 # seconds before a host is considered unreachable (default 5)
|
||||
hosts:
|
||||
8.8.8.8:
|
||||
warning: 20.0 # ms
|
||||
critical: 100.0 # ms
|
||||
192.168.1.1:
|
||||
warning: 5.0
|
||||
critical: 20.0
|
||||
- 8.8.8.8
|
||||
- 192.168.1.1
|
||||
```
|
||||
|
||||
Reported metrics per host (metric key uses the hostname with dots/colons replaced
|
||||
|
||||
@@ -89,14 +89,24 @@ class ZFSMonitorPlugin(MonitorPlugin):
|
||||
name = parts[0].strip()
|
||||
if self._pools_filter and name not in self._pools_filter:
|
||||
continue
|
||||
health = parts[1].strip()
|
||||
if health == "ONLINE":
|
||||
status = 0
|
||||
elif health in ("DEGRADED", "ONLINE with errors"):
|
||||
status = 1
|
||||
elif health in ("FAULTED", "OFFLINE", "UNAVAIL"):
|
||||
status = 2
|
||||
else:
|
||||
status = 3 # unknown status
|
||||
pools[name] = {
|
||||
"health": parts[1].strip(),
|
||||
"size": _int(parts[2]),
|
||||
"alloc": _int(parts[3]),
|
||||
"free": _int(parts[4]),
|
||||
"capacity": _float(parts[5]),
|
||||
"frag": _float(parts[6]),
|
||||
"dedup": _float(parts[7]),
|
||||
"health": health,
|
||||
"status": status,
|
||||
"size": _int(parts[2]),
|
||||
"alloc": _int(parts[3]),
|
||||
"free": _int(parts[4]),
|
||||
"capacity": _float(parts[5]),
|
||||
"frag": _float(parts[6]),
|
||||
"dedup": _float(parts[7]),
|
||||
}
|
||||
return pools
|
||||
|
||||
|
||||
@@ -134,6 +134,30 @@ thresholds:
|
||||
hysteresis: 0.1
|
||||
enabled: true
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# ZFS Monitor Thresholds
|
||||
# ----------------------------------------------------------------------------
|
||||
zfs_monitor:
|
||||
# Pool health check — built-in default; shown here for reference/override.
|
||||
# status is 0 (ONLINE) or 1 (DEGRADED) or 2 (SUSPENDED, FAULTED, UNAVAIL…).
|
||||
# Use '*' to apply the same rule to every pool, or name a specific pool.
|
||||
pools:
|
||||
'*':
|
||||
status:
|
||||
warning: 1 # Alert WARNING when pool is DEGRADED
|
||||
critical: 2 # Alert CRITICAL when pool is SUSPENDED/FAULTED/UNAVAIL
|
||||
operator: ">"
|
||||
hysteresis: 0.0 # No hysteresis — a degraded pool is always critical
|
||||
display: "ZFS pool {pool_name} is {health}"
|
||||
|
||||
# Per-pool capacity thresholds (optional; add pools you care about)
|
||||
# tank:
|
||||
# capacity:
|
||||
# warning: 75.0 # Warn at 75% used
|
||||
# critical: 90.0 # Critical at 90% used
|
||||
# operator: ">"
|
||||
# hysteresis: 0.05
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Network Monitor Thresholds
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
+25
-3
@@ -27,13 +27,16 @@ SERVER_DEFAULTS = {
|
||||
|
||||
# Monitoring settings
|
||||
"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
|
||||
|
||||
# User management
|
||||
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
|
||||
"default_owner": None, # Username that owns hosts with no explicit owner
|
||||
|
||||
# OAuth2 providers
|
||||
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
|
||||
|
||||
# Host management
|
||||
"hosts": {}, # Unified host definitions
|
||||
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
|
||||
@@ -95,7 +98,26 @@ THRESHOLD_DEFAULTS = {
|
||||
'warning': 200,
|
||||
'critical': 250.0,
|
||||
'count': 3 # Optional: number of consecutive breaches before alerting
|
||||
}
|
||||
},
|
||||
'nagios_runner': {
|
||||
'status_code': {
|
||||
'display': '{check_name} {output}',
|
||||
'operator': "nagios"
|
||||
}
|
||||
},
|
||||
'zfs_monitor': {
|
||||
'pools': {
|
||||
'*': {
|
||||
'status': {
|
||||
'warning': 1,
|
||||
'critical': 2,
|
||||
'operator': '>',
|
||||
'hysteresis': 0.0,
|
||||
'display': 'ZFS pool {pool_name} is {health}'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -303,7 +325,7 @@ def get_host_access(config, hostname) -> dict:
|
||||
"""
|
||||
host_cfg = get_host_config(config, hostname)
|
||||
|
||||
owner = host_cfg.get("owner") or get_default_owner(config)
|
||||
owner = host_cfg.get("owner") # or get_default_owner(config)
|
||||
|
||||
managers = host_cfg.get("managers", [])
|
||||
if isinstance(managers, str):
|
||||
|
||||
@@ -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}")
|
||||
@@ -95,7 +95,7 @@ class Connection:
|
||||
if not Null:
|
||||
d["addr"] = self.addr
|
||||
if self.rtts[-1]:
|
||||
d["rtt"] = "%0.1f" % self.rtts[-1]
|
||||
d["rtt"] = "%d" % round(self.rtts[-1])
|
||||
elif self.state == Connection.UNKNOWN:
|
||||
d["rtt"] = ""
|
||||
else:
|
||||
|
||||
+372
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import html as _html
|
||||
import json
|
||||
import platform
|
||||
import socket
|
||||
@@ -16,7 +17,9 @@ from . import data
|
||||
from . import notify as notify_mod
|
||||
from . import settings as settings_mod
|
||||
from . import users as users_mod
|
||||
from . import oauth as oauth_mod
|
||||
from . import ws as ws_mod
|
||||
from . import configio as configio_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -99,6 +102,30 @@ def _can_own_host(user, host) -> bool:
|
||||
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
|
||||
|
||||
|
||||
async def start(
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -154,6 +181,25 @@ async def start(
|
||||
lst = [h.jsons() for h in hosts]
|
||||
return web.json_response(json.loads("[" + ",".join(lst) + "]"))
|
||||
|
||||
async def api_alert_summary(request):
|
||||
"""GET /api/0/alert_summary — counts of ok/warning/critical hosts visible to caller."""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
from .threshold import AlertLevel
|
||||
critical = warning = ok = 0
|
||||
for host in hbdclass.Host.hosts.values():
|
||||
if not _can_operate_host(user, host):
|
||||
continue
|
||||
levels = {s.level for s in host.alert_states.values()}
|
||||
if AlertLevel.CRITICAL in levels:
|
||||
critical += 1
|
||||
elif AlertLevel.WARNING in levels:
|
||||
warning += 1
|
||||
else:
|
||||
ok += 1
|
||||
return web.json_response({"critical": critical, "warning": warning, "ok": ok})
|
||||
|
||||
async def api_messages(request):
|
||||
lst = data.msgs[-30:]
|
||||
return web.json_response(lst)
|
||||
@@ -518,6 +564,8 @@ async def start(
|
||||
hosts_with_plugins.append({
|
||||
"name": hostname,
|
||||
"plugins": list(host.plugin_data.keys()),
|
||||
"is_owner": _can_own_host(current_user, host),
|
||||
"owner": host.owner,
|
||||
})
|
||||
|
||||
tmpl = env.get_template("plugins.html")
|
||||
@@ -566,6 +614,7 @@ async def start(
|
||||
if user is None:
|
||||
return web.json_response({"error": "Invalid credentials"}, status=401)
|
||||
token = users_mod.create_session(username)
|
||||
eventlog("hbd", "INFO", f"Login: {username} via api")
|
||||
resp = web.json_response({"token": token, "username": username})
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
@@ -589,6 +638,7 @@ async def start(
|
||||
user = users_mod.authenticate(username, password)
|
||||
if user:
|
||||
token = users_mod.create_session(username)
|
||||
eventlog("hbd", "INFO", f"Login: {username} via password")
|
||||
redirect_to = request.rel_url.query.get("next", "/")
|
||||
resp = web.HTTPFound(redirect_to)
|
||||
resp.set_cookie(
|
||||
@@ -600,6 +650,21 @@ async def start(
|
||||
)
|
||||
raise resp
|
||||
error = "Invalid username or password."
|
||||
elif request.rel_url.query.get("error"):
|
||||
error = "Sign-in failed. Please try again."
|
||||
|
||||
oauth_buttons = ""
|
||||
_providers = oauth_mod.get_providers(config)
|
||||
if _providers:
|
||||
buttons_html = ""
|
||||
for _p in _providers:
|
||||
_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>"""
|
||||
oauth_buttons = f"""
|
||||
<div class="divider">or</div>{buttons_html}"""
|
||||
|
||||
html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
@@ -620,6 +685,14 @@ async def start(
|
||||
button:hover {{ background: #0055aa; }}
|
||||
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
|
||||
.field {{ margin-bottom: .9em; }}
|
||||
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
|
||||
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
|
||||
.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;
|
||||
text-decoration: none; box-sizing: border-box; margin-top: .5em; }}
|
||||
.oauth-btn:hover {{ background: #444; }}
|
||||
.oauth-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -630,7 +703,7 @@ async def start(
|
||||
<div class="field"><label>Username</label><input name="username" autofocus></div>
|
||||
<div class="field"><label>Password</label><input name="password" type="password"></div>
|
||||
<button type="submit">Sign in</button>
|
||||
</form>
|
||||
</form>{oauth_buttons}
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
@@ -639,7 +712,10 @@ async def start(
|
||||
async def web_logout(request):
|
||||
"""GET /logout — clear session cookie and redirect to /login."""
|
||||
token = request.cookies.get(SESSION_COOKIE, "")
|
||||
_user = users_mod.get_session_user(token)
|
||||
users_mod.delete_session(token)
|
||||
if _user:
|
||||
eventlog("hbd", "INFO", f"Logout: {_user.username}")
|
||||
resp = web.HTTPFound("/login")
|
||||
resp.del_cookie(SESSION_COOKIE)
|
||||
raise resp
|
||||
@@ -647,7 +723,10 @@ async def start(
|
||||
async def api_logout(request):
|
||||
"""POST /api/0/auth/logout"""
|
||||
token = _get_token(request)
|
||||
_user = users_mod.get_session_user(token)
|
||||
users_mod.delete_session(token)
|
||||
if _user:
|
||||
eventlog("hbd", "INFO", f"Logout: {_user.username}")
|
||||
resp = web.json_response({"success": True})
|
||||
resp.del_cookie(SESSION_COOKIE)
|
||||
return resp
|
||||
@@ -800,6 +879,8 @@ async def start(
|
||||
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
|
||||
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")
|
||||
body = tmpl.render(
|
||||
title="Profile - Heartbeat",
|
||||
@@ -809,6 +890,7 @@ async def start(
|
||||
managed_hosts=managed,
|
||||
monitored_hosts=monitored,
|
||||
notification_channels=notif_channels,
|
||||
all_channel_names=all_channel_names,
|
||||
active_page="profile",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
@@ -868,14 +950,292 @@ async def start(
|
||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
||||
tmpl = env.get_template("settings.html")
|
||||
settings_data = settings_mod.get_settings_data(config, threshold_checker=threshold_checker)
|
||||
body = tmpl.render(
|
||||
title="Settings - Heartbeat",
|
||||
sections=settings_mod.get_settings_sections(config),
|
||||
sections=settings_data["sections"],
|
||||
all_channel_names=settings_data["all_channel_names"],
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="settings",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
def _oauth_redirect_uri(request, provider_name: str) -> str:
|
||||
base = config.get("base_url", "").rstrip("/") or str(request.url.origin())
|
||||
return f"{base}/login/oauth/{provider_name}/callback"
|
||||
|
||||
def _get_oauth_provider(name: str):
|
||||
"""Return the ResolvedProvider for *name*, or None if not found."""
|
||||
return next(
|
||||
(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()
|
||||
raise web.HTTPFound(
|
||||
oauth_mod.build_auth_url(provider, state, _oauth_redirect_uri(request, name))
|
||||
)
|
||||
|
||||
async def oauth_callback(request):
|
||||
"""GET /login/oauth/{name}/callback — handle the provider's redirect back."""
|
||||
name = request.match_info["name"]
|
||||
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", "")
|
||||
state = request.rel_url.query.get("state", "")
|
||||
if not code or not state:
|
||||
return web.Response(status=400, text="Missing code or state")
|
||||
if not oauth_mod.validate_state(state):
|
||||
logger.warning("OAuth: invalid or expired state token from %s", request.remote)
|
||||
raise web.HTTPFound("/login?error=1")
|
||||
try:
|
||||
token = await oauth_mod.exchange_code(provider, code, _oauth_redirect_uri(request, name))
|
||||
profile = await oauth_mod.fetch_user(provider, token)
|
||||
except oauth_mod.OAuthError as exc:
|
||||
logger.warning("OAuth error: %s", exc)
|
||||
raise web.HTTPFound("/login?error=1")
|
||||
user = users_mod.provision_oauth_user(
|
||||
profile["login"],
|
||||
profile["full_name"],
|
||||
profile["avatar_url"],
|
||||
)
|
||||
session_token = users_mod.create_session(user.username)
|
||||
eventlog("hbd", "INFO", f"Login: {user.username} via {provider.type}")
|
||||
resp = web.HTTPFound("/")
|
||||
resp.set_cookie(
|
||||
SESSION_COOKIE,
|
||||
session_token,
|
||||
max_age=users_mod.SESSION_TTL,
|
||||
httponly=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
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.add_routes(
|
||||
[
|
||||
@@ -887,12 +1247,22 @@ async def start(
|
||||
web.get("/logout", web_logout),
|
||||
web.post("/api/0/auth/login", api_login),
|
||||
web.post("/api/0/auth/logout", api_logout),
|
||||
web.get("/login/oauth/{name}", oauth_redirect),
|
||||
web.get("/login/oauth/{name}/callback", oauth_callback),
|
||||
# Users
|
||||
web.get("/api/0/users", api_users),
|
||||
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),
|
||||
# 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
|
||||
web.get("/api/0/hosts", api_hosts),
|
||||
web.get("/api/0/alert_summary", api_alert_summary),
|
||||
web.get("/api/0/messages", api_messages),
|
||||
web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins),
|
||||
web.get("/api/0/hosts/{hostname}/plugins/{plugin_name}", api_host_plugin_detail),
|
||||
|
||||
+9
-1
@@ -101,9 +101,10 @@ async def reload_configuration(config_obj, config_path, components):
|
||||
access = config_mod.get_host_access(new_config, hostname)
|
||||
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||
|
||||
# Reload threshold checker
|
||||
# Reload threshold checker and prune alerts orphaned by the new config
|
||||
if 'threshold_checker' in components:
|
||||
components['threshold_checker'].reload(new_config)
|
||||
components['threshold_checker'].purge_stale_alerts(hbdclass)
|
||||
|
||||
# Note: Changes to the following require restart:
|
||||
# - hb_port, hbd_port, ws_port (already bound)
|
||||
@@ -241,6 +242,10 @@ async def _run_async(config, config_path=None):
|
||||
)
|
||||
udp.restore_connection_timers(hbdclass, restore_ctx)
|
||||
|
||||
# Drop alert states that no longer have a matching threshold (stale after
|
||||
# upgrade or config change between runs).
|
||||
threshold_checker.purge_stale_alerts(hbdclass)
|
||||
|
||||
# HTTP server (asyncio-based via aiohttp)
|
||||
try:
|
||||
http_task = asyncio.create_task(
|
||||
@@ -250,6 +255,7 @@ async def _run_async(config, config_path=None):
|
||||
config=config,
|
||||
hbdclass=hbdclass,
|
||||
tcss=None,
|
||||
threshold_checker=threshold_checker,
|
||||
verbose=config.get("verbose", False),
|
||||
get_now=lambda: time.time(),
|
||||
VER="",
|
||||
@@ -469,6 +475,8 @@ def run(config, config_path=None):
|
||||
if config.get("debug", 0) > 0:
|
||||
log_level = logging.DEBUG
|
||||
logging.basicConfig(level=log_level)
|
||||
if not config.get("debug", 0):
|
||||
logging.getLogger("aiohttp.access").propagate = False
|
||||
load_pickled_hosts(config, hbdclass)
|
||||
|
||||
notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
|
||||
|
||||
+14
-5
@@ -106,11 +106,18 @@ def closelog():
|
||||
|
||||
def eventlog(host, lvl, m, service=None):
|
||||
ts = time.time()
|
||||
msg = {
|
||||
"ts": ts,
|
||||
"host": host or None,
|
||||
"level": lvl,
|
||||
"service": service,
|
||||
"message": m,
|
||||
}
|
||||
data.msgs.append(msg)
|
||||
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
|
||||
if host:
|
||||
s += f"{host} "
|
||||
s += m
|
||||
data.msgs.append(s)
|
||||
logger.info(s)
|
||||
if logf:
|
||||
try:
|
||||
@@ -118,7 +125,7 @@ def eventlog(host, lvl, m, service=None):
|
||||
logf.flush()
|
||||
except Exception as e:
|
||||
logger.warning("failed to write to logfile: %s", e)
|
||||
msg_to_websockets("message", s)
|
||||
msg_to_websockets("message", msg)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -134,9 +141,11 @@ def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
|
||||
logger.warning("pushover: missing token or user")
|
||||
return False
|
||||
params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body}
|
||||
if channel_cfg.get("sound"):
|
||||
params["sound"] = channel_cfg["sound"]
|
||||
if notif.url:
|
||||
params["url"] = notif.url
|
||||
params["url_title"] = "Plugin metrics"
|
||||
params["url_title"] = "Heartbeat"
|
||||
conn = http.client.HTTPSConnection("api.pushover.net:443")
|
||||
try:
|
||||
conn.request(
|
||||
@@ -209,7 +218,7 @@ def _send_mattermost(channel_cfg: dict, notif: Notification) -> bool:
|
||||
return False
|
||||
text = f"**{notif.title}**\n{notif.body}"
|
||||
if notif.url:
|
||||
text += f"\n[Plugin metrics]({notif.url})"
|
||||
text += f"\n[Plugin metrics] {notif.url}"
|
||||
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
|
||||
mm = Driver(ses)
|
||||
payload: dict = {"text": text, "channel": channel, "username": channel_cfg.get("username", "hbd")}
|
||||
@@ -392,7 +401,7 @@ def _build_url(host_name: str) -> str:
|
||||
base_url = _config.get("base_url", "").rstrip("/")
|
||||
if not base_url:
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
"""OAuth2 provider support.
|
||||
|
||||
Config shape (in ~/.hb.yaml):
|
||||
|
||||
oauth:
|
||||
my-gitea: # route slug → /login/oauth/my-gitea
|
||||
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_secret: <client-secret>
|
||||
|
||||
nextcloud:
|
||||
type: nextcloud
|
||||
url: https://cloud.example.com
|
||||
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 secrets
|
||||
import time
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STATE_TTL = 600 # 10 minutes
|
||||
|
||||
# state_token -> expiry timestamp
|
||||
_states: dict[str, float] = {}
|
||||
|
||||
|
||||
def make_state() -> str:
|
||||
"""Generate a CSRF state token, store it with TTL, and return it."""
|
||||
_purge_states()
|
||||
token = secrets.token_hex(32)
|
||||
_states[token] = time.time() + STATE_TTL
|
||||
return token
|
||||
|
||||
|
||||
def validate_state(state: str) -> bool:
|
||||
"""Return True if *state* is known and unexpired; always removes it."""
|
||||
expiry = _states.pop(state, None)
|
||||
if expiry is None:
|
||||
return False
|
||||
return time.time() < expiry
|
||||
|
||||
|
||||
def _purge_states() -> None:
|
||||
"""Remove all expired CSRF state tokens from the in-memory store."""
|
||||
now = time.time()
|
||||
expired = [k for k, exp in list(_states.items()) if exp < now]
|
||||
for k in expired:
|
||||
del _states[k]
|
||||
|
||||
|
||||
class OAuthError(Exception):
|
||||
"""Raised when the OAuth2 flow fails for any reason."""
|
||||
|
||||
|
||||
PROVIDER_DEFS: dict = {
|
||||
"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:
|
||||
"""Return True when at least one OAuth provider is fully configured."""
|
||||
return bool(get_providers(config))
|
||||
|
||||
|
||||
def build_auth_url(provider: ResolvedProvider, state: str, redirect_uri: str) -> str:
|
||||
"""Return the provider's OAuth2 authorization URL to redirect the browser to."""
|
||||
params: dict = {
|
||||
"client_id": provider.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"response_type": "code",
|
||||
"state": state,
|
||||
}
|
||||
if provider.scope:
|
||||
params["scope"] = provider.scope
|
||||
return f"{provider.authorize_url}?{urllib.parse.urlencode(params)}"
|
||||
|
||||
|
||||
async def exchange_code(provider: ResolvedProvider, code: str, redirect_uri: str) -> str:
|
||||
"""Exchange an authorization *code* for an access token.
|
||||
|
||||
Returns the access token string. Raises OAuthError on any failure.
|
||||
"""
|
||||
payload = {
|
||||
"client_id": provider.client_id,
|
||||
"client_secret": provider.client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.post(
|
||||
provider.token_url,
|
||||
json=payload,
|
||||
headers={"Accept": "application/json"},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
|
||||
data = await resp.json()
|
||||
token = data.get("access_token")
|
||||
if not token:
|
||||
raise OAuthError(f"No access_token in response: {data}")
|
||||
except aiohttp.ClientError as exc:
|
||||
raise OAuthError(f"Token exchange network error: {exc}") from exc
|
||||
return token
|
||||
|
||||
|
||||
async def fetch_user(provider: ResolvedProvider, token: str) -> dict:
|
||||
"""Fetch the authenticated user's profile from the provider.
|
||||
|
||||
Returns a dict with keys: login, full_name, avatar_url.
|
||||
Raises OAuthError on any failure.
|
||||
"""
|
||||
timeout = aiohttp.ClientTimeout(total=10)
|
||||
try:
|
||||
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||
async with session.get(
|
||||
provider.profile_url,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
) as resp:
|
||||
if resp.status != 200:
|
||||
text = await resp.text()
|
||||
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
|
||||
data = await resp.json()
|
||||
except aiohttp.ClientError as exc:
|
||||
raise OAuthError(f"User fetch network error: {exc}") from exc
|
||||
|
||||
try:
|
||||
for key in provider.profile_data_path:
|
||||
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}")
|
||||
+120
-26
@@ -88,7 +88,7 @@ def _sanitize_channel(name, cfg):
|
||||
# Public API
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_settings_sections(config: dict) -> list:
|
||||
def get_settings_sections(config: dict, threshold_checker=None) -> list:
|
||||
"""Return ordered list of setting sections for the settings page.
|
||||
|
||||
Each section:
|
||||
@@ -181,6 +181,41 @@ def get_settings_sections(config: dict) -> list:
|
||||
"notification_channels": attrs.get("notification_channels", []),
|
||||
})
|
||||
|
||||
# ---- Threshold configurations -----------------------------------------
|
||||
def _tc_to_row(tc):
|
||||
return {
|
||||
"metric": tc.metric_path,
|
||||
"operator": tc.operator.value,
|
||||
"warning": tc.warning,
|
||||
"critical": tc.critical,
|
||||
"hysteresis": tc.hysteresis,
|
||||
"count": tc.count,
|
||||
"enabled": tc.enabled,
|
||||
}
|
||||
|
||||
threshold_config_list = []
|
||||
if threshold_checker is not None:
|
||||
if threshold_checker.threshold_configs:
|
||||
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
|
||||
# For the default config use the merged effective set;
|
||||
# for named overrides use only the explicitly defined metrics
|
||||
# (threshold_raw_configs) so inherited defaults are not repeated.
|
||||
if cfg_name == "default":
|
||||
display_metrics = cfg_metrics
|
||||
else:
|
||||
display_metrics = threshold_checker.threshold_raw_configs.get(cfg_name, cfg_metrics)
|
||||
metrics = sorted(
|
||||
[_tc_to_row(tc) for tc in display_metrics.values()],
|
||||
key=lambda m: m["metric"],
|
||||
)
|
||||
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
|
||||
elif threshold_checker.thresholds:
|
||||
metrics = sorted(
|
||||
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
|
||||
key=lambda m: m["metric"],
|
||||
)
|
||||
threshold_config_list.append({"name": "default", "metrics": metrics})
|
||||
|
||||
# ---- Hosts summary ----------------------------------------------------
|
||||
hosts_list = []
|
||||
for hname, hcfg in (config.get("hosts") or {}).items():
|
||||
@@ -197,28 +232,48 @@ def get_settings_sections(config: dict) -> list:
|
||||
"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 [
|
||||
{
|
||||
"id": "network",
|
||||
"title": "Network",
|
||||
"description": "Ports and bind addresses for all server sockets.",
|
||||
"section_mode": "form",
|
||||
"api_section": "server",
|
||||
"fields": [
|
||||
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",
|
||||
"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",
|
||||
"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",
|
||||
"TCP port for the plain WebSocket server."),
|
||||
"TCP port for the plain WebSocket server.", editable=True),
|
||||
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",
|
||||
"title": "TLS / WebSocket Security",
|
||||
"description": "Certificate paths used when wss_port is set.",
|
||||
"section_mode": "form",
|
||||
"api_section": None,
|
||||
"fields": [
|
||||
field("cert_path", "Certificate directory", "path",
|
||||
"Directory containing the TLS certificate and key files."),
|
||||
@@ -232,73 +287,89 @@ def get_settings_sections(config: dict) -> list:
|
||||
"id": "monitoring",
|
||||
"title": "Monitoring",
|
||||
"description": "Heartbeat timing and alert re-notification behaviour.",
|
||||
"section_mode": "form",
|
||||
"api_section": "server",
|
||||
"fields": [
|
||||
field("interval", "Heartbeat interval", "duration",
|
||||
"Expected time between heartbeat messages from each client."),
|
||||
field("grace", "Grace multiplier", "number",
|
||||
"A host is marked overdue after interval × grace seconds of silence."),
|
||||
"Expected time between heartbeat messages from each client.", editable=True),
|
||||
field("grace", "Grace period", "number",
|
||||
"Extra seconds to wait after a missed heartbeat before sending notifications.", editable=True),
|
||||
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",
|
||||
"How often the server saves its state to disk."),
|
||||
field("base_url", "Base URL", "text",
|
||||
"Base URL for notification links.", editable=True),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "persistence",
|
||||
"title": "Persistence & Logging",
|
||||
"description": "State file and event log settings.",
|
||||
"section_mode": "form",
|
||||
"api_section": "server",
|
||||
"fields": [
|
||||
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",
|
||||
"Path to the event log file."),
|
||||
"Path to the event log file.", editable=True),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "journal",
|
||||
"title": "Message Journal",
|
||||
"description": "All received heartbeat and plugin messages are journalled here.",
|
||||
"section_mode": "form",
|
||||
"api_section": "server",
|
||||
"fields": [
|
||||
field("journal_enabled", "Enabled", "boolean",
|
||||
"Turn journalling on or off."),
|
||||
"Turn journalling on or off.", editable=True),
|
||||
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",
|
||||
"Base filename for the journal (rotated copies get a numeric suffix)."),
|
||||
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",
|
||||
"Number of rotated journal files to keep."),
|
||||
"Number of rotated journal files to keep.", editable=True),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "dns",
|
||||
"title": "Dynamic DNS",
|
||||
"description": "nsupdate-based DNS registration for dynamic hosts.",
|
||||
"fields": [
|
||||
field("nsupdate_bin", "nsupdate binary", "path",
|
||||
"Full path to the nsupdate executable."),
|
||||
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."),
|
||||
],
|
||||
"description": "nsupdate-based DNS registration — edit raw YAML.",
|
||||
"section_mode": "yaml",
|
||||
"api_section": "dns",
|
||||
"fields": [],
|
||||
},
|
||||
{
|
||||
"id": "users",
|
||||
"title": "Users",
|
||||
"description": "Accounts defined in the config file. Password hashes are never shown.",
|
||||
"section_mode": "form",
|
||||
"api_section": "users",
|
||||
"users": users_list,
|
||||
"fields": [
|
||||
field("default_owner", "Default owner", "text",
|
||||
"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",
|
||||
"title": "Notification Channels",
|
||||
"description": "Named notification providers. Credentials are masked.",
|
||||
"section_mode": "yaml",
|
||||
"api_section": "notification_channels",
|
||||
"channels": notif_channels,
|
||||
"fields": [
|
||||
field("default_notification_channels", "Default channels", "list",
|
||||
@@ -309,13 +380,29 @@ def get_settings_sections(config: dict) -> list:
|
||||
"id": "hosts",
|
||||
"title": "Hosts",
|
||||
"description": "Host definitions loaded from the config file.",
|
||||
"section_mode": "yaml",
|
||||
"api_section": "hosts",
|
||||
"hosts": hosts_list,
|
||||
"fields": [],
|
||||
},
|
||||
{
|
||||
"id": "thresholds",
|
||||
"title": "Threshold Configurations",
|
||||
"description": "Named alert threshold sets. Each defines warning/critical levels per metric.",
|
||||
"section_mode": "yaml",
|
||||
"api_section": "thresholds",
|
||||
"threshold_configs": threshold_config_list,
|
||||
"fields": [
|
||||
field("default_threshold_config", "Default config", "text",
|
||||
"Threshold config used for hosts with no explicit mapping."),
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"title": "Runtime",
|
||||
"description": "Flags set at startup (require restart to change).",
|
||||
"section_mode": "form",
|
||||
"api_section": None,
|
||||
"fields": [
|
||||
field("foreground", "Foreground mode", "boolean",
|
||||
"Run in the foreground instead of daemonising."),
|
||||
@@ -326,3 +413,10 @@ def get_settings_sections(config: dict) -> 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}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
|
||||
<style>
|
||||
|
||||
html, body {
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
@@ -89,6 +94,24 @@
|
||||
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 {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
@@ -170,14 +193,18 @@
|
||||
|
||||
.alert-hostname {
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
color: #0066cc;
|
||||
font-size: 1.1em;
|
||||
text-decoration: none;
|
||||
}
|
||||
.alert-hostname:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.alert-metric {
|
||||
color: #666;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
color: #0066cc;
|
||||
font-size: 1.1em;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.alert-details {
|
||||
@@ -307,6 +334,7 @@
|
||||
<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('warning')">Warning Only</button>
|
||||
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
|
||||
</div>
|
||||
|
||||
<div class="alerts-container">
|
||||
@@ -323,6 +351,7 @@
|
||||
<script>
|
||||
let currentFilter = 'all';
|
||||
let allAlerts = [];
|
||||
let hostFilterRe = null;
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
@@ -357,10 +386,13 @@
|
||||
// Filter alerts based on current filter
|
||||
let filteredAlerts = alerts;
|
||||
if (currentFilter !== 'all') {
|
||||
filteredAlerts = alerts.filter(alert =>
|
||||
filteredAlerts = filteredAlerts.filter(alert =>
|
||||
alert.level.toLowerCase() === currentFilter
|
||||
);
|
||||
}
|
||||
if (hostFilterRe) {
|
||||
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
|
||||
}
|
||||
|
||||
if (filteredAlerts.length === 0) {
|
||||
if (currentFilter === 'all' && alerts.length === 0) {
|
||||
@@ -400,6 +432,10 @@
|
||||
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
|
||||
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
|
||||
}
|
||||
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
|
||||
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
|
||||
valueText += ` <span class="threshold-info" style="color:#888">(recovers ${recOp} ${formatValue(alert.recovery_threshold)})</span>`;
|
||||
}
|
||||
|
||||
// Build actions section
|
||||
let actionsHtml = '';
|
||||
@@ -424,9 +460,9 @@
|
||||
<div class="alert-main">
|
||||
<div class="alert-header">
|
||||
<span class="alert-level ${level}">${alert.level}</span>
|
||||
<span class="alert-hostname">${alert.hostname}</span>
|
||||
<a class="alert-hostname" href="/plugins#${alert.hostname}">${alert.hostname}</a>
|
||||
<span class="alert-metric">${(alert.metric_path.includes('.') ? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1) : alert.metric_path).replace(/_status_code$/, '')}</span>
|
||||
</div>
|
||||
<div class="alert-metric">${alert.metric_path}</div>
|
||||
<div class="alert-details">
|
||||
<span>${valueText}</span>
|
||||
<span class="alert-duration">Active for ${duration}</span>
|
||||
@@ -525,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
|
||||
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
|
||||
loadAlerts();
|
||||
</script>
|
||||
|
||||
@@ -126,11 +126,17 @@
|
||||
}
|
||||
|
||||
/* Swiss railway clock — nav */
|
||||
.nav-clock {
|
||||
.nav-pie {
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
margin-left: auto;
|
||||
padding: 4px 4px 4px 0;
|
||||
}
|
||||
#alert-pie { display: block; cursor: default; }
|
||||
.nav-clock {
|
||||
flex-shrink: 0;
|
||||
line-height: 0;
|
||||
padding: 4px 4px 4px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
#swiss-clock { display: block; }
|
||||
@@ -208,7 +214,7 @@
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
hand((m + s / 60) / 60 * Math.PI * 2 - Math.PI / 2,
|
||||
hand((sFrac >= 58.5 ? m + 1 : m) / 60 * Math.PI * 2 - Math.PI / 2,
|
||||
R * 0.88, -R * 0.12, SIZE * 0.027, '#222'); /* minute */
|
||||
hand((h + m / 60) / 12 * Math.PI * 2 - Math.PI / 2,
|
||||
R * 0.58, -R * 0.12, SIZE * 0.039, '#222'); /* hour */
|
||||
|
||||
@@ -183,11 +183,24 @@
|
||||
line-height: 1.0;
|
||||
}
|
||||
|
||||
#messages div {
|
||||
#messages .log-entry {
|
||||
padding: 5px 0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.log-ts { color: #888; white-space: nowrap; }
|
||||
.log-level { font-weight: bold; min-width: 6em; }
|
||||
.log-host { font-weight: 600; }
|
||||
.log-service { color: #888; }
|
||||
|
||||
.log-warning .log-level { color: #b8860b; }
|
||||
.log-critical .log-level { color: #c00; }
|
||||
.log-recover .log-level { color: #2a7a2a; }
|
||||
.log-info .log-level { color: #555; }
|
||||
|
||||
/* Modal for connection status messages */
|
||||
.connection-modal {
|
||||
display: none;
|
||||
@@ -236,6 +249,8 @@
|
||||
color: #ff9800;
|
||||
font-weight: 700;
|
||||
}
|
||||
#ntable a.host-link { color: inherit; text-decoration: none; }
|
||||
#ntable a.host-link:hover { text-decoration: underline; }
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
var cnt = 0;
|
||||
@@ -245,11 +260,13 @@
|
||||
var HBD_VERSION = "{{ hbd_version }}";
|
||||
|
||||
function hostNameHtml(data) {
|
||||
var rawName = data.raw_name || data.name.replace(/<[^>]+>/g, '').replace('*', '').trim();
|
||||
var nameHtml = data.name;
|
||||
if (!data.hbc_version || data.hbc_version !== HBD_VERSION) {
|
||||
nameHtml += ' 🥀';
|
||||
}
|
||||
return data.dyn ? '<b>' + nameHtml + '</b>' : nameHtml;
|
||||
var display = data.dyn ? '<b>' + nameHtml + '</b>' : nameHtml;
|
||||
return '<a class="host-link" href="/plugins#' + encodeURIComponent(rawName) + '">' + display + '</a>';
|
||||
}
|
||||
|
||||
function setup() {
|
||||
@@ -404,7 +421,7 @@
|
||||
);
|
||||
if (data.connections[i].state == "up") {
|
||||
state = '<span class="state-up">up</span>';
|
||||
latency = Number.parseFloat(data.connections[i].rtts[0]).toFixed(2);
|
||||
latency = String(Math.round(Number.parseFloat(data.connections[i].rtts[0])));
|
||||
} else {
|
||||
if (data.connections[i].state == "unknown") {
|
||||
state = "";
|
||||
@@ -456,7 +473,20 @@
|
||||
update_table(state.data);
|
||||
} else if (state.type == "message") {
|
||||
var msgs = document.getElementById("messages");
|
||||
msgs.insertAdjacentHTML("afterbegin", "<div>" + state.data + "</div>");
|
||||
var msg = state.data;
|
||||
var _d = new Date(msg.ts * 1000);
|
||||
function _p(n) { return n < 10 ? '0' + n : '' + n; }
|
||||
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
|
||||
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
|
||||
var lvl = (msg.level || "INFO").toLowerCase();
|
||||
var html = '<div class="log-entry log-' + lvl + '">';
|
||||
html += '<span class="log-ts">' + ts_str + '</span>';
|
||||
html += '<span class="log-level">' + (msg.level || "") + '</span>';
|
||||
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
|
||||
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
|
||||
html += '<span class="log-msg">' + msg.message + '</span>';
|
||||
html += '</div>';
|
||||
msgs.insertAdjacentHTML("afterbegin", html);
|
||||
}
|
||||
cnt++;
|
||||
};
|
||||
@@ -511,7 +541,7 @@
|
||||
<tbody id="ntablebody">
|
||||
{% for host in hosts %}
|
||||
<tr class="{% if host.alert_critical_unacked > 0 or host.alert_critical_acked > 0 %}row-critical{% elif host.alert_warning_unacked > 0 or host.alert_warning_acked > 0 %}row-warning{% endif %}">
|
||||
<td data-name="{{ host.name }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</td>
|
||||
<td data-name="{{ host.name }}"><a class="host-link" href="/plugins#{{ host.raw_name | urlencode }}">{{ host.name }}{% if not host.hbc_version or host.hbc_version != hbd_version %} 🥀{% endif %}</a></td>
|
||||
<td style="text-align: center; color: #ff9800; font-weight: bold;">
|
||||
{%- set warning_unacked = host.alert_warning_unacked -%}
|
||||
{%- set warning_acked = host.alert_warning_acked -%}
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
{% endif %}
|
||||
<a href="/about"{% if active_page == "about" %} class="active"{% endif %}>About</a>
|
||||
</div>
|
||||
<div class="nav-pie" title="Host alert status">
|
||||
<canvas id="alert-pie" width="44" height="44"></canvas>
|
||||
</div>
|
||||
<div class="nav-clock" title="Click for full-screen clock">
|
||||
<canvas id="swiss-clock" width="44" height="44"></canvas>
|
||||
</div>
|
||||
@@ -42,4 +45,52 @@
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
function drawAlertPie(critical, warning, ok) {
|
||||
var canvas = document.getElementById('alert-pie');
|
||||
if (!canvas) return;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var SIZE = canvas.width;
|
||||
var R = SIZE / 2;
|
||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||
var total = critical + warning + ok;
|
||||
if (total === 0) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#ccc';
|
||||
ctx.fill();
|
||||
return;
|
||||
}
|
||||
var slices = [
|
||||
{ value: critical, color: '#e53935' },
|
||||
{ value: warning, color: '#ffb300' },
|
||||
{ value: ok, color: '#43a047' }
|
||||
];
|
||||
var start = -Math.PI / 2;
|
||||
slices.forEach(function(s) {
|
||||
if (s.value === 0) return;
|
||||
var sweep = (s.value / total) * Math.PI * 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(R, R);
|
||||
ctx.arc(R, R, R - 1, start, start + sweep);
|
||||
ctx.closePath();
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.fill();
|
||||
start += sweep;
|
||||
});
|
||||
}
|
||||
|
||||
function updateAlertPie() {
|
||||
fetch('/api/0/alert_summary').then(function(r) {
|
||||
if (!r.ok) return;
|
||||
return r.json();
|
||||
}).then(function(d) {
|
||||
if (d) drawAlertPie(d.critical || 0, d.warning || 0, d.ok || 0);
|
||||
}).catch(function() {});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
updateAlertPie();
|
||||
setInterval(updateAlertPie, 30000);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -131,6 +131,52 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.host-action-btn {
|
||||
font-size: 0.75em;
|
||||
font-weight: bold;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.host-action-btn.update-btn {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
.host-action-btn.update-btn:hover { background: #bbdefb; }
|
||||
.host-action-btn.delete-btn {
|
||||
background: #ffebee;
|
||||
color: #c62828;
|
||||
}
|
||||
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
|
||||
|
||||
/* ── Action result toast ───────────────────────────────────── */
|
||||
#action-toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(20px);
|
||||
background: #323232;
|
||||
color: #fff;
|
||||
padding: 12px 22px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
z-index: 9000;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
#action-toast.show {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
#action-toast.error { background: #c62828; }
|
||||
|
||||
/* ── Host body ──────────────────────────────────────────────── */
|
||||
|
||||
.host-body {
|
||||
@@ -370,7 +416,8 @@
|
||||
<span class="host-name">{{ host.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="glance-strip" id="glance-{{ host.name }}">
|
||||
<div class="glance-strip" id="glance-{{ host.name }}" data-owner="{{ host.owner or '' }}">
|
||||
{% if current_user and current_user.admin and host.owner %}<span class="glance-chip neutral">{{ host.owner }}</span>{% endif %}
|
||||
<span class="glance-loading">—</span>
|
||||
</div>
|
||||
|
||||
@@ -379,6 +426,12 @@
|
||||
<span class="nagios-badge" id="nagios-badge-{{ host.name }}">—</span>
|
||||
{% endif %}
|
||||
<span class="os-label" id="os-label-{{ host.name }}"></span>
|
||||
{% if host.is_owner %}
|
||||
<button class="host-action-btn update-btn"
|
||||
onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
|
||||
<button class="host-action-btn delete-btn"
|
||||
onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -428,6 +481,7 @@
|
||||
const GLANCE_PLUGINS = ['cpu_monitor','memory_monitor','disk_monitor',
|
||||
'network_monitor','nagios_runner','os_info'];
|
||||
const SKIP_FIELDS = new Set(['id','name']);
|
||||
const CURRENT_USER_ADMIN = {{ 'true' if current_user and current_user.admin else 'false' }};
|
||||
|
||||
// ── Cache ───────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -447,6 +501,17 @@
|
||||
return pluginCache[hostname]?.[pluginName] ?? null;
|
||||
}
|
||||
|
||||
// Return worst nagios exit code (0-3) found in a nagios_runner data object.
|
||||
function nagiosWorstStatus(data) {
|
||||
let worst = 0;
|
||||
for (const [k, v] of Object.entries(data || {})) {
|
||||
if (k.endsWith('_status_code') && typeof v === 'number' && v > worst) {
|
||||
worst = v;
|
||||
}
|
||||
}
|
||||
return worst;
|
||||
}
|
||||
|
||||
// ── Fetch helpers ───────────────────────────────────────────────────────
|
||||
|
||||
async function fetchPlugin(hostname, pluginName) {
|
||||
@@ -495,6 +560,12 @@
|
||||
|
||||
const chips = [];
|
||||
|
||||
// Owner (admin only, static from server)
|
||||
const owner = strip.dataset.owner;
|
||||
if (CURRENT_USER_ADMIN && owner) {
|
||||
chips.push(`<span class="glance-chip neutral">${owner}</span>`);
|
||||
}
|
||||
|
||||
// CPU
|
||||
const cpu = getCache(hostname, 'cpu_monitor');
|
||||
if (cpu) {
|
||||
@@ -548,13 +619,13 @@
|
||||
? chips.join('')
|
||||
: '<span class="glance-loading">—</span>';
|
||||
|
||||
// Nagios badge
|
||||
// Nagios badge — derive worst status from individual check codes
|
||||
const nagios = getCache(hostname, 'nagios_runner');
|
||||
if (nagosBadge && nagios) {
|
||||
const status = (nagios.data.overall_status || '—').toUpperCase();
|
||||
const cls = status === 'OK' ? 'ok'
|
||||
: status === 'WARNING' ? 'warning'
|
||||
: status === 'CRITICAL' ? 'critical' : '';
|
||||
const worst = nagiosWorstStatus(nagios.data);
|
||||
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
|
||||
const status = names[worst] || '—';
|
||||
const cls = worst === 0 ? 'ok' : worst === 1 ? 'warning' : worst >= 2 ? 'critical' : '';
|
||||
nagosBadge.className = `nagios-badge ${cls}`;
|
||||
nagosBadge.textContent = status;
|
||||
}
|
||||
@@ -663,9 +734,10 @@
|
||||
break;
|
||||
}
|
||||
case 'nagios_runner': {
|
||||
const status = (d.overall_status || '?').toUpperCase();
|
||||
const count = d.plugin_count;
|
||||
text = status + (count != null ? ` — ${count} checks` : '');
|
||||
const worst = nagiosWorstStatus(d);
|
||||
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
|
||||
const codes = Object.keys(d).filter(k => k.endsWith('_status_code'));
|
||||
text = (names[worst] || '?') + (codes.length ? ` — ${codes.length} checks` : '');
|
||||
break;
|
||||
}
|
||||
case 'filesystem_info': {
|
||||
@@ -1156,12 +1228,68 @@
|
||||
// ── Init ────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// If a host fragment is in the URL, expand and scroll to that host;
|
||||
// otherwise expand the first host as before.
|
||||
const hash = window.location.hash;
|
||||
if (hash) {
|
||||
const hostname = decodeURIComponent(hash.slice(1));
|
||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||
if (card) {
|
||||
card.classList.remove('collapsed');
|
||||
fetchHostGlance(hostname);
|
||||
setTimeout(() => card.scrollIntoView({ behavior: 'smooth', block: 'start' }), 150);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const first = document.querySelector('.host-card');
|
||||
if (first) {
|
||||
first.classList.remove('collapsed');
|
||||
fetchHostGlance(first.dataset.hostname);
|
||||
}
|
||||
});
|
||||
// ── Host action helpers ──────────────────────────────────────
|
||||
|
||||
let _toastTimer = null;
|
||||
function showToast(msg, isError) {
|
||||
const t = document.getElementById('action-toast');
|
||||
t.textContent = msg;
|
||||
t.classList.toggle('error', !!isError);
|
||||
t.classList.add('show');
|
||||
clearTimeout(_toastTimer);
|
||||
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
|
||||
}
|
||||
|
||||
async function hostAction(btn, url) {
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
const text = await res.text();
|
||||
showToast(text, !res.ok);
|
||||
} catch (e) {
|
||||
showToast('Request failed: ' + e.message, true);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function hostDelete(btn, hostname) {
|
||||
if (!confirm('Delete host ' + hostname + '?')) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
|
||||
const text = await res.text();
|
||||
showToast(text, !res.ok);
|
||||
if (res.ok) {
|
||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||
if (card) card.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Request failed: ' + e.message, true);
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div id="action-toast"></div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -204,6 +204,22 @@
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
<body>
|
||||
@@ -266,16 +282,68 @@
|
||||
</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 -->
|
||||
<div class="section">
|
||||
<h2>Notification Channels</h2>
|
||||
{% if notification_channels %}
|
||||
{% for ch in notification_channels %}
|
||||
<div class="channel-row">
|
||||
<span class="channel-type">{{ ch.type }}</span>
|
||||
<span class="channel-name">{{ ch.name }}</span>
|
||||
{% if current_user %}
|
||||
<p style="font-size:.82em;color:#888;margin:0 0 10px">Select which channels send you alerts. Channels are defined by the administrator.</p>
|
||||
{% if all_channel_names %}
|
||||
<div id="channel-checkboxes">
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<span class="no-hosts">No personal notification channels configured.</span>
|
||||
{% endif %}
|
||||
@@ -326,5 +394,68 @@
|
||||
</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>
|
||||
</html>
|
||||
|
||||
+515
-142
@@ -254,6 +254,104 @@
|
||||
.host-bool { text-align: center; }
|
||||
.dot-yes { color: #2e7d32; font-size: 1.1em; }
|
||||
.dot-no { color: #ddd; font-size: 1.1em; }
|
||||
|
||||
/* ---- Threshold configurations ---- */
|
||||
.thresh-config { margin: 12px 20px 20px; }
|
||||
.thresh-config-name {
|
||||
font-weight: 600; font-size: 0.9em; color: #1a237e;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.mini-table .warn { color: #e65100; font-weight: 600; }
|
||||
.mini-table .crit { color: #b71c1c; font-weight: 600; }
|
||||
.mini-table .dim { color: #aaa; }
|
||||
.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>
|
||||
|
||||
<body>
|
||||
@@ -261,7 +359,27 @@
|
||||
|
||||
<div class="container">
|
||||
<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">
|
||||
|
||||
@@ -272,6 +390,8 @@
|
||||
{% for section in sections %}
|
||||
<a href="#{{ section.id }}" onclick="closeSidebar()">{{ section.title }}</a>
|
||||
{% 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>
|
||||
</nav>
|
||||
|
||||
@@ -284,169 +404,423 @@
|
||||
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
|
||||
</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 %}
|
||||
<div class="field-row">
|
||||
<div class="field-label">{{ f.label }}</div>
|
||||
<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>
|
||||
{% elif f.type == "boolean" %}
|
||||
{% elif f.type == 'boolean' %}
|
||||
<div class="field-value">
|
||||
<span class="val-boolean {{ 'on' if f.value else 'off' }}">
|
||||
{{ 'Enabled' if f.value else 'Disabled' }}
|
||||
</span>
|
||||
<span class="val-boolean {{ 'on' if f.value else 'off' }}">{{ 'Enabled' if f.value else 'Disabled' }}</span>
|
||||
</div>
|
||||
{% elif f.type == "list" %}
|
||||
{% elif f.type == 'list' %}
|
||||
<div class="field-value">
|
||||
{% if f.value %}
|
||||
<span class="val-list">
|
||||
{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="val-empty">None</span>
|
||||
{% endif %}
|
||||
{% if f.value %}<span class="val-list">{% for item in f.value %}<span class="val-tag">{{ item }}</span>{% endfor %}</span>
|
||||
{% else %}<span class="val-empty">None</span>{% endif %}
|
||||
</div>
|
||||
{% elif f.value is none or f.value == "" %}
|
||||
<div class="field-value"><span class="val-empty">Not set</span></div>
|
||||
{% else %}
|
||||
<div class="field-value">{{ f.value }}</div>
|
||||
{% endif %}
|
||||
{% if f.description %}
|
||||
<div class="field-desc">{{ f.description }}</div>
|
||||
<div class="field-value">{{ f.value if f.value is not none else '' }}</div>
|
||||
{% endif %}
|
||||
{% if f.description and not f.editable %}<p class="field-desc">{{ f.description }}</p>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{# ---- Users section ---- #}
|
||||
{% if section.id == "users" and section.users %}
|
||||
<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>
|
||||
{% if section.api_section %}
|
||||
<div class="section-footer">
|
||||
<button class="btn btn-primary" onclick="stageFormSection('{{ section.id }}', '{{ section.api_section }}')">Stage changes</button>
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
{# ---- 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 #}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>{# /settings-main #}
|
||||
</div>{# /settings-layout #}
|
||||
</div>{# /container #}
|
||||
|
||||
<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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
||||
}
|
||||
|
||||
// Highlight sidebar link for the section currently in view
|
||||
const sections = document.querySelectorAll('.section');
|
||||
const navLinks = document.querySelectorAll('.sidebar-nav a');
|
||||
@@ -474,8 +848,7 @@
|
||||
sidebarToggle.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
<script>
|
||||
|
||||
function closeSidebar() {
|
||||
var sidebarNav = document.getElementById('sidebar-nav');
|
||||
var sidebarToggle = document.getElementById('sidebar-toggle');
|
||||
|
||||
+374
-101
@@ -30,12 +30,13 @@ class AlertLevel(Enum):
|
||||
|
||||
class ComparisonOperator(Enum):
|
||||
"""Supported comparison operators for threshold checks."""
|
||||
GT = ">" # Greater than
|
||||
GTE = ">=" # Greater than or equal
|
||||
LT = "<" # Less than
|
||||
LTE = "<=" # Less than or equal
|
||||
EQ = "==" # Equal to
|
||||
NEQ = "!=" # Not equal to
|
||||
GT = ">" # Greater than
|
||||
GTE = ">=" # Greater than or equal
|
||||
LT = "<" # Less than
|
||||
LTE = "<=" # Less than or equal
|
||||
EQ = "==" # Equal to
|
||||
NEQ = "!=" # Not equal to
|
||||
NAGIOS = "nagios" # Nagios exit-code semantics: 0=OK 1=WARNING 2=CRITICAL 3=UNKNOWN
|
||||
|
||||
|
||||
class AlertState:
|
||||
@@ -57,6 +58,7 @@ class AlertState:
|
||||
self.last_notification = None
|
||||
self.threshold_value = None # The threshold value that triggered alert
|
||||
self.operator = None # The comparison operator (>, <, >=, etc.)
|
||||
self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery
|
||||
self.formatted_message = None # Formatted display message for UI
|
||||
self.acknowledged = False # Whether alert has been acknowledged
|
||||
self.acknowledged_at = None # Timestamp when acknowledged
|
||||
@@ -151,7 +153,16 @@ class AlertState:
|
||||
result["operator"] = self.operator
|
||||
if self.formatted_message is not None:
|
||||
result["formatted_message"] = self.formatted_message
|
||||
|
||||
|
||||
# Compute and expose the recovery threshold so the UI can display it
|
||||
if (self.hysteresis and self.threshold_value is not None
|
||||
and self.operator is not None):
|
||||
ha = abs(self.threshold_value * self.hysteresis)
|
||||
if self.operator in ('>', '>='):
|
||||
result["recovery_threshold"] = round(self.threshold_value - ha, 4)
|
||||
elif self.operator in ('<', '<='):
|
||||
result["recovery_threshold"] = round(self.threshold_value + ha, 4)
|
||||
|
||||
return result
|
||||
|
||||
def __setstate__(self, state):
|
||||
@@ -159,6 +170,8 @@ class AlertState:
|
||||
self.__dict__.update(state)
|
||||
if not hasattr(self, 'consecutive_count'):
|
||||
self.consecutive_count = 0
|
||||
if not hasattr(self, 'hysteresis'):
|
||||
self.hysteresis = None
|
||||
|
||||
def acknowledge(self):
|
||||
"""Acknowledge this alert to stop reminder notifications."""
|
||||
@@ -217,33 +230,43 @@ class ThresholdConfig:
|
||||
def evaluate(self, value: float) -> AlertLevel:
|
||||
"""
|
||||
Evaluate a value against this threshold.
|
||||
|
||||
|
||||
Args:
|
||||
value: Metric value to check
|
||||
|
||||
|
||||
Returns:
|
||||
AlertLevel indicating the severity
|
||||
"""
|
||||
if not self.enabled:
|
||||
return AlertLevel.OK
|
||||
|
||||
|
||||
# Nagios exit-code semantics: value IS the severity
|
||||
if self.operator == ComparisonOperator.NAGIOS:
|
||||
try:
|
||||
code = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return AlertLevel.UNKNOWN
|
||||
return {0: AlertLevel.OK, 1: AlertLevel.WARNING, 2: AlertLevel.CRITICAL}.get(
|
||||
code, AlertLevel.UNKNOWN
|
||||
)
|
||||
|
||||
try:
|
||||
# Convert value to float for comparison
|
||||
value = float(value)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Cannot convert value %s to float for %s", value, self.metric_path)
|
||||
return AlertLevel.UNKNOWN
|
||||
|
||||
|
||||
# Check critical threshold first
|
||||
if self.critical is not None:
|
||||
if self._compare(value, self.critical):
|
||||
return AlertLevel.CRITICAL
|
||||
|
||||
|
||||
# Then check warning threshold
|
||||
if self.warning is not None:
|
||||
if self._compare(value, self.warning):
|
||||
return AlertLevel.WARNING
|
||||
|
||||
|
||||
return AlertLevel.OK
|
||||
|
||||
def evaluate_with_hysteresis(
|
||||
@@ -262,7 +285,11 @@ class ThresholdConfig:
|
||||
New alert level considering hysteresis
|
||||
"""
|
||||
new_level = self.evaluate(value)
|
||||
|
||||
|
||||
# Nagios exit codes are discrete integers — hysteresis doesn't apply
|
||||
if self.operator == ComparisonOperator.NAGIOS:
|
||||
return new_level
|
||||
|
||||
# If no hysteresis, return new level
|
||||
if self.hysteresis == 0.0:
|
||||
return new_level
|
||||
@@ -392,14 +419,28 @@ class ThresholdChecker:
|
||||
|
||||
def _parse_config(self, config: Dict[str, Any]):
|
||||
"""Parse threshold configuration from YAML structure.
|
||||
|
||||
|
||||
Supports two formats:
|
||||
1. Legacy format with direct 'thresholds' section
|
||||
2. New format with 'threshold_configs' and 'host_threshold_mapping'
|
||||
|
||||
In all cases, THRESHOLD_DEFAULTS are seeded into threshold_configs["default"]
|
||||
so the Settings page always shows the built-in defaults.
|
||||
_parse_multi_config() overwrites this with the fully-merged effective defaults.
|
||||
"""
|
||||
# Always expose built-in defaults through threshold_configs["default"] so
|
||||
# the Settings page has something to display even in legacy/no-config mode.
|
||||
seed: Dict[str, ThresholdConfig] = {}
|
||||
for plugin_name, plugin_thresholds in THRESHOLD_DEFAULTS.get("thresholds", {}).items():
|
||||
if isinstance(plugin_thresholds, dict):
|
||||
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=seed)
|
||||
if seed:
|
||||
self.threshold_configs["default"] = seed
|
||||
self.threshold_raw_configs["default"] = {}
|
||||
|
||||
# Check for new multi-config format
|
||||
if "threshold_configs" in config:
|
||||
self._parse_multi_config(config)
|
||||
self._parse_multi_config(config) # overwrites threshold_configs["default"]
|
||||
elif "thresholds" in config:
|
||||
# Legacy single threshold configuration
|
||||
self._parse_legacy_config(config)
|
||||
@@ -534,10 +575,13 @@ class ThresholdChecker:
|
||||
if not isinstance(threshold_config, dict):
|
||||
continue
|
||||
|
||||
# Handle nested metrics (e.g., partitions./.percent)
|
||||
# Handle nested metrics (e.g., partitions./.percent or pools.*.status)
|
||||
if metric_name == "partitions":
|
||||
self._parse_partition_thresholds(plugin_name, threshold_config, target_dict)
|
||||
continue
|
||||
if metric_name == "pools":
|
||||
self._parse_pool_thresholds(plugin_name, threshold_config, target_dict)
|
||||
continue
|
||||
|
||||
metric_path = f"{plugin_name}.{metric_name}"
|
||||
|
||||
@@ -545,11 +589,14 @@ class ThresholdChecker:
|
||||
warning = threshold_config.get("warning")
|
||||
critical = threshold_config.get("critical")
|
||||
operator = threshold_config.get("operator", ">")
|
||||
display = threshold_config.get("display", "(threshold: {op_symbol} {threshold_value})")
|
||||
hysteresis = threshold_config.get("hysteresis", 0.1) # 10% default
|
||||
# Nagios operator maps exit codes directly; no numeric thresholds needed
|
||||
is_nagios_op = (operator == "nagios")
|
||||
default_display = "{check_name}: {output}" if is_nagios_op else "(threshold: {op_symbol} {threshold_value})"
|
||||
display = threshold_config.get("display", default_display)
|
||||
hysteresis = threshold_config.get("hysteresis", 0.0 if is_nagios_op else 0.02)
|
||||
enabled = threshold_config.get("enabled", True)
|
||||
|
||||
if warning is None and critical is None:
|
||||
|
||||
if warning is None and critical is None and not is_nagios_op:
|
||||
logger.warning("No thresholds defined for %s, skipping", metric_path)
|
||||
continue
|
||||
|
||||
@@ -619,7 +666,57 @@ class ThresholdChecker:
|
||||
)
|
||||
|
||||
target_dict[metric_path] = threshold
|
||||
|
||||
|
||||
def _parse_pool_thresholds(
|
||||
self,
|
||||
plugin_name: str,
|
||||
pools: Dict[str, Any],
|
||||
target_dict: Optional[Dict[str, ThresholdConfig]] = None,
|
||||
):
|
||||
"""Parse ZFS pool thresholds. Pool names may be literal or '*' (all pools).
|
||||
|
||||
Config shape::
|
||||
|
||||
zfs_monitor:
|
||||
pools:
|
||||
'*':
|
||||
status:
|
||||
warning: 1
|
||||
critical: 2
|
||||
operator: '>'
|
||||
tank:
|
||||
capacity:
|
||||
warning: 80
|
||||
critical: 90
|
||||
"""
|
||||
if target_dict is None:
|
||||
target_dict = self.thresholds
|
||||
|
||||
for pool_name, metrics in pools.items():
|
||||
if not isinstance(metrics, dict):
|
||||
continue
|
||||
for metric_name, threshold_config in metrics.items():
|
||||
if not isinstance(threshold_config, dict):
|
||||
continue
|
||||
metric_path = f"{plugin_name}.{pool_name}.{metric_name}"
|
||||
warning = threshold_config.get("warning")
|
||||
critical = threshold_config.get("critical")
|
||||
operator = threshold_config.get("operator", ">")
|
||||
hysteresis = threshold_config.get("hysteresis", 0.02)
|
||||
enabled = threshold_config.get("enabled", True)
|
||||
display = threshold_config.get("display")
|
||||
if warning is None and critical is None:
|
||||
continue
|
||||
target_dict[metric_path] = ThresholdConfig(
|
||||
metric_path=metric_path,
|
||||
warning=warning,
|
||||
critical=critical,
|
||||
operator=operator,
|
||||
hysteresis=hysteresis,
|
||||
enabled=enabled,
|
||||
display=display,
|
||||
)
|
||||
|
||||
def _parse_rtt_thresholds(
|
||||
self,
|
||||
rtt_thresholds: Dict[str, Any],
|
||||
@@ -649,7 +746,7 @@ class ThresholdChecker:
|
||||
warning = rtt_thresholds.get("warning")
|
||||
critical = rtt_thresholds.get("critical")
|
||||
operator = rtt_thresholds.get("operator", ">")
|
||||
hysteresis = rtt_thresholds.get("hysteresis", 0.1) # 10% default
|
||||
hysteresis = rtt_thresholds.get("hysteresis", 0.02) # 2% default
|
||||
enabled = rtt_thresholds.get("enabled", True)
|
||||
display = rtt_thresholds.get("display")
|
||||
count = rtt_thresholds.get("count", 1)
|
||||
@@ -794,6 +891,12 @@ class ThresholdChecker:
|
||||
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
|
||||
threshold_value = threshold.warning
|
||||
|
||||
# Keep hysteresis on the state so the UI can show the recovery threshold
|
||||
if new_level != AlertLevel.OK:
|
||||
alert_state.hysteresis = threshold.hysteresis
|
||||
else:
|
||||
alert_state.hysteresis = None
|
||||
|
||||
# Update state and check for changes
|
||||
old_level = alert_state.level
|
||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||
@@ -803,6 +906,36 @@ class ThresholdChecker:
|
||||
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, None)
|
||||
|
||||
return None
|
||||
def _find_threshold(
|
||||
self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str
|
||||
) -> Tuple[Optional["ThresholdConfig"], Optional[str]]:
|
||||
"""Return (threshold, check_name) for *metric_path*, falling back to suffix matches.
|
||||
|
||||
Allows generic thresholds like ``nagios_runner.status_code`` to match
|
||||
fully-qualified paths like ``nagios_runner.check_disk_root_status_code``.
|
||||
The exact match is always tried first; then successive leading
|
||||
underscore-delimited segments are stripped from the field name until
|
||||
a match is found or no segments remain.
|
||||
|
||||
Returns:
|
||||
(ThresholdConfig, None) for an exact match.
|
||||
(ThresholdConfig, "check_disk_root") for a suffix match — the second
|
||||
element is the stripped prefix, available as ``{check_name}`` in
|
||||
display format templates.
|
||||
(None, None) when no threshold is found.
|
||||
"""
|
||||
if metric_path in thresholds:
|
||||
return thresholds[metric_path], None
|
||||
plugin, sep, field = metric_path.partition(".")
|
||||
if not sep:
|
||||
return None, None
|
||||
parts = field.split("_")
|
||||
for i in range(1, len(parts)):
|
||||
candidate = plugin + "." + "_".join(parts[i:])
|
||||
if candidate in thresholds:
|
||||
return thresholds[candidate], "_".join(parts[:i])
|
||||
return None, None
|
||||
|
||||
def check_plugin_data(
|
||||
self,
|
||||
host_name: str,
|
||||
@@ -830,38 +963,39 @@ class ThresholdChecker:
|
||||
# Check flat metrics
|
||||
for metric_name, value in data.items():
|
||||
metric_path = f"{plugin_name}.{metric_name}"
|
||||
|
||||
if metric_path not in thresholds:
|
||||
|
||||
threshold, check_name = self._find_threshold(thresholds, metric_path)
|
||||
if threshold is None:
|
||||
continue
|
||||
|
||||
threshold = thresholds[metric_path]
|
||||
|
||||
|
||||
# Get or create alert state
|
||||
if metric_path not in alert_states:
|
||||
alert_states[metric_path] = AlertState(metric_path)
|
||||
|
||||
|
||||
alert_state = alert_states[metric_path]
|
||||
|
||||
|
||||
# Evaluate threshold with hysteresis
|
||||
new_level = threshold.evaluate_with_hysteresis(
|
||||
value,
|
||||
alert_state.level
|
||||
)
|
||||
|
||||
|
||||
# Determine which threshold was exceeded
|
||||
threshold_value = None
|
||||
if new_level == AlertLevel.CRITICAL and threshold.critical is not None:
|
||||
threshold_value = threshold.critical
|
||||
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
|
||||
threshold_value = threshold.warning
|
||||
|
||||
|
||||
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
|
||||
|
||||
# Update state and check for changes
|
||||
old_level = alert_state.level
|
||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||
state_changes.append((metric_path, old_level, new_level, value))
|
||||
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data)
|
||||
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data, check_name=check_name, metric_name=metric_name)
|
||||
elif new_level != AlertLevel.OK:
|
||||
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data)
|
||||
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data, check_name=check_name, metric_name=metric_name)
|
||||
|
||||
# Check nested metrics (e.g., partition data in disk_monitor)
|
||||
self._check_nested_metrics(
|
||||
@@ -886,6 +1020,44 @@ class ThresholdChecker:
|
||||
# Get host-specific thresholds
|
||||
thresholds = self.get_thresholds_for_host(host_name)
|
||||
|
||||
# ZFS pool health checks
|
||||
if plugin_name == "zfs_monitor" and "pools" in data:
|
||||
pools = data["pools"]
|
||||
if isinstance(pools, dict):
|
||||
for pool_name, pool_metrics in pools.items():
|
||||
if not isinstance(pool_metrics, dict):
|
||||
continue
|
||||
# Synthesize status from health string for older clients
|
||||
# that predate the status field.
|
||||
pool_metrics_effective = dict(pool_metrics)
|
||||
if "health" in pool_metrics and "status" not in pool_metrics:
|
||||
pool_metrics_effective["status"] = 0 if pool_metrics["health"] == "ONLINE" else 1
|
||||
for metric_name, value in pool_metrics_effective.items():
|
||||
# Try specific pool name first, then wildcard '*'
|
||||
metric_path = f"{plugin_name}.{pool_name}.{metric_name}"
|
||||
wildcard_path = f"{plugin_name}.*.{metric_name}"
|
||||
threshold = thresholds.get(metric_path) or thresholds.get(wildcard_path)
|
||||
if threshold is None:
|
||||
continue
|
||||
if metric_path not in alert_states:
|
||||
alert_states[metric_path] = AlertState(metric_path)
|
||||
alert_state = alert_states[metric_path]
|
||||
new_level = threshold.evaluate_with_hysteresis(value, alert_state.level)
|
||||
threshold_value = None
|
||||
if new_level == AlertLevel.CRITICAL and threshold.critical is not None:
|
||||
threshold_value = threshold.critical
|
||||
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
|
||||
threshold_value = threshold.warning
|
||||
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
|
||||
pool_context = dict(pool_metrics_effective)
|
||||
pool_context["pool_name"] = pool_name
|
||||
old_level = alert_state.level
|
||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||
state_changes.append((metric_path, old_level, new_level, value))
|
||||
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, pool_context, metric_name=pool_name)
|
||||
elif new_level != AlertLevel.OK:
|
||||
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, pool_context, metric_name=pool_name)
|
||||
|
||||
# Look for partition data in disk_monitor
|
||||
if plugin_name == "disk_monitor" and "partitions" in data:
|
||||
partitions = data["partitions"]
|
||||
@@ -920,7 +1092,9 @@ class ThresholdChecker:
|
||||
threshold_value = threshold.critical
|
||||
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
|
||||
threshold_value = threshold.warning
|
||||
|
||||
|
||||
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
|
||||
|
||||
old_level = alert_state.level
|
||||
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
|
||||
state_changes.append((metric_path, old_level, new_level, value))
|
||||
@@ -937,6 +1111,8 @@ class ThresholdChecker:
|
||||
value: Any,
|
||||
threshold: ThresholdConfig,
|
||||
plugin_data: Optional[Dict[str, Any]] = None,
|
||||
check_name: Optional[str] = None,
|
||||
metric_name: Optional[str] = None,
|
||||
):
|
||||
"""Trigger a notification for an alert state change.
|
||||
|
||||
@@ -958,56 +1134,54 @@ class ThresholdChecker:
|
||||
|
||||
# Format operator symbol
|
||||
op_symbol = threshold.operator.value
|
||||
|
||||
|
||||
# Short metric label: strip the plugin-name prefix and _status_code suffix
|
||||
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
|
||||
|
||||
# Use a display-friendly value (inf is the sentinel for "overdue")
|
||||
import math
|
||||
display_value = "overdue" if isinstance(value, float) and math.isinf(value) else value
|
||||
|
||||
# Format message
|
||||
if new_level == AlertLevel.OK:
|
||||
lvl = "RECOVER"
|
||||
message = f"{metric_path} = {display_value} ({old_level.name} -> OK)"
|
||||
elif new_level == AlertLevel.WARNING:
|
||||
lvl = "WARNING"
|
||||
if threshold_value is not None:
|
||||
threshold_info = self._format_display(
|
||||
threshold.display,
|
||||
value=display_value,
|
||||
threshold_value=threshold_value,
|
||||
op_symbol=op_symbol,
|
||||
plugin_data=plugin_data
|
||||
)
|
||||
message = f"{metric_path} = {display_value} {threshold_info}"
|
||||
else:
|
||||
message = f"{metric_path} = {display_value}"
|
||||
elif new_level == AlertLevel.CRITICAL:
|
||||
lvl = "CRITICAL"
|
||||
if threshold_value is not None:
|
||||
threshold_info = self._format_display(
|
||||
threshold.display,
|
||||
value=display_value,
|
||||
threshold_value=threshold_value,
|
||||
op_symbol=op_symbol,
|
||||
plugin_data=plugin_data
|
||||
)
|
||||
message = f"{metric_path} = {display_value} {threshold_info}"
|
||||
else:
|
||||
message = f"{metric_path} = {display_value}"
|
||||
else:
|
||||
lvl = "UNKNOWN"
|
||||
message = f"{metric_path} = {display_value}"
|
||||
|
||||
# Return the formatted threshold info for storing in AlertState
|
||||
formatted_threshold_msg = None
|
||||
if threshold_value is not None and new_level != AlertLevel.OK:
|
||||
formatted_threshold_msg = self._format_display(
|
||||
# Format message — for the nagios operator there is no numeric threshold_value;
|
||||
# render the display template whenever one is available.
|
||||
has_display = threshold_value is not None or threshold.operator == ComparisonOperator.NAGIOS
|
||||
|
||||
def _fmt():
|
||||
return self._format_display(
|
||||
threshold.display,
|
||||
value=display_value,
|
||||
threshold_value=threshold_value,
|
||||
op_symbol=op_symbol,
|
||||
plugin_data=plugin_data
|
||||
plugin_data=plugin_data,
|
||||
check_name=check_name,
|
||||
metric_name=metric_name,
|
||||
)
|
||||
|
||||
|
||||
if new_level == AlertLevel.OK:
|
||||
lvl = "RECOVER"
|
||||
message = f"{short_path} = {display_value} ({old_level.name} -> OK)"
|
||||
elif new_level == AlertLevel.WARNING:
|
||||
lvl = "WARNING"
|
||||
if has_display:
|
||||
message = f"{short_path} = {display_value} {_fmt()}"
|
||||
else:
|
||||
message = f"{short_path} = {display_value}"
|
||||
elif new_level == AlertLevel.CRITICAL:
|
||||
lvl = "CRITICAL"
|
||||
if has_display:
|
||||
message = f"{short_path} = {display_value} {_fmt()}"
|
||||
else:
|
||||
message = f"{short_path} = {display_value}"
|
||||
else:
|
||||
lvl = "UNKNOWN"
|
||||
if has_display:
|
||||
message = f"{short_path} = {display_value} {_fmt()}"
|
||||
else:
|
||||
message = f"{short_path} = {display_value}"
|
||||
|
||||
# Formatted threshold info stored on AlertState for the UI
|
||||
formatted_threshold_msg = _fmt() if has_display and new_level != AlertLevel.OK else None
|
||||
|
||||
return lvl, message, formatted_threshold_msg
|
||||
|
||||
def _send_notification(
|
||||
@@ -1026,11 +1200,16 @@ class ThresholdChecker:
|
||||
if host is not None and not host.watched:
|
||||
eventlog(host_name, lvl, message, service="threshold")
|
||||
return
|
||||
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
|
||||
title = f"[{lvl}] {host_name} {short_path}"
|
||||
# Strip the "metric = " prefix from message so body is just the value/detail
|
||||
prefix = short_path + " = "
|
||||
body = message[len(prefix):] if message.startswith(prefix) else message
|
||||
asyncio.get_event_loop().create_task(notify_mod.send_notification(
|
||||
host_name,
|
||||
notify_mod.Notification(
|
||||
title=f"[{lvl}] {host_name}",
|
||||
body=message,
|
||||
title=title,
|
||||
body=body,
|
||||
level=lvl,
|
||||
),
|
||||
))
|
||||
@@ -1055,32 +1234,61 @@ class ThresholdChecker:
|
||||
self,
|
||||
display_format: str,
|
||||
value: Any,
|
||||
threshold_value: float,
|
||||
threshold_value: Optional[float],
|
||||
op_symbol: str,
|
||||
plugin_data: Optional[Dict[str, Any]] = None,
|
||||
check_name: Optional[str] = None,
|
||||
metric_name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Format the display string using available data.
|
||||
|
||||
Args:
|
||||
display_format: Format string from threshold config
|
||||
value: Current metric value
|
||||
threshold_value: Threshold value that was exceeded
|
||||
op_symbol: Comparison operator symbol
|
||||
plugin_data: Optional dictionary of plugin data fields
|
||||
|
||||
|
||||
Available template variables:
|
||||
{value} - current metric value
|
||||
{threshold_value} - threshold that was exceeded
|
||||
{op_symbol} - comparison operator (>, <, >=, <=, ==, !=)
|
||||
{check_name} - prefix stripped for generic threshold match
|
||||
(e.g. "check_disk_root" when metric
|
||||
"check_disk_root_status_code" matched generic
|
||||
threshold "status_code")
|
||||
{metric_name} - field name within the plugin data dict
|
||||
Any key from plugin_data is also available.
|
||||
|
||||
Returns:
|
||||
Formatted display string
|
||||
"""
|
||||
if not display_format:
|
||||
display_format = "(threshold: {op_symbol} {threshold_value})" if threshold_value is not None else ""
|
||||
|
||||
# Build format context with standard variables
|
||||
format_context = {
|
||||
'value': value,
|
||||
'threshold_value': threshold_value,
|
||||
'op_symbol': op_symbol,
|
||||
}
|
||||
|
||||
if threshold_value is not None:
|
||||
format_context['threshold_value'] = threshold_value
|
||||
|
||||
# Add generic-match context variables when available
|
||||
if check_name is not None:
|
||||
format_context['check_name'] = check_name
|
||||
if metric_name is not None:
|
||||
format_context['metric_name'] = metric_name
|
||||
|
||||
# Add all plugin data fields if available
|
||||
if plugin_data:
|
||||
format_context.update(plugin_data)
|
||||
|
||||
# For nagios_runner generic matches, expose the matched check's output
|
||||
# and status as short aliases {output} and {status} so display templates
|
||||
# don't need to use the full {check_disk_root_output} form.
|
||||
if check_name and plugin_data:
|
||||
if 'output' not in format_context:
|
||||
output = plugin_data.get(f"{check_name}_output")
|
||||
if output is not None:
|
||||
format_context['output'] = output
|
||||
if 'status' not in format_context:
|
||||
status = plugin_data.get(f"{check_name}_status")
|
||||
if status is not None:
|
||||
format_context['status'] = status
|
||||
|
||||
try:
|
||||
# Format the display string
|
||||
@@ -1111,17 +1319,22 @@ class ThresholdChecker:
|
||||
value: Any,
|
||||
threshold: ThresholdConfig,
|
||||
plugin_data: Optional[Dict[str, Any]],
|
||||
check_name: Optional[str] = None,
|
||||
metric_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Handle a state-change transition with grace-period logic.
|
||||
|
||||
Transitioning INTO alert: defers the notification for grace_seconds.
|
||||
Transitioning INTO alert (worsening): defers the notification for grace_seconds.
|
||||
De-escalation within alert states (e.g. CRITICAL→WARNING): no new notification;
|
||||
the metric is still alerting so no RECOVER was sent.
|
||||
Transitioning TO OK:
|
||||
- Still in grace window (pending_since set): suppresses both the alert
|
||||
and the recovery — the spike never warranted a page.
|
||||
- Past grace: fires the RECOVER notification normally.
|
||||
"""
|
||||
lvl, message, formatted_msg = self._trigger_notification(
|
||||
host_name, metric_path, old_level, new_level, value, threshold, plugin_data
|
||||
host_name, metric_path, old_level, new_level, value, threshold, plugin_data,
|
||||
check_name=check_name, metric_name=metric_name,
|
||||
)
|
||||
alert_state.formatted_message = formatted_msg
|
||||
|
||||
@@ -1134,12 +1347,20 @@ class ThresholdChecker:
|
||||
alert_state.pending_since = None
|
||||
else:
|
||||
self._send_notification(host_name, lvl, message, metric_path, old_level, new_level, value)
|
||||
else:
|
||||
elif new_level.value > old_level.value:
|
||||
# Worsening (OK→WARNING, OK→CRITICAL, WARNING→CRITICAL): schedule notification.
|
||||
alert_state.pending_since = time.time()
|
||||
logger.debug(
|
||||
"Alert deferred (%.0fs grace): %s on %s = %s",
|
||||
self.grace_seconds, metric_path, host_name, value,
|
||||
)
|
||||
else:
|
||||
# De-escalation within alert states (e.g. CRITICAL→WARNING): metric is still
|
||||
# alerting but did not recover, so no new notification.
|
||||
logger.debug(
|
||||
"De-escalation %s→%s for %s on %s, no notification",
|
||||
old_level.name, new_level.name, metric_path, host_name,
|
||||
)
|
||||
|
||||
def _check_pending_or_renotify(
|
||||
self,
|
||||
@@ -1149,6 +1370,8 @@ class ThresholdChecker:
|
||||
value: Any,
|
||||
threshold: ThresholdConfig,
|
||||
plugin_data: Optional[Dict[str, Any]],
|
||||
check_name: Optional[str] = None,
|
||||
metric_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Called when alert level is unchanged and non-OK.
|
||||
|
||||
@@ -1158,16 +1381,31 @@ class ThresholdChecker:
|
||||
if alert_state.pending_since is not None:
|
||||
if time.time() - alert_state.pending_since >= self.grace_seconds:
|
||||
lvl, message, formatted_msg = self._trigger_notification(
|
||||
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data
|
||||
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data,
|
||||
check_name=check_name, metric_name=metric_name,
|
||||
)
|
||||
alert_state.formatted_message = formatted_msg
|
||||
self._send_notification(
|
||||
host_name, lvl, message, metric_path, AlertLevel.OK, alert_state.level, value
|
||||
)
|
||||
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:
|
||||
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data)
|
||||
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(
|
||||
self,
|
||||
@@ -1177,6 +1415,8 @@ class ThresholdChecker:
|
||||
value: Any,
|
||||
threshold: ThresholdConfig,
|
||||
plugin_data: Optional[Dict[str, Any]] = None,
|
||||
check_name: Optional[str] = None,
|
||||
metric_name: Optional[str] = None,
|
||||
):
|
||||
"""Check if we should send a repeat notification.
|
||||
|
||||
@@ -1214,7 +1454,8 @@ class ThresholdChecker:
|
||||
|
||||
# Format operator symbol
|
||||
op_symbol = threshold.operator.value
|
||||
|
||||
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
|
||||
|
||||
# Time to re-notify
|
||||
if threshold_value is not None:
|
||||
# Use display format string
|
||||
@@ -1223,20 +1464,23 @@ class ThresholdChecker:
|
||||
value=value,
|
||||
threshold_value=threshold_value,
|
||||
op_symbol=op_symbol,
|
||||
plugin_data=plugin_data
|
||||
plugin_data=plugin_data,
|
||||
check_name=check_name,
|
||||
metric_name=metric_name,
|
||||
)
|
||||
message = f"REMINDER ({alert_state.level.name}): {host_name} - {metric_path} = {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:
|
||||
message = f"REMINDER ({alert_state.level.name}): {host_name} - {metric_path} = {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}"
|
||||
|
||||
from . import hbdclass
|
||||
host = hbdclass.Host.hosts.get(host_name)
|
||||
if host is None or host.watched:
|
||||
asyncio.get_event_loop().create_task(notify_mod.send_notification(
|
||||
host_name,
|
||||
notify_mod.Notification(
|
||||
title=f"[REMINDER/{alert_state.level.name}] {host_name}",
|
||||
body=message,
|
||||
title=f"[REMINDER/{alert_state.level.name}] {host_name} {short_path}",
|
||||
body=body,
|
||||
level=alert_state.level.name,
|
||||
),
|
||||
))
|
||||
@@ -1244,6 +1488,35 @@ class ThresholdChecker:
|
||||
alert_state.last_notification = now
|
||||
alert_state.notification_count += 1
|
||||
|
||||
def purge_stale_alerts(self, hbdclass) -> None:
|
||||
"""Remove alert states that have no matching threshold configuration.
|
||||
|
||||
Called after startup (pickle restore) and after each config reload so
|
||||
that alerts orphaned by configuration changes do not linger forever.
|
||||
Alerts whose metric_path is not present in the current threshold config
|
||||
for that host are silently dropped.
|
||||
"""
|
||||
for hostname, host in hbdclass.Host.hosts.items():
|
||||
if not host.alert_states:
|
||||
continue
|
||||
configured = self.get_thresholds_for_host(hostname)
|
||||
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:
|
||||
logger.info(
|
||||
"Purging stale alert state for %s / %s (no threshold configured)",
|
||||
hostname, mp,
|
||||
)
|
||||
del host.alert_states[mp]
|
||||
|
||||
def get_active_alerts(self, alert_states: Dict[str, AlertState]) -> list:
|
||||
"""
|
||||
Get all currently active (non-OK) alerts.
|
||||
|
||||
+22
-9
@@ -336,8 +336,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
# Apply user-access settings from config
|
||||
access = config_mod.get_host_access(cfg, uname)
|
||||
host.apply_access(access["owner"], access["managers"], access["monitors"])
|
||||
if verbose:
|
||||
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
|
||||
logger.info("New host signed on: %s (dyn=%s, access=%s)", uname, host.dyn, access)
|
||||
newh = True
|
||||
else:
|
||||
host = hbdcls.Host.hosts[uname]
|
||||
@@ -351,8 +350,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
|
||||
if msg.get("ID") == "HTB":
|
||||
host.doesack = msg.get("acks", -1)
|
||||
# send ACK back
|
||||
# send ACK back; ask client to resend plugin info when we have none yet
|
||||
rmsg = {"time": time.time()}
|
||||
if not host.plugin_data:
|
||||
rmsg["request_update"] = 1
|
||||
opkt = dicttos("ACK", rmsg)
|
||||
try:
|
||||
transport.sendto(opkt, addr)
|
||||
@@ -369,6 +370,14 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if k not in ("ID", "plugin", "id", "name")}
|
||||
# Store plugin data with timestamp
|
||||
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
|
||||
|
||||
# If os_info reports an owner and none is configured server-side, apply it
|
||||
if plugin_name == "os_info":
|
||||
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
|
||||
default_owner = config_mod.get_default_owner(cfg)
|
||||
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
|
||||
host.owner = inferred_owner
|
||||
logger.info(f"owner for {uname} is '{host.owner}")
|
||||
if DEBUG > 1:
|
||||
print(f"Stored plugin data for {uname}: {plugin_name}")
|
||||
|
||||
@@ -440,14 +449,18 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if not newh:
|
||||
if d == 0 or lasts == "unknown":
|
||||
m = "%s is up" % (conn.afam)
|
||||
elif d < 4:
|
||||
# Transient blip (likely client restart) — skip log and notification
|
||||
m = None
|
||||
else:
|
||||
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
|
||||
eventlog(uname, "RECOVER", m)
|
||||
if host.watched:
|
||||
asyncio.create_task(notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[RECOVER] {uname}", body=m, level="RECOVER"),
|
||||
))
|
||||
if m:
|
||||
eventlog(uname, "RECOVER", m)
|
||||
if host.watched:
|
||||
asyncio.create_task(notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[RECOVER] {uname}", body=m, level="RECOVER"),
|
||||
))
|
||||
|
||||
if boot or newh:
|
||||
host.upcount = host.doesack
|
||||
|
||||
@@ -146,9 +146,14 @@ def load_users(config: dict) -> dict:
|
||||
Returns the new ``users`` dict.
|
||||
"""
|
||||
global users
|
||||
old_users = dict(users) # snapshot before rebuild
|
||||
users_cfg = config.get("users", {})
|
||||
if not isinstance(users_cfg, dict):
|
||||
users = {}
|
||||
# Preserve OAuth-provisioned users (password_hash == "") that aren't in config.
|
||||
for username, existing_user in old_users.items():
|
||||
if not existing_user.password_hash and username not in users:
|
||||
users[username] = existing_user
|
||||
return users
|
||||
|
||||
result: dict = {}
|
||||
@@ -166,6 +171,10 @@ def load_users(config: dict) -> dict:
|
||||
)
|
||||
|
||||
users = result
|
||||
# Preserve OAuth-provisioned users (password_hash == "") that aren't in config.
|
||||
for username, existing_user in old_users.items():
|
||||
if not existing_user.password_hash and username not in users:
|
||||
users[username] = existing_user
|
||||
logger.info("Loaded %d user(s) from config", len(users))
|
||||
return users
|
||||
|
||||
@@ -187,6 +196,26 @@ def authenticate(username: str, password: str) -> "User | None":
|
||||
return None
|
||||
|
||||
|
||||
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
|
||||
"""Create or update a user sourced from an OAuth2 provider.
|
||||
|
||||
New users are inserted with no password_hash — they can only authenticate
|
||||
via OAuth. Existing users (e.g. defined in config with a password) have
|
||||
their display name and avatar refreshed; all other attributes are preserved.
|
||||
"""
|
||||
user = users.get(username)
|
||||
if user is None:
|
||||
user = User(username=username, full_name=full_name, avatar=avatar)
|
||||
users[username] = user
|
||||
logger.info("Provisioned OAuth user %r", username)
|
||||
else:
|
||||
if full_name:
|
||||
user.full_name = full_name
|
||||
if avatar:
|
||||
user.avatar = avatar
|
||||
return user
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+6
-2
@@ -85,11 +85,13 @@ async def handler(request):
|
||||
except Exception as e:
|
||||
logger.error("Error sending initial hosts: %s", e)
|
||||
|
||||
# Send recent messages
|
||||
# Send recent messages, filtered to hosts this user may see
|
||||
if data.msgs:
|
||||
try:
|
||||
for m in data.msgs:
|
||||
await ws.send_str(json.dumps({"type": "message", "data": m}))
|
||||
host_name = m.get("host") if isinstance(m, dict) else None
|
||||
if not host_name or _user_can_see_host(user, host_name):
|
||||
await ws.send_str(json.dumps({"type": "message", "data": m}))
|
||||
except Exception as e:
|
||||
logger.error("Error sending initial messages: %s", e)
|
||||
|
||||
@@ -128,6 +130,8 @@ def broadcast(typ: str, payload) -> bool:
|
||||
host_name: Optional[str] = None
|
||||
if typ in ("host", "plugin"):
|
||||
host_name = payload.get("raw_name") or payload.get("host") or payload.get("name")
|
||||
elif typ == "message" and isinstance(payload, dict):
|
||||
host_name = payload.get("host")
|
||||
|
||||
jmsg = json.dumps({"type": typ, "data": payload})
|
||||
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hbd"
|
||||
version = "5.1.14"
|
||||
version = "5.3.0"
|
||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -32,6 +32,7 @@ server = [
|
||||
"aiohttp>=3.11",
|
||||
"Jinja2>=3.1.6",
|
||||
"matrix-nio>=0.24",
|
||||
"ruamel.yaml>=0.18",
|
||||
]
|
||||
|
||||
# Minimal client — hbc_mini only, no external dependencies
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
hbc_mini
|
||||
hbc_mini_dbg
|
||||
@@ -0,0 +1,21 @@
|
||||
CC ?= cc
|
||||
CFLAGS = -O2 -Wall -Wextra -std=c11
|
||||
LDFLAGS = -lz -lpthread -lm
|
||||
TARGET = hbc_mini
|
||||
SRC = hbc_mini.c
|
||||
|
||||
# FreeBSD/NetBSD keep zlib in base; no extra flags needed.
|
||||
# On some NetBSD installs pthreads may need -lpthread from pkgsrc.
|
||||
|
||||
.PHONY: all clean debug
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): $(SRC)
|
||||
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
|
||||
|
||||
debug: $(SRC)
|
||||
$(CC) -g -fsanitize=address,undefined -o $(TARGET)_dbg $< $(LDFLAGS)
|
||||
|
||||
clean:
|
||||
rm -f $(TARGET) $(TARGET)_dbg
|
||||
File diff suppressed because it is too large
Load Diff
+61
-23
@@ -41,7 +41,7 @@ from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
# updated by scripts/bumpminor.sh
|
||||
__version__ = "5.1.14"
|
||||
__version__ = "5.3.0"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol (mirrors hbd/common/proto.py)
|
||||
@@ -114,6 +114,7 @@ def _stodict(data: bytes) -> Dict[str, Any]:
|
||||
_DEFAULTS: Dict[str, Any] = {
|
||||
"hb_port": 50003,
|
||||
"interval": 10,
|
||||
"owner": None,
|
||||
"plugins": {},
|
||||
}
|
||||
|
||||
@@ -239,6 +240,8 @@ class OSInfoPlugin(InfoPlugin):
|
||||
"hbc_version": __version__,
|
||||
"hbc_type": "mini",
|
||||
}
|
||||
if self.config.get("owner"):
|
||||
data["owner"] = self.config["owner"]
|
||||
if platform.system() == "Linux":
|
||||
data.update(_linux_distro())
|
||||
elif platform.system() == "Darwin":
|
||||
@@ -388,7 +391,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
||||
|
||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||
results: Dict[str, Any] = {}
|
||||
worst = 0
|
||||
for cmd_cfg in self.commands:
|
||||
name = cmd_cfg.get("name")
|
||||
command = cmd_cfg.get("command")
|
||||
@@ -399,10 +401,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
||||
results[f"{name}_status_code"] = rc
|
||||
results[f"{name}_output"] = msg
|
||||
results.update({f"{name}_{k}": v for k, v in perf.items()})
|
||||
worst = max(worst, rc)
|
||||
results["overall_status"] = _NAGIOS_STATUS.get(worst, "UNKNOWN")
|
||||
results["overall_status_code"] = worst
|
||||
results["plugin_count"] = len(self.commands)
|
||||
return results
|
||||
|
||||
|
||||
@@ -487,6 +485,12 @@ class CPUMonitorPlugin(MonitorPlugin):
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open("/proc/uptime") as fh:
|
||||
data["uptime_seconds"] = int(float(fh.read().split()[0]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
|
||||
@@ -535,6 +539,20 @@ class MemoryMonitorPlugin(MonitorPlugin):
|
||||
total = mi.get("MemTotal", 0)
|
||||
avail = mi.get("MemAvailable", mi.get("MemFree", 0))
|
||||
free = mi.get("MemFree", 0)
|
||||
|
||||
# ZFS ARC is reclaimable but not included in MemAvailable; add it.
|
||||
arc_kb = 0
|
||||
try:
|
||||
with open("/proc/spl/kstat/zfs/arcstats") as _f:
|
||||
for _line in _f:
|
||||
_p = _line.split()
|
||||
if len(_p) >= 3 and _p[0] == "size":
|
||||
arc_kb = int(_p[2]) // 1024
|
||||
break
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
avail = min(avail + arc_kb, total)
|
||||
used = total - avail
|
||||
data: Dict[str, Any] = {
|
||||
"memory_total": total * 1024,
|
||||
@@ -701,7 +719,9 @@ async def _load_plugins(cfg: Dict[str, Any]) -> List[Plugin]:
|
||||
plugins_cfg: Dict[str, Any] = cfg.get("plugins", {})
|
||||
loaded: List[Plugin] = []
|
||||
for cls in _ALL_PLUGIN_CLASSES:
|
||||
plugin_cfg = plugins_cfg.get(cls.name) or cfg.get(cls.name, {})
|
||||
plugin_cfg = dict(plugins_cfg.get(cls.name) or cfg.get(cls.name) or {})
|
||||
if "owner" in cfg and "owner" not in plugin_cfg:
|
||||
plugin_cfg["owner"] = cfg["owner"]
|
||||
plugin: Plugin = cls(config=plugin_cfg)
|
||||
try:
|
||||
ok = await plugin.initialize()
|
||||
@@ -771,7 +791,7 @@ class _HeartbeatProtocol(asyncio.DatagramProtocol):
|
||||
msg_id = msg.get("ID")
|
||||
now = time.time()
|
||||
if msg_id == "ACK":
|
||||
self._conn._handle_ack(now)
|
||||
self._conn._handle_ack(msg, now)
|
||||
elif msg_id == "CMD":
|
||||
asyncio.create_task(_handle_command(self._conn, msg))
|
||||
elif msg_id == "UPD":
|
||||
@@ -782,8 +802,7 @@ class _HeartbeatProtocol(asyncio.DatagramProtocol):
|
||||
self._log.error("datagram error: %s", e)
|
||||
|
||||
def error_received(self, exc):
|
||||
self._log.warning("protocol error on %s: %s — dropping connection", self._conn.addr, exc)
|
||||
self._conn._dead = True
|
||||
self._log.warning("protocol error on %s: %s — will retry", self._conn.addr, exc)
|
||||
self._conn.close()
|
||||
|
||||
|
||||
@@ -799,6 +818,7 @@ class AsyncConnection:
|
||||
self.rtts: List[float] = [0.0]
|
||||
self._transport: Optional[asyncio.DatagramTransport] = None
|
||||
self._dead = False
|
||||
self._request_info: asyncio.Event = asyncio.Event()
|
||||
self._log = logging.getLogger(f"hbc.conn.{addr}")
|
||||
|
||||
async def open(self) -> bool:
|
||||
@@ -817,12 +837,14 @@ class AsyncConnection:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
|
||||
def _handle_ack(self, now: float):
|
||||
def _handle_ack(self, msg: Dict[str, Any], now: float):
|
||||
rtt = (now - self.lastsend) * 1000.0
|
||||
self.rtts.append(rtt)
|
||||
if len(self.rtts) > 10:
|
||||
self.rtts.pop(0)
|
||||
self.ackcount += 1
|
||||
if msg.get("request_update"):
|
||||
self._request_info.set()
|
||||
|
||||
async def sendto(self, msg: Dict[str, Any], msg_id: str = "HTB"):
|
||||
if self._dead:
|
||||
@@ -955,6 +977,19 @@ async def _run_monitor_group(conn: AsyncConnection, plugins: List[Plugin], inter
|
||||
await _sleep(interval)
|
||||
|
||||
|
||||
async def _info_refresh_loop(conn: AsyncConnection, info: List[Plugin]):
|
||||
log = logging.getLogger("hbc.plugins")
|
||||
while _running:
|
||||
await conn._request_info.wait()
|
||||
if not _running:
|
||||
break
|
||||
conn._request_info.clear()
|
||||
log.info("refreshing InfoPlugins on server request")
|
||||
for plugin in info:
|
||||
plugin._cache = None
|
||||
await _run_info_plugins(conn, info)
|
||||
|
||||
|
||||
async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]):
|
||||
info = [p for p in plugins if isinstance(p, InfoPlugin)]
|
||||
monitor = [p for p in plugins if isinstance(p, MonitorPlugin)]
|
||||
@@ -965,12 +1000,10 @@ async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]):
|
||||
for p in monitor:
|
||||
by_interval[p.interval].append(p)
|
||||
|
||||
if by_interval:
|
||||
await asyncio.gather(
|
||||
*[asyncio.create_task(_run_monitor_group(conn, grp, iv))
|
||||
for iv, grp in by_interval.items()],
|
||||
return_exceptions=True,
|
||||
)
|
||||
tasks = [asyncio.create_task(_info_refresh_loop(conn, info))]
|
||||
tasks += [asyncio.create_task(_run_monitor_group(conn, grp, iv))
|
||||
for iv, grp in by_interval.items()]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1014,7 +1047,7 @@ def _reconfigure_syslog(level: int):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
||||
global _running, _shutdown_event, _active_tasks
|
||||
global _running, _shutdown_event, _active_tasks, send_shutdown
|
||||
_running = True
|
||||
_shutdown_event = asyncio.Event()
|
||||
_active_tasks = []
|
||||
@@ -1024,7 +1057,7 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
||||
port = cfg.get("hb_port", PORT)
|
||||
interval = cfg.get("interval", INTERVAL)
|
||||
|
||||
log.info("starting: %s -> %s port=%d interval=%ds", iam, args.hosts, port, interval)
|
||||
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
|
||||
|
||||
connections: List[AsyncConnection] = []
|
||||
conn_id = 1
|
||||
@@ -1045,15 +1078,18 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
||||
return 1
|
||||
|
||||
# Boot / one-shot message
|
||||
send_shutdown = False
|
||||
if args.boot or args.message:
|
||||
bmsg: Dict[str, Any] = {"acks": 0}
|
||||
if args.boot:
|
||||
bmsg["boot"] = 1
|
||||
args.boot = False # don't repeat on restart
|
||||
send_shutdown = True
|
||||
if args.message:
|
||||
bmsg["service"] = "service"
|
||||
bmsg["msg"] = args.message
|
||||
for c in connections:
|
||||
await c.sendto(bmsg)
|
||||
target = next((c for c in connections if c._transport), connections[0])
|
||||
await target.sendto(bmsg)
|
||||
if args.message and not args.daemon:
|
||||
await asyncio.sleep(0.3)
|
||||
for c in connections:
|
||||
@@ -1085,11 +1121,13 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
|
||||
pass
|
||||
|
||||
log.info("shutting down")
|
||||
for conn in connections:
|
||||
target = next((c for c in connections if c._transport), connections[0] if connections else None)
|
||||
if target and send_shutdown:
|
||||
try:
|
||||
await conn.sendto({"shutdown": 1, "acks": conn.ackcount})
|
||||
await target.sendto({"shutdown": 1, "acks": target.ackcount})
|
||||
except Exception:
|
||||
pass
|
||||
for conn in connections:
|
||||
conn.close()
|
||||
await asyncio.sleep(0.3)
|
||||
for plugin in plugins:
|
||||
|
||||
+1
-2
@@ -68,8 +68,7 @@ async def test_nagios_runner():
|
||||
print(f" ✓ Collected {len(data)} data points")
|
||||
|
||||
print(f"\n4. Results:")
|
||||
print(f" Overall Status: {data.get('overall_status')} (code: {data.get('overall_status_code')})")
|
||||
print(f" Plugins Executed: {data.get('plugin_count')}")
|
||||
print(f" Data points collected: {len(data)}")
|
||||
|
||||
# Show individual plugin results
|
||||
print(f"\n5. Individual Plugin Results:")
|
||||
|
||||
@@ -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"))
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"]
|
||||
@@ -0,0 +1,602 @@
|
||||
import logging
|
||||
import time as time_mod
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
import pytest
|
||||
|
||||
from hbd.server import oauth
|
||||
from hbd.server import users as users_mod
|
||||
from hbd.server.users import User
|
||||
|
||||
|
||||
CFG_OFF = {}
|
||||
CFG_ON = {
|
||||
"oauth": {
|
||||
"gitea": {
|
||||
"url": "https://git.example.com",
|
||||
"client_id": "cid",
|
||||
"client_secret": "csec",
|
||||
}
|
||||
}
|
||||
}
|
||||
CFG_PARTIAL = {"oauth": {"gitea": {"url": "https://git.example.com"}}}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_oauth_states():
|
||||
oauth._states.clear()
|
||||
yield
|
||||
oauth._states.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_users_dict():
|
||||
original = dict(users_mod.users)
|
||||
yield
|
||||
users_mod.users = original
|
||||
|
||||
|
||||
|
||||
def test_make_state_returns_unique_tokens():
|
||||
s1 = oauth.make_state()
|
||||
s2 = oauth.make_state()
|
||||
assert s1 != s2
|
||||
assert len(s1) == 64 # 32 bytes hex
|
||||
|
||||
|
||||
def test_validate_state_valid():
|
||||
state = oauth.make_state()
|
||||
assert oauth.validate_state(state) is True
|
||||
|
||||
|
||||
def test_validate_state_consumed_on_use():
|
||||
state = oauth.make_state()
|
||||
oauth.validate_state(state)
|
||||
assert oauth.validate_state(state) is False # replay rejected
|
||||
|
||||
|
||||
def test_validate_state_unknown():
|
||||
assert oauth.validate_state("notastate") is False
|
||||
|
||||
|
||||
def test_validate_state_expired(monkeypatch):
|
||||
state = oauth.make_state()
|
||||
# Wind expiry into the past
|
||||
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1000)
|
||||
assert oauth.validate_state(state) is False
|
||||
|
||||
|
||||
def _reset_users(entries=None):
|
||||
users_mod.users = entries or {}
|
||||
|
||||
|
||||
def test_provision_oauth_user_new():
|
||||
_reset_users()
|
||||
user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png")
|
||||
assert user.username == "gituser"
|
||||
assert user.full_name == "Git User"
|
||||
assert user.avatar == "https://example.com/avatar.png"
|
||||
assert user.admin is False
|
||||
assert user.password_hash == ""
|
||||
assert "gituser" in users_mod.users
|
||||
|
||||
|
||||
def test_provision_oauth_user_no_password_login():
|
||||
_reset_users()
|
||||
user = users_mod.provision_oauth_user("gituser", "Git User", "")
|
||||
assert user.check_password("anything") is False
|
||||
|
||||
|
||||
def test_provision_oauth_user_existing_updates_profile():
|
||||
existing = User(
|
||||
username="alice",
|
||||
full_name="Old Name",
|
||||
avatar="old.png",
|
||||
password_hash="pbkdf2:sha256:1:salt:abc",
|
||||
admin=True,
|
||||
notification_channels=["chan1"],
|
||||
)
|
||||
_reset_users({"alice": existing})
|
||||
user = users_mod.provision_oauth_user("alice", "New Name", "new.png")
|
||||
assert user.full_name == "New Name"
|
||||
assert user.avatar == "new.png"
|
||||
# Preserved
|
||||
assert user.admin is True
|
||||
assert user.password_hash == "pbkdf2:sha256:1:salt:abc"
|
||||
assert user.notification_channels == ["chan1"]
|
||||
|
||||
|
||||
def test_provision_oauth_user_does_not_overwrite_with_empty():
|
||||
existing = User(username="bob", full_name="Bob", avatar="bob.png")
|
||||
_reset_users({"bob": existing})
|
||||
user = users_mod.provision_oauth_user("bob", "", "")
|
||||
assert user.full_name == "Bob"
|
||||
assert user.avatar == "bob.png"
|
||||
|
||||
|
||||
def test_provision_oauth_user_survives_config_reload():
|
||||
_reset_users()
|
||||
users_mod.provision_oauth_user("oauthonly", "OAuth Only", "https://example.com/a.png")
|
||||
assert "oauthonly" in users_mod.users
|
||||
# Reload with empty config — OAuth user should survive
|
||||
users_mod.load_users({})
|
||||
assert "oauthonly" in users_mod.users
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration-style tests: callback logic chain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_callback_invalid_state_rejects():
|
||||
"""Verify validate_state returns False for unknown state tokens."""
|
||||
fake_state = "this-is-not-a-real-state"
|
||||
assert oauth.validate_state(fake_state) is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_oauth_flow_chain():
|
||||
"""Integration-style test: state → exchange → fetch → provision chain."""
|
||||
p = _gitea_provider()
|
||||
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
|
||||
|
||||
state = oauth.make_state()
|
||||
assert oauth.validate_state(state) is True
|
||||
|
||||
mock_token_response = AsyncMock()
|
||||
mock_token_response.status = 200
|
||||
mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"})
|
||||
|
||||
mock_user_response = AsyncMock()
|
||||
mock_user_response.status = 200
|
||||
mock_user_response.json = AsyncMock(return_value={
|
||||
"login": "flowuser",
|
||||
"full_name": "Flow User",
|
||||
"avatar_url": "https://git.example.com/avatars/flow.png",
|
||||
})
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_session.post = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_token_response),
|
||||
__aexit__=AsyncMock(return_value=False),
|
||||
))
|
||||
mock_session.get = MagicMock(return_value=AsyncMock(
|
||||
__aenter__=AsyncMock(return_value=mock_user_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, "authcode", redirect_uri)
|
||||
profile = await oauth.fetch_user(p, token)
|
||||
|
||||
assert token == "flow_token"
|
||||
assert profile["login"] == "flowuser"
|
||||
|
||||
_reset_users()
|
||||
user = users_mod.provision_oauth_user(
|
||||
profile["login"], profile["full_name"], profile["avatar_url"]
|
||||
)
|
||||
assert user.username == "flowuser"
|
||||
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
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user