Compare commits

...

36 Commits

Author SHA1 Message Date
Andreas Wrede daf5277507 version 5.1.0
Release / release (push) Successful in 5s
2026-04-11 15:26:37 -04:00
Andreas Wrede ee3b72878f Add a ping monitor 2026-04-11 15:25:23 -04:00
Andreas Wrede 6217f7a124 fix bogus notification on new clients 2026-04-10 13:39:18 -04:00
Andreas Wrede 2468386f24 adjust default log, pick and config locations. renotify on critical only, make user sessions persistem 2026-04-10 13:24:57 -04:00
Andreas Wrede 2015195112 Grace interval on restart of hbd, fix SIGHUP processing 2026-04-10 12:58:38 -04:00
Andreas Wrede 3426185383 Set SO_TIMESTAMP correctly for the various platforms 2026-04-10 11:19:47 -04:00
Andreas Wrede 9eedbafe97 Show overdue in alerts instead of null 2026-04-10 09:20:28 -04:00
Andreas Wrede a5f31c5cb5 update picked data strucures 2026-04-10 09:18:38 -04:00
Andreas Wrede 2f72cf0118 typo 2026-04-10 09:17:57 -04:00
Andreas Wrede c56e77c2c1 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-10 08:20:40 -04:00
Andreas Wrede e9aa7a6f8b info only if no nagios command is defined 2026-04-10 08:19:59 -04:00
Andreas Wrede a75a8a4087 warn only if no nagios command is defined 2026-04-10 08:14:31 -04:00
Andreas Wrede ba27d2e300 Add count to rtt threshold 2026-04-10 08:07:50 -04:00
Andreas Wrede 381e37efce fix log-section height 2026-04-10 08:01:22 -04:00
Andreas Wrede 97dfc08f4d fix log level settiung 2026-04-10 08:00:51 -04:00
Andreas Wrede d281ac5a70 provide defaults for threshold_configs 2026-04-10 07:47:39 -04:00
Andreas Wrede 812bbf8555 Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-09 13:02:17 -04:00
Andreas Wrede e6b7a1aa27 drop config file 2026-04-09 13:02:10 -04:00
Andreas Wrede 90f47ad018 drop config file 2026-04-09 13:00:07 -04:00
Andreas Wrede cc458e8972 update README 2026-04-09 08:33:25 -04:00
andreas 79bf00abfd version 5.0.12
Release / release (push) Successful in 6s
2026-04-08 16:47:12 -04:00
andreas d77277857f Add user management and a settings page 2026-04-08 16:21:55 -04:00
Andreas Wrede 3232239a85 version 5.0.11
Release / release (push) Successful in 5s
2026-04-07 14:19:46 -04:00
Andreas Wrede 014781de5e Merge branch 'master' of git.wrede.ca:andreas/heartbeat 2026-04-07 14:16:12 -04:00
Andreas Wrede 68b1c65384 version 5.0.10 2026-04-07 14:15:46 -04:00
Andreas Wrede e8bb553349 version 5.0.10
Release / release (push) Failing after 4s
2026-04-07 14:11:03 -04:00
Andreas Wrede e4ecb8723f release a pypi package on gitea 2026-04-07 14:10:07 -04:00
Andreas Wrede 5edbaacf81 version 5.0.9
Release / release (push) Successful in 15s
2026-04-07 11:02:19 -04:00
Andreas Wrede 8421f472f2 there is only one __version__ 2026-04-07 11:00:22 -04:00
Andreas Wrede 51f9bdc2b5 use SO_TIMESTAMP, works on Linux, FreeBSD and macOS 2026-04-07 10:46:54 -04:00
andreas 02bc42fbf0 get rtt time differently 2026-04-07 10:40:12 -04:00
andreas 832a8b0bda save state to pickle file, restart timers on restart 2026-04-06 17:24:59 -04:00
Andreas Wrede 57c4b86430 version 5.0.8
Release / release (push) Successful in 6s
2026-04-04 15:18:12 -04:00
Andreas Wrede 43fad7beed fix release.yml for freebsd runner 2026-04-04 15:11:56 -04:00
Andreas Wrede 8dd002d159 version 5.0.7
Release / release (push) Failing after 1s
2026-04-04 14:45:10 -04:00
Andreas Wrede 2373b55d8b fix actions host label. cp[e woth debian flavor sed 2026-04-04 14:43:07 -04:00
41 changed files with 3244 additions and 679 deletions
+17 -4
View File
@@ -6,15 +6,21 @@ on:
jobs: jobs:
release: release:
runs-on: ubuntu-latest runs-on: FreeBSD
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
# - name: Set up Python
# uses: actions/setup-python@v5
# with:
# python-version: '3.11'
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v5 # Use a generic run step for FreeBSD if actions/setup-python
with: # fails in restricted environments.
python-version: '3.11' run: |
python3 --version
python3 -m ensurepip --upgrade
- name: Install build tools - name: Install build tools
run: | run: |
@@ -28,6 +34,13 @@ jobs:
id: get_version id: get_version
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Upload to Gitea PyPI registry
env:
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/*
- name: Create release - name: Create release
uses: actions/gitea-release-action@v1 uses: actions/gitea-release-action@v1
with: with:
+1
View File
@@ -11,3 +11,4 @@ dist/
*.egg-info/ *.egg-info/
ssl/ ssl/
uv.lock uv.lock
.hb.yaml
-254
View File
@@ -1,254 +0,0 @@
#name: "w02"
hb_port: 50003
hbd_host: ''
#logfile: "/home/andreas/public_html/messages/andreas"
logfile: "/home/andreas/logs/heartbeat/heartbeat.log"
#logfile: "/Users/andreas/public_html/messages/andreas"
logfmt: "msg"
grace: 40
interval: 10
# Notification Channels - Define notification providers centrally
# Each channel has a type (pushover, email, signal, mattermost) and type-specific configuration
notification_channels:
pushover_standard:
type: pushover
token: ac7NLX2rPjXFareeDgLpXNoDf4iFmf
user: uDhH33UjQQDYtNzJb1ThRiWb9ingGK
signal_andreas:
type: signal
cli_path: /usr/local/bin/signal-cli
user: +14168226179
recipient: +14168226179
email_andreas:
type: email
recipients: [aew.hbd.notify@wrede.ca]
sender: aew.hbd@wrede.ca
smtp_server: smtp.fastmail.com
smtp_port: 587
smtp_user: andreas@wrede.ca
smtp_password: pvtvefyp5gbhnch2
# Example additional channels (commented out)
# pushover_urgent:
# type: pushover
# token: your-app-token
# user: your-user-key
#
mattermost_devops:
type: mattermost
host: mattermost.example.com
token: webhook-token
channel: devops-alerts
username: heartbeat-bot
icon: https://example.com/heartbeat-icon.png
# Default notification channels (used if host doesn't specify channels)
default_notification_channels: [pushover_standard]
# Host definitions - combines threshold mapping, watch status, DNS updates, and notifications
hosts:
wentworth:
threshold_config: default
watch: true
notification_channels: [pushover_standard]
dyndns: false
y:
threshold_config: default
watch: true
notification_channels: [pushover_standard]
dyndns: false
winter:
threshold_config: default
watch: true
notification_channels: [pushover_standard]
dyndns: false
wally:
threshold_config: freebsd_server
watch: false
notification_channels: [pushover_standard]
dyndns: false
eris:
threshold_config: truenas_server
watch: false
notification_channels: [pushover_standard]
dyndns: false
haschloss:
threshold_config: default
watch: false
dyndns: true
wayback:
threshold_config: default
watch: false
notification_channels: [pushover_standard]
dyndns: true
wertvoll:
threshold_config: default
watch: false
notification_channels: [pushover_standard]
dyndns: true
weekend:
threshold_config: freebsd_server
watch: false
notification_channels: [pushover_standard]
dyndns: true
cotgate:
threshold_config: default
watch: false
dyndns: true
rvgate:
threshold_config: default
watch: false
dyndns: true
draper:
threshold_config: default
watch: false
notification_channels: [pushover_standard]
dyndns: true
# Hosts to drop/ignore
drophosts: {"unknown", "wookie15", "wort"}
nsupdate_bin: "/usr/local/bin/nsupdate"
dyndomains: {"wrede.org"}
ws_port: 50005
# wss_port: 50006 # Commented out - use plain WebSocket instead of secure WSS
# cert_path: "/usr/local/etc/letsencrypt/live/hbd.wrede.ca/"
# cert_path: "test/"
# CERT_PATH = "./test/"
# wss_pem: "fullchain.pem"
# wss_key: "privkey.pem"
journal_enabled: true # Enable/disable journaling
journal_dir: /home/andreas/logs/heartbeat # Journal directory
journal_file: messages.journal # Base filename
journal_max_size: 104857600 # Max size (100MB default)
journal_max_backups: 10 # Number of backups to keep
threshold_configs:
default:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
memory_monitor:
percent:
warning: 85.0
critical: 95.0
disk_monitor:
partitions:
/:
percent:
warning: 85.0
critical: 90.0
rtt:
warning: 50
critical: 250.0
freebsd_server:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
memory_monitor:
memory_percent:
warning: 97.0
critical: 100.0
disk_monitor:
partitions:
/:
percent:
warning: 85.0
critical: 90.0
nagios_runner:
# overall_status_code:
# warning: 1
# critical: 2
# operator: ">="
load_status:
warning: WARNING
critical: CRITICAL
operator: "=="
ups_load:
display: "load to high: {ups_output}"
warning: 70
critical: 80
operator: ">="
ups_status_code:
display: "{ups_output}"
warning: 1
critical: 2
operator: ">="
nextcloud_apps_status_code:
display: "{nextcloud_apps_output}"
warning: 1
critical: 2
operator: ">="
rtt:
warning: 50
critical: 250.0
truenas_server:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
memory_monitor:
percent:
warning: 3.0
critical: 95.0
disk_monitor:
partitions:
/:
percent:
warning: 85.0
critical: 90.0
nagios_runner:
# overall_status_code:
# warning: 1
# critical: 2
# operator: ">="
load_status:
warning: WARNING
critical: CRITICAL
operator: "=="
ups_load:
display: "load to high: {ups_output}"
WARNING: 70
CRITICAL: 80
OPERATOR: ">="
ups_status_code:
DISPLAY: "{ups_output}"
warning: 1
critical: 2
operator: ">="
nextcloud_apps_status_code:
display: "{nextcloud_apps_output}"
warning: 1
critical: 2
operator: ">="
rtt:
warning: 120
critical: 250.0
+6 -5
View File
@@ -4,12 +4,13 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python: Run hbd (module)", "name": "Python: Run hbd (module)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "hbd.server.cli", "module": "hbd.server.cli",
"args": ["-c", "/home/andreas/git/heartbeat/.hb.yaml", "-f", "-v", "-x", "-x", "-x", "-x"], "args": ["-c", "~/.hb.yaml", "-f", "-v"],
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"env": { "env": {
"PYTHONPATH": "${workspaceFolder}" "PYTHONPATH": "${workspaceFolder}"
@@ -28,14 +29,14 @@
] ]
}, },
{ {
"name": "Python: Run hbd with debugpy (listen)", "name": "Python: Run hbc (module)",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"module": "debugpy", "module": "hbd.client.main",
"args": ["--listen", "5678", "--wait-for-client", "-m", "hbd.server.cli", "-c", ".hb.yaml", "-f", "-v"], "args": ["-c", "~/.hbc.yaml", "-v", "winter"],
"cwd": "${workspaceFolder}",
"env": { "PYTHONPATH": "${workspaceFolder}" }, "env": { "PYTHONPATH": "${workspaceFolder}" },
"console": "integratedTerminal", "console": "integratedTerminal",
"justMyCode": false
} }
] ]
} }
+123 -82
View File
@@ -11,8 +11,13 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k
- Queue DNS updates via `nsupdate` and run them in a background thread ✅ - Queue DNS updates via `nsupdate` and run them in a background thread ✅
- WebSocket API for live updates (hosts & messages) ✅ - WebSocket API for live updates (hosts & messages) ✅
- Notification pipeline (email, Pushover, Mattermost, Signal) ✅ - Notification pipeline (email, Pushover, Mattermost, Signal) ✅
- **User management & access control** ✅
- Optional user accounts with bcrypt-style password hashing (stdlib only)
- Per-host roles: owner, manager, monitor
- Session-based auth with cookie support (browser login page included)
- Backwards compatible: no auth required when no users are configured
- **HTTP API & Web UI** ✅ - **HTTP API & Web UI** ✅
- REST API for plugin data, alerts, and host information - REST API for plugin data, alerts, host information, and user management
- Live dashboard with WebSocket updates - Live dashboard with WebSocket updates
- Interactive plugin metrics visualization - Interactive plugin metrics visualization
- Alerts dashboard with filtering and summaries - Alerts dashboard with filtering and summaries
@@ -71,7 +76,7 @@ See [docs/NAGIOS_INTEGRATION.md](docs/NAGIOS_INTEGRATION.md) for complete integr
### Creating Custom Plugins ### Creating Custom Plugins
```python ```python
from hbd.plugin import MonitorPlugin from hbd.client.plugin import MonitorPlugin
class DiskMonitorPlugin(MonitorPlugin): class DiskMonitorPlugin(MonitorPlugin):
name = "disk_monitor" name = "disk_monitor"
@@ -84,7 +89,7 @@ class DiskMonitorPlugin(MonitorPlugin):
} }
``` ```
Place plugins in `hbd/plugins/` and they'll be automatically discovered and loaded by the client. Place plugins in `hbd/client/plugins/` and they'll be automatically discovered and loaded by the client.
--- ---
@@ -266,77 +271,93 @@ See [docs/THRESHOLD_ALERTING.md](docs/THRESHOLD_ALERTING.md) for comprehensive d
--- ---
## 👥 User Management
Heartbeat supports optional user accounts with role-based access control per host.
### Roles
- **monitor** — view status, plugin data, alerts
- **manager** — monitor + queue commands, trigger DNS, queue upgrades
- **owner** — manager + drop host, transfer ownership, update access
- **admin** (user flag) — owner-level access on every host
When no users are configured the server runs in **unauthenticated mode** — all existing behaviour is unchanged.
### Quick setup
```yaml
users:
alice:
full_name: Alice Smith
password: pbkdf2:sha256:... # hbd passwd alice
admin: true
default_owner: alice
hosts:
webserver01:
owner: alice
managers: [bob]
monitors: [carol]
```
```bash
# Generate a password hash
hbd passwd alice
```
Browser users are redirected to `/login` automatically. The session cookie is set on login, so `fetch()` calls from dashboards work without any JavaScript changes.
See [docs/USERS.md](docs/USERS.md) for complete user management documentation.
---
## 🌐 HTTP API & Web UI ## 🌐 HTTP API & Web UI
Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST API and web-based dashboards for monitoring and visualization. Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST API and web-based dashboards for monitoring and visualization.
### Features ### Features
- **REST API**: JSON endpoints for accessing plugin data, alerts, and host information - **User auth**: Optional session-based authentication with per-host role enforcement
- **REST API**: JSON endpoints for accessing plugin data, alerts, host information, and user management
- **Live Dashboard**: Real-time WebSocket-powered host status view - **Live Dashboard**: Real-time WebSocket-powered host status view
- **Plugin Metrics**: Interactive visualization of all plugin data with auto-refresh - **Plugin Metrics**: Interactive visualization of all plugin data with auto-refresh
- **Alerts Dashboard**: Comprehensive alert monitoring with filtering and summaries - **Alerts Dashboard**: Comprehensive alert monitoring with filtering and summaries
- **CORS Support**: Configurable for integration with external applications
### Web Dashboards ### Web Dashboards
- **Live View** (`/live`): Real-time host connectivity, latency, and messages - **Login** (`/login`): Browser login form (shown automatically when auth is configured)
- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins - **Live View** (`/live`): Real-time host connectivity, latency, and messages
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering - **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering
### API Endpoints ### API Endpoints
```bash ```bash
# Log in (when auth is configured)
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"secret"}' | jq -r .token)
AUTH="-H \"Authorization: Bearer $TOKEN\""
# List all monitored hosts # List all monitored hosts
curl http://localhost:50004/api/0/hosts curl $AUTH http://localhost:50004/api/0/hosts
# Get all plugin data for a host # Get all plugin data for a host
curl http://localhost:50004/api/0/hosts/webserver01/plugins curl $AUTH http://localhost:50004/api/0/hosts/webserver01/plugins
# Get detailed plugin history (last 50 samples) # Get detailed plugin history (last 50 samples)
curl http://localhost:50004/api/0/hosts/webserver01/plugins/cpu_monitor?limit=50 curl $AUTH "http://localhost:50004/api/0/hosts/webserver01/plugins/cpu_monitor?limit=50"
# Get alert states for a specific host # Get alert states for a specific host
curl http://localhost:50004/api/0/hosts/webserver01/alerts curl $AUTH http://localhost:50004/api/0/hosts/webserver01/alerts
# Get all active alerts across all hosts # Get all active alerts across all hosts
curl http://localhost:50004/api/0/alerts curl $AUTH http://localhost:50004/api/0/alerts
```
### Integration Examples # View/update host access roles
curl $AUTH http://localhost:50004/api/0/hosts/webserver01/access
**Python Client:**
```python
import requests
# Monitor for critical alerts
response = requests.get('http://localhost:50004/api/0/alerts')
alerts = response.json()
if alerts['summary']['critical'] > 0:
print(f"⚠️ {alerts['summary']['critical']} CRITICAL alerts!")
for alert in alerts['alerts']:
if alert['level'] == 'CRITICAL':
print(f" {alert['hostname']}: {alert['metric_path']} = {alert['last_value']}")
```
**Bash Monitoring Script:**
```bash
#!/bin/bash
# Check for critical alerts
CRITICAL=$(curl -s http://localhost:50004/api/0/alerts | jq '.summary.critical')
if [ "$CRITICAL" -gt 0 ]; then
echo "CRITICAL: $CRITICAL critical alerts detected!"
# Send notification
fi
```
### Demo & Testing
Run the API demo script to test all endpoints:
```bash
python3 scripts/demo_http_api.py
``` ```
See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation including response formats, error handling, and integration examples. See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation including response formats, error handling, and integration examples.
@@ -347,7 +368,7 @@ See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation includin
Prerequisites: Prerequisites:
- Python 3.10+ (project uses language features from recent Python) - Python 3.11+ (project uses language features from recent Python)
- `nsupdate` (for DNS updates) if using dynamic DNS - `nsupdate` (for DNS updates) if using dynamic DNS
Install dependencies (recommended into a venv): Install dependencies (recommended into a venv):
@@ -368,7 +389,7 @@ hbd -c .hb.yaml -f -v
You can also run it directly via the package entrypoint after installation: You can also run it directly via the package entrypoint after installation:
```bash ```bash
python -m hbd.cli -c /path/to/config.yaml python -m hbd.server.cli -c /path/to/config.yaml
``` ```
### Running the Client ### Running the Client
@@ -376,14 +397,23 @@ python -m hbd.cli -c /path/to/config.yaml
The heartbeat client (`hbc`) sends periodic heartbeats and plugin data to the server: The heartbeat client (`hbc`) sends periodic heartbeats and plugin data to the server:
```bash ```bash
# Basic usage pointing to server # Basic usage pointing to server (host is a positional argument)
python -m hbd.hbc --server your-server.example.com hbc your-server.example.com
# With custom configuration # Run as daemon with a config file
python -m hbd.hbc --server 192.168.1.100 --port 50003 --interval 30 hbc -d -c /etc/hbc.yaml your-server.example.com
# Run with specific plugins enabled/disabled # Send a one-off boot message
python -m hbd.hbc --server hbd.local --disable-plugin os_info hbc --boot your-server.example.com
# Verbose output
hbc -v your-server.example.com
```
You can also run it via the module entrypoint:
```bash
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:
@@ -417,30 +447,29 @@ This repository includes a ready-to-use `.vscode/launch.json` with configuration
- Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code). - Ensure the **Python** extension is installed and select the project `.venv` as the interpreter (bottom-left of VS Code).
- Use **F5** and pick one of these configurations from the Run view: - Use **F5** and pick one of these configurations from the Run view:
- **Python: Run hbd (module)** — runs `hbd.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended). - **Python: Run hbd (module)** — runs `hbd.server.cli` as a module and sets `PYTHONPATH` to the workspace root (recommended).
- **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger. - **Python: Run hbd with debugpy (listen)** — launches `debugpy` and `hbd` together; useful when you want the process to listen for a debugger.
- **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`. - **Python: Attach (localhost:5678)** — attach the debugger to a running process started with `debugpy`.
To start `hbd` manually and wait for the debugger to attach, run: To start `hbd` manually and wait for the debugger to attach, run:
```bash ```bash
PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.cli -c .hb.yaml -f -v PYTHONPATH=. python -m debugpy --listen 5678 --wait-for-client -m hbd.server.cli -c .hb.yaml -f -v
``` ```
Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code. Set breakpoints in modules such as `hbd/server/udp.py`, `hbd/server/dns.py`, or `hbd/server/main.py`, and use the **Attach** configuration to connect. Use `justMyCode: false` if you need to step into third-party code.
--- ---
## 🛠 Configuration ## 🛠 Configuration
`hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/config.py`): `hbd` reads YAML configuration (optional). If `PyYAML` is not installed, built-in defaults are used. Example configuration keys (see `hbd/server/config.py`):
- `hb_port`: UDP port to listen for heartbeats (default: 50003) - `hb_port`: UDP port to listen for heartbeats (default: 50003)
- `hbd_port`: internal control port (default: 50004) - `hbd_port`: internal control port (default: 50004)
- `hbd_host`: bind address for HTTP/WSS - `hbd_host`: bind address for HTTP/WSS
- `pickfile`: path for persisted state - `pickfile`: path for persisted state
- `logfile`: path to log file - `logfile`: path to log file
- `logfmt`: `text` or `msg`
- `pushsrv`: push service (`pushover`|`mattermost`|`all`) - `pushsrv`: push service (`pushover`|`mattermost`|`all`)
- `interval` / `grace`: heartbeat timing configuration - `interval` / `grace`: heartbeat timing configuration
- `dyndomains`: list of dyndomains to update via `nsupdate` - `dyndomains`: list of dyndomains to update via `nsupdate`
@@ -452,6 +481,8 @@ Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py
- `cert_path`: directory where TLS certificate and key are looked up (default: /usr/local/etc/ssl/) - `cert_path`: directory where TLS certificate and key are looked up (default: /usr/local/etc/ssl/)
- `wss_pem`: filename for the certificate chain (default: fullchain.pem) - `wss_pem`: filename for the certificate chain (default: fullchain.pem)
- `wss_key`: filename for the private key (default: privkey.pem) - `wss_key`: filename for the private key (default: privkey.pem)
- `users`: mapping of username → user attributes (full_name, avatar, password, admin, notification_channels)
- `default_owner`: username that owns hosts with no explicit owner (falls back to first admin user)
Example `.hb.yaml` (minimal): Example `.hb.yaml` (minimal):
@@ -464,29 +495,39 @@ nsupdate_bin: /usr/bin/nsupdate
pushsrv: pushover pushsrv: pushover
``` ```
> Tip: `config.DEFAULTS` in `hbd/config.py` contains the canonical defaults and accepted configuration keys. > Tip: `SERVER_DEFAULTS` in `hbd/server/config.py` contains the canonical defaults and accepted configuration keys.
--- ---
## 🔧 Architecture & Modules ## 🔧 Architecture & Modules
- `hbd.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads and plugin data) The package is organized into three subpackages:
- `hbd.udp` — UDP parsing and `handle_datagram` implementation (main state machine)
- `hbd.dns` — `create_nsupdate_payload`, `nsupdate`, and an asyncio DNS worker (`start_dns_worker`). **`hbd.common`** — shared code used by both client and server:
The DNS worker now runs as an `asyncio` task and the package exposes a - `hbd.common.proto` — serialization/deserialization of heartbeat messages (supports compressed payloads and plugin data)
small thread-safe bridge so legacy synchronous code can `put()` updates - `hbd.common.utils` — small utility helpers (`shortname`, `dur`, `initlog`)
into the queue; there is no longer a permanently-blocking background
`threading.Thread`. **`hbd.server`** — the heartbeat daemon (`hbd`):
- `hbd.notify` — email and push notification helpers - `hbd.server.cli` — CLI entrypoint and argument parsing
- `hbd.ws` — WebSocket server and thread-safe broadcast helpers - `hbd.server.main` — async orchestration to run UDP/HTTP/WSS components
- `hbd.http` — HTTP handler factory for the status UI/API - `hbd.server.udp` — UDP parsing and `handle_datagram` implementation (main state machine)
- `hbd.journal` — message journal with size-based log rotation and backup management - `hbd.server.dns` — `create_nsupdate_payload`, `nsupdate`, and an asyncio DNS worker (`start_dns_worker`).
- `hbd.plugin` — plugin framework with base classes, registry, and dynamic loader The DNS worker runs as an `asyncio` task and the package exposes a small thread-safe bridge
- `hbd.plugins/` — built-in plugins (os_info, cpu_monitor, memory_monitor, disk_monitor, network_monitor, filesystem_info, nagios_runner) so legacy synchronous code can `put()` updates into the queue.
- `hbd.hbc` — heartbeat client that sends heartbeats and plugin data to server - `hbd.server.notify` — email and push notification helpers
- `hbd.utils` — small utility helpers (`shortname`, `dur`, `initlog`) - `hbd.server.ws` — WebSocket server and thread-safe broadcast helpers
- `hbd.cli` — CLI entrypoint and argument parsing - `hbd.server.http` — HTTP handler factory for the status UI/API
- `hbd.server` — async orchestration to run UDP/HTTP/WSS components - `hbd.server.journal` — message journal with size-based log rotation and backup management
- `hbd.server.threshold` — threshold alerting engine
- `hbd.server.monitor` — host state monitoring
- `hbd.server.hbdclass` — `Host` class and shared server state
- `hbd.server.config` — configuration loader and defaults
**`hbd.client`** — the heartbeat client (`hbc`):
- `hbd.client.main` — client entrypoint; sends heartbeats and plugin data to the server
- `hbd.client.plugin` — plugin framework with base classes, registry, and dynamic loader
- `hbd.client.plugins/` — built-in plugins (os_info, cpu_monitor, memory_monitor, disk_monitor, network_monitor, filesystem_info, nagios_runner)
- `hbd.client.config` — client configuration loader
This modular layout makes the code easier to test and maintain. This modular layout makes the code easier to test and maintain.
@@ -494,12 +535,12 @@ This modular layout makes the code easier to test and maintain.
- The main runtime is asyncio-based. Services (UDP listener, HTTP server, WebSocket server, monitor, and DNS worker) run as asyncio tasks. - The main runtime is asyncio-based. Services (UDP listener, HTTP server, WebSocket server, monitor, and DNS worker) run as asyncio tasks.
- On SIGINT/SIGTERM the server triggers a graceful shutdown: it cancels active tasks, signals the DNS worker via a sentinel, and cleans up resources before exit. - On SIGINT/SIGTERM the server triggers a graceful shutdown: it cancels active tasks, signals the DNS worker via a sentinel, and cleans up resources before exit.
- The DNS update worker is implemented as an `asyncio` task; synchronous producers can still enqueue DNS updates via a small thread-safe bridge available at `hbd.hbdclass.Host.dnsQ`. - The DNS update worker is implemented as an `asyncio` task; synchronous producers can still enqueue DNS updates via a small thread-safe bridge available at `hbd.server.hbdclass.Host.dnsQ`.
**Templates & Static Files** **Templates & Static Files**
- Template files are located under `hbd/templates` by default. The HTTP server resolves templates relative to the `hbd` package but the path can be overridden with the `templates_dir` config key. - Template files are located under `hbd/server/templates`. The HTTP server resolves templates relative to the `hbd.server` package but the path can be overridden with the `templates_dir` config key.
- Static assets (CSS/JS/images) are served from `hbd/static` via the `/static/<path>` HTTP route. Place your static files in that directory or configure the HTTP server as needed. - Static assets (CSS/JS/images) are served from `hbd/server/static` via the `/static/<path>` HTTP route.
--- ---
+1 -1
View File
@@ -59,7 +59,7 @@ Server-specific defaults:
- `hb_port`: Port to listen for heartbeats (default: 50003) - `hb_port`: Port to listen for heartbeats (default: 50003)
- `hbd_port`: HTTP API port (default: 50004) - `hbd_port`: HTTP API port (default: 50004)
- `ws_port`: WebSocket port (default: 50005) - `ws_port`: WebSocket port (default: 50005)
- `logfile`, `logfmt`: Logging configuration - `logfile`: Log file path
- `pushsrv`, `pushover_token`, etc.: Notification settings - `pushsrv`, `pushover_token`, etc.: Notification settings
- `watchhosts`, `dyndnshosts`: Host monitoring - `watchhosts`, `dyndnshosts`: Host monitoring
- `smtpserver`, etc.: Email settings - `smtpserver`, etc.: Email settings
-1
View File
@@ -81,7 +81,6 @@ The following settings **cannot** be reloaded and require a service restart:
- **Logging** - **Logging**
- `logfile` - Log file path - `logfile` - Log file path
- `logfmt` - Log format
- **Journal Settings** - **Journal Settings**
- `journal_enabled` - Enable/disable journaling - `journal_enabled` - Enable/disable journaling
+105 -4
View File
@@ -15,12 +15,49 @@ Default port is `50004` (configurable via `hbd_port` in configuration).
--- ---
## Authentication
When [user accounts are configured](USERS.md), every request must be authenticated.
- **Browser requests** to HTML pages are redirected to `/login` automatically. JavaScript `fetch()` calls on the dashboards send the session cookie automatically — no JS changes are needed.
- **API / programmatic requests** must include the token in an `Authorization: Bearer <token>` header or an `X-Auth-Token` header.
Unauthenticated API requests receive `401 Unauthorized`. When no users are configured the server runs in unauthenticated mode and all endpoints are open.
### Login
```bash
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"secret"}' | jq -r .token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:50004/api/0/hosts
```
See [User Management](USERS.md) for full authentication documentation.
---
## API Endpoints ## API Endpoints
### Authentication
| Method | Path | Description | Auth required |
|--------|------|-------------|---------------|
| `POST` | `/api/0/auth/login` | Obtain session token | No |
| `POST` | `/api/0/auth/logout` | Invalidate session | Token |
### Users
| Method | Path | Description | Role |
|--------|------|-------------|------|
| `GET` | `/api/0/users` | List all users | Admin |
| `GET` | `/api/0/users/me` | Own profile | Authenticated |
### Host Management ### Host Management
#### GET /api/0/hosts #### GET /api/0/hosts
Get list of all monitored hosts with their state information. Get list of all monitored hosts with their state information. When auth is enabled, only hosts the caller has at least **monitor** access to are returned.
**Response:** **Response:**
```json ```json
@@ -28,6 +65,9 @@ Get list of all monitored hosts with their state information.
{ {
"name": "webserver01", "name": "webserver01",
"dyn": false, "dyn": false,
"owner": "alice",
"managers": ["bob"],
"monitors": ["carol"],
"connections": [...] "connections": [...]
} }
] ]
@@ -137,6 +177,32 @@ curl http://localhost:50004/api/0/hosts/database01/plugins/disk_monitor
--- ---
### Host Access
#### GET /api/0/hosts/{hostname}/access
Get owner/managers/monitors for a host. Requires **monitor** role or higher.
**Response:**
```json
{
"owner": "alice",
"managers": ["bob"],
"monitors": ["carol"]
}
```
#### PUT /api/0/hosts/{hostname}/access
Update owner/managers/monitors. Requires **owner** role or admin.
**Request body** (all fields optional):
```json
{ "owner": "bob", "managers": ["carol"], "monitors": [] }
```
Changes take effect immediately but are not written back to the config file. Update the config file and send `SIGHUP` to make them permanent.
---
### Alert Endpoints ### Alert Endpoints
#### GET /api/0/hosts/{hostname}/alerts #### GET /api/0/hosts/{hostname}/alerts
@@ -226,6 +292,16 @@ curl http://localhost:50004/api/0/alerts | jq .
## Web UI Pages ## Web UI Pages
### Login
**URL:** `/login`
Shown automatically when a browser request is made without a valid session (when users are configured). After successful login the browser is redirected to the originally requested page.
### Logout
**URL:** `/logout`
Clears the session cookie and redirects to `/login`.
### Live Dashboard ### Live Dashboard
**URL:** `/live` **URL:** `/live`
@@ -288,7 +364,13 @@ Comprehensive alert monitoring:
#!/bin/bash #!/bin/bash
# Check for critical alerts and send notification # Check for critical alerts and send notification
RESPONSE=$(curl -s http://localhost:50004/api/0/alerts) # Log in first (when auth is configured)
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"monitor","password":"secret"}' | jq -r .token)
AUTH="-H \"Authorization: Bearer $TOKEN\""
RESPONSE=$(curl -s $AUTH http://localhost:50004/api/0/alerts)
CRITICAL_COUNT=$(echo "$RESPONSE" | jq '.summary.critical') CRITICAL_COUNT=$(echo "$RESPONSE" | jq '.summary.critical')
if [ "$CRITICAL_COUNT" -gt 0 ]; then if [ "$CRITICAL_COUNT" -gt 0 ]; then
@@ -305,8 +387,16 @@ fi
import requests import requests
import json import json
BASE = 'http://localhost:50004'
# Log in (skip if auth not configured)
resp = requests.post(f'{BASE}/api/0/auth/login',
json={"username": "alice", "password": "secret"})
token = resp.json().get("token")
headers = {"Authorization": f"Bearer {token}"} if token else {}
# Get all plugin data for a host # Get all plugin data for a host
response = requests.get('http://localhost:50004/api/0/hosts/webserver01/plugins') response = requests.get(f'{BASE}/api/0/hosts/webserver01/plugins', headers=headers)
data = response.json() data = response.json()
print(f"Host: {data['hostname']}") print(f"Host: {data['hostname']}")
@@ -318,7 +408,7 @@ for plugin, info in data['plugins'].items():
print(f" {metric}: {value}") print(f" {metric}: {value}")
# Check for alerts # Check for alerts
response = requests.get('http://localhost:50004/api/0/alerts') response = requests.get(f'{BASE}/api/0/alerts', headers=headers)
alerts = response.json() alerts = response.json()
if alerts['summary']['critical'] > 0: if alerts['summary']['critical'] > 0:
@@ -389,6 +479,8 @@ API errors return appropriate HTTP status codes with JSON:
**Common Status Codes:** **Common Status Codes:**
- `200 OK` - Success - `200 OK` - Success
- `400 Bad Request` - Invalid parameters - `400 Bad Request` - Invalid parameters
- `401 Unauthorized` - Missing or invalid session token
- `403 Forbidden` - Authenticated but insufficient role
- `404 Not Found` - Resource not found - `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error - `500 Internal Server Error` - Server error
@@ -506,6 +598,14 @@ for route in list(app.router.routes()):
## Troubleshooting ## Troubleshooting
### API Returns 401
- Auth is configured — include `Authorization: Bearer <token>` header
- Token may have expired (24 h TTL) — log in again
### API Returns 403
- Authenticated user lacks the required role for this host/action
- Check host's `owner`, `managers`, `monitors` config
### API Returns 404 ### API Returns 404
- Verify hostname in URL matches actual host name - Verify hostname in URL matches actual host name
- Check host is sending heartbeats: `curl http://localhost:50004/api/0/hosts` - Check host is sending heartbeats: `curl http://localhost:50004/api/0/hosts`
@@ -525,6 +625,7 @@ for route in list(app.router.routes()):
## See Also ## See Also
- [User Management](USERS.md)
- [Plugin Development Guide](PLUGIN_DEVELOPMENT.md) - [Plugin Development Guide](PLUGIN_DEVELOPMENT.md)
- [Threshold Alerting Documentation](THRESHOLD_ALERTING.md) - [Threshold Alerting Documentation](THRESHOLD_ALERTING.md)
- [Message Journal Documentation](MESSAGE_JOURNAL.md) - [Message Journal Documentation](MESSAGE_JOURNAL.md)
+242
View File
@@ -0,0 +1,242 @@
# User Management
Heartbeat supports optional user accounts with role-based access control per host. When no users are configured the server runs in **unauthenticated mode** — all existing behaviour is unchanged.
---
## Overview
Users are defined in the server config file. Each host can have an **owner**, zero or more **managers**, and zero or more **monitors**. A **default owner** catches any host that does not name an explicit owner.
### Roles
| Role | Inherits | Permissions |
|------|----------|-------------|
| **monitor** | — | View host status, plugin data, alerts; acknowledge alerts they were notified for |
| **manager** | monitor | + Queue commands (`/c`), trigger DNS re-registration (`/n`), queue upgrades (`/u`); add/remove monitors |
| **owner** | manager | + Drop host (`/d`); add/remove managers; transfer ownership; update host access |
| **admin** *(flag)* | owner on all hosts | Full access to every host and the user list |
`admin` is a flag on the user, not a per-host role. An admin user has owner-level access on every host without being listed as owner/manager/monitor.
---
## Configuration
### Defining users
```yaml
users:
andreas:
full_name: Andreas Wrede
avatar: /path/to/avatar.png # file path, URL, or base64 data URI (optional)
password: pbkdf2:sha256:... # generated with: hbd passwd andreas
admin: true # optional — grants server-wide owner access
bob:
full_name: Bob Smith
password: pbkdf2:sha256:...
notification_channels: [pushover_standard]
carol:
full_name: Carol Jones
password: pbkdf2:sha256:...
default_owner: andreas # owns hosts with no explicit owner
# falls back to the first admin user if omitted
```
### Assigning roles to hosts
```yaml
hosts:
webserver01:
owner: andreas
managers: [bob]
monitors: [carol]
threshold_config: default
watch: true
notification_channels: [pushover_standard]
unattended-host: # no owner → owned by default_owner
threshold_config: default
watch: true
```
### Generating a password hash
```bash
hbd passwd andreas
```
Enter and confirm the password when prompted. Paste the printed hash into the config file under the user's `password` key.
You can also generate a hash non-interactively from Python:
```python
from hbd.server.users import hash_password
print(hash_password("mysecret"))
```
Passwords are stored as PBKDF2-HMAC-SHA256 hashes (260 000 iterations). No third-party libraries are required — only Python's standard `hashlib`.
---
## Authentication
When at least one user is defined, every request must be authenticated. Unauthenticated requests to HTML pages are redirected to `/login`; unauthenticated API requests receive `401 Unauthorized`.
### Browser login
Navigate to any page — you will be redirected to `/login` automatically. After submitting valid credentials the server sets an `hbd_session` cookie (HttpOnly, SameSite=Lax, 24 h lifetime). All subsequent requests, including JavaScript `fetch()` calls on the dashboards, carry the cookie automatically.
To log out, visit `/logout`.
### API / programmatic login
```bash
# Log in and capture the token
TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"andreas","password":"mysecret"}' | jq -r .token)
# Use the token in subsequent requests
curl -H "Authorization: Bearer $TOKEN" http://localhost:50004/api/0/hosts
```
The token is identical to the session cookie value — both mechanisms work simultaneously.
```bash
# Log out
curl -s -X POST http://localhost:50004/api/0/auth/logout \
-H "Authorization: Bearer $TOKEN"
```
---
## API Endpoints
### Authentication
#### POST /api/0/auth/login
Obtain a session token.
**Request body:**
```json
{ "username": "andreas", "password": "mysecret" }
```
**Response:**
```json
{ "token": "<opaque-hex-token>", "username": "andreas" }
```
Also sets the `hbd_session` cookie for browser clients.
**Status codes:** `200 OK`, `401 Unauthorized`, `404` (auth not configured)
---
#### POST /api/0/auth/logout
Invalidate the current session.
**Headers:** `Authorization: Bearer <token>` or cookie
**Response:** `{ "success": true }`
---
### Users
#### GET /api/0/users
List all users. **Admin only.**
**Response:**
```json
[
{ "username": "andreas", "full_name": "Andreas Wrede", "avatar": "", "admin": true, "notification_channels": [] },
{ "username": "bob", "full_name": "Bob Smith", "avatar": "", "admin": false, "notification_channels": ["pushover_standard"] }
]
```
---
#### GET /api/0/users/me
Return the currently authenticated user's profile.
**Response:**
```json
{ "username": "carol", "full_name": "Carol Jones", "avatar": "", "admin": false, "notification_channels": [] }
```
---
### Host Access
#### GET /api/0/hosts/{hostname}/access
Return owner/managers/monitors for a host. Requires at least **monitor** role.
**Response:**
```json
{
"owner": "andreas",
"managers": ["bob"],
"monitors": ["carol"]
}
```
---
#### PUT /api/0/hosts/{hostname}/access
Update owner/managers/monitors. Requires **owner** role or admin.
**Request body** (all fields optional):
```json
{
"owner": "bob",
"managers": ["carol"],
"monitors": []
}
```
Changes take effect immediately in memory. They are not written back to the config file — reload (`SIGHUP`) will re-apply config values. To make changes permanent, update the config file.
---
## Host visibility
When users are configured, `GET /api/0/hosts` only returns hosts the authenticated user has at least monitor access to. Admins see all hosts.
---
## Config reload
On `SIGHUP`, the server reloads the config file, re-loads the user registry, and re-applies `owner`/`managers`/`monitors` from config to all known hosts. Existing sessions remain valid after a reload.
---
## No-auth mode
If `users:` is absent or empty, the server starts in **unauthenticated mode**:
- No login required — all pages and API endpoints are accessible without credentials.
- All permission checks pass unconditionally.
- `/login`, `/logout`, and the auth/user API endpoints return `404`.
This preserves full backwards compatibility with existing deployments.
---
## Security notes
- Session tokens are 64-character cryptographically random hex strings (`secrets.token_hex(32)`).
- Sessions expire after 24 hours (configurable via `users_mod.SESSION_TTL`).
- Cookies are `HttpOnly` and `SameSite=Lax` — they are not accessible to JavaScript and are not sent on cross-site requests.
- The HTTP API does not yet enforce TLS. For production use, place hbd behind a TLS-terminating reverse proxy (nginx, Caddy, etc.) or enable WSS.
---
## See Also
- [HTTP API Documentation](HTTP_API.md)
- [Notifications](NOTIFICATIONS.md)
- Configuration example: `hbd/config_example.yaml`
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
""" """
__all__ = ["__version__"] __all__ = ["__version__"]
__version__ = "5.0.6" __version__ = "5.1.0"
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Client (hbc) - System monitoring client.""" """HeartBeat Client (hbc) - System monitoring client."""
__version__ = "5.0.5" from hbd import __version__
+8 -4
View File
@@ -2,6 +2,9 @@
import logging import logging
import os import os
import logging
logger = logging.getLogger(__name__)
try: try:
import yaml import yaml
@@ -30,18 +33,19 @@ def load_config(path=None):
If YAML is not available or the file does not exist, defaults are returned. If YAML is not available or the file does not exist, defaults are returned.
Args: Args:
path: Path to YAML config file (default: ~/.hb.yaml) path: Path to YAML config file (default: ~/.hbc.yaml)
Returns: Returns:
Dictionary with configuration Dictionary with configuration
""" """
cfg = CLIENT_DEFAULTS.copy() cfg = CLIENT_DEFAULTS.copy()
if not path: if not path:
# default path (~/.hb.yaml) # default path (~/.hbc.yaml)
path = os.path.join(os.path.expanduser("~"), ".hb.yaml") path = os.path.join(os.path.expanduser("~"), ".hbc.yaml")
if os.path.exists(path): if os.path.exists(path):
if yaml: if yaml:
logger.info("Loading configuration from %s", path)
with open(path) as fh: with open(path) as fh:
data = yaml.safe_load(fh) data = yaml.safe_load(fh)
# Merge YAML data with defaults # Merge YAML data with defaults
@@ -50,5 +54,5 @@ def load_config(path=None):
cfg[k] = v cfg[k] = v
else: else:
# yaml not installed: do not attempt to parse; user must ensure defaults # yaml not installed: do not attempt to parse; user must ensure defaults
pass logger.warning("PyYAML not available - cannot load config from %s, using defaults", path)
return cfg return cfg
+5 -5
View File
@@ -644,13 +644,10 @@ def main(argv=None):
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
# Load config
config = load_config(args.configfile)
# Setup logging # Setup logging
log_level = logging.INFO log_level = logging.WARNING
if args.verbose: if args.verbose:
log_level = logging.DEBUG log_level = logging.INFO
if args.debug: if args.debug:
log_level = logging.DEBUG log_level = logging.DEBUG
@@ -659,6 +656,9 @@ def main(argv=None):
format="%(asctime)s %(name)s %(levelname)s: %(message)s", format="%(asctime)s %(name)s %(levelname)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S" datefmt="%Y-%m-%d %H:%M:%S"
) )
# Load config
config = load_config(args.configfile)
# Daemonize if requested # Daemonize if requested
if args.daemon: if args.daemon:
+4 -1
View File
@@ -311,7 +311,10 @@ class PluginLoader:
return 0 return 0
loaded_count = 0 loaded_count = 0
plugin_config = config or {} 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)
# Scan for Python files # Scan for Python files
for plugin_file in directory.glob("*.py"): for plugin_file in directory.glob("*.py"):
+2 -2
View File
@@ -81,7 +81,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
# Validate commands # Validate commands
if not self.commands: if not self.commands:
self.logger.warning( self.logger.info(
"No Nagios commands configured. Add 'nagios_runner.commands' to config." "No Nagios commands configured. Add 'nagios_runner.commands' to config."
) )
@@ -94,7 +94,7 @@ class NagiosRunnerPlugin(MonitorPlugin):
self.logger.info(f"Initializing {self.name} plugin") self.logger.info(f"Initializing {self.name} plugin")
if not self.commands: if not self.commands:
self.logger.error("No Nagios commands configured") self.logger.info("No Nagios commands configured")
return False return False
self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)") self.logger.info(f"Configured to run {len(self.commands)} Nagios plugin(s)")
+151
View File
@@ -0,0 +1,151 @@
"""Ping Monitor Plugin for Heartbeat.
Pings one or more hosts and reports round-trip time. Results are sent as
plugin metrics so the server-side threshold system can raise WARNING/CRITICAL
alerts using the same RTT threshold configuration format used for heartbeat RTT.
Example configuration in ~/.hbc.yaml (or the plugins section of ~/.hb.yaml):
```yaml
plugins:
ping_monitor:
interval: 60 # ping every 60 seconds (default)
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
```
Reported metrics per host (metric key uses the hostname with dots/colons replaced
by underscores so it is a valid identifier):
ping.<hostname>.rtt_avg average RTT in ms (float, or inf if unreachable)
ping.<hostname>.rtt_min minimum RTT in ms
ping.<hostname>.rtt_max maximum RTT in ms
ping.<hostname>.loss packet loss percentage (0100)
Server-side threshold config example:
```yaml
threshold_configs:
default:
thresholds:
ping_monitor:
8_8_8_8_rtt_avg:
warning: 20.0
critical: 100.0
```
"""
import asyncio
import re
import sys
from typing import Any, Dict, Optional
from hbd.client.plugin import MonitorPlugin
def _host_key(host: str) -> str:
"""Convert a hostname/IP to a safe metric key (replace . and : with _)."""
return re.sub(r"[^a-zA-Z0-9_]", "_", host)
class PingMonitorPlugin(MonitorPlugin):
"""Ping one or more configured hosts and report RTT metrics."""
name = "ping_monitor"
version = "1.0.0"
description = "ICMP ping latency monitoring"
interval = 60
def __init__(self, config: Optional[Dict[str, Any]] = None):
super().__init__(config)
cfg = config or {}
self.interval = cfg.get("interval", 60)
self.count = int(cfg.get("count", 3))
self.timeout = int(cfg.get("timeout", 5))
# hosts: dict of {hostname: {warning: x, critical: y}} or list of hostnames
raw_hosts = cfg.get("hosts", {})
if isinstance(raw_hosts, list):
self.hosts = {h: {} for h in raw_hosts}
else:
self.hosts = dict(raw_hosts)
async def initialize(self) -> bool:
if not self.hosts:
self.logger.warning("ping_monitor: no hosts configured, plugin disabled")
return False
self.logger.info(
"ping_monitor initialized: %d host(s), interval=%ds, count=%d, timeout=%ds",
len(self.hosts), self.interval, self.count, self.timeout,
)
return True
async def _ping(self, host: str) -> Dict[str, float]:
"""Run a system ping command and return rtt_min/avg/max/loss."""
if sys.platform == "win32":
cmd = ["ping", "-n", str(self.count), "-w", str(self.timeout * 1000), host]
else:
cmd = ["ping", "-c", str(self.count), "-W", str(self.timeout), host]
try:
proc = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, _ = await asyncio.wait_for(
proc.communicate(),
timeout=self.timeout * self.count + 2,
)
output = stdout.decode(errors="replace")
except (asyncio.TimeoutError, FileNotFoundError, OSError) as e:
self.logger.warning("ping_monitor: ping failed for %s: %s", host, e)
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": 100.0}
# Parse packet loss
loss = 100.0
loss_match = re.search(r"(\d+(?:\.\d+)?)\s*%\s*packet\s*loss", output)
if loss_match:
loss = float(loss_match.group(1))
# Parse rtt min/avg/max — Linux: "rtt min/avg/max/mdev = x/x/x/x ms"
# macOS: "round-trip min/avg/max/stddev = x/x/x/x ms"
rtt_match = re.search(
r"(?:rtt|round-trip)\s+min/avg/max/\S+\s*=\s*([\d.]+)/([\d.]+)/([\d.]+)",
output,
)
if rtt_match:
return {
"rtt_min": float(rtt_match.group(1)),
"rtt_avg": float(rtt_match.group(2)),
"rtt_max": float(rtt_match.group(3)),
"loss": loss,
}
# Host unreachable or all packets lost
return {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": loss}
async def _collect_metrics(self) -> Dict[str, Any]:
data: Dict[str, Any] = {}
tasks = {host: asyncio.create_task(self._ping(host)) for host in self.hosts}
for host, task in tasks.items():
try:
result = await task
except Exception as e:
self.logger.error("ping_monitor: error pinging %s: %s", host, e)
result = {"rtt_min": float("inf"), "rtt_avg": float("inf"),
"rtt_max": float("inf"), "loss": 100.0}
key = _host_key(host)
for metric, value in result.items():
data[f"{key}_{metric}"] = value
status = "unreachable" if result["loss"] == 100.0 else f"{result['rtt_avg']:.1f}ms"
self.logger.debug("ping_monitor: %s -> %s", host, status)
return data
+1 -1
View File
@@ -1,3 +1,3 @@
"""Common utilities shared between hbc and hbd.""" """Common utilities shared between hbc and hbd."""
__version__ = "5.0.5" from hbd import __version__
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Daemon (hbd) - Server/daemon component.""" """HeartBeat Daemon (hbd) - Server/daemon component."""
__version__ = "5.0.5" from hbd import __version__
+60 -10
View File
@@ -1,6 +1,8 @@
"""Command line interface for hbd package.""" """Command line interface for hbd package."""
import argparse import argparse
import getpass
import sys
from .config import load_config from .config import load_config
from .main import run as run_server from .main import run as run_server
@@ -14,26 +16,74 @@ def build_parser():
description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)", description="HeartBeatDaemon - Wait for heartbeat messages and act on them (or their absence)",
formatter_class=argparse.RawDescriptionHelpFormatter, formatter_class=argparse.RawDescriptionHelpFormatter,
) )
parser.add_argument(
"-c", "--config", dest="configfile", help="Config file path (YAML)" subparsers = parser.add_subparsers(dest="command")
)
parser.add_argument( # --- serve (default) ---
"-f", "--foreground", action="store_true", help="Run in foreground" serve_p = subparsers.add_parser("serve", help="Start the hbd server (default)")
) serve_p.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
serve_p.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
serve_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
serve_p.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
help="Push service to use")
serve_p.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
# Legacy top-level flags (no subcommand) — kept for backward compatibility
parser.add_argument("-c", "--config", dest="configfile", help="Config file path (YAML)")
parser.add_argument("-f", "--foreground", action="store_true", help="Run in foreground")
parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output") parser.add_argument("-v", "--verbose", action="store_true", help="Verbose output")
parser.add_argument( parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
"-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use" help="Push service to use")
parser.add_argument("-x", "--debug", action="count", default=0, help="Increase debug level")
# --- passwd ---
passwd_p = subparsers.add_parser(
"passwd",
help="Generate a password hash for use in the config file",
) )
parser.add_argument( passwd_p.add_argument(
"-x", "--debug", action="count", default=0, help="Increase debug level" "username",
nargs="?",
help="Username (informational only, for display)",
) )
return parser return parser
def cmd_passwd(args):
"""Interactive password hash generator."""
from .users import hash_password
username = args.username or ""
prompt = f"New password for {username}: " if username else "New password: "
while True:
pw = getpass.getpass(prompt)
if not pw:
print("Password must not be empty.", file=sys.stderr)
continue
pw2 = getpass.getpass("Confirm password: ")
if pw != pw2:
print("Passwords do not match, try again.", file=sys.stderr)
continue
break
hashed = hash_password(pw)
if username:
print(f"\nAdd the following to your config under users: -> {username}:")
else:
print("\nPassword hash (paste into config file under the user's 'password' key):")
print(f" password: {hashed}")
def main(argv=None): def main(argv=None):
parser = build_parser() parser = build_parser()
args = parser.parse_args(argv) args = parser.parse_args(argv)
if args.command == "passwd":
cmd_passwd(args)
return
# Default: run the server (supports both `hbd serve ...` and `hbd ...`)
config = load_config(args.configfile) config = load_config(args.configfile)
# Apply CLI overrides # Apply CLI overrides
+95 -12
View File
@@ -14,23 +14,25 @@ SERVER_DEFAULTS = {
"hb_port": 50003, # Port to listen for heartbeats "hb_port": 50003, # Port to listen for heartbeats
"hbd_port": 50004, # HTTP API port "hbd_port": 50004, # HTTP API port
"hbd_host": "", # Bind address (empty = all interfaces) "hbd_host": "", # Bind address (empty = all interfaces)
# Persistence # Persistence
"pickfile": "/tmp/hb.pick", "pickfile": os.path.join(os.path.expanduser("~"), ".hb.pick"), # File to store host state between restarts
# Logging # Logging
"logfile": "/var/log/heartbeat.log", "logfile": os.path.join(os.path.expanduser("~"), ".hb.log"),
"logfmt": "text", # text or msg or json
# Notification channels # Notification channels
"notification_channels": {}, # Named channels with type and credentials "notification_channels": {}, # Named channels with type and credentials
"default_notification_channels": [], # Default channels if host doesn't specify "default_notification_channels": [], # Default channels if host doesn't specify
# Monitoring settings # Monitoring settings
"interval": 20, # Expected heartbeat interval (for server checks) "interval": 20, # Expected heartbeat interval (for server checks)
"grace": 2, # Grace multiplier (interval * grace = timeout) "grace": 2, # Grace multiplier (interval * grace = timeout)
"threshold_renotify_interval": 3600, # Seconds between threshold re-notifications "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
# Host management # Host management
"hosts": {}, # New unified host definitions (optional) "hosts": {}, # New unified host definitions (optional)
"watchhosts": [], # Hosts to monitor and notify about (legacy) "watchhosts": [], # Hosts to monitor and notify about (legacy)
@@ -65,6 +67,38 @@ SERVER_DEFAULTS = {
"thresholds": {}, "thresholds": {},
} }
THRESHOLD_DEFAULTS = {
'thresholds': {
'cpu_monitor': {
'cpu_percent': {
'warning': 80.0,
'critical': 90.0
}
},
'memory_monitor': {
'percent': {
'warning': 85.0,
'critical': 95.0
}
},
'disk_monitor': {
'partitions': {
'/': {
'percent': {
'warning': 85.0,
'critical': 90.0
}
}
}
},
'rtt': {
'warning': 200,
'critical': 250.0,
'count': 3 # Optional: number of consecutive breaches before alerting
}
}
}
def load_config(path=None): def load_config(path=None):
"""Load configuration from a YAML file and merge with server defaults. """Load configuration from a YAML file and merge with server defaults.
@@ -321,20 +355,69 @@ def get_channel_config(config, channel_name):
def get_notification_channels_config(config, hostname): def get_notification_channels_config(config, hostname):
"""Get list of notification channel configurations for a host. """Get list of notification channel configurations for a host.
Args: Args:
config: Configuration dictionary config: Configuration dictionary
hostname: Host name hostname: Host name
Returns: Returns:
List of (channel_name, channel_config) tuples List of (channel_name, channel_config) tuples
""" """
channel_names = get_notification_channels_for_host(config, hostname) channel_names = get_notification_channels_for_host(config, hostname)
channels = [] channels = []
for channel_name in channel_names: for channel_name in channel_names:
channel_config = get_channel_config(config, channel_name) channel_config = get_channel_config(config, channel_name)
if channel_config and channel_config.get("type"): if channel_config and channel_config.get("type"):
channels.append((channel_name, channel_config)) channels.append((channel_name, channel_config))
return channels return channels
# ---------------------------------------------------------------------------
# User / host-access helpers
# ---------------------------------------------------------------------------
def get_default_owner(config) -> str | None:
"""Return the configured default_owner username, or the first admin user, or None."""
explicit = config.get("default_owner")
if explicit:
return explicit
# Fall back to first admin user found in config
users_cfg = config.get("users", {})
if isinstance(users_cfg, dict):
for username, attrs in users_cfg.items():
if isinstance(attrs, dict) and attrs.get("admin", False):
return username
return None
def get_host_access(config, hostname) -> dict:
"""Return the access dict for *hostname*: owner, managers, monitors.
Falls back to default_owner for hosts without an explicit owner.
Returns:
{
"owner": str | None,
"managers": list[str],
"monitors": list[str],
}
"""
host_cfg = get_host_config(config, hostname)
owner = host_cfg.get("owner") or get_default_owner(config)
managers = host_cfg.get("managers", [])
if isinstance(managers, str):
managers = [managers]
monitors = host_cfg.get("monitors", [])
if isinstance(monitors, str):
monitors = [monitors]
return {
"owner": owner,
"managers": list(managers),
"monitors": list(monitors),
}
+45 -3
View File
@@ -189,7 +189,7 @@ class Connection:
except Exception: except Exception:
pass pass
self.addr = addr self.addr = addr
Connection.htab[addr] = self.host.nameconnection_count Connection.htab[addr] = self.host.name
if self.host.isDynDns(): if self.host.isDynDns():
Host.dnsQ.put((self.host.name, self.addr)) Host.dnsQ.put((self.host.name, self.addr))
return r return r
@@ -297,6 +297,10 @@ class Host:
self.plugin_retention = 100 # Keep last N samples per plugin self.plugin_retention = 100 # Keep last N samples per plugin
# Alert state tracking: {metric_path: AlertState} # Alert state tracking: {metric_path: AlertState}
self.alert_states = {} self.alert_states = {}
# User access control
self.owner: str | None = None # username of owner
self.managers: list = [] # usernames with manager role
self.monitors: list = [] # usernames with monitor role
def statedict(self): def statedict(self):
d = {} d = {}
@@ -412,7 +416,12 @@ class Host:
ddict["alert_warning_acked"] = warning_acked ddict["alert_warning_acked"] = warning_acked
ddict["alert_critical_unacked"] = critical_unacked ddict["alert_critical_unacked"] = critical_unacked
ddict["alert_critical_acked"] = critical_acked ddict["alert_critical_acked"] = critical_acked
# User access
ddict["owner"] = getattr(self, "owner", None)
ddict["managers"] = list(getattr(self, "managers", []))
ddict["monitors"] = list(getattr(self, "monitors", []))
return ddict return ddict
def jsons(self): def jsons(self):
@@ -458,6 +467,13 @@ class Host:
self.plugin_retention = 100 self.plugin_retention = 100
if not hasattr(self, "alert_states"): if not hasattr(self, "alert_states"):
self.alert_states = {} self.alert_states = {}
# User access fields (added in user-management feature)
if not hasattr(self, "owner"):
self.owner = None
if not hasattr(self, "managers"):
self.managers = []
if not hasattr(self, "monitors"):
self.monitors = []
pass pass
@@ -511,12 +527,38 @@ class Host:
def get_all_plugin_data(self): def get_all_plugin_data(self):
"""Get all plugin data for this host. """Get all plugin data for this host.
Returns: Returns:
Dict of {plugin_name: [(timestamp, data), ...]} Dict of {plugin_name: [(timestamp, data), ...]}
""" """
return self.plugin_data return self.plugin_data
# ------------------------------------------------------------------
# User-role helpers
# ------------------------------------------------------------------
def apply_access(self, owner, managers, monitors):
"""Set owner/managers/monitors on this host (called from config load)."""
self.owner = owner
self.managers = list(managers)
self.monitors = list(monitors)
def is_owner(self, username: str) -> bool:
return self.owner == username
def is_manager(self, username: str) -> bool:
return username in self.managers or self.is_owner(username)
def is_monitor(self, username: str) -> bool:
return username in self.monitors or self.is_manager(username)
def access_dict(self) -> dict:
return {
"owner": self.owner,
"managers": list(self.managers),
"monitors": list(self.monitors),
}
hostfields_long = [ hostfields_long = [
"name", "name",
"IPv4.addr", "IPv4.addr",
+451 -37
View File
@@ -10,6 +10,8 @@ from aiohttp import web
import jinja2 import jinja2
from . import data from . import data
from . import notify as notify_mod from . import notify as notify_mod
from . import settings as settings_mod
from . import users as users_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,6 +22,78 @@ def _render_template(html_str: str, **context) -> str:
return tmpl.render(**context) return tmpl.render(**context)
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
SESSION_COOKIE = "hbd_session"
def _get_token(request) -> str:
"""Extract session token from Bearer header, X-Auth-Token header, or cookie."""
auth = request.headers.get("Authorization", "")
if auth.lower().startswith("bearer "):
return auth[7:].strip()
header_token = request.headers.get("X-Auth-Token", "").strip()
if header_token:
return header_token
return request.cookies.get(SESSION_COOKIE, "")
def _current_user(request):
"""Return the authenticated User, or None when auth is not enabled."""
if not users_mod.users_enabled():
return None # unauthenticated mode — all access allowed
return users_mod.get_session_user(_get_token(request))
def _require_auth(request):
"""Return (user, None) or (None, error Response)."""
if not users_mod.users_enabled():
return None, None
user = users_mod.get_session_user(_get_token(request))
if user is None:
return None, web.json_response({"error": "Unauthorized"}, status=401)
return user, None
def _require_auth_redirect(request):
"""Like _require_auth but returns a redirect to /login for browser requests."""
if not users_mod.users_enabled():
return None, None
user = users_mod.get_session_user(_get_token(request))
if user is None:
raise web.HTTPFound("/login")
return user, None
def _can_view_host(user, host) -> bool:
"""Return True if *user* may see *host* (monitor or higher, or no auth)."""
if user is None:
return True
if user.admin:
return True
return host.is_monitor(user.username)
def _can_operate_host(user, host) -> bool:
"""Manager-level: queue commands, DNS, upgrade."""
if user is None:
return True
if user.admin:
return True
return host.is_manager(user.username)
def _can_own_host(user, host) -> bool:
"""Owner-level: drop host, transfer ownership."""
if user is None:
return True
if user.admin:
return True
return host.is_owner(user.username)
async def start( async def start(
host: str, host: str,
port: int, port: int,
@@ -37,7 +111,8 @@ async def start(
""" """
get_now = get_now or (lambda: time.time()) get_now = get_now or (lambda: time.time())
async def index(request): async def old_index(request):
_require_auth_redirect(request)
res = [] res = []
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">') res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
res.append("<html>") res.append("<html>")
@@ -62,7 +137,15 @@ async def start(
return web.Response(text=body, content_type="text/html") return web.Response(text=body, content_type="text/html")
async def api_hosts(request): async def api_hosts(request):
lst = [hbdclass.Host.hosts[h].jsons() for h in hbdclass.Host.hosts] user, err = _require_auth(request)
if err:
return err
hosts = [
hbdclass.Host.hosts[h]
for h in hbdclass.Host.hosts
if _can_view_host(user, hbdclass.Host.hosts[h])
]
lst = [h.jsons() for h in hosts]
return web.json_response(json.loads("[" + ",".join(lst) + "]")) return web.json_response(json.loads("[" + ",".join(lst) + "]"))
async def api_messages(request): async def api_messages(request):
@@ -70,6 +153,9 @@ async def start(
return web.json_response(lst) return web.json_response(lst)
async def cmd(request): async def cmd(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query qa = request.rel_url.query
uname = qa.get("h") uname = qa.get("h")
ucmd = qa.get("c") ucmd = qa.get("c")
@@ -77,34 +163,50 @@ async def start(
return web.Response(status=400, text="need h= and c= arguments") return web.Response(status=400, text="need h= and c= arguments")
if uname not in hbdclass.Host.hosts: if uname not in hbdclass.Host.hosts:
return web.Response(status=400, text=f"h={uname} not found") return web.Response(status=400, text=f"h={uname} not found")
hbdclass.Host.hosts[uname].cmds.append( host = hbdclass.Host.hosts[uname]
("CMD", {"cmd": urllib.parse.unquote(ucmd)}) if not _can_operate_host(user, host):
) return web.json_response({"error": "Forbidden"}, status=403)
host.cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)}))
return web.Response(text=f"cmd {uname} queued") return web.Response(text=f"cmd {uname} queued")
async def drop(request): async def drop(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query qa = request.rel_url.query
uname = qa.get("h") uname = qa.get("h")
if not uname: if not uname:
return web.Response(status=400, text="need h= argument") return web.Response(status=400, text="need h= argument")
if uname not in hbdclass.Host.hosts: if uname not in hbdclass.Host.hosts:
return web.Response(status=400, text=f"h={uname} not found") return web.Response(status=400, text=f"h={uname} not found")
host = hbdclass.Host.hosts[uname]
if not _can_own_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
eventlog(uname, "INFO", "dropped") eventlog(uname, "INFO", "dropped")
del hbdclass.Host.hosts[uname] del hbdclass.Host.hosts[uname]
return web.Response(text="Done") return web.Response(text="Done")
async def register(request): async def register(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query qa = request.rel_url.query
uname = qa.get("h") uname = qa.get("h")
if not uname: if not uname:
return web.Response(status=400, text="need h= argument") return web.Response(status=400, text="need h= argument")
if uname not in hbdclass.Host.hosts: if uname not in hbdclass.Host.hosts:
return web.Response(status=400, text=f"h={uname} not found") return web.Response(status=400, text=f"h={uname} not found")
ll = hbdclass.Host.hosts[uname].registerDns() host = hbdclass.Host.hosts[uname]
if not _can_operate_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
ll = host.registerDns()
eventlog(uname, "INFO", ll) eventlog(uname, "INFO", ll)
return web.Response(text=str(ll)) return web.Response(text=str(ll))
async def update(request): async def update(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query qa = request.rel_url.query
uname = urllib.parse.unquote(qa.get("h", "")) uname = urllib.parse.unquote(qa.get("h", ""))
ucode = qa.get("c") ucode = qa.get("c")
@@ -118,16 +220,21 @@ async def start(
names = [n for n in hbdclass.Host.hosts] names = [n for n in hbdclass.Host.hosts]
out = [] out = []
for n in names: for n in names:
err = None host = hbdclass.Host.hosts[n]
if not _can_operate_host(user, host):
out.append(f"update skipped for {n}: Forbidden")
continue
op_err = None
try: try:
r = {"csum": None, "code": ucode} r = {"csum": None, "code": ucode}
hbdclass.Host.hosts[n].cmds.append(("UPD", r)) host.cmds.append(("UPD", r))
except Exception as e: except Exception as e:
err = str(e) op_err = str(e)
out.append(f"update started for {n}: {err if err else 'OK'}") out.append(f"update started for {n}: {op_err if op_err else 'OK'}")
return web.Response(text="\n".join(out)) return web.Response(text="\n".join(out))
async def live(request): async def live(request):
current_user, _ = _require_auth_redirect(request)
# render template from hbd/templates/live.html using Jinja2 # render template from hbd/templates/live.html using Jinja2
# Resolve templates directory relative to the hbd package # Resolve templates directory relative to the hbd package
pkg_dir = os.path.dirname(__file__) pkg_dir = os.path.dirname(__file__)
@@ -151,6 +258,8 @@ async def start(
hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts) hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts)
], ],
messages=data.msgs[-30:], messages=data.msgs[-30:],
current_user=current_user.to_dict() if current_user else None,
active_page="live",
) )
return web.Response(text=body, content_type="text/html") return web.Response(text=body, content_type="text/html")
@@ -185,16 +294,18 @@ async def start(
async def api_host_plugins(request): async def api_host_plugins(request):
"""Get all plugin data for a specific host.""" """Get all plugin data for a specific host."""
user, err = _require_auth(request)
if err:
return err
hostname = request.match_info.get("hostname") hostname = request.match_info.get("hostname")
if hostname not in hbdclass.Host.hosts: if hostname not in hbdclass.Host.hosts:
return web.json_response( return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
{"error": f"Host '{hostname}' not found"},
status=404
)
host = hbdclass.Host.hosts[hostname] host = hbdclass.Host.hosts[hostname]
if not _can_view_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
# Get plugin data with most recent sample for each plugin # Get plugin data with most recent sample for each plugin
plugins_summary = {} plugins_summary = {}
for plugin_name, samples in host.plugin_data.items(): for plugin_name, samples in host.plugin_data.items():
@@ -214,16 +325,18 @@ async def start(
async def api_host_plugin_detail(request): async def api_host_plugin_detail(request):
"""Get detailed data for a specific plugin on a host.""" """Get detailed data for a specific plugin on a host."""
user, err = _require_auth(request)
if err:
return err
hostname = request.match_info.get("hostname") hostname = request.match_info.get("hostname")
plugin_name = request.match_info.get("plugin_name") plugin_name = request.match_info.get("plugin_name")
if hostname not in hbdclass.Host.hosts: if hostname not in hbdclass.Host.hosts:
return web.json_response( return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
{"error": f"Host '{hostname}' not found"},
status=404
)
host = hbdclass.Host.hosts[hostname] host = hbdclass.Host.hosts[hostname]
if not _can_view_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
# Get limit from query parameter # Get limit from query parameter
limit = request.rel_url.query.get("limit", "10") limit = request.rel_url.query.get("limit", "10")
@@ -259,15 +372,17 @@ async def start(
async def api_host_alerts(request): async def api_host_alerts(request):
"""Get alert states for a specific host.""" """Get alert states for a specific host."""
user, err = _require_auth(request)
if err:
return err
hostname = request.match_info.get("hostname") hostname = request.match_info.get("hostname")
if hostname not in hbdclass.Host.hosts: if hostname not in hbdclass.Host.hosts:
return web.json_response( return web.json_response({"error": f"Host '{hostname}' not found"}, status=404)
{"error": f"Host '{hostname}' not found"},
status=404
)
host = hbdclass.Host.hosts[hostname] host = hbdclass.Host.hosts[hostname]
if not _can_view_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
# Get alert states # Get alert states
alerts = [] alerts = []
@@ -287,9 +402,14 @@ async def start(
async def api_all_alerts(request): async def api_all_alerts(request):
"""Get all active alerts across all hosts.""" """Get all active alerts across all hosts."""
user, err = _require_auth(request)
if err:
return err
all_alerts = [] all_alerts = []
for hostname, host in hbdclass.Host.hosts.items(): for hostname, host in hbdclass.Host.hosts.items():
if not _can_view_host(user, host):
continue
if threshold_checker: if threshold_checker:
active_alerts = threshold_checker.get_active_alerts(host.alert_states) active_alerts = threshold_checker.get_active_alerts(host.alert_states)
else: else:
@@ -326,6 +446,9 @@ async def start(
async def api_acknowledge_alert(request): async def api_acknowledge_alert(request):
"""Acknowledge an alert to stop reminder notifications.""" """Acknowledge an alert to stop reminder notifications."""
user, err = _require_auth(request)
if err:
return err
try: try:
data = await request.json() data = await request.json()
except Exception: except Exception:
@@ -350,7 +473,9 @@ async def start(
) )
host = hbdclass.Host.hosts[hostname] host = hbdclass.Host.hosts[hostname]
if not _can_view_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
if metric_path not in host.alert_states: if metric_path not in host.alert_states:
return web.json_response( return web.json_response(
{"error": f"Alert '{metric_path}' not found for host '{hostname}'"}, {"error": f"Alert '{metric_path}' not found for host '{hostname}'"},
@@ -373,50 +498,338 @@ async def start(
async def plugins_page(request): async def plugins_page(request):
"""Render the plugin metrics visualization page.""" """Render the plugin metrics visualization page."""
current_user, _ = _require_auth_redirect(request)
pkg_dir = os.path.dirname(__file__) pkg_dir = os.path.dirname(__file__)
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
# Collect all hosts with plugin data # Collect all hosts with plugin data (filtered by visibility)
hosts_with_plugins = [] hosts_with_plugins = []
for hostname in sorted(hbdclass.Host.hosts.keys()): for hostname in sorted(hbdclass.Host.hosts.keys()):
host = hbdclass.Host.hosts[hostname] host = hbdclass.Host.hosts[hostname]
if not _can_view_host(current_user, host):
continue
if host.plugin_data: if host.plugin_data:
hosts_with_plugins.append({ hosts_with_plugins.append({
"name": hostname, "name": hostname,
"plugins": list(host.plugin_data.keys()), "plugins": list(host.plugin_data.keys()),
}) })
tmpl = env.get_template("plugins.html") tmpl = env.get_template("plugins.html")
body = tmpl.render( body = tmpl.render(
title="Plugin Metrics - Heartbeat", title="Plugin Metrics - Heartbeat",
header="Plugin Metrics", header="Plugin Metrics",
hosts=hosts_with_plugins, hosts=hosts_with_plugins,
current_user=current_user.to_dict() if current_user else None,
active_page="plugins",
) )
return web.Response(text=body, content_type="text/html") return web.Response(text=body, content_type="text/html")
async def alerts_page(request): async def alerts_page(request):
"""Render the alerts dashboard page.""" """Render the alerts dashboard page."""
current_user, _ = _require_auth_redirect(request)
pkg_dir = os.path.dirname(__file__) pkg_dir = os.path.dirname(__file__)
templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates"))
env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir))
tmpl = env.get_template("alerts.html") tmpl = env.get_template("alerts.html")
body = tmpl.render( body = tmpl.render(
title="Alerts Dashboard - Heartbeat", title="Alerts Dashboard - Heartbeat",
header="Alerts Dashboard", header="Alerts Dashboard",
current_user=current_user.to_dict() if current_user else None,
active_page="alerts",
)
return web.Response(text=body, content_type="text/html")
# -------------------------------------------------------------------------
# Auth endpoints
# -------------------------------------------------------------------------
async def api_login(request):
"""POST /api/0/auth/login {username, password} -> {token}
Also sets an hbd_session cookie for browser clients.
"""
if not users_mod.users_enabled():
return web.json_response({"error": "Auth not configured"}, status=404)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
username = body.get("username", "")
password = body.get("password", "")
user = users_mod.authenticate(username, password)
if user is None:
return web.json_response({"error": "Invalid credentials"}, status=401)
token = users_mod.create_session(username)
resp = web.json_response({"token": token, "username": username})
resp.set_cookie(
SESSION_COOKIE,
token,
max_age=users_mod.SESSION_TTL,
httponly=True,
samesite="Lax",
)
return resp
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."
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; }}
</style>
</head>
<body>
<div class="box">
<h2>Heartbeat</h2>
{'<p class="error">' + 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>
</div>
</body>
</html>"""
return web.Response(text=html, content_type="text/html")
async def web_logout(request):
"""GET /logout — clear session cookie and redirect to /login."""
token = request.cookies.get(SESSION_COOKIE, "")
users_mod.delete_session(token)
resp = web.HTTPFound("/login")
resp.del_cookie(SESSION_COOKIE)
raise resp
async def api_logout(request):
"""POST /api/0/auth/logout"""
token = _get_token(request)
users_mod.delete_session(token)
resp = web.json_response({"success": True})
resp.del_cookie(SESSION_COOKIE)
return resp
# -------------------------------------------------------------------------
# User endpoints
# -------------------------------------------------------------------------
async def api_user_avatar(request):
"""GET /api/0/users/{username}/avatar — serve a local avatar file.
Only reachable when the user's avatar config value starts with '/'.
Falls back to 404 for external URLs (the browser fetches those directly).
"""
user, err = _require_auth(request)
if err:
return err
username = request.match_info.get("username")
target_user = users_mod.get_user(username)
if target_user is None:
return web.Response(status=404, text="User not found")
if not target_user.avatar_is_local():
return web.Response(status=404, text="No local avatar configured")
path = target_user.avatar
if not os.path.isfile(path):
return web.Response(status=404, text="Avatar file not found")
# Infer content-type from extension
ext = os.path.splitext(path)[1].lower()
mime = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".svg": "image/svg+xml",
}.get(ext, "application/octet-stream")
return web.FileResponse(path=path, headers={"Content-Type": mime})
async def api_users(request):
"""GET /api/0/users — admin only."""
user, err = _require_auth(request)
if err:
return err
if users_mod.users_enabled() and (user is None or not user.admin):
return web.json_response({"error": "Forbidden"}, status=403)
return web.json_response([u.to_dict() for u in users_mod.users.values()])
async def api_user_self(request):
"""GET /api/0/users/me — own profile."""
user, err = _require_auth(request)
if err:
return err
if user is None:
return web.json_response({"error": "Auth not configured"}, status=404)
return web.json_response(user.to_dict())
# -------------------------------------------------------------------------
# Host access endpoints
# -------------------------------------------------------------------------
async def api_host_access_get(request):
"""GET /api/0/hosts/{hostname}/access"""
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(host.access_dict())
async def api_host_access_put(request):
"""PUT /api/0/hosts/{hostname}/access — owner or admin only.
Body: {owner?: str, managers?: [str], monitors?: [str]}
"""
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_own_host(user, host):
return web.json_response({"error": "Forbidden"}, status=403)
try:
body = await request.json()
except Exception:
return web.json_response({"error": "Invalid JSON"}, status=400)
if "owner" in body:
host.owner = body["owner"] or None
if "managers" in body:
host.managers = list(body["managers"])
if "monitors" in body:
host.monitors = list(body["monitors"])
return web.json_response(host.access_dict())
# -------------------------------------------------------------------------
# User profile page
# -------------------------------------------------------------------------
async def profile_page(request):
"""GET /profile — current user's settings and host access summary."""
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))
# Build host access summary for this user
owned, managed, monitored = [], [], []
if current_user:
for hostname, host in sorted(hbdclass.Host.hosts.items()):
if host.is_owner(current_user.username):
owned.append(hostname)
elif host.is_manager(current_user.username):
managed.append(hostname)
elif host.is_monitor(current_user.username):
monitored.append(hostname)
# Resolve notification channel configs for display
notif_channels = []
if current_user:
for ch_name in (current_user.notification_channels or []):
ch_cfg = config.get("notification_channels", {}).get(ch_name, {})
notif_channels.append({"name": ch_name, "type": ch_cfg.get("type", "")})
tmpl = env.get_template("profile.html")
body = tmpl.render(
title="Profile - Heartbeat",
header="My Profile",
current_user=current_user.to_dict() if current_user else None,
owned_hosts=owned,
managed_hosts=managed,
monitored_hosts=monitored,
notification_channels=notif_channels,
active_page="profile",
)
return web.Response(text=body, content_type="text/html")
# -------------------------------------------------------------------------
# Settings page (admin only)
# -------------------------------------------------------------------------
async def settings_page(request):
"""GET /settings — read-only view of the current server configuration."""
current_user, _ = _require_auth_redirect(request)
if current_user and not current_user.admin:
raise web.HTTPForbidden(reason="Admin access required")
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))
tmpl = env.get_template("settings.html")
body = tmpl.render(
title="Settings - Heartbeat",
sections=settings_mod.get_settings_sections(config),
current_user=current_user.to_dict() if current_user else None,
active_page="settings",
) )
return web.Response(text=body, content_type="text/html") return web.Response(text=body, content_type="text/html")
app = web.Application() app = web.Application()
app.add_routes( app.add_routes(
[ [
web.get("/", index), web.get("/", live),
web.get("/old", old_index),
# Auth
web.get("/login", login_page),
web.post("/login", login_page),
web.get("/logout", web_logout),
web.post("/api/0/auth/login", api_login),
web.post("/api/0/auth/logout", api_logout),
# Users
web.get("/api/0/users", api_users),
web.get("/api/0/users/me", api_user_self),
web.get("/api/0/users/{username}/avatar", api_user_avatar),
# Hosts
web.get("/api/0/hosts", api_hosts), web.get("/api/0/hosts", api_hosts),
web.get("/api/0/messages", api_messages), web.get("/api/0/messages", api_messages),
web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins), 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}/plugins/{plugin_name}", api_host_plugin_detail),
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts), web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
web.get("/api/0/alerts", api_all_alerts), web.get("/api/0/alerts", api_all_alerts),
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert), web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
web.get("/c", cmd), web.get("/c", cmd),
@@ -426,6 +839,8 @@ async def start(
web.get("/live", live), web.get("/live", live),
web.get("/plugins", plugins_page), web.get("/plugins", plugins_page),
web.get("/alerts", alerts_page), web.get("/alerts", alerts_page),
web.get("/profile", profile_page),
web.get("/settings", settings_page),
web.get("/static/{path:.*}", static), web.get("/static/{path:.*}", static),
web.get("/favicon.ico", favicon), web.get("/favicon.ico", favicon),
] ]
@@ -436,8 +851,7 @@ async def start(
site = web.TCPSite(runner, host, port) site = web.TCPSite(runner, host, port)
await site.start() await site.start()
if verbose: logger.info(f"HTTP server started on {host}:{port}")
print(f"HTTP server started on {host}:{port}")
try: try:
await asyncio.Future() await asyncio.Future()
+113 -22
View File
@@ -14,7 +14,8 @@ from . import hbdclass
from . import ws as ws_mod from . import ws as ws_mod
from . import notify as notify_mod from . import notify as notify_mod
from . import data from . import data
from . import users as users_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
msg_to_websockets = ws_mod.broadcast msg_to_websockets = ws_mod.broadcast
@@ -22,12 +23,13 @@ eventlog = notify_mod.eventlog
# shared runtime collections and helpers # shared runtime collections and helpers
def cleanup_function(config, hbdclass): def save_state(config, hbdclass):
"""This function will be executed upon program exit.""" """Save current state to pickle file. Safe to call at any time."""
logger.info("Running cleanup function...")
import pickle import pickle
import os
from . import users as users_mod
# Ensure all timer references are cleared before pickling # Clear timer references before pickling (they can't be serialized)
for hostname, host in list(hbdclass.Host.hosts.items()): for hostname, host in list(hbdclass.Host.hosts.items()):
for conn_type, conn in host.connections.items(): for conn_type, conn in host.connections.items():
if hasattr(conn, 'cancel_overdue_timer'): if hasattr(conn, 'cancel_overdue_timer'):
@@ -40,13 +42,27 @@ def cleanup_function(config, hbdclass):
conn.timeout_duration = None conn.timeout_duration = None
pickfile = config.get("pickfile", "hbd.pickle") pickfile = config.get("pickfile", "hbd.pickle")
tmpfile = pickfile + ".tmp"
pickf = open(pickfile, "wb") try:
pick = pickle.Pickler(pickf) with open(tmpfile, "wb") as pickf:
pick.dump(hbdclass.Host.hosts) pick = pickle.Pickler(pickf)
pick.dump(data.msgs) pick.dump(hbdclass.Host.hosts)
pickf.close() pick.dump(data.msgs)
pick.dump(users_mod.save_sessions())
os.replace(tmpfile, pickfile)
except Exception as e:
logger.error("Failed to save state: %s", e)
try:
os.unlink(tmpfile)
except Exception:
pass
def cleanup_function(config, hbdclass):
"""This function will be executed upon program exit."""
logger.info("Running cleanup function...")
save_state(config, hbdclass)
logger.info("Cleanup complete.") logger.info("Cleanup complete.")
@@ -71,7 +87,20 @@ async def reload_configuration(config_obj, config_path, components):
# Update notify module # Update notify module
notify_mod.reload_config(new_config) notify_mod.reload_config(new_config)
# Reload users
users_mod.load_users(new_config)
# Re-apply host attributes from updated config to all known hosts
from . import config as config_mod
dyndnshosts = config_mod.get_dyndnshosts(new_config)
watchhosts = config_mod.get_watchhosts(new_config)
for hostname, host in hbdclass.Host.hosts.items():
host.dyn = hostname in dyndnshosts
host.watched = hostname in watchhosts
access = config_mod.get_host_access(new_config, hostname)
host.apply_access(access["owner"], access["managers"], access["monitors"])
# Reload threshold checker # Reload threshold checker
if 'threshold_checker' in components: if 'threshold_checker' in components:
components['threshold_checker'].reload(new_config) components['threshold_checker'].reload(new_config)
@@ -103,6 +132,10 @@ async def reload_configuration(config_obj, config_path, components):
async def _run_async(config, config_path=None): async def _run_async(config, config_path=None):
from .config import ReloadableConfig
if not isinstance(config, ReloadableConfig):
config = ReloadableConfig(config, config_path)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
shutdown_event = asyncio.Event() shutdown_event = asyncio.Event()
reload_event = asyncio.Event() reload_event = asyncio.Event()
@@ -160,14 +193,20 @@ async def _run_async(config, config_path=None):
f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}" f"Warning: Could not reset IPV6_V6ONLY not supported or dual-stack is unavailable. Error: {e}"
) )
# 3. Bind to all interfaces (::) on a specific port
# UDP server endpoint (handler wired to handle_datagram with context)
bind_addr = ("::", config.get("hb_port", 50003)) bind_addr = ("::", config.get("hb_port", 50003))
sock.bind(bind_addr) sock.bind(bind_addr)
logger.info("Starting UDP server on %s:%s", *bind_addr) logger.info("Starting UDP server on %s:%s", *bind_addr)
def udp_handler(msg, addr, transport): # Try to enable kernel receive timestamps (Linux SO_TIMESTAMP).
# If supported, read datagrams via recvmsg() so RTT uses the kernel
# timestamp rather than the time.time() call after asyncio scheduling.
use_kernel_ts = udp.enable_kernel_timestamps(sock)
if use_kernel_ts:
logger.info("SO_TIMESTAMP enabled: using kernel receive timestamps for RTT")
else:
logger.info("SO_TIMESTAMP not available: using time.time() for RTT")
def udp_handler(msg, addr, transport, recv_ts=None):
ctx = dict( ctx = dict(
config=config, config=config,
hbdclass=hbdclass, hbdclass=hbdclass,
@@ -177,13 +216,32 @@ async def _run_async(config, config_path=None):
threshold_checker=threshold_checker, threshold_checker=threshold_checker,
DEBUG=config.get("debug", 0), DEBUG=config.get("debug", 0),
verbose=config.get("verbose", False), verbose=config.get("verbose", False),
recv_ts=recv_ts,
) )
udp.handle_datagram(msg, addr, transport, ctx) udp.handle_datagram(msg, addr, transport, ctx)
transport, protocol = await loop.create_datagram_endpoint( if use_kernel_ts:
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler), # recvmsg path: manage the socket ourselves with loop.add_reader()
sock=sock, sock.setblocking(False)
transport = udp.RecvmsgTransport(loop, sock)
reader = udp.make_recvmsg_reader(sock, udp_handler, transport)
loop.add_reader(sock.fileno(), reader)
protocol = None
else:
transport, protocol = await loop.create_datagram_endpoint(
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
sock=sock,
)
# Restore connection timers for hosts loaded from pickle
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)
# HTTP server (asyncio-based via aiohttp) # HTTP server (asyncio-based via aiohttp)
try: try:
@@ -257,6 +315,19 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.exception("websocket server failed to start: %s", e) logger.exception("websocket server failed to start: %s", e)
# Periodic autosave task
autosave_interval = config.get("autosave_interval", 300) # default: 5 minutes
async def autosave_task():
while True:
await asyncio.sleep(autosave_interval)
logger.debug("Autosaving state...")
save_state(config, hbdclass)
logger.debug("Autosave complete (%d hosts)", len(hbdclass.Host.hosts))
autosave = asyncio.create_task(autosave_task())
logger.info("Autosave task started (interval: %ds)", autosave_interval)
# Main event loop - monitor shutdown and reload events # Main event loop - monitor shutdown and reload events
try: try:
while True: while True:
@@ -304,7 +375,7 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.warning("Error closing UDP transport: %s", e) logger.warning("Error closing UDP transport: %s", e)
tasks_to_cancel = [http_task, ws_task] tasks_to_cancel = [http_task, ws_task, autosave]
for task in tasks_to_cancel: for task in tasks_to_cancel:
if task: if task:
try: try:
@@ -355,6 +426,13 @@ async def _run_async(config, config_path=None):
except Exception as e: except Exception as e:
logger.warning("Error stopping DNS worker: %s", e) logger.warning("Error stopping DNS worker: %s", e)
# Save state (hosts + sessions) on clean shutdown
try:
save_state(config, hbdclass)
logger.info("State saved on shutdown")
except Exception as e:
logger.warning("Error saving state on shutdown: %s", e)
logger.info("All tasks cancelled") logger.info("All tasks cancelled")
@@ -363,6 +441,7 @@ def load_pickled_hosts(config, hbdclass):
import os import os
import pickle import pickle
from . import config as config_mod from . import config as config_mod
from . import users as users_mod
pickfile = config.get("pickfile", "hbd.pickle") pickfile = config.get("pickfile", "hbd.pickle")
dyndnshosts = config_mod.get_dyndnshosts(config) dyndnshosts = config_mod.get_dyndnshosts(config)
@@ -376,6 +455,10 @@ def load_pickled_hosts(config, hbdclass):
try: try:
hbdclass.Host.hosts = pick.load() hbdclass.Host.hosts = pick.load()
data.msgs = pick.load() data.msgs = pick.load()
try:
users_mod.load_sessions(pick.load())
except Exception:
pass # older pickle without sessions — fine
pickf.close() pickf.close()
except Exception as e: except Exception as e:
logger.exception("load pickled failed: %s", e) logger.exception("load pickled failed: %s", e)
@@ -385,6 +468,10 @@ def load_pickled_hosts(config, hbdclass):
hbdclass.Host.hosts[h].dyn = h in dyndnshosts hbdclass.Host.hosts[h].dyn = h in dyndnshosts
hbdclass.Host.hosts[h].watched = h in watchhosts hbdclass.Host.hosts[h].watched = h in watchhosts
hbdclass.Host.hosts[h].fixup() hbdclass.Host.hosts[h].fixup()
access = config_mod.get_host_access(config, h)
hbdclass.Host.hosts[h].apply_access(
access["owner"], access["managers"], access["monitors"]
)
for h in drophosts: for h in drophosts:
if h in hbdclass.Host.hosts: if h in hbdclass.Host.hosts:
del hbdclass.Host.hosts[h] del hbdclass.Host.hosts[h]
@@ -406,12 +493,16 @@ def run(config, config_path=None):
""" """
import os import os
logging.basicConfig( log_level = logging.WARNING
level=logging.DEBUG if config.get("debug", 0) > 0 else logging.INFO if config.get("verbose", False):
) log_level = logging.INFO
if config.get("debug", 0) > 0:
log_level = logging.DEBUG
logging.basicConfig(level=log_level)
load_pickled_hosts(config, hbdclass) load_pickled_hosts(config, hbdclass)
notify_mod.initlog(logfile=config.get("logfile", "messages.log")) notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
users_mod.load_users(config)
eventlog(None, "INFO", f"hbd version {__version__} starting up") eventlog(None, "INFO", f"hbd version {__version__} starting up")
if config_path: if config_path:
+328
View File
@@ -0,0 +1,328 @@
"""Settings descriptor: maps config keys to display metadata.
``get_settings_sections(config)`` returns an ordered list of sections, each
containing a list of field descriptors. The template iterates this structure
generically, so adding editability later is a matter of:
1. Setting ``"editable": True`` on a field.
2. Adding the matching ``<input>``/``<select>`` in the template
(guided by ``"type"``).
3. Wiring a POST handler in http.py.
Field descriptor keys
---------------------
key str Config key (for future form POST matching)
label str Human-readable label
description str One-line help text shown below the value
value any Sanitized display value (secrets replaced with "•••")
type str One of: text | number | port | boolean | path | duration |
list | secret | size | select
editable bool Reserved for future use — currently always False
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",
})
_CHANNEL_TYPE_LABELS = {
"pushover": "Pushover",
"email": "E-mail",
"signal": "Signal",
"mattermost": "Mattermost",
}
def _mask(value):
"""Return a masked placeholder for sensitive values."""
if not value:
return ""
return "•••"
def _fmt_size(n):
"""Format a byte count as a human-readable string."""
try:
n = int(n)
except (TypeError, ValueError):
return str(n)
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n} {unit}"
n //= 1024
return f"{n} TB"
def _fmt_duration(seconds):
"""Format seconds into a human-readable duration string."""
try:
s = int(seconds)
except (TypeError, ValueError):
return str(seconds)
if s < 60:
return f"{s}s"
if s < 3600:
m, sec = divmod(s, 60)
return f"{m}m {sec}s" if sec else f"{m}m"
h, rem = divmod(s, 3600)
m = rem // 60
return f"{h}h {m}m" if m else f"{h}h"
def _sanitize_channel(name, cfg):
"""Return a sanitized copy of a notification channel config."""
result = {}
for k, v in cfg.items():
if k in _SECRET_KEYS:
result[k] = _mask(v)
elif isinstance(v, list):
result[k] = v
else:
result[k] = v
return result
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def get_settings_sections(config: dict) -> list:
"""Return ordered list of setting sections for the settings page.
Each section:
{
"title": str,
"description": str,
"fields": [ field_descriptor, ... ]
}
Each field_descriptor:
{
"key": str,
"label": str,
"description": str,
"value": display_value,
"raw": raw_config_value, # None for sensitive
"type": str,
"editable": bool,
"sensitive": bool,
}
"""
def field(key, label, ftype, description="", editable=False, sensitive=False):
raw = config.get(key)
if sensitive:
display = _mask(raw)
raw_out = None
elif ftype == "size":
display = _fmt_size(raw)
raw_out = raw
elif ftype == "duration":
display = _fmt_duration(raw)
raw_out = raw
elif ftype == "boolean":
display = bool(raw)
raw_out = raw
elif ftype == "list":
val = raw or []
display = list(val) if not isinstance(val, list) else val
raw_out = display
else:
display = raw if raw is not None else ""
raw_out = raw
return {
"key": key,
"label": label,
"description": description,
"value": display,
"raw": raw_out,
"type": ftype,
"editable": editable,
"sensitive": sensitive,
}
# ---- Notification channels (complex, built separately) ----------------
notif_channels = []
for ch_name, ch_cfg in (config.get("notification_channels") or {}).items():
if not isinstance(ch_cfg, dict):
continue
ch_type = ch_cfg.get("type", "")
fields = []
for k, v in ch_cfg.items():
if k == "type":
continue
sensitive = k in _SECRET_KEYS
fields.append({
"key": k,
"label": k.replace("_", " ").title(),
"value": _mask(v) if sensitive else (
", ".join(v) if isinstance(v, list) else str(v)
),
"sensitive": sensitive,
})
notif_channels.append({
"name": ch_name,
"type": ch_type,
"type_label": _CHANNEL_TYPE_LABELS.get(ch_type, ch_type.title()),
"fields": fields,
})
# ---- Users (show metadata only, never password hashes) ----------------
users_list = []
for username, attrs in (config.get("users") or {}).items():
if not isinstance(attrs, dict):
continue
users_list.append({
"username": username,
"full_name": attrs.get("full_name", ""),
"admin": bool(attrs.get("admin", False)),
"avatar": attrs.get("avatar", ""),
"notification_channels": attrs.get("notification_channels", []),
})
# ---- Hosts summary ----------------------------------------------------
hosts_list = []
for hname, hcfg in (config.get("hosts") or {}).items():
if not isinstance(hcfg, dict):
continue
hosts_list.append({
"name": hname,
"watch": bool(hcfg.get("watch", False)),
"dyndns": bool(hcfg.get("dyndns", False)),
"owner": hcfg.get("owner", ""),
"managers": hcfg.get("managers", []),
"monitors": hcfg.get("monitors", []),
"threshold_config": hcfg.get("threshold_config", ""),
"notification_channels": hcfg.get("notification_channels", []),
})
return [
{
"id": "network",
"title": "Network",
"description": "Ports and bind addresses for all server sockets.",
"fields": [
field("hb_port", "Heartbeat UDP port", "port",
"UDP port the server listens on for heartbeat datagrams."),
field("hbd_host", "HTTP bind address", "text",
"Interface to bind the HTTP server to. Empty = all interfaces."),
field("hbd_port", "HTTP API port", "port",
"TCP port for the HTTP API and web UI."),
field("ws_port", "WebSocket port", "port",
"TCP port for the plain WebSocket server."),
field("wss_port", "Secure WebSocket port", "port",
"TCP port for WSS (TLS WebSocket). Leave empty to disable."),
],
},
{
"id": "tls",
"title": "TLS / WebSocket Security",
"description": "Certificate paths used when wss_port is set.",
"fields": [
field("cert_path", "Certificate directory", "path",
"Directory containing the TLS certificate and key files."),
field("wss_pem", "Certificate file", "text",
"Filename of the TLS certificate chain (PEM format)."),
field("wss_key", "Key file", "text",
"Filename of the TLS private key (PEM format)."),
],
},
{
"id": "monitoring",
"title": "Monitoring",
"description": "Heartbeat timing and alert re-notification behaviour.",
"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."),
field("threshold_renotify_interval", "Re-notify interval", "duration",
"How often to re-send notifications for ongoing threshold alerts."),
field("autosave_interval", "Autosave interval", "duration",
"How often the server saves its state to disk."),
],
},
{
"id": "persistence",
"title": "Persistence & Logging",
"description": "State file and event log settings.",
"fields": [
field("pickfile", "State file", "path",
"Path to the pickle file used to persist host state across restarts."),
field("logfile", "Event log", "path",
"Path to the event log file."),
],
},
{
"id": "journal",
"title": "Message Journal",
"description": "All received heartbeat and plugin messages are journalled here.",
"fields": [
field("journal_enabled", "Enabled", "boolean",
"Turn journalling on or off."),
field("journal_dir", "Journal directory","path",
"Directory where journal files are written."),
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."),
field("journal_max_backups", "Backup count", "number",
"Number of rotated journal files to keep."),
],
},
{
"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."),
],
},
{
"id": "users",
"title": "Users",
"description": "Accounts defined in the config file. Password hashes are never shown.",
"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."),
],
},
{
"id": "channels",
"title": "Notification Channels",
"description": "Named notification providers. Credentials are masked.",
"channels": notif_channels,
"fields": [
field("default_notification_channels", "Default channels", "list",
"Channels used when a host does not specify its own."),
],
},
{
"id": "hosts",
"title": "Hosts",
"description": "Host definitions loaded from the config file.",
"hosts": hosts_list,
"fields": [],
},
{
"id": "runtime",
"title": "Runtime",
"description": "Flags set at startup (require restart to change).",
"fields": [
field("foreground", "Foreground mode", "boolean",
"Run in the foreground instead of daemonising."),
field("verbose", "Verbose logging", "boolean",
"Enable verbose log output."),
field("debug", "Debug level", "number",
"0 = off. Higher values increase log verbosity."),
],
},
]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 181 KiB

+2 -1
View File
@@ -139,4 +139,5 @@
font-size: 9px; font-size: 9px;
float: left; float: left;
} }
+1 -29
View File
@@ -8,30 +8,6 @@
background: #f5f5f5; background: #f5f5f5;
} }
.nav {
background: #fff;
padding: 15px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 4px;
}
.nav a {
margin-right: 20px;
text-decoration: none;
color: #0066cc;
font-weight: 500;
}
.nav a:hover {
text-decoration: underline;
}
.nav a.active {
color: #333;
font-weight: bold;
}
.container { .container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
@@ -327,11 +303,7 @@
</style> </style>
<body> <body>
<div class="nav"> {% include 'nav.html' %}
<a href="/live">Live Dashboard</a>
<a href="/plugins">Plugin Metrics</a>
<a href="/alerts" class="active">Alerts</a>
</div>
<div class="container"> <div class="container">
<h1>{{ header }}</h1> <h1>{{ header }}</h1>
+55 -1
View File
@@ -3,5 +3,59 @@
<link rel="stylesheet" href="/static/style.css" type="text/css" /> <link rel="stylesheet" href="/static/style.css" type="text/css" />
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" /> <link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
<title>{{ title }}</title> <title>{{ title }}</title>
<script src="{{ extra_scripts }}"></script> {% if extra_scripts %}<script src="{{ extra_scripts }}"></script>{% endif %}
<style>
/* Navigation bar — shared across all pages */
.nav {
background: #fff;
padding: 10px 15px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,.1);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
}
.nav-links { display: flex; align-items: center; }
.nav a {
margin-right: 20px;
text-decoration: none;
color: #0066cc;
font-weight: 500;
font-size: 0.9em;
}
.nav a:hover { text-decoration: underline; }
.nav a.active { color: #333; font-weight: bold; }
.nav-user {
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
color: #333;
font-size: 0.9em;
font-weight: 500;
padding: 4px 8px;
border-radius: 20px;
transition: background 0.15s;
}
.nav-user:hover { background: #f0f4ff; text-decoration: none; }
.nav-avatar {
width: 28px; height: 28px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
}
.nav-initials {
width: 28px; height: 28px;
border-radius: 50%;
background: #0066cc;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75em;
font-weight: 700;
flex-shrink: 0;
}
</style>
</head> </head>
+16 -39
View File
@@ -4,42 +4,26 @@
<style> <style>
body { body {
margin: 10px; display: flex;
flex-direction: column;
height: 100vh;
box-sizing: border-box;
padding: 10px;
margin: 0;
background: #f5f5f5; background: #f5f5f5;
overflow: hidden; overflow: hidden;
} }
.nav {
background: #fff;
padding: 10px 15px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 4px;
}
.nav a {
margin-right: 20px;
text-decoration: none;
color: #0066cc;
font-weight: 500;
font-size: 0.9em;
}
.nav a:hover {
text-decoration: underline;
}
.nav a.active {
color: #333;
font-weight: bold;
}
.container { .container {
flex: 1;
min-height: 0;
max-width: 1600px; max-width: 1600px;
width: 100%;
margin: 0 auto; margin: 0 auto;
max-height: calc(100vh - 120px); display: flex;
overflow-y: auto; flex-direction: column;
padding-right: 10px; gap: 15px;
overflow: hidden;
} }
h1 { h1 {
@@ -78,11 +62,12 @@
} }
.log-section { .log-section {
flex: 1;
min-height: 0;
background: white; background: white;
border-radius: 6px; border-radius: 6px;
padding: 15px; padding: 15px;
box-shadow: 0 1px 4px rgba(0,0,0,0.1); box-shadow: 0 1px 4px rgba(0,0,0,0.1);
max-height: 400px;
overflow-y: auto; overflow-y: auto;
} }
@@ -137,24 +122,20 @@
} }
/* Scrollbar styling */ /* Scrollbar styling */
.container::-webkit-scrollbar,
.log-section::-webkit-scrollbar { .log-section::-webkit-scrollbar {
width: 8px; width: 8px;
} }
.container::-webkit-scrollbar-track,
.log-section::-webkit-scrollbar-track { .log-section::-webkit-scrollbar-track {
background: #f1f1f1; background: #f1f1f1;
border-radius: 4px; border-radius: 4px;
} }
.container::-webkit-scrollbar-thumb,
.log-section::-webkit-scrollbar-thumb { .log-section::-webkit-scrollbar-thumb {
background: #888; background: #888;
border-radius: 4px; border-radius: 4px;
} }
.container::-webkit-scrollbar-thumb:hover,
.log-section::-webkit-scrollbar-thumb:hover { .log-section::-webkit-scrollbar-thumb:hover {
background: #555; background: #555;
} }
@@ -419,11 +400,7 @@
WS_Connect(); WS_Connect();
</script> </script>
<body> <body>
<div class="nav"> {% include 'nav.html' %}
<a href="/live" class="active">Live Dashboard</a>
<a href="/plugins">Plugin Metrics</a>
<a href="/alerts">Alerts</a>
</div>
{% include 'menu.html' %} {% include 'menu.html' %}
-1
View File
@@ -1,3 +1,2 @@
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label> <!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
s<header>{{ header }}</header> --> s<header>{{ header }}</header> -->
+19
View File
@@ -0,0 +1,19 @@
<div class="nav">
<div class="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="/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 %}
</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 }}">
{% if current_user.avatar %}
<img class="nav-avatar" src="{{ current_user.avatar_url }}" alt="{{ current_user.full_name or current_user.username }}">
{% else %}
<span class="nav-initials">{{ (current_user.full_name or current_user.username)[:1] | upper }}</span>
{% endif %}
</a>
{% endif %}
</div>
+16 -35
View File
@@ -9,31 +9,6 @@
overflow: hidden; overflow: hidden;
} }
.nav {
background: #fff;
padding: 10px 15px;
margin-bottom: 10px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
border-radius: 4px;
}
.nav a {
margin-right: 20px;
text-decoration: none;
color: #0066cc;
font-weight: 500;
font-size: 0.9em;
}
.nav a:hover {
text-decoration: underline;
}
.nav a.active {
color: #333;
font-weight: bold;
}
.container { .container {
max-width: 1400px; max-width: 1400px;
margin: 0 auto; margin: 0 auto;
@@ -357,11 +332,7 @@
</style> </style>
<body> <body>
<div class="nav"> {% include 'nav.html' %}
<a href="/live">Live Dashboard</a>
<a href="/plugins" class="active">Plugin Metrics</a>
<a href="/alerts">Alerts</a>
</div>
<div class="container"> <div class="container">
<h1>{{ header }}</h1> <h1>{{ header }}</h1>
@@ -459,23 +430,27 @@
} }
} }
// Protocol metadata fields injected by the client never plugin metrics
const SKIP_FIELDS = new Set(['id', 'name']);
function renderPluginData(data, timestamp) { function renderPluginData(data, timestamp) {
// Check if this should be rendered as a simple table // Check if this should be rendered as a simple table
const pluginName = getCurrentPluginName(); const pluginName = getCurrentPluginName();
const simplePlugins = ['os_info', 'cpu_monitor', 'memory_monitor', 'nagios_runner']; const simplePlugins = ['os_info', 'cpu_monitor', 'memory_monitor', 'nagios_runner'];
if (simplePlugins.includes(pluginName) && isSimpleKeyValueData(data)) { if (simplePlugins.includes(pluginName) && isSimpleKeyValueData(data)) {
return renderSimpleDataTable(data, timestamp); return renderSimpleDataTable(data, timestamp);
} }
let html = '<div class="metric-grid">'; let html = '<div class="metric-grid">';
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
if (SKIP_FIELDS.has(key)) continue;
// Skip nested objects for now, handle them separately // Skip nested objects for now, handle them separately
if (typeof value === 'object' && value !== null) { if (typeof value === 'object' && value !== null) {
continue; continue;
} }
html += renderMetric(key, value); html += renderMetric(key, value);
} }
@@ -572,10 +547,11 @@
// Table body // Table body
html += '<tbody>'; html += '<tbody>';
for (const [key, value] of Object.entries(data)) { for (const [key, value] of Object.entries(data)) {
if (SKIP_FIELDS.has(key)) continue;
const label = formatLabel(key); const label = formatLabel(key);
const formattedValue = formatValue(key, value); const formattedValue = formatValue(key, value);
const unit = getUnit(key); const unit = getUnit(key);
html += '<tr>'; html += '<tr>';
html += `<td class="name">${label}</td>`; html += `<td class="name">${label}</td>`;
html += `<td class="value">${formattedValue}${unit ? ' ' + unit : ''}</td>`; html += `<td class="value">${formattedValue}${unit ? ' ' + unit : ''}</td>`;
@@ -1012,12 +988,17 @@
} }
function formatLabel(key) { function formatLabel(key) {
if (key === 'time') return 'Collected At';
return key return key
.replace(/_/g, ' ') .replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase()); .replace(/\b\w/g, l => l.toUpperCase());
} }
function formatValue(key, value) { function formatValue(key, value) {
// Epoch timestamp field sent by the client alongside plugin data
if (key === 'time' && typeof value === 'number') {
return new Date(value * 1000).toLocaleString();
}
if (typeof value === 'number') { if (typeof value === 'number') {
// Format percentages // Format percentages
if (key.includes('percent') || key.includes('usage')) { if (key.includes('percent') || key.includes('usage')) {
+334
View File
@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
body {
margin: 20px;
background: #f5f5f5;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.container {
max-width: 900px;
margin: 0 auto;
}
h1 {
color: #333;
margin-bottom: 4px;
font-size: 1.5em;
}
.subtitle {
color: #666;
margin-bottom: 24px;
font-size: 0.9em;
}
/* ---- Profile card ---- */
.profile-card {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 6px rgba(0,0,0,0.1);
padding: 28px 32px;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 28px;
}
.avatar-large {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.avatar-initials-large {
width: 80px;
height: 80px;
border-radius: 50%;
background: #0066cc;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
font-weight: 700;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.profile-info { flex: 1; }
.profile-name {
font-size: 1.4em;
font-weight: 700;
color: #222;
margin-bottom: 4px;
}
.profile-username {
font-size: 0.9em;
color: #666;
margin-bottom: 10px;
}
.badge {
display: inline-block;
padding: 2px 10px;
border-radius: 12px;
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.badge-admin { background: #e8f0fe; color: #1a73e8; }
.badge-user { background: #f1f3f4; color: #555; }
.profile-logout {
margin-top: 14px;
}
.btn-logout {
display: inline-block;
padding: 6px 16px;
border-radius: 4px;
background: #f44336;
color: #fff;
font-size: 0.85em;
font-weight: 500;
text-decoration: none;
transition: background 0.15s;
}
.btn-logout:hover { background: #d32f2f; text-decoration: none; }
/* ---- Section cards ---- */
.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;
}
/* ---- Settings rows ---- */
.settings-row {
display: flex;
align-items: baseline;
padding: 8px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 0.9em;
}
.settings-row:last-child { border-bottom: none; }
.settings-label {
width: 180px;
flex-shrink: 0;
color: #666;
font-size: 0.88em;
}
.settings-value { color: #222; }
.settings-empty { color: #aaa; font-style: italic; }
/* ---- Host lists ---- */
.host-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.host-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 12px;
border-radius: 16px;
font-size: 0.85em;
font-weight: 500;
text-decoration: none;
}
.host-chip.owner { background: #e8f5e9; color: #2e7d32; }
.host-chip.manager { background: #e3f2fd; color: #1565c0; }
.host-chip.monitor { background: #f3e5f5; color: #6a1b9a; }
.host-chip-dot {
width: 7px; height: 7px; border-radius: 50%;
}
.owner .host-chip-dot { background: #2e7d32; }
.manager .host-chip-dot { background: #1565c0; }
.monitor .host-chip-dot { background: #6a1b9a; }
.no-hosts {
color: #aaa;
font-size: 0.9em;
font-style: italic;
}
/* ---- Notification channels ---- */
.channel-row {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 0;
border-bottom: 1px solid #f5f5f5;
font-size: 0.9em;
}
.channel-row:last-child { border-bottom: none; }
.channel-type {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 0.78em;
font-weight: 600;
text-transform: uppercase;
background: #f1f3f4;
color: #555;
min-width: 70px;
text-align: center;
}
.channel-name { color: #333; }
</style>
<body>
{% include 'nav.html' %}
<div class="container">
<h1>{{ header }}</h1>
<p class="subtitle">Your account settings and host access</p>
<!-- Profile card -->
<div class="profile-card">
{% if current_user and current_user.avatar %}
<img class="avatar-large" src="{{ current_user.avatar_url }}" alt="">
{% else %}
<div class="avatar-initials-large">
{{ ((current_user.full_name if current_user else '') or (current_user.username if current_user else '?'))[:1] | upper }}
</div>
{% endif %}
<div class="profile-info">
<div class="profile-name">{{ current_user.full_name if current_user and current_user.full_name else (current_user.username if current_user else '—') }}</div>
<div class="profile-username">@{{ current_user.username if current_user else '—' }}</div>
{% if current_user and current_user.admin %}
<span class="badge badge-admin">Admin</span>
{% else %}
<span class="badge badge-user">User</span>
{% endif %}
<div class="profile-logout">
<a href="/logout" class="btn-logout">Sign out</a>
</div>
</div>
</div>
<!-- Account settings -->
<div class="section">
<h2>Account</h2>
<div class="settings-row">
<span class="settings-label">Username</span>
<span class="settings-value">{{ current_user.username if current_user else '—' }}</span>
</div>
<div class="settings-row">
<span class="settings-label">Full name</span>
{% if current_user and current_user.full_name %}
<span class="settings-value">{{ current_user.full_name }}</span>
{% else %}
<span class="settings-empty">Not set</span>
{% endif %}
</div>
<div class="settings-row">
<span class="settings-label">Role</span>
<span class="settings-value">{{ 'Administrator' if current_user and current_user.admin else 'User' }}</span>
</div>
<div class="settings-row">
<span class="settings-label">Avatar</span>
{% if current_user and current_user.avatar %}
<span class="settings-value" style="word-break:break-all;">{{ current_user.avatar }}</span>
{% else %}
<span class="settings-empty">Not set (initials used)</span>
{% endif %}
</div>
</div>
<!-- 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>
</div>
{% endfor %}
{% else %}
<span class="no-hosts">No personal notification channels configured.</span>
{% endif %}
</div>
<!-- Host access -->
<div class="section">
<h2>Host Access</h2>
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
<span class="settings-label" style="padding-top: 2px;">Owner</span>
<div class="host-grid">
{% if owned_hosts %}
{% for h in owned_hosts %}
<span class="host-chip owner"><span class="host-chip-dot"></span>{{ h }}</span>
{% endfor %}
{% else %}
<span class="no-hosts">None</span>
{% endif %}
</div>
</div>
<div class="settings-row" style="align-items: flex-start; padding-bottom: 14px;">
<span class="settings-label" style="padding-top: 2px;">Manager</span>
<div class="host-grid">
{% if managed_hosts %}
{% for h in managed_hosts %}
<span class="host-chip manager"><span class="host-chip-dot"></span>{{ h }}</span>
{% endfor %}
{% else %}
<span class="no-hosts">None</span>
{% endif %}
</div>
</div>
<div class="settings-row" style="align-items: flex-start; padding-bottom: 4px;">
<span class="settings-label" style="padding-top: 2px;">Monitor</span>
<div class="host-grid">
{% if monitored_hosts %}
{% for h in monitored_hosts %}
<span class="host-chip monitor"><span class="host-chip-dot"></span>{{ h }}</span>
{% endfor %}
{% else %}
<span class="no-hosts">None</span>
{% endif %}
</div>
</div>
</div>
</div>
</body>
</html>
+433
View File
@@ -0,0 +1,433 @@
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
html, body {
overflow: visible;
}
body {
margin: 20px;
background: #f5f5f5;
font-family: 'Segoe UI', system-ui, sans-serif;
}
.container {
max-width: 960px;
margin: 0 auto;
}
h1 { color: #333; margin-bottom: 4px; font-size: 1.5em; }
.subtitle { color: #666; margin-bottom: 24px; font-size: 0.9em; }
/* ---- Sidebar + content layout ---- */
.settings-layout {
display: flex;
gap: 24px;
align-items: flex-start;
}
.settings-sidebar {
width: 180px;
flex-shrink: 0;
position: sticky;
top: 20px;
}
.sidebar-nav a {
display: block;
padding: 6px 10px;
border-radius: 4px;
text-decoration: none;
font-size: 0.85em;
color: #444;
margin-bottom: 2px;
transition: background 0.1s, color 0.1s;
}
.sidebar-nav a:hover { background: #e8eaf6; color: #1a237e; }
.sidebar-nav a.active { background: #e3f2fd; color: #0066cc; font-weight: 600; }
.settings-main { flex: 1; min-width: 0; }
/* ---- Section card ---- */
.section {
background: #fff;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.08);
margin-bottom: 24px;
overflow: hidden;
}
.section-header {
padding: 14px 20px 12px;
border-bottom: 1px solid #eee;
}
.section-title {
font-size: 0.95em;
font-weight: 700;
color: #222;
text-transform: uppercase;
letter-spacing: 0.5px;
margin: 0 0 3px;
}
.section-desc {
font-size: 0.82em;
color: #888;
margin: 0;
}
/* ---- Field rows ---- */
.field-row {
display: flex;
align-items: baseline;
padding: 10px 20px;
border-bottom: 1px solid #f5f5f5;
gap: 16px;
}
.field-row:last-child { border-bottom: none; }
.field-label {
width: 200px;
flex-shrink: 0;
font-size: 0.88em;
font-weight: 500;
color: #444;
}
.field-body { flex: 1; min-width: 0; }
.field-value {
font-size: 0.9em;
color: #222;
word-break: break-all;
}
.field-desc {
font-size: 0.78em;
color: #999;
margin-top: 2px;
}
/* ---- Value type renderers ---- */
.val-boolean {
display: inline-block;
padding: 2px 9px;
border-radius: 10px;
font-size: 0.8em;
font-weight: 600;
}
.val-boolean.on { background: #e8f5e9; color: #2e7d32; }
.val-boolean.off { background: #fce4ec; color: #c62828; }
.val-masked {
font-family: monospace;
color: #bbb;
letter-spacing: 2px;
}
.val-list { display: flex; flex-wrap: wrap; gap: 5px; }
.val-tag {
display: inline-block;
padding: 2px 9px;
background: #e8eaf6;
color: #283593;
border-radius: 10px;
font-size: 0.8em;
}
.val-empty { color: #ccc; font-style: italic; font-size: 0.88em; }
/* ---- Users table ---- */
.mini-table {
width: 100%;
border-collapse: collapse;
font-size: 0.875em;
}
.mini-table th {
background: #f5f5f5;
padding: 7px 12px;
text-align: left;
font-weight: 600;
color: #555;
font-size: 0.82em;
text-transform: uppercase;
letter-spacing: 0.4px;
border-bottom: 1px solid #e0e0e0;
}
.mini-table td {
padding: 7px 12px;
border-bottom: 1px solid #f0f0f0;
color: #333;
vertical-align: middle;
}
.mini-table tbody tr:last-child td { border-bottom: none; }
.mini-table tbody tr:hover { background: #fafafa; }
.badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 0.75em;
font-weight: 600;
}
.badge-admin { background: #e8f0fe; color: #1a73e8; }
.badge-user { background: #f1f3f4; color: #666; }
/* ---- Notification channels ---- */
.channel-card {
border: 1px solid #e8eaf6;
border-radius: 6px;
margin: 12px 20px;
overflow: hidden;
}
.channel-header {
display: flex;
align-items: center;
gap: 10px;
padding: 9px 14px;
background: #f8f9ff;
border-bottom: 1px solid #e8eaf6;
}
.channel-name-text { font-weight: 600; font-size: 0.9em; color: #222; }
.ch-type-badge {
padding: 2px 8px;
border-radius: 8px;
font-size: 0.75em;
font-weight: 600;
background: #e8eaf6;
color: #3949ab;
}
.channel-fields { padding: 6px 0; }
.channel-field {
display: flex;
padding: 5px 14px;
font-size: 0.85em;
border-bottom: 1px solid #f5f5f5;
gap: 12px;
}
.channel-field:last-child { border-bottom: none; }
.channel-field-label { width: 130px; flex-shrink: 0; color: #777; }
.channel-field-value { color: #333; word-break: break-all; }
/* ---- Hosts table ---- */
.host-bool { text-align: center; }
.dot-yes { color: #2e7d32; font-size: 1.1em; }
.dot-no { color: #ddd; font-size: 1.1em; }
</style>
<body>
{% include 'nav.html' %}
<div class="container">
<h1>Settings</h1>
<p class="subtitle">Current server configuration — read from the config file at startup.</p>
<div class="settings-layout">
<!-- Sidebar navigation -->
<nav class="settings-sidebar">
<div class="sidebar-nav" id="sidebar-nav">
{% for section in sections %}
<a href="#{{ section.id }}">{{ section.title }}</a>
{% endfor %}
</div>
</nav>
<!-- Main content -->
<div class="settings-main">
{% for section in sections %}
<div class="section" id="{{ section.id }}">
<div class="section-header">
<p class="section-title">{{ section.title }}</p>
{% if section.description %}<p class="section-desc">{{ section.description }}</p>{% endif %}
</div>
{# ---- Standard field rows ---- #}
{% for f in section.fields %}
<div class="field-row">
<div class="field-label">{{ f.label }}</div>
<div class="field-body">
{% if f.sensitive %}
<div class="field-value"><span class="val-masked">••••••••</span></div>
{% 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>
</div>
{% 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 %}
</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>
{% 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>
</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 #}
{% endfor %}
</div>{# /settings-main #}
</div>{# /settings-layout #}
</div>{# /container #}
<script>
// Highlight sidebar link for the section currently in view
const sections = document.querySelectorAll('.section');
const navLinks = document.querySelectorAll('.sidebar-nav a');
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const id = entry.target.id;
navLinks.forEach(a => {
a.classList.toggle('active', a.getAttribute('href') === '#' + id);
});
}
});
}, { threshold: 0.25 });
sections.forEach(s => observer.observe(s));
</script>
</body>
</html>
+96 -37
View File
@@ -14,6 +14,7 @@ import time
from enum import Enum from enum import Enum
from typing import Dict, Any, Optional, Tuple, Callable from typing import Dict, Any, Optional, Tuple, Callable
from . import notify as notify_mod from . import notify as notify_mod
from .config import THRESHOLD_DEFAULTS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
eventlog = notify_mod.eventlog eventlog = notify_mod.eventlog
@@ -38,11 +39,11 @@ class ComparisonOperator(Enum):
class AlertState: class AlertState:
"""Represents the current alert state for a specific metric.""" """Represents the current alert state for a specific metric."""
def __init__(self, metric_path: str): def __init__(self, metric_path: str):
""" """
Initialize alert state. Initialize alert state.
Args: Args:
metric_path: Full path to metric (e.g., "cpu_monitor.cpu_percent") metric_path: Full path to metric (e.g., "cpu_monitor.cpu_percent")
""" """
@@ -58,6 +59,7 @@ class AlertState:
self.formatted_message = None # Formatted display message for UI self.formatted_message = None # Formatted display message for UI
self.acknowledged = False # Whether alert has been acknowledged self.acknowledged = False # Whether alert has been acknowledged
self.acknowledged_at = None # Timestamp when acknowledged self.acknowledged_at = None # Timestamp when acknowledged
self.consecutive_count = 0 # Consecutive exceedances while still OK (for count gating)
def update( def update(
self, self,
@@ -118,8 +120,11 @@ class AlertState:
# Helper to sanitize numeric values for JSON (handle inf/nan) # Helper to sanitize numeric values for JSON (handle inf/nan)
def sanitize_value(val): def sanitize_value(val):
if isinstance(val, float) and (math.isinf(val) or math.isnan(val)): if isinstance(val, float):
return None if math.isinf(val):
return "overdue"
if math.isnan(val):
return None
return val return val
result = { result = {
@@ -146,6 +151,12 @@ class AlertState:
return result return result
def __setstate__(self, state):
"""Restore from pickle, backfilling fields added after the pickle was written."""
self.__dict__.update(state)
if not hasattr(self, 'consecutive_count'):
self.consecutive_count = 0
def acknowledge(self): def acknowledge(self):
"""Acknowledge this alert to stop reminder notifications.""" """Acknowledge this alert to stop reminder notifications."""
self.acknowledged = True self.acknowledged = True
@@ -157,7 +168,7 @@ class AlertState:
class ThresholdConfig: class ThresholdConfig:
"""Configuration for a single threshold check.""" """Configuration for a single threshold check."""
def __init__( def __init__(
self, self,
metric_path: str, metric_path: str,
@@ -167,10 +178,11 @@ class ThresholdConfig:
operator: str = ">", operator: str = ">",
hysteresis: float = 0.0, hysteresis: float = 0.0,
enabled: bool = True, enabled: bool = True,
count: int = 1,
): ):
""" """
Initialize threshold configuration. Initialize threshold configuration.
Args: Args:
metric_path: Full path to metric (e.g., "cpu_monitor.cpu_percent") metric_path: Full path to metric (e.g., "cpu_monitor.cpu_percent")
warning: Warning threshold value warning: Warning threshold value
@@ -178,6 +190,7 @@ class ThresholdConfig:
operator: Comparison operator (>, >=, <, <=, ==, !=) operator: Comparison operator (>, >=, <, <=, ==, !=)
hysteresis: Hysteresis percentage to prevent flapping (0.0-1.0) hysteresis: Hysteresis percentage to prevent flapping (0.0-1.0)
enabled: Whether this threshold is enabled enabled: Whether this threshold is enabled
count: Number of consecutive exceedances required before alerting (default 1)
""" """
self.metric_path = metric_path self.metric_path = metric_path
self.warning = warning self.warning = warning
@@ -185,6 +198,7 @@ class ThresholdConfig:
self.enabled = enabled self.enabled = enabled
self.hysteresis = hysteresis self.hysteresis = hysteresis
self.display = display self.display = display
self.count = max(1, int(count))
# Parse operator # Parse operator
try: try:
@@ -386,29 +400,49 @@ class ThresholdChecker:
def _parse_multi_config(self, config: Dict[str, Any]): def _parse_multi_config(self, config: Dict[str, Any]):
"""Parse multiple named threshold configurations.""" """Parse multiple named threshold configurations."""
threshold_configs = config.get("threshold_configs", {}) threshold_configs = config.get("threshold_configs", {})
if not threshold_configs: if not threshold_configs:
logger.info("No threshold configurations defined") logger.info("No threshold configurations defined")
return return
# Parse each named configuration # Build effective_defaults: THRESHOLD_DEFAULTS merged with the 'default' config (if present).
# All other configs inherit any metric not explicitly defined from effective_defaults.
effective_defaults: Dict[str, ThresholdConfig] = {}
for plugin_name, plugin_thresholds in THRESHOLD_DEFAULTS.get("thresholds", {}).items():
if isinstance(plugin_thresholds, dict):
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=effective_defaults)
if "default" in threshold_configs:
default_data = threshold_configs["default"]
if isinstance(default_data, dict) and "thresholds" in default_data:
for plugin_name, plugin_thresholds in default_data["thresholds"].items():
if isinstance(plugin_thresholds, dict):
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=effective_defaults)
self.threshold_configs["default"] = dict(effective_defaults)
logger.info("Registered 'default' threshold config with %d metrics", len(effective_defaults))
# Parse each named configuration, seeding it with effective_defaults first
for config_name, config_data in threshold_configs.items(): for config_name, config_data in threshold_configs.items():
if config_name == "default":
continue # already handled above
if not isinstance(config_data, dict): if not isinstance(config_data, dict):
logger.warning("Invalid threshold config '%s', skipping", config_name) logger.warning("Invalid threshold config '%s', skipping", config_name)
continue continue
if "thresholds" not in config_data: if "thresholds" not in config_data:
logger.warning("No thresholds in config '%s', skipping", config_name) logger.warning("No thresholds in config '%s', skipping", config_name)
continue continue
logger.info("Parsing threshold configuration: %s", config_name) logger.info("Parsing threshold configuration: %s", config_name)
self.threshold_configs[config_name] = {} self.threshold_configs[config_name] = dict(effective_defaults)
thresholds_config = config_data["thresholds"] thresholds_config = config_data["thresholds"]
for plugin_name, plugin_thresholds in thresholds_config.items(): for plugin_name, plugin_thresholds in thresholds_config.items():
if not isinstance(plugin_thresholds, dict): if not isinstance(plugin_thresholds, dict):
continue continue
self._parse_plugin_thresholds( self._parse_plugin_thresholds(
plugin_name, plugin_name,
plugin_thresholds, plugin_thresholds,
@@ -600,11 +634,12 @@ class ThresholdChecker:
hysteresis = rtt_thresholds.get("hysteresis", 0.1) # 10% default hysteresis = rtt_thresholds.get("hysteresis", 0.1) # 10% default
enabled = rtt_thresholds.get("enabled", True) enabled = rtt_thresholds.get("enabled", True)
display = rtt_thresholds.get("display") display = rtt_thresholds.get("display")
count = rtt_thresholds.get("count", 1)
if warning is None and critical is None: if warning is None and critical is None:
logger.warning("No RTT thresholds defined, skipping") logger.warning("No RTT thresholds defined, skipping")
return return
threshold = ThresholdConfig( threshold = ThresholdConfig(
metric_path=metric_path, metric_path=metric_path,
warning=warning, warning=warning,
@@ -612,14 +647,16 @@ class ThresholdChecker:
operator=operator, operator=operator,
hysteresis=hysteresis, hysteresis=hysteresis,
enabled=enabled, enabled=enabled,
display=display display=display,
count=count,
) )
target_dict[metric_path] = threshold target_dict[metric_path] = threshold
logger.debug( logger.debug(
"Registered RTT threshold: warn=%s ms, crit=%s ms", "Registered RTT threshold: warn=%s ms, crit=%s ms, count=%d",
warning, warning,
critical critical,
count,
) )
def get_thresholds_for_host(self, host_name: str) -> Dict[str, ThresholdConfig]: def get_thresholds_for_host(self, host_name: str) -> Dict[str, ThresholdConfig]:
@@ -691,14 +728,34 @@ class ThresholdChecker:
value, value,
alert_state.level alert_state.level
) )
# Apply consecutive-count gating: when currently OK, require threshold.count
# consecutive exceedances before escalating to WARNING/CRITICAL.
if new_level == AlertLevel.OK:
# Value is fine (or recovered) — reset the pending counter immediately.
alert_state.consecutive_count = 0
elif alert_state.level == AlertLevel.OK and new_level != AlertLevel.OK:
# First time we exceed while still OK: count up.
alert_state.consecutive_count += 1
if alert_state.consecutive_count < threshold.count:
logger.debug(
"RTT threshold exceeded %d/%d consecutive times for %s on %s",
alert_state.consecutive_count,
threshold.count,
metric_path,
host_name,
)
return None
# Count reached — fire the alert and reset the counter.
alert_state.consecutive_count = 0
# Determine which threshold was exceeded # Determine which threshold was exceeded
threshold_value = None threshold_value = None
if new_level == AlertLevel.CRITICAL and threshold.critical is not None: if new_level == AlertLevel.CRITICAL and threshold.critical is not None:
threshold_value = threshold.critical threshold_value = threshold.critical
elif new_level == AlertLevel.WARNING and threshold.warning is not None: elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning threshold_value = threshold.warning
# Update state and check for changes # Update state and check for changes
old_level = alert_state.level old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value): if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
@@ -711,7 +768,7 @@ class ThresholdChecker:
elif new_level != AlertLevel.OK: elif new_level != AlertLevel.OK:
# Check if we should re-notify # Check if we should re-notify
self._check_renotify(host_name, alert_state, metric_path, value, threshold, None) self._check_renotify(host_name, alert_state, metric_path, value, threshold, None)
return None return None
def check_plugin_data( def check_plugin_data(
self, self,
@@ -884,48 +941,50 @@ class ThresholdChecker:
# Format operator symbol # Format operator symbol
op_symbol = threshold.operator.value op_symbol = threshold.operator.value
# Use a display-friendly value (inf is the sentinel for "overdue")
import math
display_value = "overdue" if isinstance(value, float) and math.isinf(value) else value
# Format message # Format message
if new_level == AlertLevel.OK: if new_level == AlertLevel.OK:
lvl = "RECOVERED" lvl = "RECOVERED"
message = f"{metric_path} = {value} ({old_level.name} -> OK)" message = f"{metric_path} = {display_value} ({old_level.name} -> OK)"
elif new_level == AlertLevel.WARNING: elif new_level == AlertLevel.WARNING:
lvl = "WARNING" lvl = "WARNING"
if threshold_value is not None: if threshold_value is not None:
# Use display format string
threshold_info = self._format_display( threshold_info = self._format_display(
threshold.display, threshold.display,
value=value, value=display_value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data
) )
message = f"{metric_path} = {value} {threshold_info}" message = f"{metric_path} = {display_value} {threshold_info}"
else: else:
message = f"{metric_path} = {value}" message = f"{metric_path} = {display_value}"
elif new_level == AlertLevel.CRITICAL: elif new_level == AlertLevel.CRITICAL:
lvl = "CRITICAL" lvl = "CRITICAL"
if threshold_value is not None: if threshold_value is not None:
# Use display format string
threshold_info = self._format_display( threshold_info = self._format_display(
threshold.display, threshold.display,
value=value, value=display_value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data
) )
message = f"{metric_path} = {value} {threshold_info}" message = f"{metric_path} = {display_value} {threshold_info}"
else: else:
message = f"{metric_path} = {value}" message = f"{metric_path} = {display_value}"
else: else:
lvl = "UNKNOWN" lvl = "UNKNOWN"
message = f"{metric_path} = {value}" message = f"{metric_path} = {display_value}"
# Return the formatted threshold info for storing in AlertState # Return the formatted threshold info for storing in AlertState
formatted_threshold_msg = None formatted_threshold_msg = None
if threshold_value is not None and new_level != AlertLevel.OK: if threshold_value is not None and new_level != AlertLevel.OK:
formatted_threshold_msg = self._format_display( formatted_threshold_msg = self._format_display(
threshold.display, threshold.display,
value=value, value=display_value,
threshold_value=threshold_value, threshold_value=threshold_value,
op_symbol=op_symbol, op_symbol=op_symbol,
plugin_data=plugin_data plugin_data=plugin_data
@@ -1037,9 +1096,9 @@ class ThresholdChecker:
threshold: Threshold configuration threshold: Threshold configuration
plugin_data: Optional dictionary of all plugin data fields plugin_data: Optional dictionary of all plugin data fields
""" """
if alert_state.level == AlertLevel.OK: if alert_state.level != AlertLevel.CRITICAL:
return return
# Skip reminders if alert has been acknowledged # Skip reminders if alert has been acknowledged
if alert_state.acknowledged: if alert_state.acknowledged:
return return
+225 -56
View File
@@ -1,9 +1,14 @@
"""UDP listener and datagram processing.""" """UDP listener and datagram processing."""
import asyncio import asyncio
import socket
import struct
import time
import zlib import zlib
import logging import logging
from platform import system as platform_system
from ..common.proto import stodict, oldmtodict from ..common.proto import stodict, oldmtodict
from ..common.utils import dur from ..common.utils import dur
from . import notify as notify_mod from . import notify as notify_mod
@@ -11,6 +16,108 @@ from . import notify as notify_mod
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
eventlog = notify_mod.eventlog eventlog = notify_mod.eventlog
# SO_TIMESTAMP: kernel attaches a struct timeval to each received datagram.
# Supported on Linux, FreeBSD, and macOS. The constant is not exposed by
# Python's socket module on all platforms
platform = platform_system()
if platform == "Darwin":
_SO_TIMESTAMP = 1024 # SO_TIMESTAMP on macOS (not in Python's socket module)
elif platform == "Linux":
_SO_TIMESTAMP = 29 # Linux value (not in older Python versions)
elif platform == "FreeBSD":
_SO_TIMESTAMP = 32 # FreeBSD value (not in older Python versions)
else:
logger.warning("SO_TIMESTAMP may not be supported on this platform (%s)", platform)
_SO_TIMESTAMP = None
# struct timeval uses two native C longs: tv_sec and tv_usec
_TIMEVAL = struct.Struct('@ll')
def enable_kernel_timestamps(sock) -> bool:
"""Try to enable SO_TIMESTAMP on *sock*.
Returns True if the kernel will supply receive timestamps, False otherwise
(unsupported platform, older kernel, or insufficient permissions).
"""
try:
sock.setsockopt(socket.SOL_SOCKET, _SO_TIMESTAMP, 1)
return True
except OSError:
return False
def _extract_kernel_ts(ancdata) -> float | None:
"""Parse recvmsg ancillary data and return the kernel receive time.
Returns seconds as a float, or None if no SO_TIMESTAMP cmsg is present.
"""
for cmsg_level, cmsg_type, cmsg_data in ancdata:
if cmsg_level == socket.SOL_SOCKET and cmsg_type == _SO_TIMESTAMP:
if len(cmsg_data) >= _TIMEVAL.size:
sec, usec = _TIMEVAL.unpack_from(cmsg_data)
return sec + usec * 1e-6
return None
class RecvmsgTransport:
"""Thin wrapper used when SO_TIMESTAMP is active (add_reader path).
Exposes the same sendto() / close() interface as asyncio's DatagramTransport
so the rest of the code does not need to know which path is in use.
"""
def __init__(self, loop, sock):
self._loop = loop
self._sock = sock
def sendto(self, data, addr):
try:
self._sock.sendto(data, addr)
except Exception as e:
logger.debug("sendto failed: %s", e)
def close(self):
try:
self._loop.remove_reader(self._sock.fileno())
except Exception:
pass
try:
self._sock.close()
except Exception:
pass
def make_recvmsg_reader(sock, handler, transport):
"""Return a callback suitable for loop.add_reader().
Reads one datagram per call using recvmsg() so that kernel timestamps in
the ancillary data are accessible. Falls back to time.time() if the
cmsg is missing.
handler(msg, addr, transport, kernel_ts) same signature as udp_handler
in main.py with the optional kernel_ts argument.
"""
BUFSIZE = 65536
ANCBUFSIZE = 128 # enough for one struct timespec cmsg
def _read():
try:
data, ancdata, _, addr = sock.recvmsg(BUFSIZE, ANCBUFSIZE)
except BlockingIOError:
return
except OSError as e:
logger.warning("recvmsg error: %s", e)
return
try:
kernel_ts = _extract_kernel_ts(ancdata)
msg = parse_message(data)
if msg:
handler(msg, addr, transport, kernel_ts)
except Exception:
logger.exception("Error processing datagram from %s", addr)
return _read
class EchoServerProtocol(asyncio.DatagramProtocol): class EchoServerProtocol(asyncio.DatagramProtocol):
def __init__(self, config=None, handler=None): def __init__(self, config=None, handler=None):
@@ -61,6 +168,104 @@ def dicttos(ID, d):
return opk return opk
DROPOVERDUE = 7 * 24 * 3600 # seconds before an overdue host becomes UNKNOWN
def _make_timer_callbacks(uname, host, watchhosts, ctx):
"""Return (on_overdue, on_unknown) async callbacks for connection timer logic.
Captured values are bound at call time so callbacks are safe to use in loops.
"""
msg_to_websockets = ctx.get("msg_to_websockets")
threshold_checker = ctx.get("threshold_checker")
cfg = ctx.get("config", {})
async def on_unknown(connection):
connection.newstate(connection.__class__.UNKNOWN, connection.lastbeat)
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
async def on_overdue(connection):
if connection.getstate() != connection.__class__.UP:
return
now = time.time()
connection.newstate(connection.__class__.OVERDUE, now, cfg.get("grace", 2))
msg = f"{connection.afam} overdue"
eventlog(uname, "CRITICAL" if uname in watchhosts else "WARNING", msg)
if uname in watchhosts:
notify_mod.pushmsg_for_host(uname, f"{uname} {msg}")
if threshold_checker:
threshold_checker.check_value(
host_name=uname,
metric_path="rtt",
value=float("inf"),
alert_states=host.alert_states,
)
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
return on_overdue, on_unknown
def restore_connection_timers(hbdclass, ctx):
"""Restore overdue timers for all loaded connections after a pickle restore.
For UP connections, the remaining time until overdue is calculated from
lastbeat so that clients that vanished during hbd's downtime are detected.
For OVERDUE connections, the UNKNOWN drop timer is restored.
"""
now = time.time()
cfg = ctx.get("config", {})
grace = cfg.get("grace", 2)
from . import config as config_mod
watchhosts = config_mod.get_watchhosts(cfg)
restored = 0
for uname, host in list(hbdclass.Host.hosts.items()):
interval = host.interval
for afam, conn in list(host.connections.items()):
state = conn.getstate()
if state == hbdclass.Connection.DOWN:
continue
on_overdue, on_unknown = _make_timer_callbacks(uname, host, watchhosts, ctx)
if state == hbdclass.Connection.UP and interval > 0:
elapsed = now - conn.lastbeat
# Give hosts one full (interval + grace) of extra time on startup
# so hosts that were silent while hbd was down are not immediately
# flagged as overdue before they have a chance to check in.
startup_grace = interval + grace
remaining = max(startup_grace, 2 * startup_grace - elapsed)
conn.reset_overdue_timer(remaining, on_overdue)
logger.debug(
"Restored UP timer %s/%s: %.0fs remaining (elapsed %.0fs, startup grace %.0fs)",
uname, afam, remaining, elapsed, startup_grace,
)
restored += 1
elif state == hbdclass.Connection.OVERDUE:
elapsed_overdue = now - conn.statetime
remaining = DROPOVERDUE - elapsed_overdue
if remaining <= 1:
# Already past the drop window — mark UNKNOWN immediately
conn.newstate(hbdclass.Connection.UNKNOWN, conn.lastbeat)
logger.info(
"Marking %s/%s UNKNOWN (overdue %.1f days)",
uname, afam, elapsed_overdue / 86400,
)
else:
conn.reset_overdue_timer(remaining, on_unknown)
logger.debug(
"Restored OVERDUE timer %s/%s: %.0fs remaining",
uname, afam, remaining,
)
restored += 1
logger.info("Restored timers for %d connection(s)", restored)
def handle_datagram(msg: dict, addr, transport, ctx: dict): def handle_datagram(msg: dict, addr, transport, ctx: dict):
"""Handle a parsed datagram message. """Handle a parsed datagram message.
@@ -74,7 +279,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
""" """
if not msg: if not msg:
return return
now = __import__("time").time() now = ctx.get("recv_ts") or time.time()
# Log message to journal # Log message to journal
msg_journal = ctx.get("msg_journal") msg_journal = ctx.get("msg_journal")
@@ -107,6 +312,9 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Use new config function to check dyndns # Use new config function to check dyndns
dyndnshosts = config_mod.get_dyndnshosts(cfg) dyndnshosts = config_mod.get_dyndnshosts(cfg)
host.dyn = uname in dyndnshosts host.dyn = uname in dyndnshosts
# 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: if verbose:
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts)))) print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
newh = True newh = True
@@ -126,7 +334,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if msg.get("ID") == "HTB": if msg.get("ID") == "HTB":
host.doesack = msg.get("acks", -1) host.doesack = msg.get("acks", -1)
# send ACK back # send ACK back
rmsg = {"time": __import__("time").time()} rmsg = {"time": time.time()}
opkt = dicttos("ACK", rmsg) opkt = dicttos("ACK", rmsg)
try: try:
transport.sendto(opkt, addr) transport.sendto(opkt, addr)
@@ -138,8 +346,9 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Handle plugin data message # Handle plugin data message
plugin_name = msg.get("plugin") plugin_name = msg.get("plugin")
if plugin_name: if plugin_name:
# Extract all fields except ID and plugin name # Extract plugin fields, dropping protocol metadata fields
plugin_data = {k: v for k, v in msg.items() if k not in ["ID", "plugin"]} plugin_data = {k: v for k, v in msg.items()
if k not in ("ID", "plugin", "id", "name")}
# Store plugin data with timestamp # Store plugin data with timestamp
host.add_plugin_data(plugin_name, plugin_data, timestamp=now) host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
if DEBUG > 1: if DEBUG > 1:
@@ -203,13 +412,16 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if conn.getstate() != hbdcls.Connection.UP: if conn.getstate() != hbdcls.Connection.UP:
lasts = conn.state lasts = conn.state
d = conn.newstate(hbdcls.Connection.UP, now) d = conn.newstate(hbdcls.Connection.UP, now)
if d == 0 or lasts == "unknown": # Don't log/notify RECOVER for a brand-new host seen for the first time —
m = "%s is up" % (conn.afam) # it was never down, it just hasn't been seen before.
else: if not newh:
m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d)) if d == 0 or lasts == "unknown":
eventlog(uname, "RECOVER", m) m = "%s is up" % (conn.afam)
if uname in watchhosts: else:
notify_mod.pushmsg_for_host(uname, "%s %s is back" % (uname, conn.afam)) m = "%s back after being %s for %s" % (conn.afam, lasts, dur(d))
eventlog(uname, "RECOVER", m)
if uname in watchhosts:
notify_mod.pushmsg_for_host(uname, "%s %s is back" % (uname, conn.afam))
if boot or newh: if boot or newh:
host.upcount = host.doesack host.upcount = host.doesack
@@ -229,51 +441,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Reset overdue timer on every heartbeat # Reset overdue timer on every heartbeat
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN: if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
grace = cfg.get("grace", 2) grace = cfg.get("grace", 2)
timeout_seconds = (interval + grace) if interval > 0 else 30 timeout_seconds = interval + grace
on_overdue, _ = _make_timer_callbacks(uname, host, watchhosts, ctx)
# Create callback for timer expiration
async def on_overdue(connection):
"""Called when connection timer expires (no heartbeat received)."""
import time
now = time.time()
# Only mark as overdue if still in UP state (not already marked)
if connection.getstate() == hbdcls.Connection.UP:
connection.newstate(hbdcls.Connection.OVERDUE, now, cfg.get("grace", 2))
msg = f"{connection.afam} overdue"
eventlog(uname, "CRITICAL" if uname in watchhosts else "WARNING", msg)
if uname in watchhosts:
notify_mod.pushmsg_for_host(uname, f"{uname} {msg}")
# Check RTT thresholds with infinite RTT for overdue hosts
threshold_checker = ctx.get("threshold_checker")
if threshold_checker:
metric_path = "rtt"
threshold_checker.check_value(
host_name=uname,
metric_path=metric_path,
value=float('inf'),
alert_states=host.alert_states
)
# Notify websockets
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
# Set a longer timer for marking as UNKNOWN (7 days)
DROPOVERDUE = 7 * 24 * 3600
async def on_unknown(connection):
"""Mark connection as unknown after extended absence."""
connection.newstate(hbdcls.Connection.UNKNOWN, connection.lastbeat)
if msg_to_websockets:
msg_to_websockets("host", host.stateinfo())
connection.reset_overdue_timer(DROPOVERDUE, on_unknown)
# Reset the timer
conn.reset_overdue_timer(timeout_seconds, on_overdue) conn.reset_overdue_timer(timeout_seconds, on_overdue)
# Check RTT thresholds using the threshold checker # Check RTT thresholds using the threshold checker
+242
View File
@@ -0,0 +1,242 @@
"""User management: loading, authentication, and session tracking.
Users are defined in the config file under the ``users`` key:
users:
alice:
full_name: Alice Smith
avatar: /path/to/avatar.png # file path, URL, or base64 data URI
password: pbkdf2:sha256:... # generated with: hbd passwd
admin: true # optional server-level admin
notification_channels: [pushover_standard]
Roles are assigned per-host:
hosts:
webserver01:
owner: alice
managers: [bob]
monitors: [carol]
If no users are defined the server runs in unauthenticated mode (backwards
compatible). When users are defined every API call must carry a valid session
token in an ``Authorization: Bearer <token>`` or ``X-Auth-Token`` header,
obtained via ``POST /api/0/auth/login``.
"""
import hashlib
import hmac
import logging
import secrets
import time
logger = logging.getLogger(__name__)
# Session lifetime in seconds (24 hours).
SESSION_TTL = 86400
# Global session store: token -> {"username": str, "expires": float, "created": float}
_sessions: dict = {}
# ---------------------------------------------------------------------------
# User class
# ---------------------------------------------------------------------------
class User:
def __init__(
self,
username: str,
full_name: str = "",
avatar: str = "",
password_hash: str = "",
admin: bool = False,
notification_channels: list | None = None,
):
self.username = username
self.full_name = full_name
self.avatar = avatar
self.password_hash = password_hash
self.admin = admin
self.notification_channels: list = notification_channels or []
def check_password(self, password: str) -> bool:
if not self.password_hash:
return False
return _verify_password(password, self.password_hash)
def avatar_is_local(self) -> bool:
"""Return True when the avatar is a local filesystem path (starts with '/')."""
return bool(self.avatar and self.avatar.startswith("/"))
def avatar_url(self) -> str:
"""Return the URL to use as an <img src>.
Local file paths are served via the /api/0/users/{username}/avatar
endpoint. External URLs and data URIs are returned as-is.
"""
if self.avatar_is_local():
return f"/api/0/users/{self.username}/avatar"
return self.avatar
def to_dict(self) -> dict:
return {
"username": self.username,
"full_name": self.full_name,
"avatar": self.avatar,
"avatar_url": self.avatar_url(),
"admin": self.admin,
"notification_channels": self.notification_channels,
}
# ---------------------------------------------------------------------------
# Password hashing (PBKDF2-HMAC-SHA256, stdlib only)
# ---------------------------------------------------------------------------
def hash_password(password: str) -> str:
"""Return a storable hash for *password*.
Format: ``pbkdf2:sha256:<iterations>:<salt>:<hex-digest>``
Use this to generate the ``password`` value in the config file::
python -c "from hbd.server.users import hash_password; print(hash_password('secret'))"
Or via the CLI::
hbd passwd
"""
salt = secrets.token_hex(16)
iterations = 260_000
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
)
return f"pbkdf2:sha256:{iterations}:{salt}:{dk.hex()}"
def _verify_password(password: str, stored_hash: str) -> bool:
"""Return True if *password* matches *stored_hash*."""
try:
parts = stored_hash.split(":")
if len(parts) != 5 or parts[0] != "pbkdf2" or parts[1] != "sha256":
return False
_, _, iterations_str, salt, expected_hex = parts
iterations = int(iterations_str)
dk = hashlib.pbkdf2_hmac(
"sha256", password.encode("utf-8"), salt.encode("utf-8"), iterations
)
return hmac.compare_digest(dk.hex(), expected_hex)
except Exception:
return False
# ---------------------------------------------------------------------------
# Global user registry
# ---------------------------------------------------------------------------
# username -> User
users: dict = {}
def load_users(config: dict) -> dict:
"""Populate the global user registry from *config*.
Called once at startup and again on SIGHUP config reload.
Returns the new ``users`` dict.
"""
global users
users_cfg = config.get("users", {})
if not isinstance(users_cfg, dict):
users = {}
return users
result: dict = {}
for username, attrs in users_cfg.items():
if not isinstance(attrs, dict):
logger.warning("Skipping user %r: expected a mapping", username)
continue
result[username] = User(
username=username,
full_name=attrs.get("full_name", ""),
avatar=attrs.get("avatar", ""),
password_hash=attrs.get("password", ""),
admin=bool(attrs.get("admin", False)),
notification_channels=attrs.get("notification_channels", []),
)
users = result
logger.info("Loaded %d user(s) from config", len(users))
return users
def users_enabled() -> bool:
"""Return True if at least one user is configured (auth-required mode)."""
return bool(users)
def get_user(username: str) -> "User | None":
return users.get(username)
def authenticate(username: str, password: str) -> "User | None":
"""Return the User if credentials are valid, else None."""
user = users.get(username)
if user and user.check_password(password):
return user
return None
# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------
def create_session(username: str) -> str:
"""Create a new session for *username* and return the opaque token."""
_purge_expired_sessions()
token = secrets.token_hex(32)
_sessions[token] = {
"username": username,
"expires": time.time() + SESSION_TTL,
"created": time.time(),
}
return token
def get_session_user(token: str) -> "User | None":
"""Return the User for a valid *token*, or None if missing/expired."""
if not token:
return None
session = _sessions.get(token)
if not session:
return None
if session["expires"] < time.time():
del _sessions[token]
return None
return get_user(session["username"])
def delete_session(token: str) -> None:
"""Invalidate *token* (logout)."""
_sessions.pop(token, None)
def _purge_expired_sessions() -> None:
now = time.time()
expired = [t for t, s in list(_sessions.items()) if s["expires"] < now]
for t in expired:
del _sessions[t]
def save_sessions() -> dict:
"""Return a snapshot of non-expired sessions suitable for pickling."""
_purge_expired_sessions()
return dict(_sessions)
def load_sessions(snapshot: dict) -> None:
"""Restore sessions from a pickled snapshot, dropping any that have expired."""
global _sessions
now = time.time()
_sessions = {t: s for t, s in snapshot.items() if s.get("expires", 0) > now}
logger.debug("Restored %d session(s) from pickle", len(_sessions))
+29 -26
View File
@@ -65,11 +65,7 @@ async def _handler(websocket, path=None):
logger.exception("WebSocket handler exception from %s: %s", remote_address, e) logger.exception("WebSocket handler exception from %s: %s", remote_address, e)
finally: finally:
logger.debug("Removing WebSocket connection from %s", remote_address) logger.debug("Removing WebSocket connection from %s", remote_address)
try: _connections.discard(websocket)
_connections.remove(websocket)
except KeyError:
pass
await websocket.wait_closed()
async def start( async def start(
@@ -93,33 +89,40 @@ async def start(
_verbose = config.get("verbose", False), _verbose = config.get("verbose", False),
_debug = config.get("debug", 0), _debug = config.get("debug", 0),
servers = [] # Start servers and keep the server objects for clean shutdown
# plain WebSocket running_servers = []
websockets_logger = logging.getLogger("websockets.server") ws_server = await websockets.serve(_handler, host, ws_port)
#if _debug > 2: running_servers.append(ws_server)
# websockets_logger.setLevel(logging.DEBUG)
#else:
# websockets_logger.setLevel(logging.INFO)
# regular WebSocket
ws_server = websockets.serve(_handler, host, ws_port) # , subprotocols=["hbd"])
servers.append(ws_server)
# secure WebSocket (optional)
if wss_port and ssl_context: if wss_port and ssl_context:
wss_server = websockets.serve( wss_server = await websockets.serve(_handler, host, wss_port, ssl=ssl_context)
_handler, host, wss_port, ssl=ssl_context running_servers.append(wss_server)
) # , subprotocols=["hbd"])
servers.append(wss_server)
# await starting of all servers
for srv in servers:
await srv
logger.info( logger.info(
"WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port "WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port
) )
# block forever (until loop is stopped or cancelled) try:
await asyncio.Future() # Block until cancelled
await asyncio.Future()
except asyncio.CancelledError:
pass
finally:
# Close all active browser connections so their handler coroutines exit
active = list(_connections)
if active:
logger.info("Closing %d active WebSocket connection(s)...", len(active))
await asyncio.gather(
*[ws.close() for ws in active],
return_exceptions=True,
)
# Stop the listening servers and wait for all handlers to finish
for srv in running_servers:
srv.close()
await asyncio.gather(
*[srv.wait_closed() for srv in running_servers],
return_exceptions=True,
)
logger.info("WebSocket server(s) stopped")
def broadcast(typ: str, data) -> bool: def broadcast(typ: str, data) -> bool:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hbd" name = "hbd"
version = "5.0.6" version = "5.1.0"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)" description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+3 -1
View File
@@ -3,7 +3,7 @@
set -e set -e
uv version --bump patch uv version --bump patch
VER=$(uv version --short) VER=$(uv version --short)
sed -i "" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py sed -i".bak" "s/__version__ = \"[0-9.]*\"\(.*\)$/__version__ = \"$VER\"\1/" hbd/__init__.py
# commit pyproject.toml # commit pyproject.toml
git commit -m "version $VER" pyproject.toml hbd/__init__.py git commit -m "version $VER" pyproject.toml hbd/__init__.py
@@ -11,3 +11,5 @@ git push
# tag version # tag version
git tag -a v$VER -m "Version $VER" git tag -a v$VER -m "Version $VER"
git push --tags git push --tags
rm hbd/__init__.py.bak
+11 -1
View File
@@ -1,6 +1,16 @@
#!/bin/sh #!/bin/sh
# install hbd/hbc from wheel and create symlinks for hbd and hbc in ~/bin # install the heartbeat tools. By default, this will install the hbc
# client only. 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 set -e
if [ ! -d ~/venvs/hbd ]; then if [ ! -d ~/venvs/hbd ]; then