Compare commits
185 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3401cc0dbb | |||
| ab0132a38d | |||
| 9e389736f8 | |||
| b64a2a9313 | |||
| a52744a448 | |||
| 5e2b04b811 | |||
| 8e07b09d7e | |||
| 653e018e4f | |||
| c7326da7d9 | |||
| 0426a75d8c | |||
| 539f25d877 | |||
| 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 | |||
| f50acca509 | |||
| 72fc82b91f | |||
| 46f8c32c0b | |||
| 691f62aa69 | |||
| cffc9805f9 | |||
| 917d6a401b | |||
| 2bd3a9beb6 | |||
| 5523c60866 | |||
| ab37ac7194 | |||
| f811a19d80 | |||
| 6239825f43 | |||
| b56245bb23 | |||
| 331c4e804d | |||
| 9fd945a481 | |||
| 26df08eeff | |||
| 5819dd6b25 | |||
| 6fb67f8615 | |||
| e70ae6f176 | |||
| a77f6d380c | |||
| 6aae2a1dab | |||
| 85ee0e1040 | |||
| c4f09e9ced | |||
| 64710fd4cd | |||
| 1f5e7465a3 | |||
| b290b21e23 | |||
| 65c4267847 | |||
| 462a445235 | |||
| 368e178f93 | |||
| 6905bf266a | |||
| b6dcce4f35 | |||
| e6436fc236 | |||
| c5ce41762e | |||
| 26ca0c095f | |||
| 1eecd67594 | |||
| caf3c2c0ac | |||
| 9af4006097 | |||
| ddf7067d13 | |||
| 505353a8a8 | |||
| 0402d33c71 | |||
| 7d8ca5d8db | |||
| 56037a036d | |||
| 65ceb31d8d | |||
| 1c9b6c1ca9 | |||
| d7e6b478e1 | |||
| 535dbda47d | |||
| c9567dddae | |||
| b5963badd6 | |||
| a76a39b4a0 | |||
| 94e1597978 | |||
| c9c2ed772f | |||
| aeb78dcb8e | |||
| 77b337e4dd | |||
| 293461f3f6 | |||
| c70a4807dc | |||
| 1a470e7cfa | |||
| 990c658e65 | |||
| b78d6ac0fe | |||
| afd5060f59 | |||
| f61f7aebc2 | |||
| 5c382d2b8d | |||
| 35bba451f5 | |||
| 80edfba0c0 | |||
| 6bc8de192e | |||
| 2d8166d04a | |||
| ab33d81b30 | |||
| 2c0328f36d | |||
| fb8e27825d |
@@ -24,11 +24,11 @@ jobs:
|
||||
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install build twine
|
||||
python3 -m pip install --upgrade pip
|
||||
python3 -m pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
run: python3 -m build
|
||||
|
||||
- name: Extract version from tag
|
||||
id: get_version
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
|
||||
TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }}
|
||||
run: |
|
||||
python -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
||||
python3 -m twine upload --repository-url https://git.wrede.ca/api/packages/andreas/pypi dist/*
|
||||
|
||||
- name: Create release
|
||||
uses: actions/gitea-release-action@v1
|
||||
|
||||
@@ -12,3 +12,4 @@ dist/
|
||||
ssl/
|
||||
uv.lock
|
||||
.hb.yaml
|
||||
.superpowers/
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
1. Don't assume. Don't hide confusion. Surface tradeoffs.
|
||||
2. Minimum code that solves the problem. Nothing speculative.
|
||||
3. Touch only what you must. Clean up only your own mess.
|
||||
4. Define success criteria. Loop until verified.
|
||||
@@ -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,94 @@ 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
|
||||
|
||||
Named threshold configurations let different hosts use different limits. A host's `threshold_config` can be a single name or a **list** — lists are applied left-to-right so profiles compose without duplication:
|
||||
|
||||
```yaml
|
||||
threshold_configs:
|
||||
default:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent: {warning: 80, critical: 90}
|
||||
memory_monitor:
|
||||
memory_percent: {warning: 85, critical: 95}
|
||||
|
||||
tight_cpu: # override CPU limits only
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent: {warning: 60, critical: 75}
|
||||
|
||||
db_disk: # add a database partition check
|
||||
thresholds:
|
||||
disk_monitor:
|
||||
partitions:
|
||||
/var/lib/postgresql:
|
||||
percent: {warning: 75, critical: 88}
|
||||
|
||||
hosts:
|
||||
web-01:
|
||||
threshold_config: default # single profile
|
||||
|
||||
db-01:
|
||||
threshold_config: [tight_cpu, db_disk] # layered: CPU override + extra disk check
|
||||
```
|
||||
|
||||
Each named config's overrides are applied in order on top of the defaults. Metrics not mentioned in a profile are inherited unchanged.
|
||||
|
||||
See [docs/THRESHOLD_ALERTING.md](docs/THRESHOLD_ALERTING.md) for comprehensive documentation including best practices, troubleshooting, and advanced configuration.
|
||||
|
||||
@@ -328,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
|
||||
|
||||
@@ -377,7 +476,7 @@ This project now declares its dependencies in `pyproject.toml`. Instead
|
||||
of the old `requirements.txt` flow, install the package into a virtualenv
|
||||
using `pip`:
|
||||
|
||||
See `scripts/install.sh` for a way to install.
|
||||
See `scripts/hb_install.sh` for a way to install.
|
||||
|
||||
Run the daemon (example):
|
||||
|
||||
@@ -408,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:
|
||||
@@ -416,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)
|
||||
@@ -435,12 +536,84 @@ 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`.
|
||||
|
||||
```bash
|
||||
# Basic usage
|
||||
python3 hbc_mini.py your-server.example.com
|
||||
|
||||
# Run as daemon
|
||||
python3 hbc_mini.py -d your-server.example.com
|
||||
|
||||
# Send a boot message
|
||||
python3 hbc_mini.py -b your-server.example.com
|
||||
|
||||
# Send a one-off message
|
||||
python3 hbc_mini.py -m "maintenance starting" your-server.example.com
|
||||
```
|
||||
|
||||
**Config:** `~/.hbc.json` (same keys as `~/.hbc.yaml`, JSON format). Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"hb_port": 50003,
|
||||
"interval": 30,
|
||||
"plugins": {
|
||||
"ping_monitor": {
|
||||
"interval": 60,
|
||||
"hosts": ["8.8.8.8", "192.168.1.1"]
|
||||
},
|
||||
"nagios_runner": {
|
||||
"interval": 300,
|
||||
"commands": [
|
||||
{"name": "check_load", "command": "/usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6"}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Plugin availability:**
|
||||
|
||||
| Plugin | Platform | Data source |
|
||||
|---|---|---|
|
||||
| `os_info` | all | `platform` stdlib |
|
||||
| `ping_monitor` | all | `ping` subprocess |
|
||||
| `nagios_runner` | all (not Windows) | subprocess |
|
||||
| `cpu_monitor` | Linux | `/proc/stat` |
|
||||
| `memory_monitor` | Linux | `/proc/meminfo` |
|
||||
| `disk_monitor` | Linux, macOS, BSD | `df -P` subprocess |
|
||||
| `network_monitor` | Linux | `/proc/net/dev` |
|
||||
|
||||
**What is not available compared to the full `hbc`:**
|
||||
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 🐞 Debugging in VS Code
|
||||
|
||||
This repository includes a ready-to-use `.vscode/launch.json` with configurations to run or attach the VS Code debugger to `hbd`.
|
||||
|
||||
-234
@@ -1,234 +0,0 @@
|
||||
# HBD/HBC Separation Refactoring
|
||||
|
||||
## Overview
|
||||
|
||||
The heartbeat monitoring system has been refactored into a modular package structure with separate client and server components. This allows users to install only what they need and provides clear separation of concerns.
|
||||
|
||||
## New Package Structure
|
||||
|
||||
```
|
||||
hbd/
|
||||
├── __init__.py # Main package (minimal)
|
||||
├── client/ # HBC - System monitoring client
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # Entry point (was hbc.py)
|
||||
│ ├── config.py # Client-specific configuration
|
||||
│ ├── plugin.py # Plugin framework
|
||||
│ ├── threshold.py # Threshold checking
|
||||
│ └── plugins/ # Monitoring plugins
|
||||
│ ├── cpu_monitor.py
|
||||
│ ├── disk_monitor.py
|
||||
│ ├── memory_monitor.py
|
||||
│ ├── network_monitor.py
|
||||
│ ├── filesystem_info.py
|
||||
│ ├── os_info.py
|
||||
│ └── nagios_runner.py
|
||||
├── server/ # HBD - Heartbeat daemon/server
|
||||
│ ├── __init__.py
|
||||
│ ├── main.py # Server runtime (was server.py)
|
||||
│ ├── cli.py # Command-line interface
|
||||
│ ├── config.py # Server-specific configuration
|
||||
│ ├── http.py # HTTP/REST API
|
||||
│ ├── ws.py # WebSocket server
|
||||
│ ├── udp.py # UDP heartbeat listener
|
||||
│ ├── dns.py # DNS update functionality
|
||||
│ ├── notify.py # Notification handlers
|
||||
│ ├── monitor.py # Host monitoring
|
||||
│ ├── hbdclass.py # Host class definitions
|
||||
│ ├── journal.py # Message journaling
|
||||
│ ├── templates/ # Jinja2 web templates
|
||||
│ └── static/ # Web UI assets
|
||||
└── common/ # Shared utilities
|
||||
├── __init__.py
|
||||
├── proto.py # Protocol encoding/decoding
|
||||
└── utils.py # Common utilities
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### Client Configuration (hbd/client/config.py)
|
||||
|
||||
Client-specific defaults:
|
||||
- `hb_port`: Port where hbd servers listen (default: 50003)
|
||||
- `interval`: Heartbeat interval in seconds (default: 10)
|
||||
- `plugins`: Per-plugin configuration
|
||||
- `thresholds`: Threshold configuration for monitoring
|
||||
|
||||
### Server Configuration (hbd/server/config.py)
|
||||
|
||||
Server-specific defaults:
|
||||
- `hb_port`: Port to listen for heartbeats (default: 50003)
|
||||
- `hbd_port`: HTTP API port (default: 50004)
|
||||
- `ws_port`: WebSocket port (default: 50005)
|
||||
- `logfile`: Log file path
|
||||
- `pushsrv`, `pushover_token`, etc.: Notification settings
|
||||
- `watchhosts`, `dyndnshosts`: Host monitoring
|
||||
- `smtpserver`, etc.: Email settings
|
||||
- `journal_*`: Message journaling settings
|
||||
|
||||
## Installation Options
|
||||
|
||||
### Install Core Only (minimal, PyYAML only)
|
||||
```bash
|
||||
pip install hbd
|
||||
```
|
||||
|
||||
### Install Client Only (for monitoring)
|
||||
```bash
|
||||
pip install hbd[client]
|
||||
# Installs: PyYAML, psutil
|
||||
```
|
||||
|
||||
### Install Server Only (for daemon)
|
||||
```bash
|
||||
pip install hbd[server]
|
||||
# Installs: PyYAML, websockets, mattermostdriver, aiohttp, Jinja2
|
||||
```
|
||||
|
||||
### Install Everything
|
||||
```bash
|
||||
pip install hbd[all]
|
||||
# Installs all dependencies for both client and server
|
||||
```
|
||||
|
||||
### Development Installation
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
# Includes all dependencies plus testing/linting tools
|
||||
```
|
||||
|
||||
## Command-Line Interfaces
|
||||
|
||||
### HBC (Client)
|
||||
```bash
|
||||
hbc [options] host1 [host2 ...]
|
||||
|
||||
# Entry point: hbd.client.main:main
|
||||
# Location: hbd/client/main.py
|
||||
```
|
||||
|
||||
### HBD (Server)
|
||||
```bash
|
||||
hbd [options]
|
||||
|
||||
# Entry point: hbd.server.cli:main
|
||||
# Location: hbd/server/cli.py → hbd/server/main.py
|
||||
```
|
||||
|
||||
## Import Changes
|
||||
|
||||
### Client Code
|
||||
```python
|
||||
# Old imports
|
||||
from .config import load_config
|
||||
from .proto import dicttos, stodict
|
||||
from .plugin import PluginRegistry
|
||||
|
||||
# New imports
|
||||
from .config import load_config # Still in client/
|
||||
from ..common.proto import dicttos # Moved to common/
|
||||
from .plugin import PluginRegistry # Still in client/
|
||||
```
|
||||
|
||||
### Server Code
|
||||
```python
|
||||
# Old imports
|
||||
from .config import load_config
|
||||
from .proto import stodict
|
||||
from .threshold import AlertLevel
|
||||
|
||||
# New imports
|
||||
from .config import load_config # Server-specific config
|
||||
from ..common.proto import stodict # Moved to common/
|
||||
from ..client.threshold import AlertLevel # Client module
|
||||
```
|
||||
|
||||
### Plugin Code
|
||||
```python
|
||||
# Old import
|
||||
from hbd.plugin import MonitorPlugin
|
||||
|
||||
# New import
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Modular Installation**: Install only what you need
|
||||
- Client-only systems don't need web server dependencies
|
||||
- Server-only systems don't need psutil
|
||||
|
||||
2. **Clearer Architecture**: Explicit separation of concerns
|
||||
- Client: System monitoring and data collection
|
||||
- Server: Heartbeat reception, web UI, notifications
|
||||
- Common: Shared protocol and utilities
|
||||
|
||||
3. **Independent Evolution**: Client and server can evolve separately
|
||||
- Different release cycles possible
|
||||
- Clear API boundaries via common/
|
||||
|
||||
4. **Smaller Footprint**: Reduced dependency installation
|
||||
- Client: ~1 dependency (psutil)
|
||||
- Server: ~4 dependencies (websockets, aiohttp, Jinja2, mattermostdriver)
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For Existing Installations
|
||||
|
||||
1. **Reinstall the package**:
|
||||
```bash
|
||||
pip install -e ".[all]" # For development
|
||||
# or
|
||||
pip install hbd[all] # For production
|
||||
```
|
||||
|
||||
2. **Configuration files remain unchanged**:
|
||||
- Both client and server read from `~/.hb.yaml`
|
||||
- All existing config keys are supported in both configs
|
||||
- Server has additional keys (journal, websocket, email, etc.)
|
||||
- Client has minimal keys (interval, plugins, thresholds)
|
||||
|
||||
3. **Commands remain the same**:
|
||||
- `hbc` command works identically
|
||||
- `hbd` command works identically
|
||||
|
||||
### For New Deployments
|
||||
|
||||
1. **Client-only system** (monitoring host):
|
||||
```bash
|
||||
pip install hbd[client]
|
||||
hbc server1.example.com server2.example.com
|
||||
```
|
||||
|
||||
2. **Server-only system** (monitoring daemon):
|
||||
```bash
|
||||
pip install hbd[server]
|
||||
hbd -c /etc/hbd.yaml -f
|
||||
```
|
||||
|
||||
3. **Combined system** (dev/test):
|
||||
```bash
|
||||
pip install hbd[all]
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
All imports and entry points have been tested and validated:
|
||||
- ✅ Package imports work correctly
|
||||
- ✅ `hbc` command entry point functional
|
||||
- ✅ `hbd` command entry point functional
|
||||
- ✅ Optional dependencies properly configured
|
||||
- ✅ All internal imports updated
|
||||
|
||||
## Files Archived
|
||||
|
||||
The following files were renamed to avoid conflicts:
|
||||
- `hbd/config.py` → `hbd/config.py.old` (split into client/server configs)
|
||||
- `hbd/hbc_old.py` → `hbd/hbc_old.py.bak` (backup file)
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Test client functionality with a monitoring host
|
||||
2. Test server functionality with web UI and notifications
|
||||
3. Update documentation (README.md) with new structure
|
||||
4. Consider publishing to PyPI with new structure
|
||||
5. Update any deployment scripts/Dockerfiles to use optional dependencies
|
||||
@@ -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
|
||||
|
||||
+231
-72
@@ -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
|
||||
@@ -814,42 +864,39 @@ Planned features:
|
||||
|
||||
## Multi-Threshold Configuration
|
||||
|
||||
**New in version 2.0**: Support for multiple named threshold configurations with per-host mapping.
|
||||
Support for multiple named threshold configurations with per-host mapping and composable layering.
|
||||
|
||||
### Overview
|
||||
|
||||
The multi-threshold feature allows you to:
|
||||
- Define multiple sets of threshold configurations
|
||||
- Map different hosts to different threshold sets
|
||||
- Define multiple named threshold configurations
|
||||
- Assign one or more configurations to each host
|
||||
- Compose configurations by layering — each named config's overrides are applied in order on top of the defaults
|
||||
- Use different sensitivity levels for different environments
|
||||
- Maintain a default configuration for unmapped hosts
|
||||
|
||||
### Configuration Structure
|
||||
|
||||
Named configurations are defined under `threshold_configs`. Each host selects which ones to use via `threshold_config` in the `hosts` section (a string for a single config, or a list to layer multiple):
|
||||
|
||||
```yaml
|
||||
# Optional: Set the default configuration name (defaults to "default")
|
||||
# Optional: set the default configuration name (defaults to "default")
|
||||
default_threshold_config: "default"
|
||||
|
||||
# Define multiple named threshold configurations
|
||||
threshold_configs:
|
||||
# Configuration name 1
|
||||
default:
|
||||
thresholds:
|
||||
# Standard threshold definitions
|
||||
cpu_monitor:
|
||||
cpu_percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
|
||||
# Configuration name 2
|
||||
|
||||
high_sensitivity:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent:
|
||||
warning: 60.0
|
||||
critical: 75.0
|
||||
|
||||
# Configuration name 3
|
||||
|
||||
low_sensitivity:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
@@ -857,14 +904,77 @@ threshold_configs:
|
||||
warning: 90.0
|
||||
critical: 95.0
|
||||
|
||||
# Map specific hosts to specific configurations
|
||||
host_threshold_mapping:
|
||||
prod-web-01: high_sensitivity
|
||||
prod-web-02: high_sensitivity
|
||||
dev-server-01: low_sensitivity
|
||||
# Unmapped hosts use default_threshold_config
|
||||
hosts:
|
||||
prod-web-01:
|
||||
threshold_config: high_sensitivity # single config
|
||||
|
||||
dev-server-01:
|
||||
threshold_config: low_sensitivity
|
||||
|
||||
# Hosts with no threshold_config use default_threshold_config
|
||||
```
|
||||
|
||||
### Composable Configurations (list form)
|
||||
|
||||
`threshold_config` can be a list. Configs are applied **left to right**: the defaults are the base, then each named config's overrides are layered on top. Later entries in the list win on any metric they define.
|
||||
|
||||
```yaml
|
||||
threshold_configs:
|
||||
default:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent: {warning: 80, critical: 90}
|
||||
memory_monitor:
|
||||
memory_percent: {warning: 85, critical: 95}
|
||||
disk_monitor:
|
||||
partitions:
|
||||
/:
|
||||
percent: {warning: 80, critical: 90}
|
||||
|
||||
# Tighter CPU limits for busy servers
|
||||
high_cpu_load:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent: {warning: 60, critical: 75}
|
||||
|
||||
# Tighter disk limits for data-heavy servers
|
||||
busy_disk:
|
||||
thresholds:
|
||||
disk_monitor:
|
||||
partitions:
|
||||
/:
|
||||
percent: {warning: 70, critical: 85}
|
||||
|
||||
hosts:
|
||||
# Gets default thresholds only
|
||||
web-01:
|
||||
threshold_config: default
|
||||
|
||||
# Gets tighter CPU limits, default memory and disk
|
||||
build-server:
|
||||
threshold_config: high_cpu_load
|
||||
|
||||
# Layers both: tighter CPU AND tighter disk, default memory
|
||||
db-01:
|
||||
threshold_config: [high_cpu_load, busy_disk]
|
||||
|
||||
# Three layers: busy_disk overrides high_cpu_load if they conflict
|
||||
storage-01:
|
||||
threshold_config: [default, high_cpu_load, busy_disk]
|
||||
```
|
||||
|
||||
**How layering works:**
|
||||
|
||||
Starting from the `default` thresholds:
|
||||
|
||||
| Layer | Applied config | Effect |
|
||||
|-------|---------------|--------|
|
||||
| Base | `default` | all default thresholds |
|
||||
| +1 | `high_cpu_load` | cpu_percent overridden to 60/75 |
|
||||
| +2 | `busy_disk` | disk percent overridden to 70/85; cpu_percent stays at 60/75 |
|
||||
|
||||
Each named config only overrides the metrics it explicitly defines. Metrics not mentioned in a config inherit from the layers beneath.
|
||||
|
||||
### Use Cases
|
||||
|
||||
#### 1. Environment-Based Thresholds
|
||||
@@ -879,7 +989,7 @@ threshold_configs:
|
||||
cpu_percent:
|
||||
warning: 70.0 # Alert earlier in production
|
||||
critical: 85.0
|
||||
|
||||
|
||||
development:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
@@ -887,11 +997,15 @@ threshold_configs:
|
||||
warning: 90.0 # More relaxed for dev
|
||||
critical: 98.0
|
||||
|
||||
host_threshold_mapping:
|
||||
prod-web-01: production
|
||||
prod-web-02: production
|
||||
dev-web-01: development
|
||||
dev-web-02: development
|
||||
hosts:
|
||||
prod-web-01:
|
||||
threshold_config: production
|
||||
prod-web-02:
|
||||
threshold_config: production
|
||||
dev-web-01:
|
||||
threshold_config: development
|
||||
dev-web-02:
|
||||
threshold_config: development
|
||||
```
|
||||
|
||||
#### 2. Server Role-Based Thresholds
|
||||
@@ -906,7 +1020,7 @@ threshold_configs:
|
||||
cpu_percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
|
||||
|
||||
database:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
@@ -914,7 +1028,7 @@ threshold_configs:
|
||||
warning: 70.0
|
||||
critical: 85.0
|
||||
memory_monitor:
|
||||
percent:
|
||||
memory_percent:
|
||||
warning: 90.0 # Databases can use high memory
|
||||
critical: 97.0
|
||||
disk_monitor:
|
||||
@@ -923,21 +1037,27 @@ threshold_configs:
|
||||
percent:
|
||||
warning: 75.0
|
||||
critical: 85.0
|
||||
|
||||
|
||||
cache:
|
||||
thresholds:
|
||||
memory_monitor:
|
||||
percent:
|
||||
memory_percent:
|
||||
warning: 95.0 # Redis/Memcached can use very high memory
|
||||
critical: 99.0
|
||||
|
||||
host_threshold_mapping:
|
||||
web-01: webserver
|
||||
web-02: webserver
|
||||
db-01: database
|
||||
db-02: database
|
||||
redis-01: cache
|
||||
memcached-01: cache
|
||||
hosts:
|
||||
web-01:
|
||||
threshold_config: webserver
|
||||
web-02:
|
||||
threshold_config: webserver
|
||||
db-01:
|
||||
threshold_config: database
|
||||
db-02:
|
||||
threshold_config: database
|
||||
redis-01:
|
||||
threshold_config: cache
|
||||
memcached-01:
|
||||
threshold_config: cache
|
||||
```
|
||||
|
||||
#### 3. Sensitivity Levels
|
||||
@@ -952,10 +1072,10 @@ threshold_configs:
|
||||
partitions:
|
||||
/:
|
||||
percent:
|
||||
warning: 70.0 # Very sensitive
|
||||
warning: 70.0
|
||||
critical: 80.0
|
||||
hysteresis: 0.15
|
||||
|
||||
|
||||
standard:
|
||||
thresholds:
|
||||
disk_monitor:
|
||||
@@ -965,7 +1085,7 @@ threshold_configs:
|
||||
warning: 85.0
|
||||
critical: 95.0
|
||||
hysteresis: 0.1
|
||||
|
||||
|
||||
relaxed:
|
||||
thresholds:
|
||||
disk_monitor:
|
||||
@@ -976,52 +1096,91 @@ threshold_configs:
|
||||
critical: 98.0
|
||||
hysteresis: 0.05
|
||||
|
||||
host_threshold_mapping:
|
||||
payment-gateway: critical
|
||||
auth-server: critical
|
||||
web-01: standard
|
||||
web-02: standard
|
||||
test-server: relaxed
|
||||
hosts:
|
||||
payment-gateway:
|
||||
threshold_config: critical
|
||||
auth-server:
|
||||
threshold_config: critical
|
||||
web-01:
|
||||
threshold_config: standard
|
||||
web-02:
|
||||
threshold_config: standard
|
||||
test-server:
|
||||
threshold_config: relaxed
|
||||
```
|
||||
|
||||
### Backward Compatibility
|
||||
#### 4. Composable Profiles
|
||||
|
||||
The legacy single threshold configuration is fully supported:
|
||||
Build host-specific thresholds by combining small, focused configs:
|
||||
|
||||
```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:
|
||||
# Baseline — everything at default levels
|
||||
default:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent:
|
||||
warning: 80.0
|
||||
critical: 90.0
|
||||
```
|
||||
cpu_percent: {warning: 80, critical: 90}
|
||||
memory_monitor:
|
||||
memory_percent: {warning: 85, critical: 95}
|
||||
|
||||
# Overlay: tighter CPU only
|
||||
tight_cpu:
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent: {warning: 60, critical: 75}
|
||||
|
||||
# Overlay: tighter memory only
|
||||
tight_memory:
|
||||
thresholds:
|
||||
memory_monitor:
|
||||
memory_percent: {warning: 70, critical: 85}
|
||||
|
||||
# Overlay: extra disk partition for database servers
|
||||
db_disk:
|
||||
thresholds:
|
||||
disk_monitor:
|
||||
partitions:
|
||||
/var/lib/postgresql:
|
||||
percent: {warning: 75, critical: 88}
|
||||
|
||||
hosts:
|
||||
# Plain web server
|
||||
web-01:
|
||||
threshold_config: default
|
||||
|
||||
# Build server: tight CPU, default memory and disk
|
||||
build-01:
|
||||
threshold_config: tight_cpu
|
||||
|
||||
# Database: tight CPU + tight memory + extra disk partition
|
||||
db-01:
|
||||
threshold_config: [tight_cpu, tight_memory, db_disk]
|
||||
|
||||
# Replica database: tight memory + extra disk, normal CPU
|
||||
db-02:
|
||||
threshold_config: [tight_memory, db_disk]
|
||||
```
|
||||
### Configuration Priority
|
||||
|
||||
1. **Host-specific mapping**: If host is in `host_threshold_mapping`, use that config
|
||||
2. **Default config**: Use `default_threshold_config`
|
||||
3. **First alphabetically**: If default not found, use first config alphabetically
|
||||
4. **Legacy fallback**: If `threshold_configs` not present, use `thresholds`
|
||||
1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults
|
||||
2. **Host `threshold_config` (string)**: Use that single named config directly
|
||||
3. **`host_threshold_mapping`** (legacy): Same as above, string only
|
||||
4. **`default_threshold_config`**: Used for hosts with no mapping
|
||||
5. **First alphabetically**: If the default config is not found, use the first config alphabetically
|
||||
6. **Legacy `thresholds` section**: Used when `threshold_configs` is absent entirely
|
||||
|
||||
### Example: Complete Multi-Threshold Setup
|
||||
### Backward Compatibility
|
||||
|
||||
See `hbd/config_multi_threshold_example.yaml` for a complete example with:
|
||||
- 4 named configurations (default, high_sensitivity, low_sensitivity, database)
|
||||
- Host-to-config mappings for production, development, and test systems
|
||||
- Specialized database server thresholds
|
||||
- Custom display messages with plugin data
|
||||
The legacy `host_threshold_mapping` top-level key and the flat `thresholds` section are still fully supported:
|
||||
|
||||
```yaml
|
||||
# Still works — equivalent to hosts: {prod-web-01: {threshold_config: high_sensitivity}}
|
||||
host_threshold_mapping:
|
||||
prod-web-01: high_sensitivity
|
||||
|
||||
# Still works — equivalent to threshold_configs: {default: {thresholds: ...}}
|
||||
thresholds:
|
||||
cpu_monitor:
|
||||
cpu_percent: {warning: 80, critical: 90}
|
||||
```
|
||||
|
||||
|
||||
@@ -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,602 @@
|
||||
# Plugin Error Checking 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:** Improve plugin error checking in hbc, especially for nagios_runner, and fix logger messages silently discarded in daemon mode.
|
||||
|
||||
**Architecture:** Three focused changes across three files: (1) `hbd/client/plugin.py` gains a `skip_reason` attribute on Plugin and updated PluginLoader messaging; (2) `hbd/client/plugins/nagios_runner.py` gains async subprocess execution, stderr capture, signal-killed process handling, and init-time command path validation; (3) `hbd/client/main.py` gains proper post-fork logging reconfiguration to syslog.
|
||||
|
||||
**Tech Stack:** Python 3.11+, asyncio, `logging.handlers.SysLogHandler`, pytest
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
| Action | Path | What changes |
|
||||
|---|---|---|
|
||||
| Modify | `hbd/client/plugin.py` | `Plugin.__init__` gains `skip_reason`; `PluginLoader` checks it |
|
||||
| Modify | `hbd/client/plugins/nagios_runner.py` | async subprocess, stderr, signal codes, init validation, `skip_reason` |
|
||||
| Modify | `hbd/client/main.py` | `_reconfigure_logging_for_daemon()` helper; remove redundant syslog calls |
|
||||
| Create | `tests/test_plugin.py` | PluginLoader messaging tests |
|
||||
| Create | `tests/test_nagios_runner.py` | NagiosRunnerPlugin behaviour tests |
|
||||
|
||||
Run tests throughout with:
|
||||
```bash
|
||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Plugin.skip_reason + PluginLoader messaging
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/client/plugin.py:40-48` (Plugin.__init__)
|
||||
- Modify: `hbd/client/plugin.py:369-381` (PluginLoader.load_from_directory)
|
||||
- Create: `tests/test_plugin.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Create `tests/test_plugin.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
import textwrap
|
||||
|
||||
from hbd.client.plugin import Plugin, PluginLoader, PluginRegistry
|
||||
|
||||
|
||||
def test_plugin_skip_reason_defaults_none(tmp_path):
|
||||
plugin_code = textwrap.dedent("""
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
class MinimalPlugin(MonitorPlugin):
|
||||
name = "minimal"
|
||||
version = "1.0.0"
|
||||
interval = 60
|
||||
|
||||
async def initialize(self):
|
||||
return True
|
||||
|
||||
async def _collect_metrics(self):
|
||||
return {}
|
||||
""")
|
||||
(tmp_path / "minimal.py").write_text(plugin_code)
|
||||
registry = PluginRegistry()
|
||||
loader = PluginLoader(registry)
|
||||
asyncio.run(loader.load_from_directory(tmp_path))
|
||||
plugin = registry.get("minimal")
|
||||
assert plugin is not None
|
||||
assert plugin.skip_reason is None
|
||||
|
||||
|
||||
def test_loader_logs_info_when_skip_reason_set(tmp_path, caplog):
|
||||
plugin_code = textwrap.dedent("""
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
class SkippablePlugin(MonitorPlugin):
|
||||
name = "skippable"
|
||||
version = "1.0.0"
|
||||
interval = 60
|
||||
|
||||
async def initialize(self):
|
||||
self.skip_reason = "not configured in yaml"
|
||||
return False
|
||||
|
||||
async def _collect_metrics(self):
|
||||
return {}
|
||||
""")
|
||||
(tmp_path / "skippable.py").write_text(plugin_code)
|
||||
registry = PluginRegistry()
|
||||
loader = PluginLoader(registry)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="plugin.loader"):
|
||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
||||
|
||||
assert count == 0
|
||||
assert any("skipped: not configured in yaml" in r.message for r in caplog.records)
|
||||
assert not any("failed initialization" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_loader_logs_warning_when_no_skip_reason(tmp_path, caplog):
|
||||
plugin_code = textwrap.dedent("""
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
class FailPlugin(MonitorPlugin):
|
||||
name = "fail"
|
||||
version = "1.0.0"
|
||||
interval = 60
|
||||
|
||||
async def initialize(self):
|
||||
return False
|
||||
|
||||
async def _collect_metrics(self):
|
||||
return {}
|
||||
""")
|
||||
(tmp_path / "fail_plugin.py").write_text(plugin_code)
|
||||
registry = PluginRegistry()
|
||||
loader = PluginLoader(registry)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.loader"):
|
||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
||||
|
||||
assert count == 0
|
||||
assert any("failed initialization" in r.message for r in caplog.records)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_plugin.py -v
|
||||
```
|
||||
Expected: `test_plugin_skip_reason_defaults_none` FAILS (attribute missing), others may error.
|
||||
|
||||
- [ ] **Step 3: Add `skip_reason` to `Plugin.__init__`**
|
||||
|
||||
In `hbd/client/plugin.py`, in `Plugin.__init__` (around line 46), add one line:
|
||||
|
||||
```python
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"plugin.{self.name}")
|
||||
self._initialized = False
|
||||
self.skip_reason: Optional[str] = None
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Update PluginLoader messaging**
|
||||
|
||||
In `hbd/client/plugin.py`, replace the `if not initialized:` block (around line 372):
|
||||
|
||||
```python
|
||||
if not initialized:
|
||||
if plugin.skip_reason:
|
||||
self.logger.info(
|
||||
f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Plugin {plugin.name} failed initialization, skipping"
|
||||
)
|
||||
continue
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_plugin.py -v
|
||||
```
|
||||
Expected: all 3 tests PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/client/plugin.py tests/test_plugin.py
|
||||
git commit -m "feat: add skip_reason to Plugin; improve PluginLoader init messaging"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: NagiosRunnerPlugin — skip_reason when no commands
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/client/plugins/nagios_runner.py:88-105` (initialize)
|
||||
- Modify: `tests/test_nagios_runner.py` (create)
|
||||
|
||||
- [ ] **Step 1: Write failing test**
|
||||
|
||||
Create `tests/test_nagios_runner.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
import pytest
|
||||
|
||||
from hbd.client.plugins.nagios_runner import (
|
||||
NagiosRunnerPlugin,
|
||||
NAGIOS_OK,
|
||||
NAGIOS_WARNING,
|
||||
NAGIOS_CRITICAL,
|
||||
NAGIOS_UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
def test_no_commands_sets_skip_reason():
|
||||
plugin = NagiosRunnerPlugin(config={"commands": []})
|
||||
result = asyncio.run(plugin.initialize())
|
||||
assert result is False
|
||||
assert plugin.skip_reason is not None
|
||||
assert "nagios_runner.commands" in plugin.skip_reason
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
|
||||
```
|
||||
Expected: FAIL — `plugin.skip_reason` is `None`.
|
||||
|
||||
- [ ] **Step 3: Set skip_reason in NagiosRunnerPlugin.initialize()**
|
||||
|
||||
In `hbd/client/plugins/nagios_runner.py`, replace the early-return block in `initialize()` (around line 96):
|
||||
|
||||
```python
|
||||
if not self.commands:
|
||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
||||
self.logger.info("No Nagios commands configured")
|
||||
return False
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_nagios_runner.py::test_no_commands_sets_skip_reason -v
|
||||
```
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
||||
git commit -m "feat: set skip_reason on nagios_runner when no commands configured"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: NagiosRunnerPlugin — async subprocess, stderr capture, negative return codes
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/client/plugins/nagios_runner.py` (imports + `_run_nagios_plugin`)
|
||||
- Modify: `tests/test_nagios_runner.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Append to `tests/test_nagios_runner.py`:
|
||||
|
||||
```python
|
||||
def test_stderr_used_when_stdout_empty(tmp_path):
|
||||
script = tmp_path / "check_err.sh"
|
||||
script.write_text("#!/bin/sh\necho 'error from stderr' >&2\nexit 2\n")
|
||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
asyncio.run(plugin.initialize())
|
||||
data = asyncio.run(plugin._collect_metrics())
|
||||
|
||||
assert "error from stderr" in data["t_output"]
|
||||
assert data["t_status_code"] == NAGIOS_CRITICAL
|
||||
|
||||
|
||||
def test_stderr_appended_when_both_present(tmp_path):
|
||||
script = tmp_path / "check_both.sh"
|
||||
script.write_text("#!/bin/sh\necho 'OK - all good'\necho 'extra detail' >&2\nexit 0\n")
|
||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
asyncio.run(plugin.initialize())
|
||||
data = asyncio.run(plugin._collect_metrics())
|
||||
|
||||
assert "OK - all good" in data["t_output"]
|
||||
assert "extra detail" in data["t_output"]
|
||||
assert data["t_status_code"] == NAGIOS_OK
|
||||
|
||||
|
||||
def test_negative_returncode_maps_to_unknown():
|
||||
# kill -9 $$ kills the shell itself; asyncio sees returncode -9
|
||||
config = {"commands": [{"name": "t", "command": "kill -9 $$"}], "timeout": 5}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
asyncio.run(plugin.initialize())
|
||||
data = asyncio.run(plugin._collect_metrics())
|
||||
|
||||
assert data["t_status_code"] == NAGIOS_UNKNOWN
|
||||
assert "signal" in data["t_output"].lower()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_nagios_runner.py::test_stderr_used_when_stdout_empty \
|
||||
tests/test_nagios_runner.py::test_stderr_appended_when_both_present \
|
||||
tests/test_nagios_runner.py::test_negative_returncode_maps_to_unknown -v
|
||||
```
|
||||
Expected: all FAIL — current implementation ignores stderr and doesn't handle negative codes.
|
||||
|
||||
- [ ] **Step 3: Update imports in nagios_runner.py**
|
||||
|
||||
Replace the import block at the top of `hbd/client/plugins/nagios_runner.py`:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
```
|
||||
|
||||
(Remove `import subprocess`; add `import asyncio` and `import os`.)
|
||||
|
||||
- [ ] **Step 4: Upgrade collection log level from DEBUG to INFO**
|
||||
|
||||
In `hbd/client/plugins/nagios_runner.py`, in `_collect_metrics()`, change the debug log (around line 144) so results are visible at INFO level:
|
||||
|
||||
```python
|
||||
self.logger.info(
|
||||
f"Executed {name}: {STATUS_NAMES.get(status_code, 'UNKNOWN')} - {output[:50]}"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Replace `_run_nagios_plugin` with async implementation**
|
||||
|
||||
Replace the entire `_run_nagios_plugin` method in `hbd/client/plugins/nagios_runner.py`:
|
||||
|
||||
```python
|
||||
async def _run_nagios_plugin(
|
||||
self,
|
||||
command: str
|
||||
) -> Tuple[int, str, Dict[str, Any]]:
|
||||
"""Execute a Nagios plugin and parse its output."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=self.timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
self.logger.error(f"Command timed out: {command}")
|
||||
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
|
||||
|
||||
status_code = proc.returncode
|
||||
|
||||
if status_code < 0:
|
||||
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
|
||||
|
||||
if status_code > 3:
|
||||
status_code = NAGIOS_UNKNOWN
|
||||
|
||||
stdout = stdout_bytes.decode(errors="replace").strip()
|
||||
stderr = stderr_bytes.decode(errors="replace").strip()
|
||||
|
||||
# Parse perfdata from stdout before mixing in stderr
|
||||
perfdata = self._parse_perfdata(stdout)
|
||||
|
||||
# Build status message
|
||||
status_part = stdout.split('|')[0].strip() if '|' in stdout else stdout
|
||||
|
||||
if not stdout and stderr:
|
||||
output_msg = stderr
|
||||
elif stdout and stderr:
|
||||
output_msg = f"{status_part} [stderr: {stderr}]"
|
||||
else:
|
||||
output_msg = status_part
|
||||
|
||||
return status_code, output_msg, perfdata
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing command: {e}")
|
||||
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
||||
```
|
||||
|
||||
Also remove the now-unused `self.shell` line from `__init__` (the `shell` config key is no longer used since `create_subprocess_shell` always uses a shell):
|
||||
|
||||
In `NagiosRunnerPlugin.__init__`, remove:
|
||||
```python
|
||||
self.shell: bool = config.get("shell", True) if config else True
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run tests to verify they pass**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_nagios_runner.py -v
|
||||
```
|
||||
Expected: all tests PASS including the 3 new ones.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
||||
git commit -m "feat: async subprocess in nagios_runner with stderr capture and signal handling"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: NagiosRunnerPlugin — command path validation at init
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/client/plugins/nagios_runner.py` (initialize)
|
||||
- Modify: `tests/test_nagios_runner.py`
|
||||
|
||||
- [ ] **Step 1: Write failing tests**
|
||||
|
||||
Append to `tests/test_nagios_runner.py`:
|
||||
|
||||
```python
|
||||
def test_absolute_path_not_found_warns(caplog):
|
||||
fake_cmd = "/nonexistent_hbc_test_path/check_something"
|
||||
config = {"commands": [{"name": "t", "command": fake_cmd}]}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
||||
asyncio.run(plugin.initialize())
|
||||
|
||||
assert any("not found" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_absolute_path_not_executable_warns(caplog, tmp_path):
|
||||
non_exec = tmp_path / "check_test"
|
||||
non_exec.write_text("#!/bin/sh\necho OK\n")
|
||||
non_exec.chmod(0o644) # readable but not executable
|
||||
|
||||
config = {"commands": [{"name": "t", "command": str(non_exec)}]}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
||||
asyncio.run(plugin.initialize())
|
||||
|
||||
assert any("not executable" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_relative_path_not_checked(caplog):
|
||||
# Relative paths (resolved via PATH) must not generate warnings
|
||||
config = {"commands": [{"name": "t", "command": "echo OK"}]}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
||||
asyncio.run(plugin.initialize())
|
||||
|
||||
assert not any(
|
||||
"not found" in r.message or "not executable" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to verify they fail**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_nagios_runner.py::test_absolute_path_not_found_warns \
|
||||
tests/test_nagios_runner.py::test_absolute_path_not_executable_warns \
|
||||
tests/test_nagios_runner.py::test_relative_path_not_checked -v
|
||||
```
|
||||
Expected: `test_absolute_path_not_found_warns` and `test_absolute_path_not_executable_warns` FAIL (no warnings logged); `test_relative_path_not_checked` may pass.
|
||||
|
||||
- [ ] **Step 3: Add command path validation to `initialize()`**
|
||||
|
||||
In `hbd/client/plugins/nagios_runner.py`, extend `initialize()` by adding validation after the existing "log each command" loop (after line 103, before `return True`):
|
||||
|
||||
```python
|
||||
# Validate absolute command paths early
|
||||
for cmd_config in self.commands:
|
||||
name = cmd_config.get("name", "unnamed")
|
||||
command = cmd_config.get("command", "")
|
||||
if not command:
|
||||
continue
|
||||
exe = command.split()[0]
|
||||
if os.path.isabs(exe):
|
||||
if not os.path.isfile(exe):
|
||||
self.logger.warning(
|
||||
f"Command '{name}': executable not found: {exe}"
|
||||
)
|
||||
elif not os.access(exe, os.X_OK):
|
||||
self.logger.warning(
|
||||
f"Command '{name}': executable not executable: {exe}"
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run full test suite to verify all pass**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
||||
```
|
||||
Expected: all tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/client/plugins/nagios_runner.py tests/test_nagios_runner.py
|
||||
git commit -m "feat: validate absolute command paths at nagios_runner init"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Daemon mode logging — route to syslog after fork
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/client/main.py` (new helper + updated daemon block)
|
||||
|
||||
No automated test for daemonization itself (fork behaviour is hard to unit-test). Manual verification steps are provided below.
|
||||
|
||||
- [ ] **Step 1: Add `_reconfigure_logging_for_daemon` helper**
|
||||
|
||||
In `hbd/client/main.py`, add this function just before `def build_parser()` (around line 589):
|
||||
|
||||
```python
|
||||
def _reconfigure_logging_for_daemon(log_level: int) -> None:
|
||||
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
|
||||
from logging.handlers import SysLogHandler
|
||||
|
||||
root = logging.getLogger()
|
||||
for handler in root.handlers[:]:
|
||||
root.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
try:
|
||||
syslog_handler = SysLogHandler(
|
||||
address="/dev/log",
|
||||
facility=SysLogHandler.LOG_DAEMON,
|
||||
)
|
||||
except OSError:
|
||||
syslog_handler = SysLogHandler(
|
||||
address=("localhost", 514),
|
||||
facility=SysLogHandler.LOG_DAEMON,
|
||||
)
|
||||
# Attach the fallback first so the warning reaches syslog
|
||||
syslog_handler.setFormatter(
|
||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
||||
)
|
||||
root.addHandler(syslog_handler)
|
||||
root.setLevel(log_level)
|
||||
logging.warning("/dev/log not found, using syslog UDP localhost:514")
|
||||
return
|
||||
|
||||
syslog_handler.setFormatter(
|
||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
||||
)
|
||||
root.addHandler(syslog_handler)
|
||||
root.setLevel(log_level)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the daemon block in `main()`**
|
||||
|
||||
In `hbd/client/main.py`, replace the entire `if args.daemon:` block (lines 664–675):
|
||||
|
||||
```python
|
||||
if args.daemon:
|
||||
print("Daemonizing...")
|
||||
daemonize()
|
||||
_reconfigure_logging_for_daemon(log_level)
|
||||
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
||||
```
|
||||
|
||||
This removes the `import syslog`, `syslog.openlog()`, and `syslog.syslog()` calls (now handled by the logging system) and removes the no-op second `logging.basicConfig()` call.
|
||||
|
||||
- [ ] **Step 3: Run existing test suite to confirm no regressions**
|
||||
|
||||
```bash
|
||||
python -m pytest tests/test_plugin.py tests/test_nagios_runner.py -v
|
||||
```
|
||||
Expected: all tests still PASS.
|
||||
|
||||
- [ ] **Step 4: Manual smoke test — verify syslog output in daemon mode**
|
||||
|
||||
```bash
|
||||
# In one terminal, tail syslog
|
||||
sudo journalctl -f -t hbc
|
||||
|
||||
# In another terminal, start hbc in daemon mode (replace HOST with a real or dummy host)
|
||||
python -m hbd.client.main -d -v localhost
|
||||
|
||||
# Expected in journalctl output:
|
||||
# hbc[<pid>]: hbc.main INFO: Starting hbc for <hostname> -> ['localhost']
|
||||
# hbc[<pid>]: hbc.main INFO: hbc starting, sending heartbeat to localhost
|
||||
# hbc[<pid>]: plugin.loader INFO: ...
|
||||
|
||||
# Stop the daemon
|
||||
pkill -f "hbd.client.main"
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/client/main.py
|
||||
git commit -m "fix: reconfigure logging to syslog after daemonize() instead of no-op basicConfig"
|
||||
```
|
||||
@@ -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,539 @@
|
||||
# Host Overview Info Section — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add an always-visible info section to each host card on `/plugins`, showing owner, managers, agent version/type, last packet timestamp, and effective thresholds; move hbc_version/hbc_type out of the os_info accordion.
|
||||
|
||||
**Architecture:** A new `_build_host_info` module-level helper in `http.py` assembles the info dict from the host object and threshold_checker. A new `GET /api/0/hosts/{hostname}/info` closure inside `serve()` calls it and returns JSON. The `plugins.html` template adds a static placeholder div per host; JS fetches the endpoint on first card expand, caches the result, and renders it.
|
||||
|
||||
**Tech Stack:** Python/aiohttp (backend), Jinja2 (template), vanilla JS/HTML/CSS (frontend). Tests with pytest and unittest.mock.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `_build_host_info` helper — tests first
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/test_http_host_info.py`
|
||||
- Modify: `hbd/server/http.py` (add module-level helper after `_mask_config_for_api`, around line 128)
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/test_http_host_info.py`:
|
||||
|
||||
```python
|
||||
"""Tests for _build_host_info helper in http.py."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from hbd.server.http import _build_host_info
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, lastbeat):
|
||||
self.lastbeat = lastbeat
|
||||
|
||||
|
||||
class _FakeHost:
|
||||
def __init__(self, name="myhost", owner=None, managers=None,
|
||||
connections=None, os_data=None):
|
||||
self.name = name
|
||||
self.owner = owner
|
||||
self.managers = managers or []
|
||||
self.connections = connections or {}
|
||||
self._os_data = os_data
|
||||
|
||||
def get_latest_plugin_data(self, plugin_name):
|
||||
if plugin_name == "os_info" and self._os_data is not None:
|
||||
return (1234567890.0, self._os_data)
|
||||
return None
|
||||
|
||||
|
||||
def test_build_host_info_basic_fields():
|
||||
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
||||
result = _build_host_info(host)
|
||||
assert result["owner"] == "alice"
|
||||
assert result["managers"] == ["bob", "carol"]
|
||||
assert result["hbc_version"] is None
|
||||
assert result["hbc_type"] is None
|
||||
assert result["last_packet"] is None
|
||||
assert result["thresholds"] is None
|
||||
|
||||
|
||||
def test_build_host_info_no_owner():
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host)
|
||||
assert result["owner"] is None
|
||||
assert result["managers"] == []
|
||||
|
||||
|
||||
def test_build_host_info_reads_hbc_from_os_info():
|
||||
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
||||
result = _build_host_info(host)
|
||||
assert result["hbc_version"] == "5.3.0"
|
||||
assert result["hbc_type"] == "full"
|
||||
|
||||
|
||||
def test_build_host_info_hbc_none_when_no_os_info():
|
||||
host = _FakeHost(os_data=None)
|
||||
result = _build_host_info(host)
|
||||
assert result["hbc_version"] is None
|
||||
assert result["hbc_type"] is None
|
||||
|
||||
|
||||
def test_build_host_info_last_packet_is_max_lastbeat():
|
||||
host = _FakeHost(connections={
|
||||
"IPv4": _FakeConn(1000.0),
|
||||
"IPv6": _FakeConn(2000.0),
|
||||
})
|
||||
result = _build_host_info(host)
|
||||
assert result["last_packet"] == 2000.0
|
||||
|
||||
|
||||
def test_build_host_info_last_packet_none_when_no_connections():
|
||||
host = _FakeHost(connections={})
|
||||
result = _build_host_info(host)
|
||||
assert result["last_packet"] is None
|
||||
|
||||
|
||||
def test_build_host_info_thresholds_none_without_checker():
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=None)
|
||||
assert result["thresholds"] is None
|
||||
|
||||
|
||||
def test_build_host_info_thresholds_sorted_by_metric():
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
||||
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
||||
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {
|
||||
"memory_monitor.memory_percent": tc_mem,
|
||||
"cpu_monitor.cpu_percent": tc_cpu,
|
||||
}
|
||||
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
|
||||
assert result["thresholds"] is not None
|
||||
assert len(result["thresholds"]) == 2
|
||||
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
||||
assert result["thresholds"][0]["warning"] == 80.0
|
||||
assert result["thresholds"][0]["critical"] == 95.0
|
||||
assert result["thresholds"][0]["operator"] == ">"
|
||||
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
||||
|
||||
|
||||
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {}
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
assert result["thresholds"] == []
|
||||
|
||||
|
||||
def test_build_host_info_threshold_null_warning_critical():
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
assert result["thresholds"][0]["warning"] is None
|
||||
assert result["thresholds"][0]["critical"] == 500.0
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests to confirm they fail**
|
||||
|
||||
```bash
|
||||
pytest tests/test_http_host_info.py -v
|
||||
```
|
||||
|
||||
Expected: `ImportError` or `AttributeError` — `_build_host_info` does not exist yet.
|
||||
|
||||
- [ ] **Step 3: Implement `_build_host_info` in `hbd/server/http.py`**
|
||||
|
||||
Insert after `_mask_config_for_api` (around line 128, before `def serve(`):
|
||||
|
||||
```python
|
||||
def _build_host_info(host, threshold_checker=None):
|
||||
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||
hbc_version = None
|
||||
hbc_type = None
|
||||
latest_os = host.get_latest_plugin_data("os_info")
|
||||
if latest_os:
|
||||
_, os_data = latest_os
|
||||
hbc_version = os_data.get("hbc_version")
|
||||
hbc_type = os_data.get("hbc_type")
|
||||
|
||||
last_packet = None
|
||||
if host.connections:
|
||||
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
||||
|
||||
thresholds = None
|
||||
if threshold_checker is not None:
|
||||
raw = threshold_checker.get_thresholds_for_host(host.name)
|
||||
thresholds = sorted(
|
||||
[
|
||||
{
|
||||
"metric": tc.metric_path,
|
||||
"warning": tc.warning,
|
||||
"critical": tc.critical,
|
||||
"operator": tc.operator.value,
|
||||
}
|
||||
for tc in raw.values()
|
||||
],
|
||||
key=lambda x: x["metric"],
|
||||
)
|
||||
|
||||
return {
|
||||
"owner": getattr(host, "owner", None),
|
||||
"managers": list(getattr(host, "managers", [])),
|
||||
"hbc_version": hbc_version,
|
||||
"hbc_type": hbc_type,
|
||||
"last_packet": last_packet,
|
||||
"thresholds": thresholds,
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run tests to confirm they pass**
|
||||
|
||||
```bash
|
||||
pytest tests/test_http_host_info.py -v
|
||||
```
|
||||
|
||||
Expected: all 11 tests PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/test_http_host_info.py hbd/server/http.py
|
||||
git commit -m "feat: add _build_host_info helper for host info endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: `api_host_info` route handler
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/http.py`
|
||||
- Add `api_host_info` closure inside `serve()` (after `api_host_access_put`, around line 829)
|
||||
- Register route (around line 1271)
|
||||
|
||||
- [ ] **Step 1: Add `api_host_info` closure inside `serve()`**
|
||||
|
||||
Insert after `api_host_access_put` (after line 829, before the comment `# User profile page`):
|
||||
|
||||
```python
|
||||
# -------------------------------------------------------------------------
|
||||
# Host info endpoint
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def api_host_info(request):
|
||||
"""GET /api/0/hosts/{hostname}/info"""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Register the route**
|
||||
|
||||
In the route list (around line 1271, after the existing `/api/0/hosts/{hostname}/access` routes):
|
||||
|
||||
```python
|
||||
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify the full test suite still passes**
|
||||
|
||||
```bash
|
||||
pytest tests/ -q
|
||||
```
|
||||
|
||||
Expected: all tests PASS (no regressions).
|
||||
|
||||
- [ ] **Step 4: Smoke-test the endpoint manually** (if a dev server is running)
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:50004/api/0/hosts/<hostname>/info | python3 -m json.tool
|
||||
```
|
||||
|
||||
Expected: JSON with `owner`, `managers`, `hbc_version`, `hbc_type`, `last_packet`, `thresholds` keys.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/http.py
|
||||
git commit -m "feat: add GET /api/0/hosts/{hostname}/info endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Info section HTML and CSS in `plugins.html`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/templates/plugins.html`
|
||||
|
||||
- [ ] **Step 1: Add CSS for the info section**
|
||||
|
||||
In the `<style>` block (find the closing `</style>` tag around line 391 and insert before it):
|
||||
|
||||
```css
|
||||
/* ── Host info section ──────────────────────────────────────────────────── */
|
||||
.host-info-section {
|
||||
padding: 12px 16px;
|
||||
background: #fafafa;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.info-meta {
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 3px 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.info-label { font-weight: 600; color: #555; white-space: nowrap; }
|
||||
.info-value { color: #222; }
|
||||
.info-thresholds-title {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.info-note { color: #888; font-style: italic; }
|
||||
.info-loading { color: #bbb; font-style: italic; }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add info section placeholder to each host card**
|
||||
|
||||
Inside the host loop, at the very start of `.host-body` (before the `{% set plugin_order %}` line, around line 438):
|
||||
|
||||
```html
|
||||
<div class="host-body">
|
||||
<div class="host-info-section" id="info-{{ host.name }}">
|
||||
<div class="info-loading">Loading…</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
The existing `{% set plugin_order %}` line and everything after stays unchanged. Only add the two new lines between `<div class="host-body">` and `{% set plugin_order %}`.
|
||||
|
||||
- [ ] **Step 3: Verify the page still renders without JS errors**
|
||||
|
||||
Start the dev server and open `/plugins` in a browser. Expand any host card — you should see the "Loading…" italic line above the plugin accordions (it will not be replaced yet, that comes in Task 4).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/templates/plugins.html
|
||||
git commit -m "feat: add host info section placeholder and CSS to plugins.html"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: JS — `infoCache`, `fetchHostInfo`, `renderInfoSection`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/templates/plugins.html` (JS `<script>` block)
|
||||
|
||||
- [ ] **Step 1: Add `infoCache` constant**
|
||||
|
||||
After the `pluginCache` declaration (after `const pluginCache = {};`, around line 489), add:
|
||||
|
||||
```javascript
|
||||
// infoCache[hostname] = info data object from /api/0/hosts/{hostname}/info
|
||||
const infoCache = {};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `fetchHostInfo` function**
|
||||
|
||||
After the existing `fetchPlugin` function (around line 522, before `fetchHostGlance`), add:
|
||||
|
||||
```javascript
|
||||
async function fetchHostInfo(hostname) {
|
||||
const r = await fetch(`/api/0/hosts/${encodeURIComponent(hostname)}/info`);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
return r.json();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add `renderInfoSection` function**
|
||||
|
||||
After `fetchHostInfo` (before `fetchHostGlance`), add:
|
||||
|
||||
```javascript
|
||||
function renderInfoSection(hostname, data) {
|
||||
const el = document.getElementById(`info-${hostname}`);
|
||||
if (!el) return;
|
||||
|
||||
const owner = data.owner ? escHtml(data.owner) : '—';
|
||||
const managers = data.managers && data.managers.length
|
||||
? data.managers.map(escHtml).join(', ') : '—';
|
||||
const hbcVer = data.hbc_version ? escHtml(String(data.hbc_version)) : '—';
|
||||
const hbcType = data.hbc_type ? escHtml(String(data.hbc_type)) : '—';
|
||||
const lastPkt = data.last_packet
|
||||
? new Date(data.last_packet * 1000).toLocaleString() : '—';
|
||||
|
||||
let html = `<div class="info-meta">
|
||||
<span class="info-label">Owner</span><span class="info-value">${owner}</span>
|
||||
<span class="info-label">Managers</span><span class="info-value">${managers}</span>
|
||||
<span class="info-label">Agent Version</span><span class="info-value">${hbcVer}</span>
|
||||
<span class="info-label">Agent Type</span><span class="info-value">${hbcType}</span>
|
||||
<span class="info-label">Last Packet</span><span class="info-value">${lastPkt}</span>
|
||||
</div>`;
|
||||
|
||||
if (data.thresholds === null) {
|
||||
html += `<div class="info-note">Threshold alerting not configured.</div>`;
|
||||
} else if (data.thresholds.length === 0) {
|
||||
html += `<div class="info-note">No thresholds defined.</div>`;
|
||||
} else {
|
||||
html += `<div class="info-thresholds-title">Effective Thresholds</div>
|
||||
<table class="data-table"><thead><tr>
|
||||
<th>Metric</th><th>Op</th><th>Warning</th><th>Critical</th>
|
||||
</tr></thead><tbody>`;
|
||||
for (const t of data.thresholds) {
|
||||
const w = t.warning !== null && t.warning !== undefined ? t.warning : '—';
|
||||
const c = t.critical !== null && t.critical !== undefined ? t.critical : '—';
|
||||
html += `<tr>
|
||||
<td class="key">${escHtml(t.metric)}</td>
|
||||
<td>${escHtml(t.operator)}</td>
|
||||
<td>${w}</td>
|
||||
<td>${c}</td>
|
||||
</tr>`;
|
||||
}
|
||||
html += `</tbody></table>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/templates/plugins.html
|
||||
git commit -m "feat: add fetchHostInfo and renderInfoSection JS functions"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Wire `fetchHostInfo` into `toggleHost`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/templates/plugins.html` (the `toggleHost` function, around line 643)
|
||||
|
||||
- [ ] **Step 1: Replace `toggleHost` with the updated version**
|
||||
|
||||
Find the existing `toggleHost` function:
|
||||
|
||||
```javascript
|
||||
function toggleHost(hostname) {
|
||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||
const wasCollapsed = card.classList.contains('collapsed');
|
||||
card.classList.toggle('collapsed');
|
||||
if (wasCollapsed && !pluginCache[hostname]) {
|
||||
fetchHostGlance(hostname);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```javascript
|
||||
function toggleHost(hostname) {
|
||||
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
|
||||
const wasCollapsed = card.classList.contains('collapsed');
|
||||
card.classList.toggle('collapsed');
|
||||
if (wasCollapsed) {
|
||||
if (!pluginCache[hostname]) {
|
||||
fetchHostGlance(hostname);
|
||||
}
|
||||
if (!infoCache[hostname]) {
|
||||
const infoEl = document.getElementById(`info-${hostname}`);
|
||||
if (infoEl) infoEl.innerHTML = '<div class="info-loading">Loading…</div>';
|
||||
fetchHostInfo(hostname).then(data => {
|
||||
infoCache[hostname] = data;
|
||||
renderInfoSection(hostname, data);
|
||||
}).catch(() => {
|
||||
const el = document.getElementById(`info-${hostname}`);
|
||||
if (el) el.innerHTML = '<div class="info-loading">Could not load host info.</div>';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test in browser**
|
||||
|
||||
Open `/plugins`, expand a host card. Verify:
|
||||
- The info section appears above the plugin accordions.
|
||||
- Owner, managers (or "—"), agent version, agent type, last packet render correctly.
|
||||
- Threshold table renders (or the appropriate "not configured" / "none defined" message).
|
||||
- Collapsing and re-expanding does not re-fetch (no second network request).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/templates/plugins.html
|
||||
git commit -m "feat: fetch and render host info section on card expand"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Remove `hbc_version` and `hbc_type` from `renderOsInfoTable`
|
||||
|
||||
**Files:**
|
||||
- Modify: `hbd/server/templates/plugins.html` (the `renderOsInfoTable` function, around line 794)
|
||||
|
||||
- [ ] **Step 1: Update `renderOsInfoTable`**
|
||||
|
||||
Find the existing function:
|
||||
|
||||
```javascript
|
||||
function renderOsInfoTable(d) {
|
||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||
'processor','architecture','node','python_version',
|
||||
'python_implementation','hbc_version',
|
||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||
const shown = new Set(ORDER);
|
||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k))];
|
||||
```
|
||||
|
||||
Replace with:
|
||||
|
||||
```javascript
|
||||
function renderOsInfoTable(d) {
|
||||
const ORDER = ['distro_pretty_name','system','release','version','machine',
|
||||
'processor','architecture','node','python_version',
|
||||
'python_implementation',
|
||||
'distro_name','distro_version','distro_id','distro_version_id'];
|
||||
const INFO_FIELDS = new Set(['hbc_version', 'hbc_type']);
|
||||
const shown = new Set(ORDER);
|
||||
const keys = [...ORDER, ...Object.keys(d).filter(k => !shown.has(k) && !SKIP_FIELDS.has(k) && !INFO_FIELDS.has(k))];
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Verify in browser**
|
||||
|
||||
Expand a host card, then expand the "Os Info" accordion. Confirm:
|
||||
- `hbc_version` no longer appears in the os_info table.
|
||||
- `hbc_type` no longer appears in the os_info table.
|
||||
- Both values are shown correctly in the info section at the top.
|
||||
|
||||
- [ ] **Step 3: Run the full test suite**
|
||||
|
||||
```bash
|
||||
pytest tests/ -q
|
||||
```
|
||||
|
||||
Expected: all tests PASS.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add hbd/server/templates/plugins.html
|
||||
git commit -m "feat: move hbc_version and hbc_type out of os_info into host info section"
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
# Plugin Error Checking & Daemon Logging — Design Spec
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Scope:** hbc client — daemon mode logging, nagios_runner plugin robustness, PluginLoader messaging
|
||||
**Files affected:** `hbd/client/main.py`, `hbd/client/plugins/nagios_runner.py`, `hbd/client/plugin.py`
|
||||
|
||||
---
|
||||
|
||||
## 1. Daemon Mode Logging
|
||||
|
||||
### Problem
|
||||
In `main()`, `logging.basicConfig()` is called before `daemonize()` (establishing a StreamHandler to stderr), then called again after `daemonize()`. The second call is a no-op — Python ignores `basicConfig()` when handlers are already configured. After daemonization, stderr is redirected to `/dev/null`, so all subsequent log output is silently discarded.
|
||||
|
||||
The existing `syslog.openlog()` / `syslog.syslog()` calls (lines 666–668) write a single startup message but do not integrate with the `logging` system, so plugin and connection log messages never reach syslog.
|
||||
|
||||
### Fix
|
||||
After `daemonize()`, explicitly reconfigure the root logger:
|
||||
|
||||
1. Remove all existing handlers (they now write to `/dev/null`).
|
||||
2. Add `logging.handlers.SysLogHandler(address='/dev/log', facility=LOG_DAEMON)`.
|
||||
3. Set formatter: `hbc[%(process)d]: %(name)s %(levelname)s: %(message)s`
|
||||
4. Preserve the `log_level` already determined from `-v`/`-x` CLI flags.
|
||||
|
||||
Remove the redundant `syslog.openlog()` / `syslog.syslog()` calls — the logging system handles routing.
|
||||
|
||||
**Fallback:** If `/dev/log` does not exist (containers, some BSDs), fall back to `SysLogHandler(address=('localhost', 514))`. Log one warning (to stderr, before handlers are replaced) so the operator knows.
|
||||
|
||||
---
|
||||
|
||||
## 2. Nagios Runner Improvements
|
||||
|
||||
### 2a — Async Subprocess
|
||||
`_run_nagios_plugin()` is declared `async def` but calls `subprocess.run()` synchronously, blocking the event loop for the full command duration.
|
||||
|
||||
**Fix:** Replace with `asyncio.create_subprocess_shell()` + `await proc.communicate()`. Enforce timeout with `asyncio.wait_for(..., timeout=self.timeout)` and catch `asyncio.TimeoutError`.
|
||||
|
||||
### 2b — Stderr Capture
|
||||
Subprocess stderr is currently discarded (`capture_output=True` only captures stdout in the sync call; stderr content is lost).
|
||||
|
||||
**Fix:** Pass `stderr=asyncio.subprocess.PIPE` to `create_subprocess_shell`. After `communicate()`, if stdout is empty but stderr has content, use stderr as the output message. If both have content, append stderr to the output for visibility.
|
||||
|
||||
### 2c — Negative Return Codes
|
||||
A negative `returncode` means the process was killed by a signal (SIGKILL, OOM, etc.). The current code treats these as-is, which may produce unexpected status values.
|
||||
|
||||
**Fix:** If `returncode < 0`, map to `NAGIOS_UNKNOWN` with message `"Process killed by signal {-returncode}"`.
|
||||
|
||||
### 2d — Command Path Validation at Init
|
||||
`initialize()` currently only checks that the commands list is non-empty.
|
||||
|
||||
**Fix:** For each command entry during `initialize()`:
|
||||
- Warn and skip the entry if `name` or `command` is missing.
|
||||
- Extract the executable (first whitespace-delimited token of the command string).
|
||||
- If the executable is an absolute path, check `os.path.isfile()` and `os.access(..., os.X_OK)`. Log a `WARNING` if either check fails.
|
||||
- Commands with relative paths or shell builtins are not checked (they may be on PATH) — just noted.
|
||||
- Validation warns only; all original entries in `self.commands` are retained and still attempted at collection time (where the existing missing-name/command guard already skips them). The plugin initializes successfully as long as the commands list is non-empty.
|
||||
|
||||
---
|
||||
|
||||
## 3. PluginLoader Messaging
|
||||
|
||||
### Problem
|
||||
When `initialize()` returns `False`, the loader always logs:
|
||||
> `WARNING: Plugin X failed initialization, skipping`
|
||||
|
||||
This is alarming when the real reason is simply "no commands configured". There is no API to distinguish "not configured" from "genuinely broken".
|
||||
|
||||
### Fix
|
||||
Add an optional `skip_reason` attribute to `Plugin.__init__()` (defaults to `None`).
|
||||
|
||||
In `PluginLoader.load_from_directory()`, after `initialize()` returns `False`:
|
||||
- If `plugin.skip_reason` is set → `logger.info(f"Plugin {plugin.name} skipped: {plugin.skip_reason}")`
|
||||
- If `plugin.skip_reason` is `None` → `logger.warning(f"Plugin {plugin.name} failed initialization, skipping")` (existing behaviour)
|
||||
|
||||
In `NagiosRunnerPlugin.initialize()`, when no commands are configured:
|
||||
```python
|
||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
||||
return False
|
||||
```
|
||||
|
||||
Genuine failures (exceptions) continue to go through the existing `except` block in the loader, logging at `ERROR` with traceback — unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Daemon log destination | syslog only (LOG_DAEMON facility) |
|
||||
| Syslog fallback | localhost:514 UDP if `/dev/log` absent |
|
||||
| Nagios result log level | INFO for all statuses (OK/WARNING/CRITICAL/UNKNOWN) |
|
||||
| Invalid command handling at init | Warn and continue; still attempt at collection time |
|
||||
| PluginLoader API change | `skip_reason` attribute on Plugin base class, checked by loader |
|
||||
@@ -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
|
||||
@@ -0,0 +1,135 @@
|
||||
# Host Overview Info Section
|
||||
|
||||
**Date:** 2026-05-10
|
||||
**Status:** Approved
|
||||
|
||||
## Summary
|
||||
|
||||
Add an always-visible info section to each host card on the Host Overview (`/plugins`) page. The section shows owner, managers, agent version/type, last packet timestamp, and the host's effective alert thresholds. The fields `hbc_version` and `hbc_type` are moved out of the `os_info` plugin accordion into this section.
|
||||
|
||||
---
|
||||
|
||||
## Backend: New API Endpoint
|
||||
|
||||
**Route:** `GET /api/0/hosts/{hostname}/info`
|
||||
|
||||
**Auth:** Same as other per-host endpoints (`_can_view_host`).
|
||||
|
||||
**Response schema:**
|
||||
|
||||
```json
|
||||
{
|
||||
"owner": "alice",
|
||||
"managers": ["bob", "carol"],
|
||||
"hbc_version": "5.3.0",
|
||||
"hbc_type": "full",
|
||||
"last_packet": 1746894000.0,
|
||||
"thresholds": [
|
||||
{
|
||||
"metric": "cpu_monitor.cpu_percent",
|
||||
"warning": 80.0,
|
||||
"critical": 95.0,
|
||||
"operator": ">"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Field details:**
|
||||
|
||||
- `owner` — `host.owner`, or `null` if unset.
|
||||
- `managers` — `host.managers` list (may be empty).
|
||||
- `hbc_version` — from `host.get_latest_plugin_data("os_info")`, key `hbc_version`; `null` if no os_info data.
|
||||
- `hbc_type` — same source, key `hbc_type`; `null` if unavailable.
|
||||
- `last_packet` — `max(conn.lastbeat for conn in host.connections.values())`, or `null` if no connections.
|
||||
- `thresholds` — list derived from `threshold_checker.get_thresholds_for_host(hostname)`, sorted by `metric` ascending. Each entry includes `metric`, `warning` (null if unset), `critical` (null if unset), `operator`. Returns `null` (not `[]`) if no `threshold_checker` is configured, so the frontend can distinguish "not configured" from "configured but empty".
|
||||
|
||||
**Location:** `hbd/server/http.py`, added alongside the other `api_host_*` functions. Registered as `web.get("/api/0/hosts/{hostname}/info", api_host_info)`.
|
||||
|
||||
---
|
||||
|
||||
## Frontend: Info Section
|
||||
|
||||
### HTML structure
|
||||
|
||||
Inserted as the first child of `.host-body`, before the plugin accordions. It is not a collapsible accordion — it is always visible when the host card is expanded.
|
||||
|
||||
```html
|
||||
<div class="host-info-section" id="info-{hostname}">
|
||||
<div class="loading">Loading…</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Fetch lifecycle
|
||||
|
||||
- Fetched once per host on the first expansion of the host card (same trigger as the glance/plugin data).
|
||||
- Result cached in a new per-host `infoCache` object (parallel to `pluginCache`).
|
||||
- On subsequent expansions the cached data is rendered immediately without a new request.
|
||||
|
||||
### Rendered layout
|
||||
|
||||
Two logical areas rendered client-side from the JSON:
|
||||
|
||||
**Meta row** — a CSS-grid or simple `<dl>` showing:
|
||||
|
||||
| Label | Value |
|
||||
|---------------|------------------------------|
|
||||
| Owner | alice (or "—" if null) |
|
||||
| Managers | bob, carol (or "—" if empty) |
|
||||
| Agent Version | 5.3.0 (or "—") |
|
||||
| Agent Type | full (or "—") |
|
||||
| Last Packet | localized datetime string (or "—") |
|
||||
|
||||
**Threshold table** — rendered with the existing `data-table` CSS class:
|
||||
|
||||
| Metric | Operator | Warning | Critical |
|
||||
|--------|----------|---------|----------|
|
||||
| cpu_monitor.cpu_percent | > | 80 | 95 |
|
||||
| … | … | … | … |
|
||||
|
||||
- If `thresholds` is `null`: show "Threshold alerting not configured."
|
||||
- If `thresholds` is `[]`: show "No thresholds defined."
|
||||
- Numeric threshold values rendered as-is (no units); `null` warning/critical shown as "—".
|
||||
|
||||
### CSS
|
||||
|
||||
New `.host-info-section` styles added in the `<style>` block of `plugins.html`. The section gets a subtle background (e.g. `#fafafa`) and a bottom border to separate it visually from the plugin accordions below. The meta row uses a two-column grid layout for compactness.
|
||||
|
||||
---
|
||||
|
||||
## Changes to `renderOsInfoTable()`
|
||||
|
||||
- Remove `hbc_version` from the `ORDER` array.
|
||||
- Add `hbc_type` to the `SKIP_FIELDS` set (or the local `shown` set) so it is excluded from the os_info table.
|
||||
|
||||
Both fields will now appear only in the info section.
|
||||
|
||||
---
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
User expands host card
|
||||
→ toggleHost()
|
||||
→ fetchGlanceData(hostname) [existing, unchanged]
|
||||
→ fetchInfoData(hostname) [new]
|
||||
GET /api/0/hosts/{hostname}/info
|
||||
→ renderInfoSection(hostname, data)
|
||||
→ writes into #info-{hostname}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
- If the info fetch fails (non-200), show a one-line error message in the info section ("Could not load host info.").
|
||||
- If `hbc_version`/`hbc_type` are null (host has never sent os_info), display "—".
|
||||
- If `last_packet` is null (no connections recorded), display "—".
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Editing owner/managers from this section (covered by existing profile/access UI).
|
||||
- Editing thresholds from this section.
|
||||
- Monitors list (not shown — monitors are operational, not informational in this context).
|
||||
-21
@@ -1,21 +0,0 @@
|
||||
Plan the following changes, ask questions to clarify before implementing
|
||||
|
||||
Re-factor the notification system:
|
||||
- use available libraries for pushover, matrix, email and sms notifications.
|
||||
- notifications have a title/subject: alert_type (recover/warning/critical), a body (info from threshold check) and a link to the host plugin metrix page
|
||||
- define a list of notification channels for each user
|
||||
- notifications are dispatched to users that are listed as managers for the host
|
||||
|
||||
|
||||
|
||||
1 - correct
|
||||
2 - for now channels are defined globaly
|
||||
3 - matrix-nio)sounds good, homeserver URL, access token, room ID per channel?
|
||||
4 - use the REST api provided by https://voip.ms/api/v1/rest.php
|
||||
5 - The page does not exist yet, point at the host tab in the /plugins
|
||||
6 - per-channel minimum severity is a good idea, go fo it
|
||||
7 - yes
|
||||
|
||||
1 - use base_url, there might not have been any incoming requests yet
|
||||
2 - use same asyncio loop for matrix-nio
|
||||
3 - for now, just silently do nothing
|
||||
+1
-1
@@ -14,4 +14,4 @@ Install options:
|
||||
"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "5.1.1"
|
||||
__version__ = "5.3.1"
|
||||
|
||||
@@ -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
|
||||
|
||||
+196
-106
@@ -14,13 +14,14 @@ import signal
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
from hashlib import md5
|
||||
from logging.handlers import SysLogHandler
|
||||
from pathlib import Path
|
||||
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
|
||||
@@ -55,23 +56,28 @@ 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:
|
||||
@@ -92,9 +98,12 @@ class AsyncConnection:
|
||||
msg: Message dictionary
|
||||
msg_id: Message ID (HTB, PLG, etc.)
|
||||
"""
|
||||
if self._dead:
|
||||
return
|
||||
|
||||
if not self.transport:
|
||||
await self.open()
|
||||
|
||||
|
||||
if not self.transport:
|
||||
self.logger.error("Cannot send - no transport")
|
||||
return
|
||||
@@ -130,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):
|
||||
@@ -165,8 +177,9 @@ 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.error(f"Protocol error: {exc}")
|
||||
"""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()
|
||||
|
||||
|
||||
async def handle_command(conn: AsyncConnection, msg: dict):
|
||||
@@ -203,55 +216,52 @@ async def handle_command(conn: AsyncConnection, msg: dict):
|
||||
await conn.sendto(response)
|
||||
|
||||
|
||||
async def handle_update(conn: AsyncConnection, msg: dict):
|
||||
"""Handle self-update from server."""
|
||||
import codecs
|
||||
async def handle_update(conn: AsyncConnection, _msg: dict): # pyright: ignore[reportUnusedParameter]
|
||||
"""Handle self-update by running hb_install.sh."""
|
||||
import shutil
|
||||
|
||||
|
||||
logger = logging.getLogger("hbc.update")
|
||||
|
||||
|
||||
installer = shutil.which("hb_install.sh")
|
||||
if installer is None:
|
||||
candidate = Path(sys.argv[0]).parent / "hb_install.sh"
|
||||
if candidate.exists():
|
||||
installer = str(candidate)
|
||||
|
||||
if installer is None:
|
||||
error = "hb_install.sh not found in PATH or alongside hbc"
|
||||
logger.error(error)
|
||||
await conn.sendto({"service": "update", "msg": error})
|
||||
return
|
||||
|
||||
logger.info(f"Running installer: {installer}")
|
||||
try:
|
||||
code = codecs.decode(msg["code"], "base64").decode()
|
||||
csum = msg["csum"]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
installer, "client",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
out, _ = await asyncio.wait_for(proc.communicate(), timeout=120)
|
||||
except asyncio.TimeoutError:
|
||||
error = "Installer timed out"
|
||||
logger.error(error)
|
||||
await conn.sendto({"service": "update", "msg": error})
|
||||
return
|
||||
except Exception as e:
|
||||
error = f"Missing code/csum: {e}"
|
||||
error = f"Installer failed: {e}"
|
||||
logger.error(error)
|
||||
await conn.sendto({"service": "update", "msg": error})
|
||||
return
|
||||
|
||||
# Verify checksum
|
||||
m = md5()
|
||||
m.update(code.encode())
|
||||
if m.hexdigest() != csum:
|
||||
error = "Checksum mismatch"
|
||||
|
||||
if proc.returncode != 0:
|
||||
error = f"Installer exited {proc.returncode}: {out.decode().strip()}"
|
||||
logger.error(error)
|
||||
await conn.sendto({"service": "update", "msg": error})
|
||||
return
|
||||
|
||||
# Backup current file
|
||||
fn = sys.argv[0]
|
||||
ofn = f"{fn}.sav"
|
||||
try:
|
||||
shutil.copy2(fn, ofn)
|
||||
except Exception as e:
|
||||
error = f"Backup failed: {e}"
|
||||
logger.error(error)
|
||||
await conn.sendto({"service": "update", "msg": error})
|
||||
return
|
||||
|
||||
# Write new code
|
||||
try:
|
||||
with open(fn, "w") as fh:
|
||||
fh.write(code)
|
||||
except Exception as e:
|
||||
error = f"Write failed: {e}"
|
||||
logger.error(error)
|
||||
await conn.sendto({"service": "update", "msg": error})
|
||||
return
|
||||
|
||||
|
||||
logger.info("Update successful, restart required")
|
||||
await conn.sendto({"service": "update", "msg": "OK"})
|
||||
|
||||
|
||||
# Trigger restart
|
||||
global dorestart
|
||||
dorestart = True
|
||||
@@ -259,15 +269,51 @@ async def handle_update(conn: AsyncConnection, msg: dict):
|
||||
|
||||
|
||||
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,
|
||||
@@ -275,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)
|
||||
@@ -299,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:
|
||||
@@ -320,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(
|
||||
@@ -424,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
|
||||
@@ -442,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()
|
||||
@@ -459,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 = []
|
||||
@@ -476,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
|
||||
@@ -521,6 +581,13 @@ async def async_main(args, config):
|
||||
loop = asyncio.get_event_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, stop)
|
||||
|
||||
def _sighup():
|
||||
global dorestart
|
||||
dorestart = True
|
||||
stop()
|
||||
|
||||
loop.add_signal_handler(signal.SIGHUP, _sighup)
|
||||
|
||||
# Start async tasks
|
||||
# Heartbeat senders (one per connection)
|
||||
@@ -586,6 +653,36 @@ def daemonize(
|
||||
os.dup2(se.fileno(), sys.stderr.fileno())
|
||||
|
||||
|
||||
def _reconfigure_logging_for_daemon(log_level: int) -> None:
|
||||
"""Replace StreamHandlers (now writing to /dev/null) with a SysLogHandler."""
|
||||
root = logging.getLogger()
|
||||
for handler in root.handlers[:]:
|
||||
root.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
use_udp_fallback = not os.path.exists("/dev/log")
|
||||
|
||||
if use_udp_fallback:
|
||||
syslog_handler = SysLogHandler(
|
||||
address=("localhost", 514),
|
||||
facility=SysLogHandler.LOG_DAEMON,
|
||||
)
|
||||
else:
|
||||
syslog_handler = SysLogHandler(
|
||||
address="/dev/log",
|
||||
facility=SysLogHandler.LOG_DAEMON,
|
||||
)
|
||||
|
||||
syslog_handler.setFormatter(
|
||||
logging.Formatter("hbc[%(process)d]: %(name)s %(levelname)s: %(message)s")
|
||||
)
|
||||
root.addHandler(syslog_handler)
|
||||
root.setLevel(log_level)
|
||||
|
||||
if use_udp_fallback:
|
||||
logging.warning("/dev/log not found, using syslog UDP localhost:514")
|
||||
|
||||
|
||||
def build_parser():
|
||||
"""Build argument parser."""
|
||||
parser = argparse.ArgumentParser(
|
||||
@@ -662,17 +759,10 @@ def main(argv=None):
|
||||
|
||||
# Daemonize if requested
|
||||
if args.daemon:
|
||||
print("Daemonizing...")
|
||||
import syslog
|
||||
syslog.openlog("hbc", syslog.LOG_PID, syslog.LOG_DAEMON)
|
||||
syslog.syslog(syslog.LOG_INFO, f"Starting heartbeat to {', '.join(args.hosts)}")
|
||||
logging.info("Daemonizing...")
|
||||
daemonize()
|
||||
|
||||
# Reconfigure logging for syslog
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="hbc[%(process)d]: %(name)s %(levelname)s: %(message)s"
|
||||
)
|
||||
_reconfigure_logging_for_daemon(log_level)
|
||||
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
|
||||
|
||||
# Run async main
|
||||
try:
|
||||
|
||||
+22
-10
@@ -22,13 +22,14 @@ from typing import Any, Dict, List, Optional, Type
|
||||
|
||||
class Plugin(ABC):
|
||||
"""Base class for all plugins.
|
||||
|
||||
|
||||
Attributes:
|
||||
name: Unique plugin identifier (e.g., "os_info", "cpu_monitor")
|
||||
version: Plugin version string
|
||||
description: Human-readable description
|
||||
interval: Collection interval in seconds (0 for InfoPlugin = collect once)
|
||||
enabled: Whether plugin is active (can be disabled via config)
|
||||
skip_reason: Set by plugin before returning False from initialize(); causes loader to log INFO instead of WARNING.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
@@ -39,13 +40,14 @@ class Plugin(ABC):
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
"""Initialize plugin with optional configuration.
|
||||
|
||||
|
||||
Args:
|
||||
config: Plugin-specific configuration from YAML (e.g., thresholds, paths)
|
||||
"""
|
||||
self.config = config or {}
|
||||
self.logger = logging.getLogger(f"plugin.{self.name}")
|
||||
self._initialized = False
|
||||
self.skip_reason: Optional[str] = None
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self) -> bool:
|
||||
@@ -312,9 +314,10 @@ class PluginLoader:
|
||||
|
||||
loaded_count = 0
|
||||
raw_config = config or {}
|
||||
# Per-plugin config lives under the 'plugins' key; fall back to top-level
|
||||
# for backwards compatibility.
|
||||
plugin_config = raw_config.get("plugins", raw_config)
|
||||
# Per-plugin config lives under the 'plugins' key or at top-level.
|
||||
# CLIENT_DEFAULTS seeds "plugins": {} so the key always exists; check
|
||||
# both the subdict and top-level so that either layout in .hbc.yaml works.
|
||||
plugins_subconfig = raw_config.get("plugins", {})
|
||||
|
||||
# Scan for Python files
|
||||
for plugin_file in directory.glob("*.py"):
|
||||
@@ -359,17 +362,26 @@ class PluginLoader:
|
||||
|
||||
self.logger.debug(f"Found plugin class: {name}")
|
||||
|
||||
# Instantiate plugin with config
|
||||
plugin_instance_config = plugin_config.get(obj.name, {})
|
||||
# Instantiate plugin with config — check plugins subdict first,
|
||||
# then top-level keys (e.g. nagios_runner: ... at root of config).
|
||||
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
|
||||
try:
|
||||
initialized = await plugin.initialize()
|
||||
if not initialized:
|
||||
self.logger.warning(
|
||||
f"Plugin {plugin.name} failed initialization, skipping"
|
||||
)
|
||||
if plugin.skip_reason:
|
||||
self.logger.info(
|
||||
f"Plugin {plugin.name} skipped: {plugin.skip_reason}"
|
||||
)
|
||||
else:
|
||||
self.logger.warning(
|
||||
f"Plugin {plugin.name} failed initialization, skipping"
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -21,24 +21,23 @@ nagios_runner:
|
||||
```
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import shlex
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@@ -52,8 +51,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
||||
interval: Collection interval in seconds (default: 300)
|
||||
commands: List of command definitions with 'name' and 'command' keys
|
||||
timeout: Command execution timeout in seconds (default: 30)
|
||||
shell: Whether to execute commands via shell (default: True)
|
||||
|
||||
|
||||
Example:
|
||||
nagios_runner:
|
||||
interval: 300 # Check every 5 minutes
|
||||
@@ -76,32 +74,48 @@ class NagiosRunnerPlugin(MonitorPlugin):
|
||||
# Extract configuration
|
||||
self.commands: List[Dict[str, str]] = config.get("commands", []) if config else []
|
||||
self.timeout: int = config.get("timeout", 30) if config else 30
|
||||
self.shell: bool = config.get("shell", True) if config else True
|
||||
self.interval = config.get("interval", 300) if config else 300
|
||||
|
||||
# Validate commands
|
||||
if not self.commands:
|
||||
self.logger.info(
|
||||
"No Nagios commands configured. Add 'nagios_runner.commands' to config."
|
||||
)
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize the Nagios runner plugin.
|
||||
|
||||
|
||||
Returns:
|
||||
True if at least one command is configured, False otherwise
|
||||
"""
|
||||
self.logger.info(f"Initializing {self.name} plugin")
|
||||
|
||||
|
||||
if not self.commands:
|
||||
self.logger.info("No Nagios commands configured")
|
||||
self.skip_reason = "no commands configured (add nagios_runner.commands to config)"
|
||||
return False
|
||||
|
||||
|
||||
self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)")
|
||||
for cmd_config in self.commands:
|
||||
name = cmd_config.get("name", "unnamed")
|
||||
self.logger.info(f" - {name}: {cmd_config.get('command', 'N/A')}")
|
||||
|
||||
|
||||
# Validate absolute command paths early
|
||||
for cmd_config in self.commands:
|
||||
name = cmd_config.get("name", "unnamed")
|
||||
command = cmd_config.get("command", "")
|
||||
if not command:
|
||||
continue
|
||||
try:
|
||||
tokens = shlex.split(command)
|
||||
except ValueError:
|
||||
continue # malformed command string; skip validation
|
||||
if not tokens:
|
||||
continue
|
||||
exe = tokens[0]
|
||||
if os.path.isabs(exe):
|
||||
if not os.path.isfile(exe):
|
||||
self.logger.warning(
|
||||
f"Command '{name}': executable not found: {exe}"
|
||||
)
|
||||
elif not os.access(exe, os.X_OK):
|
||||
self.logger.warning(
|
||||
f"Command '{name}': executable not executable: {exe}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||
@@ -111,98 +125,88 @@ 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.debug(
|
||||
|
||||
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(
|
||||
self,
|
||||
command: str
|
||||
) -> Tuple[int, str, Dict[str, Any]]:
|
||||
"""Execute a Nagios plugin and parse its output.
|
||||
|
||||
Args:
|
||||
command: Command string to execute
|
||||
|
||||
Returns:
|
||||
Tuple of (status_code, output_message, performance_data_dict)
|
||||
"""
|
||||
"""Execute a Nagios plugin and parse its output."""
|
||||
try:
|
||||
# Run command
|
||||
result = subprocess.run(
|
||||
proc = await asyncio.create_subprocess_shell(
|
||||
command,
|
||||
shell=self.shell,
|
||||
capture_output=True,
|
||||
timeout=self.timeout,
|
||||
text=True
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
status_code = result.returncode
|
||||
output = result.stdout.strip()
|
||||
|
||||
# Nagios plugins can return codes > 3, treat as UNKNOWN
|
||||
try:
|
||||
stdout_bytes, stderr_bytes = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=self.timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.communicate()
|
||||
self.logger.error(f"Command timed out: {command}")
|
||||
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
|
||||
|
||||
status_code = proc.returncode
|
||||
|
||||
if status_code < 0:
|
||||
return NAGIOS_UNKNOWN, f"Process killed by signal {-status_code}", {}
|
||||
|
||||
if status_code > 3:
|
||||
status_code = NAGIOS_UNKNOWN
|
||||
|
||||
# Parse performance data
|
||||
perfdata = self._parse_perfdata(output)
|
||||
|
||||
# Extract just the status message (before the pipe if present)
|
||||
if '|' in output:
|
||||
output_msg = output.split('|')[0].strip()
|
||||
|
||||
stdout = stdout_bytes.decode(errors="replace").strip()
|
||||
stderr = stderr_bytes.decode(errors="replace").strip()
|
||||
|
||||
# Parse perfdata from stdout before mixing in stderr
|
||||
perfdata = self._parse_perfdata(stdout)
|
||||
|
||||
# Build status message
|
||||
status_part = stdout.split('|')[0].strip() if '|' in stdout else stdout
|
||||
|
||||
if not stdout and stderr:
|
||||
output_msg = stderr
|
||||
elif stdout and stderr:
|
||||
output_msg = f"{status_part} [stderr: {stderr}]"
|
||||
else:
|
||||
output_msg = output
|
||||
|
||||
output_msg = status_part
|
||||
|
||||
return status_code, output_msg, perfdata
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error(f"Command timed out: {command}")
|
||||
return NAGIOS_UNKNOWN, f"Command timed out after {self.timeout}s", {}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing command: {e}")
|
||||
return NAGIOS_UNKNOWN, f"Execution error: {str(e)}", {}
|
||||
|
||||
@@ -60,7 +60,11 @@ class OSInfoPlugin(InfoPlugin):
|
||||
"python_version": platform.python_version(),
|
||||
"python_implementation": platform.python_implementation(),
|
||||
"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
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
ZFS pool monitoring plugin for Heartbeat.
|
||||
|
||||
Collects per-pool health, capacity, and cumulative I/O statistics via zpool(8).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shutil
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _int(s: str) -> Optional[int]:
|
||||
try:
|
||||
return int(s.strip().rstrip("KMGTkBkmgt%x"))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def _float(s: str) -> Optional[float]:
|
||||
try:
|
||||
return float(s.strip().rstrip("%x"))
|
||||
except (ValueError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
class ZFSMonitorPlugin(MonitorPlugin):
|
||||
"""Monitor ZFS pool health, capacity, and I/O statistics.
|
||||
|
||||
Collects per pool:
|
||||
- health: ONLINE, DEGRADED, FAULTED, etc.
|
||||
- size / alloc / free: total, allocated and free bytes
|
||||
- capacity: percentage used (0-100)
|
||||
- frag: fragmentation percentage
|
||||
- dedup: deduplication ratio
|
||||
- read_ops / write_ops: cumulative I/O operations since last boot/clear
|
||||
- read_bw / write_bw: cumulative bytes transferred since last boot/clear
|
||||
|
||||
Configuration:
|
||||
interval: collection interval in seconds (default: 300)
|
||||
pools: list of pool names to monitor (default: all)
|
||||
"""
|
||||
|
||||
name = "zfs_monitor"
|
||||
description = "ZFS pool health, capacity, and I/O statistics"
|
||||
interval = 300
|
||||
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None):
|
||||
super().__init__(config)
|
||||
self.interval = self.config.get("interval", 300)
|
||||
self._pools_filter: Optional[List[str]] = self.config.get("pools", None)
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
if not shutil.which("zpool"):
|
||||
self.skip_reason = "zpool not found"
|
||||
return False
|
||||
logger.info("ZFS monitor initialized (interval: %ds)", self.interval)
|
||||
return True
|
||||
|
||||
async def _run(self, *args: str) -> List[str]:
|
||||
"""Run a command and return its stdout lines, or [] on error."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=15)
|
||||
return stdout.decode(errors="replace").splitlines()
|
||||
except (FileNotFoundError, asyncio.TimeoutError) as exc:
|
||||
logger.warning("zfs_monitor: %s: %s", args[0], exc)
|
||||
return []
|
||||
|
||||
async def _zpool_list(self) -> Dict[str, Dict]:
|
||||
"""Return per-pool health and capacity from `zpool list`."""
|
||||
lines = await self._run(
|
||||
"zpool", "list", "-H", "-p",
|
||||
"-o", "name,health,size,alloc,free,cap,frag,dedup",
|
||||
)
|
||||
pools: Dict[str, Dict] = {}
|
||||
for line in lines:
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 8:
|
||||
continue
|
||||
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": 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
|
||||
|
||||
async def _zpool_iostat(self) -> Dict[str, Dict]:
|
||||
"""Return per-pool cumulative I/O counters from `zpool iostat`."""
|
||||
lines = await self._run("zpool", "iostat", "-H", "-p")
|
||||
io: Dict[str, Dict] = {}
|
||||
for line in lines:
|
||||
parts = line.split("\t")
|
||||
if len(parts) < 7:
|
||||
continue
|
||||
name = parts[0].strip()
|
||||
if not name or name.startswith(" "):
|
||||
continue
|
||||
io[name] = {
|
||||
"read_ops": _int(parts[3]),
|
||||
"write_ops": _int(parts[4]),
|
||||
"read_bw": _int(parts[5]),
|
||||
"write_bw": _int(parts[6]),
|
||||
}
|
||||
return io
|
||||
|
||||
async def _collect_metrics(self) -> Dict[str, Any]:
|
||||
pools, io = await asyncio.gather(self._zpool_list(), self._zpool_iostat())
|
||||
for name, stats in io.items():
|
||||
if name in pools:
|
||||
pools[name].update(stats)
|
||||
return {"pools": pools}
|
||||
|
||||
|
||||
plugin = ZFSMonitorPlugin
|
||||
+9
-4
@@ -52,12 +52,17 @@ def decode_value(val: str) -> Any:
|
||||
except Exception:
|
||||
return val[1:] # Return as string without @
|
||||
|
||||
# Try numeric evaluation (original behavior)
|
||||
# Try numeric conversion (avoid eval to prevent SyntaxWarnings on version strings)
|
||||
if val[0].isdigit() or (val[0] == '-' and len(val) > 1 and val[1].isdigit()):
|
||||
try:
|
||||
return eval(val)
|
||||
except Exception:
|
||||
return val
|
||||
return int(val)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
return float(val)
|
||||
except ValueError:
|
||||
pass
|
||||
return val
|
||||
|
||||
return val
|
||||
|
||||
|
||||
@@ -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
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
+5
-6
@@ -144,17 +144,16 @@ def cmd_notify(args):
|
||||
url=f"{base_url}/plugins" if base_url else "",
|
||||
)
|
||||
|
||||
# Bypass min_level for explicit test sends; run async channels directly
|
||||
import asyncio
|
||||
from .notify import _send_matrix_async, _send_sms_voipms_async, _DRIVERS
|
||||
ch_type = channel_cfg.get("type", "")
|
||||
print(f"Sending via {args.channel} ({ch_type}): {title} — {args.message}")
|
||||
|
||||
if ch_type in ("matrix", "sms_voipms"):
|
||||
from .notify import _send_matrix_async, _send_sms_voipms_async
|
||||
driver_async = _send_matrix_async if ch_type == "matrix" else _send_sms_voipms_async
|
||||
ok = asyncio.run(driver_async(channel_cfg, notif))
|
||||
if ch_type == "matrix":
|
||||
ok = asyncio.run(_send_matrix_async(channel_cfg, notif))
|
||||
elif ch_type == "sms_voipms":
|
||||
ok = asyncio.run(_send_sms_voipms_async(channel_cfg, notif))
|
||||
else:
|
||||
from .notify import _DRIVERS
|
||||
driver = _DRIVERS.get(ch_type)
|
||||
if driver is None:
|
||||
print(f"Error: unknown channel type '{ch_type}'", file=sys.stderr)
|
||||
|
||||
+35
-5
@@ -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)
|
||||
@@ -76,9 +79,13 @@ THRESHOLD_DEFAULTS = {
|
||||
}
|
||||
},
|
||||
'memory_monitor': {
|
||||
'percent': {
|
||||
'memory_percent': {
|
||||
'warning': 85.0,
|
||||
'critical': 95.0
|
||||
},
|
||||
'swap_percent': {
|
||||
'warning': 40.0,
|
||||
'critical': 75.0
|
||||
}
|
||||
},
|
||||
'disk_monitor': {
|
||||
@@ -95,7 +102,30 @@ 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}'
|
||||
},
|
||||
'capacity': {
|
||||
'warning': 80.0,
|
||||
'critical': 90.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +255,7 @@ def get_watchhosts(config):
|
||||
hosts_config = config.get("hosts", {})
|
||||
if isinstance(hosts_config, dict):
|
||||
for host_name, host_attrs in hosts_config.items():
|
||||
if isinstance(host_attrs, dict) and host_attrs.get("watch", False):
|
||||
if isinstance(host_attrs, dict) and host_attrs.get("watch", True):
|
||||
watchhosts.append(host_name)
|
||||
return watchhosts
|
||||
|
||||
@@ -303,7 +333,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:
|
||||
@@ -286,7 +286,7 @@ class Host:
|
||||
Host.hosts[name] = self
|
||||
self.num = num
|
||||
self.dyn = False
|
||||
self.watched = False
|
||||
self.watched = True
|
||||
self.upcount = 0
|
||||
self.interval = 0
|
||||
self.doesack = -1
|
||||
@@ -304,6 +304,7 @@ class Host:
|
||||
|
||||
def statedict(self):
|
||||
d = {}
|
||||
d["raw_name"] = self.name
|
||||
d["name"] = self.name
|
||||
if self.dyn:
|
||||
d["name"] += "*"
|
||||
|
||||
+508
-15
@@ -1,7 +1,12 @@
|
||||
"""HTTP server implementation using aiohttp and jinja2."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import html as _html
|
||||
import json
|
||||
import platform
|
||||
import socket
|
||||
import sys
|
||||
import time
|
||||
import urllib.parse
|
||||
import os
|
||||
@@ -12,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__)
|
||||
|
||||
@@ -95,6 +102,90 @@ 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
|
||||
|
||||
|
||||
def _build_host_info(host, threshold_checker=None) -> dict:
|
||||
"""Assemble the info payload for GET /api/0/hosts/{hostname}/info."""
|
||||
hbc_version = None
|
||||
hbc_type = None
|
||||
latest_os = host.get_latest_plugin_data("os_info")
|
||||
if latest_os:
|
||||
_, os_data = latest_os
|
||||
hbc_version = os_data.get("hbc_version")
|
||||
hbc_type = os_data.get("hbc_type")
|
||||
|
||||
last_packet = None
|
||||
if host.connections:
|
||||
last_packet = max(conn.lastbeat for conn in host.connections.values())
|
||||
|
||||
thresholds = None
|
||||
if threshold_checker is not None:
|
||||
raw = threshold_checker.get_thresholds_for_host(host.name)
|
||||
|
||||
# Build reverse coverage: which metric paths suffix-match to each threshold.
|
||||
# Mirrors the logic in ThresholdChecker._find_threshold.
|
||||
coverage: dict = {}
|
||||
for plugin_name, samples in host.plugin_data.items():
|
||||
if not samples:
|
||||
continue
|
||||
_, pdata = samples[-1]
|
||||
for field_name in pdata:
|
||||
full_path = f"{plugin_name}.{field_name}"
|
||||
if full_path in raw:
|
||||
continue # exact match — the threshold IS this metric
|
||||
parts = field_name.split("_")
|
||||
for i in range(1, len(parts)):
|
||||
candidate = f"{plugin_name}." + "_".join(parts[i:])
|
||||
if candidate in raw:
|
||||
coverage.setdefault(candidate, []).append(full_path)
|
||||
break
|
||||
|
||||
thresholds = sorted(
|
||||
[
|
||||
{
|
||||
"metric": tc.metric_path,
|
||||
"warning": tc.warning,
|
||||
"critical": tc.critical,
|
||||
"operator": tc.operator.value,
|
||||
"covers": sorted(coverage.get(tc.metric_path, [])),
|
||||
}
|
||||
for tc in raw.values()
|
||||
],
|
||||
key=lambda x: x["metric"],
|
||||
)
|
||||
|
||||
return {
|
||||
"owner": getattr(host, "owner", None),
|
||||
"managers": list(getattr(host, "managers", [])),
|
||||
"hbc_version": hbc_version,
|
||||
"hbc_type": hbc_type,
|
||||
"last_packet": last_packet,
|
||||
"thresholds": thresholds,
|
||||
}
|
||||
|
||||
|
||||
async def start(
|
||||
host: str,
|
||||
port: int,
|
||||
@@ -111,6 +202,7 @@ async def start(
|
||||
This function is intended to be awaited inside the main asyncio event loop.
|
||||
"""
|
||||
get_now = get_now or (lambda: time.time())
|
||||
_start_epoch = time.time()
|
||||
|
||||
async def old_index(request):
|
||||
_require_auth_redirect(request)
|
||||
@@ -149,6 +241,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)
|
||||
@@ -210,15 +321,11 @@ async def start(
|
||||
return err
|
||||
qa = request.rel_url.query
|
||||
uname = urllib.parse.unquote(qa.get("h", ""))
|
||||
ucode = qa.get("c")
|
||||
if not ucode or not uname:
|
||||
return web.Response(status=400, text="need h= and c= arguments")
|
||||
if not uname:
|
||||
return web.Response(status=400, text="need h= argument")
|
||||
if uname != "All" and uname not in hbdclass.Host.hosts:
|
||||
return web.Response(status=400, text=f"h={uname} not found")
|
||||
if uname != "All":
|
||||
names = [uname]
|
||||
else:
|
||||
names = [n for n in hbdclass.Host.hosts]
|
||||
names = [uname] if uname != "All" else list(hbdclass.Host.hosts)
|
||||
out = []
|
||||
for n in names:
|
||||
host = hbdclass.Host.hosts[n]
|
||||
@@ -227,8 +334,7 @@ async def start(
|
||||
continue
|
||||
op_err = None
|
||||
try:
|
||||
r = {"csum": None, "code": ucode}
|
||||
host.cmds.append(("UPD", r))
|
||||
host.cmds.append(("UPD", {}))
|
||||
except Exception as e:
|
||||
op_err = str(e)
|
||||
out.append(f"update started for {n}: {op_err if op_err else 'OK'}")
|
||||
@@ -258,7 +364,9 @@ async def start(
|
||||
extra_scripts=extra_scripts,
|
||||
hbd_version=hbd_version,
|
||||
hosts=[
|
||||
hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)
|
||||
hbdclass.Host.hosts[h].stateinfo()
|
||||
for h in sorted(hbdclass.Host.hosts)
|
||||
if _can_operate_host(current_user, hbdclass.Host.hosts[h])
|
||||
],
|
||||
messages=data.msgs[-30:],
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
@@ -510,18 +618,20 @@ async def start(
|
||||
hosts_with_plugins = []
|
||||
for hostname in sorted(hbdclass.Host.hosts.keys()):
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(current_user, host):
|
||||
if not _can_operate_host(current_user, host):
|
||||
continue
|
||||
if host.plugin_data:
|
||||
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")
|
||||
body = tmpl.render(
|
||||
title="Plugin Metrics - Heartbeat",
|
||||
header="Plugin Metrics",
|
||||
title="Host Overview - Heartbeat",
|
||||
header="Host Overview",
|
||||
hosts=hosts_with_plugins,
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="plugins",
|
||||
@@ -564,6 +674,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,
|
||||
@@ -587,6 +698,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(
|
||||
@@ -598,6 +710,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>
|
||||
@@ -618,6 +745,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>
|
||||
@@ -628,7 +763,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>"""
|
||||
@@ -637,7 +772,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
|
||||
@@ -645,7 +783,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
|
||||
@@ -747,6 +888,23 @@ async def start(
|
||||
|
||||
return web.json_response(host.access_dict())
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Host info endpoint
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def api_host_info(request):
|
||||
"""GET /api/0/hosts/{hostname}/info"""
|
||||
user, err = _require_auth(request)
|
||||
if err:
|
||||
return err
|
||||
hostname = request.match_info.get("hostname")
|
||||
if hostname not in hbdclass.Host.hosts:
|
||||
return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
|
||||
host = hbdclass.Host.hosts[hostname]
|
||||
if not _can_view_host(user, host):
|
||||
return web.json_response({"error": "Forbidden"}, status=403)
|
||||
return web.json_response(_build_host_info(host, threshold_checker=threshold_checker))
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# User profile page
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -798,6 +956,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",
|
||||
@@ -807,10 +967,53 @@ 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")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# About page
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
async def about_page(request):
|
||||
"""GET /about — version, runtime, and project information."""
|
||||
current_user, _ = _require_auth_redirect(request)
|
||||
pkg_dir = os.path.dirname(__file__)
|
||||
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
|
||||
from hbd import __version__ as hbd_version
|
||||
|
||||
uptime_secs = int(time.time() - _start_epoch)
|
||||
days, rem = divmod(uptime_secs, 86400)
|
||||
hours, rem = divmod(rem, 3600)
|
||||
mins, secs = divmod(rem, 60)
|
||||
if days:
|
||||
uptime_str = f"{days}d {hours}h {mins}m"
|
||||
elif hours:
|
||||
uptime_str = f"{hours}h {mins}m {secs}s"
|
||||
else:
|
||||
uptime_str = f"{mins}m {secs}s"
|
||||
|
||||
start_dt = datetime.datetime.fromtimestamp(_start_epoch)
|
||||
start_time_str = start_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
tmpl = env.get_template("about.html")
|
||||
body = tmpl.render(
|
||||
title="About - Heartbeat",
|
||||
header="About",
|
||||
hbd_version=hbd_version,
|
||||
python_version=f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro} ({platform.python_implementation()})",
|
||||
server_hostname=socket.gethostname(),
|
||||
start_epoch=int(_start_epoch),
|
||||
start_time_str=start_time_str,
|
||||
uptime_str=uptime_str,
|
||||
host_count=len(hbdclass.Host.hosts),
|
||||
current_user=current_user.to_dict() if current_user else None,
|
||||
active_page="about",
|
||||
)
|
||||
return web.Response(text=body, content_type="text/html")
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Settings page (admin only)
|
||||
# -------------------------------------------------------------------------
|
||||
@@ -824,14 +1027,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(
|
||||
[
|
||||
@@ -843,18 +1324,29 @@ 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),
|
||||
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
|
||||
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
|
||||
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
|
||||
web.get("/api/0/hosts/{hostname}/info", api_host_info),
|
||||
web.get("/api/0/alerts", api_all_alerts),
|
||||
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
|
||||
web.get("/c", cmd),
|
||||
@@ -864,6 +1356,7 @@ async def start(
|
||||
web.get("/live", live),
|
||||
web.get("/plugins", plugins_page),
|
||||
web.get("/alerts", alerts_page),
|
||||
web.get("/about", about_page),
|
||||
web.get("/profile", profile_page),
|
||||
web.get("/settings", settings_page),
|
||||
web.get("/static/{path:.*}", static),
|
||||
|
||||
+9
-3
@@ -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)
|
||||
@@ -210,7 +211,6 @@ async def _run_async(config, config_path=None):
|
||||
ctx = dict(
|
||||
config=config,
|
||||
hbdclass=hbdclass,
|
||||
log=eventlog,
|
||||
msg_to_websockets=msg_to_websockets,
|
||||
msg_journal=msg_journal,
|
||||
threshold_checker=threshold_checker,
|
||||
@@ -237,12 +237,15 @@ async def _run_async(config, config_path=None):
|
||||
restore_ctx = dict(
|
||||
config=config,
|
||||
hbdclass=hbdclass,
|
||||
log=eventlog,
|
||||
msg_to_websockets=msg_to_websockets,
|
||||
threshold_checker=threshold_checker,
|
||||
)
|
||||
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(
|
||||
@@ -252,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="",
|
||||
@@ -471,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"))
|
||||
|
||||
+50
-62
@@ -15,7 +15,6 @@ their own ``notification_channels`` list. When no users are configured the
|
||||
server runs silently (no notifications sent).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
@@ -30,13 +29,10 @@ from . import ws as ws_mod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
msg_to_websockets = ws_mod.broadcast
|
||||
|
||||
# Module-level state set via setup()
|
||||
_config: dict = {}
|
||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# Tracks which channels fired a WARNING/CRITICAL per host.
|
||||
# {host_name: set of channel_names} — used to route RECOVER to the same channels.
|
||||
@@ -73,11 +69,9 @@ class Notification:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def setup(cfg: dict, loop: Optional[asyncio.AbstractEventLoop] = None):
|
||||
"""Initialize notifier from configuration dict and event loop."""
|
||||
global _config, _loop
|
||||
"""Initialize notifier from configuration dict."""
|
||||
global _config
|
||||
_config = dict(cfg)
|
||||
if loop is not None:
|
||||
_loop = loop
|
||||
|
||||
|
||||
def reload_config(cfg: dict):
|
||||
@@ -112,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:
|
||||
@@ -124,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -140,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(
|
||||
@@ -215,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")}
|
||||
@@ -299,17 +302,6 @@ async def _send_sms_voipms_async(channel_cfg: dict, notif: Notification) -> bool
|
||||
return False
|
||||
|
||||
|
||||
def _send_sms_voipms(channel_cfg: dict, notif: Notification) -> bool:
|
||||
"""Dispatch voip.ms SMS send onto the shared event loop."""
|
||||
if _loop is None:
|
||||
logger.warning("sms_voipms: event loop not available")
|
||||
return False
|
||||
future = asyncio.run_coroutine_threadsafe(_send_sms_voipms_async(channel_cfg, notif), _loop)
|
||||
try:
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.error("sms_voipms send timed out or failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
|
||||
@@ -357,48 +349,48 @@ async def _send_matrix_async(channel_cfg: dict, notif: Notification) -> bool:
|
||||
await client.close()
|
||||
|
||||
|
||||
def _send_matrix(channel_cfg: dict, notif: Notification) -> bool:
|
||||
"""Dispatch matrix send onto the shared event loop."""
|
||||
if _loop is None:
|
||||
logger.warning("matrix: event loop not available")
|
||||
return False
|
||||
future = asyncio.run_coroutine_threadsafe(_send_matrix_async(channel_cfg, notif), _loop)
|
||||
try:
|
||||
return future.result(timeout=15)
|
||||
except Exception as e:
|
||||
logger.error("matrix send timed out or failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Channel dispatcher
|
||||
# Channel dispatcher (all async — sync drivers run in a thread executor)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Sync drivers kept for `hbd notify` CLI usage (asyncio.run wraps them there).
|
||||
_DRIVERS = {
|
||||
"pushover": _send_pushover,
|
||||
"email": _send_email,
|
||||
"mattermost": _send_mattermost,
|
||||
"signal": _send_signal,
|
||||
"sms_voipms": _send_sms_voipms,
|
||||
"matrix": _send_matrix,
|
||||
}
|
||||
|
||||
_TIMEOUT = 15 # seconds per channel send
|
||||
|
||||
def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||
|
||||
async def _dispatch_to_channel(channel_name: str, channel_cfg: dict, notif: Notification) -> bool:
|
||||
"""Send *notif* to a single named channel, honouring min_level."""
|
||||
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||
if _level_value(notif.level) < _level_value(min_level):
|
||||
logger.debug(
|
||||
"channel '%s': skipping level %s (min_level=%s)", channel_name, notif.level, min_level
|
||||
)
|
||||
return True # not an error — filtered intentionally
|
||||
level = notif.level.upper()
|
||||
if level != "RECOVER":
|
||||
min_level = channel_cfg.get("min_level", "WARNING").upper()
|
||||
if _level_value(level) < _level_value(min_level):
|
||||
logger.debug(
|
||||
"channel '%s': skipping level %s (min_level=%s)", channel_name, level, min_level
|
||||
)
|
||||
return True # filtered intentionally
|
||||
|
||||
ch_type = channel_cfg.get("type", "")
|
||||
driver = _DRIVERS.get(ch_type)
|
||||
if driver is None:
|
||||
logger.warning("unknown channel type '%s' for channel '%s'", ch_type, channel_name)
|
||||
try:
|
||||
if ch_type == "matrix":
|
||||
return await asyncio.wait_for(_send_matrix_async(channel_cfg, notif), timeout=_TIMEOUT)
|
||||
if ch_type == "sms_voipms":
|
||||
return await asyncio.wait_for(_send_sms_voipms_async(channel_cfg, notif), timeout=_TIMEOUT)
|
||||
sync_driver = _DRIVERS.get(ch_type)
|
||||
if sync_driver is None:
|
||||
logger.warning("unknown channel type '%s' for channel '%s'", ch_type, channel_name)
|
||||
return False
|
||||
return await asyncio.wait_for(
|
||||
asyncio.to_thread(sync_driver, channel_cfg, notif), timeout=_TIMEOUT
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("channel '%s' timed out after %ds", channel_name, _TIMEOUT)
|
||||
return False
|
||||
return driver(channel_cfg, notif)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -409,10 +401,10 @@ 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}"
|
||||
|
||||
|
||||
def send_notification(host_name: str, notif: Notification) -> dict:
|
||||
async def send_notification(host_name: str, notif: Notification) -> dict:
|
||||
"""Dispatch *notif* to all managers/owner of *host_name*.
|
||||
|
||||
Looks up the host's owner + managers, resolves each user's
|
||||
@@ -462,16 +454,12 @@ def send_notification(host_name: str, notif: Notification) -> dict:
|
||||
if not channel_cfg:
|
||||
continue
|
||||
try:
|
||||
ch_type = channel_cfg.get("type", "")
|
||||
driver = _DRIVERS.get(ch_type)
|
||||
if driver:
|
||||
ok = driver(channel_cfg, notif)
|
||||
results[channel_name] = ok
|
||||
if ok:
|
||||
logger.info("recover sent to channel '%s': %s", channel_name, notif.title)
|
||||
ok = await _dispatch_to_channel(channel_name, channel_cfg, notif)
|
||||
results[channel_name] = ok
|
||||
if ok:
|
||||
logger.info("recover sent to channel '%s': %s", channel_name, notif.title)
|
||||
except Exception as e:
|
||||
logger.error("error sending recover to channel '%s': %s", channel_name, e)
|
||||
# Clear the alerted set once recovery is delivered
|
||||
del _alerted_channels[host_name]
|
||||
return results
|
||||
|
||||
@@ -482,14 +470,14 @@ def send_notification(host_name: str, notif: Notification) -> dict:
|
||||
continue
|
||||
for channel_name in user.notification_channels:
|
||||
if channel_name in results:
|
||||
continue # already dispatched to this channel this notification
|
||||
continue
|
||||
channel_cfg = global_channels.get(channel_name)
|
||||
if not channel_cfg:
|
||||
logger.warning("channel '%s' not defined in notification_channels", channel_name)
|
||||
results[channel_name] = False
|
||||
continue
|
||||
try:
|
||||
ok = _dispatch_to_channel(channel_name, channel_cfg, notif)
|
||||
ok = await _dispatch_to_channel(channel_name, channel_cfg, notif)
|
||||
results[channel_name] = ok
|
||||
if ok:
|
||||
logger.info("notification sent to channel '%s': %s", channel_name, notif.title)
|
||||
|
||||
@@ -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}")
|
||||
+122
-28
@@ -24,7 +24,7 @@ sensitive bool True when the raw value must never be shown
|
||||
# Credential field names that should always be masked.
|
||||
_SECRET_KEYS = frozenset({
|
||||
"password", "token", "user_key", "api_key", "secret",
|
||||
"smtp_password", "smtp_user",
|
||||
"smtp_password", "smtp_user", "api_password", "access_token",
|
||||
})
|
||||
|
||||
_CHANNEL_TYPE_LABELS = {
|
||||
@@ -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():
|
||||
@@ -188,7 +223,7 @@ def get_settings_sections(config: dict) -> list:
|
||||
continue
|
||||
hosts_list.append({
|
||||
"name": hname,
|
||||
"watch": bool(hcfg.get("watch", False)),
|
||||
"watch": bool(hcfg.get("watch", True)),
|
||||
"dyndns": bool(hcfg.get("dyndns", False)),
|
||||
"owner": hcfg.get("owner", ""),
|
||||
"managers": hcfg.get("managers", []),
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
html, body { overflow: visible; }
|
||||
|
||||
.container {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 4px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 24px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.section h2 {
|
||||
font-size: 1em;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
margin: 0 0 16px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #f5f5f5;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
|
||||
.info-label {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #222;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.info-value a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
.info-value a:hover { text-decoration: underline; }
|
||||
|
||||
.version-badge {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
background: #e8f0fe;
|
||||
color: #1a73e8;
|
||||
border-radius: 12px;
|
||||
font-size: 0.85em;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.hb-logo {
|
||||
font-size: 2.5em;
|
||||
font-weight: 700;
|
||||
color: #0066cc;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.hb-tagline {
|
||||
color: #555;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
.logo-text { flex: 1; }
|
||||
</style>
|
||||
|
||||
<body>
|
||||
{% include 'nav.html' %}
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
<p class="subtitle">Heartbeat monitoring system</p>
|
||||
|
||||
<div class="section">
|
||||
<div class="logo-section">
|
||||
<div class="logo-text">
|
||||
<div class="hb-logo">Heartbeat</div>
|
||||
<div class="hb-tagline">Lightweight host monitoring over UDP</div>
|
||||
</div>
|
||||
<span class="version-badge">v{{ hbd_version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Version</h2>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Server version</span>
|
||||
<span class="info-value">{{ hbd_version }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Python</span>
|
||||
<span class="info-value">{{ python_version }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">License</span>
|
||||
<span class="info-value">MIT</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Runtime</h2>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Host</span>
|
||||
<span class="info-value">{{ server_hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Started</span>
|
||||
<span class="info-value">{{ start_time_str }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Uptime</span>
|
||||
<span class="info-value" id="uptime-value">{{ uptime_str }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Hosts monitored</span>
|
||||
<span class="info-value">{{ host_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Contact & Source</h2>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Author</span>
|
||||
<span class="info-value">Andreas Wrede</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Email</span>
|
||||
<span class="info-value"><a href="mailto:aew@wrede.ca">aew@wrede.ca</a></span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Repository</span>
|
||||
<span class="info-value"><a href="https://git.wrede.ca/andreas/heartbeat" target="_blank" rel="noopener">git.wrede.ca/andreas/heartbeat</a></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var startEpoch = {{ start_epoch }};
|
||||
var el = document.getElementById('uptime-value');
|
||||
if (!el) return;
|
||||
function fmt(s) {
|
||||
var d = Math.floor(s / 86400);
|
||||
var h = Math.floor((s % 86400) / 3600);
|
||||
var m = Math.floor((s % 3600) / 60);
|
||||
var sec = s % 60;
|
||||
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
|
||||
if (h > 0) return h + 'h ' + m + 'm ' + sec + 's';
|
||||
return m + 'm ' + sec + 's';
|
||||
}
|
||||
function tick() {
|
||||
var up = Math.floor(Date.now() / 1000 - startEpoch);
|
||||
el.textContent = fmt(up);
|
||||
}
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -3,9 +3,10 @@
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
|
||||
html, body {
|
||||
height: auto;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
@@ -13,10 +14,7 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
@@ -41,7 +39,7 @@
|
||||
border-left: 4px solid #ddd;
|
||||
}
|
||||
|
||||
.summary-card.critical { border-left-color: #f44336; }
|
||||
.summary-card.critical { border-left-color: #ea1e0f; }
|
||||
.summary-card.warning { border-left-color: #ff9800; }
|
||||
.summary-card.ok { border-left-color: #4caf50; }
|
||||
|
||||
@@ -51,7 +49,7 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.summary-number.critical { color: #f44336; }
|
||||
.summary-number.critical { color: #ea1e0f; }
|
||||
.summary-number.warning { color: #ff9800; }
|
||||
.summary-number.ok { color: #4caf50; }
|
||||
|
||||
@@ -96,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;
|
||||
@@ -116,7 +132,7 @@
|
||||
}
|
||||
|
||||
.alert-item.acknowledged {
|
||||
opacity: 0.6;
|
||||
opacity: 0.8;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
@@ -177,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 {
|
||||
@@ -314,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">
|
||||
@@ -330,6 +351,7 @@
|
||||
<script>
|
||||
let currentFilter = 'all';
|
||||
let allAlerts = [];
|
||||
let hostFilterRe = null;
|
||||
|
||||
async function loadAlerts() {
|
||||
try {
|
||||
@@ -364,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) {
|
||||
@@ -407,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 = '';
|
||||
@@ -431,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>
|
||||
@@ -532,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>
|
||||
|
||||
@@ -6,13 +6,32 @@
|
||||
<title>{{ title }}</title>
|
||||
{% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
|
||||
<style>
|
||||
/* ── Reset / shared baseline ── */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html {
|
||||
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
padding-top: 60px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
h1 { font-size: 1.5em; color: #333; margin: 0 0 5px; }
|
||||
h2 { font-size: 1.1em; color: #333; margin: 0 0 8px; }
|
||||
p { margin: 0; }
|
||||
|
||||
/* Navigation bar — shared across all pages */
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 200;
|
||||
background: #fff;
|
||||
padding: 10px 15px;
|
||||
margin-bottom: 10px;
|
||||
padding: 6px 12px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,.1);
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
@@ -42,6 +61,17 @@
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
|
||||
.nav-username {
|
||||
max-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: max-width 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.nav-user:hover .nav-username {
|
||||
max-width: 160px;
|
||||
opacity: 1;
|
||||
}
|
||||
.nav-avatar {
|
||||
width: 28px; height: 28px;
|
||||
border-radius: 50%;
|
||||
@@ -94,6 +124,164 @@
|
||||
.nav-links.nav-open { display: flex; }
|
||||
.nav-links a { margin-right: 0; padding: 6px 0; font-size: 1em; }
|
||||
}
|
||||
|
||||
/* Swiss railway clock — nav */
|
||||
.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; }
|
||||
|
||||
/* Swiss railway clock — full-page overlay */
|
||||
#clock-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: #1a1a1a;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
#clock-overlay.visible { display: flex; }
|
||||
#swiss-clock-overlay { display: block; }
|
||||
</style>
|
||||
<script src="static/sorttable.js"></script>
|
||||
<script>
|
||||
/* ── Swiss Federal Railway (SBB) clock ── */
|
||||
|
||||
/* Draw one frame of the clock onto any canvas element. */
|
||||
function drawSwissClock(canvas) {
|
||||
var SIZE = canvas.width;
|
||||
var R = SIZE / 2;
|
||||
var ctx = canvas.getContext('2d');
|
||||
var now = new Date();
|
||||
var h = now.getHours() % 12;
|
||||
var m = now.getMinutes();
|
||||
var s = now.getSeconds();
|
||||
var ms = now.getMilliseconds();
|
||||
|
||||
/* Seconds hand idles ~1.5 s at 12 before advancing (SBB behaviour) */
|
||||
var sFrac = s + ms / 1000;
|
||||
var sAngle = sFrac >= 58.5 ? 0 : (sFrac / 58.5) * Math.PI * 2;
|
||||
|
||||
ctx.clearRect(0, 0, SIZE, SIZE);
|
||||
|
||||
/* face */
|
||||
ctx.beginPath();
|
||||
ctx.arc(R, R, R - 1, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#333';
|
||||
ctx.lineWidth = SIZE * 0.018;
|
||||
ctx.stroke();
|
||||
|
||||
/* tick marks */
|
||||
for (var i = 0; i < 60; i++) {
|
||||
var a = (i / 60) * Math.PI * 2 - Math.PI / 2;
|
||||
var isHour = (i % 5 === 0);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(R + Math.cos(a) * (isHour ? R * 0.72 : R * 0.88),
|
||||
R + Math.sin(a) * (isHour ? R * 0.72 : R * 0.88));
|
||||
ctx.lineTo(R + Math.cos(a) * R * 0.94,
|
||||
R + Math.sin(a) * R * 0.94);
|
||||
ctx.strokeStyle = '#222';
|
||||
ctx.lineWidth = isHour ? SIZE * 0.027 : SIZE * 0.011;
|
||||
ctx.lineCap = 'butt';
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
/* hands */
|
||||
function hand(angle, tip, tail, width, color) {
|
||||
ctx.save();
|
||||
ctx.translate(R, R);
|
||||
ctx.rotate(angle);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tail, 0);
|
||||
ctx.lineTo(tip, 0);
|
||||
ctx.strokeStyle = color;
|
||||
ctx.lineWidth = width;
|
||||
ctx.lineCap = 'square';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
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 */
|
||||
hand(sAngle - Math.PI / 2, R * 0.78, -R * 0.22,
|
||||
SIZE * 0.013, '#e00'); /* second tail+tip */
|
||||
|
||||
/* round dot at tip of second hand */
|
||||
var dotR = SIZE * 0.028;
|
||||
ctx.save();
|
||||
ctx.translate(R, R);
|
||||
ctx.rotate(sAngle - Math.PI / 2);
|
||||
ctx.beginPath();
|
||||
ctx.arc(R * 0.78, 0, dotR, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#e00';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
|
||||
/* centre cap */
|
||||
ctx.beginPath();
|
||||
ctx.arc(R, R, R * 0.04, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#222';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/* Resize the overlay canvas to fit the viewport, keeping it square. */
|
||||
function resizeOverlayClock() {
|
||||
var oc = document.getElementById('swiss-clock-overlay');
|
||||
if (!oc) return;
|
||||
var size = Math.min(window.innerWidth, window.innerHeight) * 0.88;
|
||||
size = Math.floor(size);
|
||||
oc.width = size;
|
||||
oc.height = size;
|
||||
}
|
||||
|
||||
/* Main tick — redraws both nav clock and (if visible) overlay clock. */
|
||||
function clockTick() {
|
||||
var nav = document.getElementById('swiss-clock');
|
||||
if (nav) drawSwissClock(nav);
|
||||
var overlay = document.getElementById('clock-overlay');
|
||||
if (overlay && overlay.classList.contains('visible')) {
|
||||
var oc = document.getElementById('swiss-clock-overlay');
|
||||
if (oc) drawSwissClock(oc);
|
||||
}
|
||||
var delay = 100 - (Date.now() % 100);
|
||||
setTimeout(clockTick, delay);
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
/* Start the shared tick loop */
|
||||
clockTick();
|
||||
|
||||
/* Overlay toggle — clicking the nav clock opens it */
|
||||
var navClock = document.querySelector('.nav-clock');
|
||||
var overlay = document.getElementById('clock-overlay');
|
||||
if (navClock && overlay) {
|
||||
navClock.addEventListener('click', function() {
|
||||
resizeOverlayClock();
|
||||
overlay.classList.add('visible');
|
||||
});
|
||||
overlay.addEventListener('click', function() {
|
||||
overlay.classList.remove('visible');
|
||||
});
|
||||
window.addEventListener('resize', function() {
|
||||
if (overlay.classList.contains('visible')) resizeOverlayClock();
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<script src="static/sorttable.js"></script>
|
||||
</head>
|
||||
@@ -7,10 +7,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
box-sizing: border-box;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -49,6 +45,7 @@
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 15px;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
@@ -186,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;
|
||||
@@ -239,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;
|
||||
@@ -248,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() {
|
||||
@@ -407,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 = "";
|
||||
@@ -459,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++;
|
||||
};
|
||||
@@ -489,8 +516,10 @@
|
||||
{% include 'menu.html' %}
|
||||
|
||||
<div class="container">
|
||||
<h1>{{ header }}</h1>
|
||||
<p class="subtitle">Real-time host monitoring and event log</p>
|
||||
<div>
|
||||
<h1>{{ header }}</h1>
|
||||
<p class="subtitle">Real-time host monitoring and event log</p>
|
||||
</div>
|
||||
|
||||
<div class="table-section">
|
||||
<table id="ntable" class="sortable">
|
||||
@@ -512,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 -%}
|
||||
|
||||
@@ -4,11 +4,18 @@
|
||||
</button>
|
||||
<div class="nav-links" id="nav-links">
|
||||
<a href="/live"{% if active_page == "live" %} class="active"{% endif %}>Live Dashboard</a>
|
||||
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Plugin Metrics</a>
|
||||
<a href="/plugins"{% if active_page == "plugins" %} class="active"{% endif %}>Host Overview</a>
|
||||
<a href="/alerts"{% if active_page == "alerts" %} class="active"{% endif %}>Alerts</a>
|
||||
{% if current_user and current_user.admin %}
|
||||
<a href="/settings"{% if active_page == "settings" %} class="active"{% endif %}>Settings</a>
|
||||
{% 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>
|
||||
{% if current_user %}
|
||||
<a href="/profile" class="nav-user{% if active_page == 'profile' %} active{% endif %}" title="{{ current_user.full_name or current_user.username }}">
|
||||
@@ -21,6 +28,12 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Full-page clock overlay (click anywhere to dismiss) -->
|
||||
<div id="clock-overlay">
|
||||
<canvas id="swiss-clock-overlay" width="400" height="400"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var btn = document.getElementById('nav-hamburger-btn');
|
||||
@@ -32,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>
|
||||
|
||||
+1207
-881
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,7 @@
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
html, body { overflow: visible; }
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
@@ -212,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>
|
||||
@@ -274,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 %}
|
||||
@@ -334,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>
|
||||
|
||||
+518
-154
@@ -3,22 +3,13 @@
|
||||
{% include 'head.html' %}
|
||||
|
||||
<style>
|
||||
html, body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 20px;
|
||||
background: #f5f5f5;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
}
|
||||
html, body { overflow: visible; }
|
||||
|
||||
.container {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 { color: #333; margin-bottom: 4px; font-size: 1.5em; }
|
||||
h1 { color: #333; margin-bottom: 5px; margin-top: 15px; font-size: 1.5em; }
|
||||
.subtitle { color: #666; margin-bottom: 24px; font-size: 0.9em; }
|
||||
|
||||
/* ---- Sidebar + content layout ---- */
|
||||
@@ -32,7 +23,7 @@
|
||||
width: 180px;
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
top: 60px;
|
||||
}
|
||||
|
||||
.sidebar-nav a {
|
||||
@@ -263,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>
|
||||
@@ -270,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">
|
||||
|
||||
@@ -281,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>
|
||||
|
||||
@@ -293,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');
|
||||
@@ -483,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');
|
||||
|
||||
+542
-183
File diff suppressed because it is too large
Load Diff
+68
-29
@@ -171,6 +171,24 @@ def dicttos(ID, d):
|
||||
DROPOVERDUE = 7 * 24 * 3600 # seconds before an overdue host becomes UNKNOWN
|
||||
|
||||
|
||||
def _set_connectivity_alert(host, afam, level_name):
|
||||
"""Update (or clear) a connectivity alert_state entry for a host/address-family.
|
||||
|
||||
level_name is "CRITICAL", "WARNING", or "OK". "OK" removes the entry so
|
||||
that recovered hosts don't clutter the Alerts Dashboard.
|
||||
"""
|
||||
from .threshold import AlertState, AlertLevel
|
||||
metric_path = f"connectivity.{afam}"
|
||||
level = getattr(AlertLevel, level_name, AlertLevel.OK)
|
||||
if level == AlertLevel.OK:
|
||||
host.alert_states.pop(metric_path, None)
|
||||
return
|
||||
if metric_path not in host.alert_states:
|
||||
host.alert_states[metric_path] = AlertState(metric_path)
|
||||
state = host.alert_states[metric_path]
|
||||
state.update(level, level_name)
|
||||
|
||||
|
||||
def _make_timer_callbacks(uname, host, ctx):
|
||||
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic.
|
||||
|
||||
@@ -182,6 +200,7 @@ def _make_timer_callbacks(uname, host, ctx):
|
||||
|
||||
async def on_unknown(connection):
|
||||
connection.newstate(connection.__class__.UNKNOWN, connection.lastbeat)
|
||||
# Keep connectivity alert active when host transitions to unknown
|
||||
if msg_to_websockets:
|
||||
msg_to_websockets("host", host.stateinfo())
|
||||
|
||||
@@ -192,10 +211,13 @@ def _make_timer_callbacks(uname, host, ctx):
|
||||
connection.newstate(connection.__class__.OVERDUE, now, cfg.get("grace", 2))
|
||||
msg = f"{connection.afam} overdue"
|
||||
eventlog(uname, "CRITICAL", msg)
|
||||
notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"),
|
||||
)
|
||||
if host.watched:
|
||||
asyncio.create_task(notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[CRITICAL] {uname}", body=msg, level="CRITICAL"),
|
||||
))
|
||||
# Track in alert_states so the Alerts Dashboard shows this
|
||||
_set_connectivity_alert(host, connection.afam, "CRITICAL")
|
||||
if threshold_checker:
|
||||
threshold_checker.check_value(
|
||||
host_name=uname,
|
||||
@@ -294,7 +316,6 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
|
||||
cfg = ctx.get("config", {})
|
||||
hbdcls = ctx.get("hbdclass")
|
||||
log = ctx.get("log")
|
||||
msg_to_websockets = ctx.get("msg_to_websockets")
|
||||
DEBUG = ctx.get("DEBUG", 0)
|
||||
verbose = ctx.get("verbose", False)
|
||||
@@ -315,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]
|
||||
@@ -330,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)
|
||||
@@ -348,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}")
|
||||
|
||||
@@ -387,10 +417,11 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
|
||||
if res:
|
||||
eventlog(uname, "WARNING", res)
|
||||
notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[WARNING] {uname}", body=res, level="WARNING"),
|
||||
)
|
||||
if host.watched:
|
||||
asyncio.create_task(notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[WARNING] {uname}", body=res, level="WARNING"),
|
||||
))
|
||||
|
||||
interval = int(msg.get("interval", 0) or 0)
|
||||
shutdown = msg.get("shutdown", 0)
|
||||
@@ -400,28 +431,36 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
|
||||
if boot:
|
||||
eventlog(uname, "INFO", "booted")
|
||||
notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
||||
)
|
||||
if host.watched:
|
||||
asyncio.create_task(notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[INFO] {uname}", body=f"{host.name} booted", level="INFO"),
|
||||
))
|
||||
if message:
|
||||
eventlog(uname, "INFO", "msg: %s" % message, service=service)
|
||||
|
||||
if conn.getstate() != hbdcls.Connection.UP:
|
||||
lasts = conn.state
|
||||
d = conn.newstate(hbdcls.Connection.UP, now)
|
||||
# Clear connectivity alert now that the host is back up
|
||||
_set_connectivity_alert(host, conn.afam, "OK")
|
||||
# Don't log/notify RECOVER for a brand-new host seen for the first time —
|
||||
# it was never down, it just hasn't been seen before.
|
||||
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)
|
||||
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
|
||||
@@ -431,11 +470,13 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
if shutdown:
|
||||
m = "%s shutdown" % conn.afam
|
||||
eventlog(uname, "INFO", m)
|
||||
notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[INFO] {uname}", body=m, level="INFO"),
|
||||
)
|
||||
if host.watched:
|
||||
asyncio.create_task(notify_mod.send_notification(
|
||||
uname,
|
||||
notify_mod.Notification(title=f"[INFO] {uname}", body=m, level="INFO"),
|
||||
))
|
||||
conn.newstate(hbdcls.Connection.DOWN, now)
|
||||
_set_connectivity_alert(host, conn.afam, "CRITICAL")
|
||||
|
||||
if interval > 0:
|
||||
host.interval = interval
|
||||
@@ -467,12 +508,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
|
||||
op, rmsg = host.cmds[0]
|
||||
if op == "CMD":
|
||||
del host.cmds[0]
|
||||
if log:
|
||||
log(uname, "command sent")
|
||||
eventlog(uname, "INFO", "command sent")
|
||||
elif op == "UPD":
|
||||
del host.cmds[0]
|
||||
if log:
|
||||
log(uname, "update initiated")
|
||||
eventlog(uname, "INFO", "update initiated")
|
||||
opkt = dicttos(op, rmsg)
|
||||
try:
|
||||
transport.sendto(opkt, addr)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+59
-12
@@ -13,7 +13,8 @@ from . import data
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_connections: set = set()
|
||||
# Map of WebSocket → User object (or None when auth is disabled)
|
||||
_connections: dict = {}
|
||||
_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
_get_hosts: Optional[Callable[[], Iterable]] = None
|
||||
_verbose: bool = False
|
||||
@@ -34,31 +35,63 @@ def setup(
|
||||
_verbose = verbose
|
||||
|
||||
|
||||
def _user_can_see_host(user, host_name: str) -> bool:
|
||||
"""Return True if *user* may see updates for *host_name* (manager or higher)."""
|
||||
from . import hbdclass, users as users_mod
|
||||
if user is None or not users_mod.users_enabled():
|
||||
return True
|
||||
if user.admin:
|
||||
return True
|
||||
host = hbdclass.Host.hosts.get(host_name)
|
||||
if host is None:
|
||||
return False
|
||||
return host.is_manager(user.username)
|
||||
|
||||
|
||||
def _get_token(request) -> str:
|
||||
"""Extract session token from request (mirrors logic in http.py)."""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
return auth[7:].strip()
|
||||
token = request.headers.get("X-Auth-Token", "")
|
||||
if token:
|
||||
return token
|
||||
return request.cookies.get("hbd_session", "")
|
||||
|
||||
|
||||
async def handler(request):
|
||||
"""aiohttp WebSocket upgrade handler — register as GET /ws."""
|
||||
from aiohttp import web
|
||||
from . import users as users_mod
|
||||
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
_connections.add(ws)
|
||||
token = _get_token(request)
|
||||
user = users_mod.get_session_user(token) if token else None
|
||||
|
||||
_connections[ws] = user
|
||||
remote = request.remote
|
||||
logger.info("WebSocket connected from %s", remote)
|
||||
|
||||
try:
|
||||
# Send current host state to the new client
|
||||
# Send current host state, filtered to hosts this user may see
|
||||
if _get_hosts:
|
||||
try:
|
||||
for h in list(_get_hosts()):
|
||||
await ws.send_str(json.dumps({"type": "host", "data": h}))
|
||||
host_name = h.get("raw_name") or h.get("name", "")
|
||||
if _user_can_see_host(user, host_name):
|
||||
await ws.send_str(json.dumps({"type": "host", "data": h}))
|
||||
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)
|
||||
|
||||
@@ -74,7 +107,7 @@ async def handler(request):
|
||||
except Exception as e:
|
||||
logger.exception("WebSocket handler error from %s: %s", remote, e)
|
||||
finally:
|
||||
_connections.discard(ws)
|
||||
_connections.pop(ws, None)
|
||||
logger.info("WebSocket disconnected from %s", remote)
|
||||
|
||||
return ws
|
||||
@@ -83,25 +116,39 @@ async def handler(request):
|
||||
def broadcast(typ: str, payload) -> bool:
|
||||
"""Thread-safe broadcast to all connected WebSocket clients.
|
||||
|
||||
For host and plugin updates, only sends to clients whose user has
|
||||
manager-or-higher access to that host. Other message types are
|
||||
broadcast to all clients.
|
||||
|
||||
Can be called from any thread; schedules sends on the event loop.
|
||||
Returns False if the loop is not running yet.
|
||||
"""
|
||||
if not _loop:
|
||||
return False
|
||||
|
||||
# Determine the host name for access-filtered message types
|
||||
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})
|
||||
|
||||
async def _send_all():
|
||||
dead = set()
|
||||
for ws in list(_connections):
|
||||
for ws, user in list(_connections.items()):
|
||||
try:
|
||||
if not ws.closed:
|
||||
await ws.send_str(jmsg)
|
||||
else:
|
||||
if ws.closed:
|
||||
dead.add(ws)
|
||||
continue
|
||||
if host_name is not None and not _user_can_see_host(user, host_name):
|
||||
continue
|
||||
await ws.send_str(jmsg)
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
for ws in dead:
|
||||
_connections.discard(ws)
|
||||
_connections.pop(ws, None)
|
||||
|
||||
asyncio.run_coroutine_threadsafe(_send_all(), _loop)
|
||||
return True
|
||||
|
||||
+8
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hbd"
|
||||
version = "5.1.1"
|
||||
version = "5.3.1"
|
||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -32,8 +32,12 @@ server = [
|
||||
"aiohttp>=3.11",
|
||||
"Jinja2>=3.1.6",
|
||||
"matrix-nio>=0.24",
|
||||
"ruamel.yaml>=0.18",
|
||||
]
|
||||
|
||||
# Minimal client — hbc_mini only, no external dependencies
|
||||
mini = []
|
||||
|
||||
# Install both client and server
|
||||
all = [
|
||||
"hbd[client,server]",
|
||||
@@ -54,6 +58,9 @@ dev = [
|
||||
hbd = "hbd.server.cli:main"
|
||||
hbc = "hbd.client.main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
script-files = ["scripts/hb_install.sh", "scripts/hbc_mini.py"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["hbd*"]
|
||||
|
||||
@@ -4,12 +4,14 @@ set -e
|
||||
uv version --bump patch
|
||||
VER=$(uv version --short)
|
||||
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
|
||||
sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" scripts/hbc_mini.py
|
||||
|
||||
# commit pyproject.toml
|
||||
git commit -m "version $VER" pyproject.toml hbd/__init__.py
|
||||
git commit -m "version $VER" pyproject.toml hbd/__init__.py scripts/hbc_mini.py
|
||||
git push
|
||||
# tag version
|
||||
git tag -a v$VER -m "Version $VER"
|
||||
git push --tags
|
||||
|
||||
rm hbd/__init__.py.bak
|
||||
rm scripts/hbc_mini.py.bak
|
||||
|
||||
@@ -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
Executable
+115
@@ -0,0 +1,115 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Helper script to install the heartbeat tools. By default, it will only
|
||||
# install the heartbeat client, hbc. The server is installed when the arg 'server' is passed
|
||||
# to the script. The script will install the heartbeat tools in a python
|
||||
# virtual environment in ~/venvs/hbd. The hbd and hbc commands will be
|
||||
# installed from the wheel and symlinked to ~/bin/hbd and ~/bin/hbc,
|
||||
# respectively. If the virtual environment already exists, it will be
|
||||
# reused. The script will also remove any existing symlinks for hbd and hbc
|
||||
# in ~/bin before creating new ones.
|
||||
|
||||
set -e
|
||||
what=$1
|
||||
on_ha=0
|
||||
where=""
|
||||
venv=""
|
||||
[ "$2" = "HA" ] && on_ha=1
|
||||
[ -z "$what" ] && what="client"
|
||||
|
||||
if [ -d /homeassistant ]; then # if running from HA command line
|
||||
echo "HA, running \"docker exec homeassistant /config/bin/hb_install.sh $@\""
|
||||
docker exec homeassistant /config/bin/hb_install.sh $@ HA
|
||||
rc=$?
|
||||
if [ $rc -ne 0 ]; then
|
||||
echo "Failed to install heartbeat in HA, please check the logs for more details"
|
||||
exit 1
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $on_ha -eq 1 ] || [ -r /.dockerenv ] && [ -d /config/bin ]; then
|
||||
# Installing under docker on Home Assistant OS, using /config/bin for executables and /config/venvs for virtual environments
|
||||
echo "Home Assistant OS detected, installing under docker"
|
||||
where="/config/bin"
|
||||
venv="/config/venvs"
|
||||
else
|
||||
if [ ! -d $HOME/.local/bin ] && [ ! -d $HOME/bin ]; then
|
||||
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
|
||||
exit 1
|
||||
fi
|
||||
for where in $HOME/bin $HOME/.local/bin notset ; do
|
||||
if echo ":$PATH:" | grep -q ":$where:" ; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ "$where" = "notset" ]; then
|
||||
echo "No suitable bin directory found in PATH, please add either $HOME/.local/bin or $HOME/bin to your PATH"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$what" = "mini" ]; then
|
||||
venv=""
|
||||
else
|
||||
venv="$HOME/venvs"
|
||||
fi
|
||||
fi
|
||||
echo "Installing $what to $where"
|
||||
if [ ! -z "$venv" ]; then
|
||||
echo "Using virtual environment at $venv/hbd"
|
||||
fi
|
||||
|
||||
if [ "$venv" != "" ] && [ ! -d $venv/hbd ]; then
|
||||
arg=""
|
||||
have_pip=$(python3 -c "import pip" 2>/dev/null &> /dev/null && echo "Installed" || echo "Not Installed")
|
||||
if [ "$have_pip" = "Not Installed" ]; then
|
||||
# some systems do not have pip installed by default, so we need to fetch get-pip.py and install pip
|
||||
echo "pip is not installed, fetching get-pip.py and installing pip"
|
||||
arg="--without-pip"
|
||||
fi
|
||||
mkdir -p $venv
|
||||
have_venv=$(python3 -c "import venv" 2>/dev/null &> /dev/null && echo "Installed" || echo "Not Installed")
|
||||
if [ "$have_venv" = "Not Installed" ]; then
|
||||
if [ "$have_pip" = "Not Installed" ]; then
|
||||
echo "python has no venv, and no pip to install virtualenv, cannot continue"
|
||||
exit 1
|
||||
fi
|
||||
echo "python venv module not found, installing virtualenv"
|
||||
python3 -m pip install --user virtualenv
|
||||
python3 -m virtualenv $venv/hbd --system-site-packages $arg
|
||||
else
|
||||
python3 -m venv $venv/hbd --system-site-packages $arg
|
||||
fi
|
||||
. $venv/hbd/bin/activate
|
||||
if [ -n "$arg" ]; then
|
||||
curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py && python3 get-pip.py
|
||||
fi
|
||||
deactivate
|
||||
fi
|
||||
|
||||
if [ ! -z "$venv" ]; then
|
||||
. $venv/hbd/bin/activate
|
||||
fi
|
||||
if [ "$what" = "mini" ]; then
|
||||
curl -s -o $where/hbc_mini https://git.wrede.ca/andreas/heartbeat/raw/branch/master/scripts/hbc_mini.py
|
||||
chmod +x $where/hbc_mini
|
||||
else
|
||||
python3 -mpip install --upgrade --index-url https://git.wrede.ca/api/packages/andreas/pypi/simple/ --extra-index-url https://pypi.org/simple hbd[$what]
|
||||
fi
|
||||
|
||||
if [ ! -z "$venv" ]; then
|
||||
echo "linking executables to $where"
|
||||
if [ "$what" = "server" ]; then
|
||||
rm -f $where/hbd
|
||||
ln -sf $(which hbd) $where/hbd
|
||||
elif [ "$what" = "client" ]; then
|
||||
rm -f $where/hbc
|
||||
ln -sf $(which hbc) $where/hbc
|
||||
fi
|
||||
rm -f $where/hb_install.sh
|
||||
ln -sf $(which hb_install.sh) $where/hb_install.sh
|
||||
fi
|
||||
echo "Installation complete. To upgrade, run the following:"
|
||||
echo " $where/hb_install.sh $what"
|
||||
echo "To install on another machine, run the following obtain the install script and run it:"
|
||||
echo "from https://git.wrede.ca/andreas/heartbeat/raw/branch/master/scripts/hb_install.sh"
|
||||
echo "and then run sh hb_install.sh [mini|client]"
|
||||
Executable
+1192
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# install the heartbeat client, hbc. The server is installed when the arg 'server' is passed
|
||||
# install the heartbeat client, hbc. The server is installed when the arg 'server' is passed
|
||||
# to the script. The script will install the heartbeat tools in a python
|
||||
# virtual environment in ~/venvs/hbd. The hbd and hbc commands will be
|
||||
# installed from the wheel and symlinked to ~/bin/hbd and ~/bin/hbc,
|
||||
# respectively. If the virtual environment already exists, it will be
|
||||
# reused. The script will also remove any existing symlinks for hbd and hbc
|
||||
# in ~/bin before creating new ones.
|
||||
|
||||
|
||||
# hbd/hbc from wheel and create symlinks for hbd and hbc in ~/bin
|
||||
|
||||
set -e
|
||||
what=$1
|
||||
|
||||
if [ -d /homeassistant ]; then
|
||||
echo "cannot install in HA, run \"docker exec -it homeassistant $0 $@\""
|
||||
exit 1
|
||||
fi
|
||||
if [ -d /config ]; then
|
||||
echo "Installing on HA"
|
||||
where="/config/bin"
|
||||
venv="/config/venvs"
|
||||
else
|
||||
if [ ! -d ~/.local/bin ] && [ ! -d ~/bin ]; then
|
||||
echo "No suitable bin directory found in PATH, please add either ~/.local/bin or ~/bin to your PATH"
|
||||
exit 1
|
||||
fi
|
||||
for where in ~/bin ~/.local/bin; do
|
||||
if echo ":$PATH:" | grep -q ":$where:" ; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
venv="~/venvs"
|
||||
fi
|
||||
python3 -m pip --version > /dev/null 2>&1 || { echo "pip is not installed, please install pip for python3"; exit 1; }
|
||||
|
||||
if [ "$what" = "server" ]; then
|
||||
echo "Installing heartbeat server (hbd)"
|
||||
else
|
||||
what="client"
|
||||
echo "Installing heartbeat client (hbc)"
|
||||
fi
|
||||
if [ ! -d $venv/hbd ]; then
|
||||
mkdir -p $venv
|
||||
python3 -m venv $venv/hbd --system-site-packages
|
||||
fi
|
||||
. $venv/hbd/bin/activate
|
||||
pip install --index-url https://git.wrede.ca/api/packages/andreas/pypi/simple/ --extra-index-url https://pypi.org/simple hbd[$what]
|
||||
if [ "$what" = "server" ]; then
|
||||
rm -f ~$where/hbd
|
||||
ln -sf $(which hbd) $where/hbd
|
||||
else
|
||||
rm -f $where/hbc
|
||||
ln -sf $(which hbc) $where/hbc
|
||||
fi
|
||||
+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,174 @@
|
||||
"""Tests for _build_host_info helper in http.py."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from hbd.server.http import _build_host_info
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self, lastbeat):
|
||||
self.lastbeat = lastbeat
|
||||
|
||||
|
||||
class _FakeHost:
|
||||
def __init__(self, name="myhost", owner=None, managers=None,
|
||||
connections=None, os_data=None, plugin_data=None):
|
||||
self.name = name
|
||||
self.owner = owner
|
||||
self.managers = managers or []
|
||||
self.connections = connections or {}
|
||||
self._os_data = os_data
|
||||
self.plugin_data = plugin_data or {}
|
||||
|
||||
def get_latest_plugin_data(self, plugin_name):
|
||||
if plugin_name == "os_info" and self._os_data is not None:
|
||||
return (1234567890.0, self._os_data)
|
||||
return None
|
||||
|
||||
|
||||
def test_build_host_info_basic_fields():
|
||||
host = _FakeHost(owner="alice", managers=["bob", "carol"])
|
||||
result = _build_host_info(host)
|
||||
assert result["owner"] == "alice"
|
||||
assert result["managers"] == ["bob", "carol"]
|
||||
assert result["hbc_version"] is None
|
||||
assert result["hbc_type"] is None
|
||||
assert result["last_packet"] is None
|
||||
assert result["thresholds"] is None
|
||||
|
||||
|
||||
def test_build_host_info_no_owner():
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host)
|
||||
assert result["owner"] is None
|
||||
assert result["managers"] == []
|
||||
|
||||
|
||||
def test_build_host_info_reads_hbc_from_os_info():
|
||||
host = _FakeHost(os_data={"hbc_version": "5.3.0", "hbc_type": "full"})
|
||||
result = _build_host_info(host)
|
||||
assert result["hbc_version"] == "5.3.0"
|
||||
assert result["hbc_type"] == "full"
|
||||
|
||||
|
||||
def test_build_host_info_hbc_none_when_no_os_info():
|
||||
host = _FakeHost(os_data=None)
|
||||
result = _build_host_info(host)
|
||||
assert result["hbc_version"] is None
|
||||
assert result["hbc_type"] is None
|
||||
|
||||
|
||||
def test_build_host_info_last_packet_is_max_lastbeat():
|
||||
host = _FakeHost(connections={
|
||||
"IPv4": _FakeConn(1000.0),
|
||||
"IPv6": _FakeConn(2000.0),
|
||||
})
|
||||
result = _build_host_info(host)
|
||||
assert result["last_packet"] == 2000.0
|
||||
|
||||
|
||||
def test_build_host_info_last_packet_none_when_no_connections():
|
||||
host = _FakeHost(connections={})
|
||||
result = _build_host_info(host)
|
||||
assert result["last_packet"] is None
|
||||
|
||||
|
||||
def test_build_host_info_thresholds_none_without_checker():
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=None)
|
||||
assert result["thresholds"] is None
|
||||
|
||||
|
||||
def test_build_host_info_thresholds_sorted_by_metric():
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc_cpu = ThresholdConfig("cpu_monitor.cpu_percent", warning=80.0, critical=95.0)
|
||||
tc_mem = ThresholdConfig("memory_monitor.memory_percent", warning=85.0, critical=98.0)
|
||||
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {
|
||||
"memory_monitor.memory_percent": tc_mem,
|
||||
"cpu_monitor.cpu_percent": tc_cpu,
|
||||
}
|
||||
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
|
||||
assert result["thresholds"] is not None
|
||||
assert len(result["thresholds"]) == 2
|
||||
assert result["thresholds"][0]["metric"] == "cpu_monitor.cpu_percent"
|
||||
assert result["thresholds"][0]["warning"] == 80.0
|
||||
assert result["thresholds"][0]["critical"] == 95.0
|
||||
assert result["thresholds"][0]["operator"] == ">"
|
||||
assert result["thresholds"][1]["metric"] == "memory_monitor.memory_percent"
|
||||
|
||||
|
||||
def test_build_host_info_thresholds_empty_list_when_no_thresholds():
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {}
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
assert result["thresholds"] == []
|
||||
|
||||
|
||||
def test_build_host_info_threshold_null_warning_critical():
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc = ThresholdConfig("rtt.myhost", warning=None, critical=500.0)
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {"rtt.myhost": tc}
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
assert result["thresholds"][0]["warning"] is None
|
||||
assert result["thresholds"][0]["critical"] == 500.0
|
||||
|
||||
|
||||
def test_build_host_info_nagios_operator_serialized():
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc = ThresholdConfig("nagios_runner.check_http", operator="nagios")
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {"nagios_runner.check_http": tc}
|
||||
host = _FakeHost()
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
assert result["thresholds"][0]["operator"] == "nagios"
|
||||
|
||||
|
||||
def test_build_host_info_covers_suffix_matched_metrics():
|
||||
"""memory_monitor.percent threshold covers swap_percent via suffix match."""
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||
|
||||
host = _FakeHost(
|
||||
connections={},
|
||||
os_data=None,
|
||||
)
|
||||
# Simulate plugin_data with both percent and swap_percent fields
|
||||
host.plugin_data = {
|
||||
"memory_monitor": [(1234567890.0, {
|
||||
"percent": 80.0,
|
||||
"swap_percent": 25.0,
|
||||
"available_mb": 2000,
|
||||
})]
|
||||
}
|
||||
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
assert result["thresholds"] is not None
|
||||
t = result["thresholds"][0]
|
||||
assert t["metric"] == "memory_monitor.percent"
|
||||
assert t["covers"] == ["memory_monitor.swap_percent"]
|
||||
|
||||
|
||||
def test_build_host_info_covers_empty_when_exact_matches_only():
|
||||
"""No covers when all plugin fields match their threshold exactly."""
|
||||
from hbd.server.threshold import ThresholdConfig
|
||||
tc_pct = ThresholdConfig("memory_monitor.percent", warning=85.0, critical=95.0)
|
||||
checker = MagicMock()
|
||||
checker.get_thresholds_for_host.return_value = {"memory_monitor.percent": tc_pct}
|
||||
|
||||
host = _FakeHost()
|
||||
host.plugin_data = {
|
||||
"memory_monitor": [(1234567890.0, {"percent": 80.0})]
|
||||
}
|
||||
|
||||
result = _build_host_info(host, threshold_checker=checker)
|
||||
t = result["thresholds"][0]
|
||||
assert t["covers"] == []
|
||||
@@ -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,99 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import stat
|
||||
|
||||
from hbd.client.plugins.nagios_runner import (
|
||||
NagiosRunnerPlugin,
|
||||
NAGIOS_OK,
|
||||
NAGIOS_WARNING,
|
||||
NAGIOS_CRITICAL,
|
||||
NAGIOS_UNKNOWN,
|
||||
)
|
||||
|
||||
|
||||
def test_no_commands_sets_skip_reason():
|
||||
plugin = NagiosRunnerPlugin(config={"commands": []})
|
||||
result = asyncio.run(plugin.initialize())
|
||||
assert result is False
|
||||
assert plugin.skip_reason is not None
|
||||
assert "nagios_runner.commands" in plugin.skip_reason
|
||||
|
||||
|
||||
def test_stderr_used_when_stdout_empty(tmp_path):
|
||||
script = tmp_path / "check_err.sh"
|
||||
script.write_text("#!/bin/sh\necho 'error from stderr' >&2\nexit 2\n")
|
||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
asyncio.run(plugin.initialize())
|
||||
data = asyncio.run(plugin._collect_metrics())
|
||||
|
||||
assert "error from stderr" in data["t_output"]
|
||||
assert data["t_status_code"] == NAGIOS_CRITICAL
|
||||
|
||||
|
||||
def test_stderr_appended_when_both_present(tmp_path):
|
||||
script = tmp_path / "check_both.sh"
|
||||
script.write_text("#!/bin/sh\necho 'OK - all good'\necho 'extra detail' >&2\nexit 0\n")
|
||||
script.chmod(script.stat().st_mode | stat.S_IEXEC)
|
||||
|
||||
config = {"commands": [{"name": "t", "command": str(script)}], "timeout": 5}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
asyncio.run(plugin.initialize())
|
||||
data = asyncio.run(plugin._collect_metrics())
|
||||
|
||||
assert "OK - all good" in data["t_output"]
|
||||
assert "extra detail" in data["t_output"]
|
||||
assert data["t_status_code"] == NAGIOS_OK
|
||||
|
||||
|
||||
def test_negative_returncode_maps_to_unknown():
|
||||
# kill -9 $$ kills the shell itself; asyncio sees returncode -9
|
||||
config = {"commands": [{"name": "t", "command": "kill -9 $$"}], "timeout": 5}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
asyncio.run(plugin.initialize())
|
||||
data = asyncio.run(plugin._collect_metrics())
|
||||
|
||||
assert data["t_status_code"] == NAGIOS_UNKNOWN
|
||||
assert "signal" in data["t_output"].lower()
|
||||
|
||||
|
||||
def test_absolute_path_not_found_warns(caplog):
|
||||
fake_cmd = "/nonexistent_hbc_test_path/check_something"
|
||||
config = {"commands": [{"name": "t", "command": fake_cmd}]}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
||||
asyncio.run(plugin.initialize())
|
||||
|
||||
assert any("not found" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_absolute_path_not_executable_warns(caplog, tmp_path):
|
||||
non_exec = tmp_path / "check_test"
|
||||
non_exec.write_text("#!/bin/sh\necho OK\n")
|
||||
non_exec.chmod(0o644) # readable but not executable
|
||||
|
||||
config = {"commands": [{"name": "t", "command": str(non_exec)}]}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
||||
asyncio.run(plugin.initialize())
|
||||
|
||||
assert any("not executable" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_relative_path_not_checked(caplog):
|
||||
# Relative paths (resolved via PATH) must not generate warnings
|
||||
config = {"commands": [{"name": "t", "command": "echo OK"}]}
|
||||
plugin = NagiosRunnerPlugin(config=config)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.nagios_runner"):
|
||||
asyncio.run(plugin.initialize())
|
||||
|
||||
assert not any(
|
||||
"not found" in r.message or "not executable" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
@@ -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 asyncio
|
||||
import logging
|
||||
import textwrap
|
||||
|
||||
from hbd.client.plugin import PluginLoader, PluginRegistry
|
||||
|
||||
|
||||
def test_plugin_skip_reason_defaults_none(tmp_path):
|
||||
plugin_code = textwrap.dedent("""
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
class MinimalPlugin(MonitorPlugin):
|
||||
name = "minimal"
|
||||
version = "1.0.0"
|
||||
interval = 60
|
||||
|
||||
async def initialize(self):
|
||||
return True
|
||||
|
||||
async def _collect_metrics(self):
|
||||
return {}
|
||||
""")
|
||||
(tmp_path / "minimal.py").write_text(plugin_code)
|
||||
registry = PluginRegistry()
|
||||
loader = PluginLoader(registry)
|
||||
asyncio.run(loader.load_from_directory(tmp_path))
|
||||
plugin = registry.get("minimal")
|
||||
assert plugin is not None
|
||||
assert plugin.skip_reason is None
|
||||
|
||||
|
||||
def test_loader_logs_info_when_skip_reason_set(tmp_path, caplog):
|
||||
plugin_code = textwrap.dedent("""
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
class SkippablePlugin(MonitorPlugin):
|
||||
name = "skippable"
|
||||
version = "1.0.0"
|
||||
interval = 60
|
||||
|
||||
async def initialize(self):
|
||||
self.skip_reason = "not configured in yaml"
|
||||
return False
|
||||
|
||||
async def _collect_metrics(self):
|
||||
return {}
|
||||
""")
|
||||
(tmp_path / "skippable.py").write_text(plugin_code)
|
||||
registry = PluginRegistry()
|
||||
loader = PluginLoader(registry)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="plugin.loader"):
|
||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
||||
|
||||
assert count == 0
|
||||
assert any("skipped: not configured in yaml" in r.message for r in caplog.records)
|
||||
assert not any("failed initialization" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_loader_logs_warning_when_no_skip_reason(tmp_path, caplog):
|
||||
plugin_code = textwrap.dedent("""
|
||||
from hbd.client.plugin import MonitorPlugin
|
||||
|
||||
class FailPlugin(MonitorPlugin):
|
||||
name = "fail"
|
||||
version = "1.0.0"
|
||||
interval = 60
|
||||
|
||||
async def initialize(self):
|
||||
return False
|
||||
|
||||
async def _collect_metrics(self):
|
||||
return {}
|
||||
""")
|
||||
(tmp_path / "fail_plugin.py").write_text(plugin_code)
|
||||
registry = PluginRegistry()
|
||||
loader = PluginLoader(registry)
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="plugin.loader"):
|
||||
count = asyncio.run(loader.load_from_directory(tmp_path))
|
||||
|
||||
assert count == 0
|
||||
assert any("failed initialization" in r.message for r in caplog.records)
|
||||
@@ -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