Compare commits

...

16 Commits

Author SHA1 Message Date
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
32 changed files with 2826 additions and 312 deletions
+17 -4
View File
@@ -6,15 +6,21 @@ on:
jobs:
release:
runs-on: ubuntu-latest
runs-on: FreeBSD
steps:
- name: Checkout code
uses: actions/checkout@v4
# - name: Set up Python
# uses: actions/setup-python@v5
# with:
# python-version: '3.11'
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Use a generic run step for FreeBSD if actions/setup-python
# fails in restricted environments.
run: |
python3 --version
python3 -m ensurepip --upgrade
- name: Install build tools
run: |
@@ -28,6 +34,13 @@ jobs:
id: get_version
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
uses: actions/gitea-release-action@v1
with:
+27 -2
View File
@@ -7,6 +7,31 @@ logfile: "/home/andreas/logs/heartbeat/heartbeat.log"
logfmt: "msg"
grace: 40
interval: 10
autosave_interval: 300 # Autosave interval in seconds (default: 5 minutes)
users:
andreas:
full_name: Andreas Wrede
password: pbkdf2:sha256:260000:eece9cdaebc22247566f78983bf5b2a3:f8c74cc057c5590943c115a60bac62f9458e9ba0d2e7e7421b6f0fe5d860e18f # hbd passwd andreas
avatar: /home/andreas/.avatar/Andreas-avatar3-small.png
admin: true
ops:
full_name: Operations Team
password: pbkdf2:sha256:260000:... # hbd passwd ops
admin: false
readonly:
full_name: Read-Only User
password: pbkdf2:sha256:260000:... # hbd
default_owner: andreas
hosts:
weekend:
owner: andreas
managers: [ops]
monitors: [readonly]
# Notification Channels - Define notification providers centrally
# Each channel has a type (pushover, email, signal, mattermost) and type-specific configuration
@@ -159,7 +184,7 @@ threshold_configs:
warning: 85.0
critical: 90.0
rtt:
warning: 50
warning: 200
critical: 250.0
@@ -204,7 +229,7 @@ threshold_configs:
critical: 2
operator: ">="
rtt:
warning: 50
warning: 200
critical: 250.0
truenas_server:
+1 -1
View File
@@ -9,7 +9,7 @@
"type": "debugpy",
"request": "launch",
"module": "hbd.server.cli",
"args": ["-c", "/home/andreas/git/heartbeat/.hb.yaml", "-f", "-v", "-x", "-x", "-x", "-x"],
"args": ["-c", "/home/andreas/git/heartbeat/.hb.yaml", "-f", "-v", "-x"],
"cwd": "${workspaceFolder}",
"env": {
"PYTHONPATH": "${workspaceFolder}"
+69 -46
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 ✅
- WebSocket API for live updates (hosts & messages) ✅
- 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** ✅
- 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
- Interactive plugin metrics visualization
- Alerts dashboard with filtering and summaries
@@ -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
Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST API and web-based dashboards for monitoring and visualization.
### 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
- **Plugin Metrics**: Interactive visualization of all plugin data with auto-refresh
- **Alerts Dashboard**: Comprehensive alert monitoring with filtering and summaries
- **CORS Support**: Configurable for integration with external applications
### Web Dashboards
- **Live View** (`/live`): Real-time host connectivity, latency, and messages
- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering
- **Login** (`/login`): Browser login form (shown automatically when auth is configured)
- **Live View** (`/live`): Real-time host connectivity, latency, and messages
- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins
- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering
### API Endpoints
```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
curl http://localhost:50004/api/0/hosts
curl $AUTH http://localhost:50004/api/0/hosts
# 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)
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
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
curl http://localhost:50004/api/0/alerts
```
curl $AUTH http://localhost:50004/api/0/alerts
### Integration Examples
**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
# View/update host access roles
curl $AUTH http://localhost:50004/api/0/hosts/webserver01/access
```
See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation including response formats, error handling, and integration examples.
@@ -452,6 +473,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/)
- `wss_pem`: filename for the certificate chain (default: fullchain.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):
+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
### 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
#### 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:**
```json
@@ -28,6 +65,9 @@ Get list of all monitored hosts with their state information.
{
"name": "webserver01",
"dyn": false,
"owner": "alice",
"managers": ["bob"],
"monitors": ["carol"],
"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
#### GET /api/0/hosts/{hostname}/alerts
@@ -226,6 +292,16 @@ curl http://localhost:50004/api/0/alerts | jq .
## 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
**URL:** `/live`
@@ -288,7 +364,13 @@ Comprehensive alert monitoring:
#!/bin/bash
# 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')
if [ "$CRITICAL_COUNT" -gt 0 ]; then
@@ -305,8 +387,16 @@ fi
import requests
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
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()
print(f"Host: {data['hostname']}")
@@ -318,7 +408,7 @@ for plugin, info in data['plugins'].items():
print(f" {metric}: {value}")
# 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()
if alerts['summary']['critical'] > 0:
@@ -389,6 +479,8 @@ API errors return appropriate HTTP status codes with JSON:
**Common Status Codes:**
- `200 OK` - Success
- `400 Bad Request` - Invalid parameters
- `401 Unauthorized` - Missing or invalid session token
- `403 Forbidden` - Authenticated but insufficient role
- `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error
@@ -506,6 +598,14 @@ for route in list(app.router.routes()):
## 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
- Verify hostname in URL matches actual host name
- 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
- [User Management](USERS.md)
- [Plugin Development Guide](PLUGIN_DEVELOPMENT.md)
- [Threshold Alerting Documentation](THRESHOLD_ALERTING.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__"]
__version__ = "5.0.6"
__version__ = "5.0.12"
+1 -1
View File
@@ -1,3 +1,3 @@
"""HeartBeat Client (hbc) - System monitoring client."""
__version__ = "5.0.5"
from hbd import __version__
+1 -1
View File
@@ -1,3 +1,3 @@
"""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."""
__version__ = "5.0.5"
from hbd import __version__
+60 -10
View File
@@ -1,6 +1,8 @@
"""Command line interface for hbd package."""
import argparse
import getpass
import sys
from .config import load_config
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)",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument(
"-c", "--config", dest="configfile", help="Config file path (YAML)"
)
parser.add_argument(
"-f", "--foreground", action="store_true", help="Run in foreground"
)
subparsers = parser.add_subparsers(dest="command")
# --- serve (default) ---
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(
"-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS, help="Push service to use"
parser.add_argument("-p", "--pushsrv", dest="pushsrv", choices=PUSHSRVS,
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(
"-x", "--debug", action="count", default=0, help="Increase debug level"
passwd_p.add_argument(
"username",
nargs="?",
help="Username (informational only, for display)",
)
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):
parser = build_parser()
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)
# Apply CLI overrides
+62 -9
View File
@@ -14,23 +14,27 @@ SERVER_DEFAULTS = {
"hb_port": 50003, # Port to listen for heartbeats
"hbd_port": 50004, # HTTP API port
"hbd_host": "", # Bind address (empty = all interfaces)
# Persistence
"pickfile": "/tmp/hb.pick",
# Logging
"logfile": "/var/log/heartbeat.log",
"logfmt": "text", # text or msg or json
# Notification channels
"notification_channels": {}, # Named channels with type and credentials
"default_notification_channels": [], # Default channels if host doesn't specify
# Monitoring settings
"interval": 20, # Expected heartbeat interval (for server checks)
"grace": 2, # Grace multiplier (interval * grace = timeout)
"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
"hosts": {}, # New unified host definitions (optional)
"watchhosts": [], # Hosts to monitor and notify about (legacy)
@@ -321,20 +325,69 @@ def get_channel_config(config, channel_name):
def get_notification_channels_config(config, hostname):
"""Get list of notification channel configurations for a host.
Args:
config: Configuration dictionary
hostname: Host name
Returns:
List of (channel_name, channel_config) tuples
"""
channel_names = get_notification_channels_for_host(config, hostname)
channels = []
for channel_name in channel_names:
channel_config = get_channel_config(config, channel_name)
if channel_config and channel_config.get("type"):
channels.append((channel_name, channel_config))
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:
pass
self.addr = addr
Connection.htab[addr] = self.host.nameconnection_count
Connection.htab[addr] = self.host.name
if self.host.isDynDns():
Host.dnsQ.put((self.host.name, self.addr))
return r
@@ -297,6 +297,10 @@ class Host:
self.plugin_retention = 100 # Keep last N samples per plugin
# Alert state tracking: {metric_path: AlertState}
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):
d = {}
@@ -412,7 +416,12 @@ class Host:
ddict["alert_warning_acked"] = warning_acked
ddict["alert_critical_unacked"] = critical_unacked
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
def jsons(self):
@@ -458,6 +467,13 @@ class Host:
self.plugin_retention = 100
if not hasattr(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
@@ -511,12 +527,38 @@ class Host:
def get_all_plugin_data(self):
"""Get all plugin data for this host.
Returns:
Dict of {plugin_name: [(timestamp, 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 = [
"name",
"IPv4.addr",
+450 -35
View File
@@ -10,6 +10,8 @@ from aiohttp import web
import jinja2
from . import data
from . import notify as notify_mod
from . import settings as settings_mod
from . import users as users_mod
logger = logging.getLogger(__name__)
@@ -20,6 +22,78 @@ def _render_template(html_str: str, **context) -> str:
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(
host: str,
port: int,
@@ -37,7 +111,8 @@ async def start(
"""
get_now = get_now or (lambda: time.time())
async def index(request):
async def old_index(request):
_require_auth_redirect(request)
res = []
res.append('<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">')
res.append("<html>")
@@ -62,7 +137,15 @@ async def start(
return web.Response(text=body, content_type="text/html")
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) + "]"))
async def api_messages(request):
@@ -70,6 +153,9 @@ async def start(
return web.json_response(lst)
async def cmd(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query
uname = qa.get("h")
ucmd = qa.get("c")
@@ -77,34 +163,50 @@ async def start(
return web.Response(status=400, text="need h= and c= arguments")
if uname not in hbdclass.Host.hosts:
return web.Response(status=400, text=f"h={uname} not found")
hbdclass.Host.hosts[uname].cmds.append(
("CMD", {"cmd": urllib.parse.unquote(ucmd)})
)
host = hbdclass.Host.hosts[uname]
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")
async def drop(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query
uname = qa.get("h")
if not uname:
return web.Response(status=400, text="need h= argument")
if uname not in hbdclass.Host.hosts:
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")
del hbdclass.Host.hosts[uname]
return web.Response(text="Done")
async def register(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query
uname = qa.get("h")
if not uname:
return web.Response(status=400, text="need h= argument")
if uname not in hbdclass.Host.hosts:
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)
return web.Response(text=str(ll))
async def update(request):
user, err = _require_auth(request)
if err:
return err
qa = request.rel_url.query
uname = urllib.parse.unquote(qa.get("h", ""))
ucode = qa.get("c")
@@ -118,16 +220,21 @@ async def start(
names = [n for n in hbdclass.Host.hosts]
out = []
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:
r = {"csum": None, "code": ucode}
hbdclass.Host.hosts[n].cmds.append(("UPD", r))
host.cmds.append(("UPD", r))
except Exception as e:
err = str(e)
out.append(f"update started for {n}: {err if err else 'OK'}")
op_err = str(e)
out.append(f"update started for {n}: {op_err if op_err else 'OK'}")
return web.Response(text="\n".join(out))
async def live(request):
current_user, _ = _require_auth_redirect(request)
# render template from hbd/templates/live.html using Jinja2
# Resolve templates directory relative to the hbd package
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)
],
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")
@@ -185,16 +294,18 @@ async def start(
async def api_host_plugins(request):
"""Get all plugin data for a specific host."""
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
)
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)
# Get plugin data with most recent sample for each plugin
plugins_summary = {}
for plugin_name, samples in host.plugin_data.items():
@@ -214,16 +325,18 @@ async def start(
async def api_host_plugin_detail(request):
"""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")
plugin_name = request.match_info.get("plugin_name")
if hostname not in hbdclass.Host.hosts:
return web.json_response(
{"error": f"Host '{hostname}' not found"},
status=404
)
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)
# Get limit from query parameter
limit = request.rel_url.query.get("limit", "10")
@@ -259,15 +372,17 @@ async def start(
async def api_host_alerts(request):
"""Get alert states for a specific host."""
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
)
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)
# Get alert states
alerts = []
@@ -287,9 +402,14 @@ async def start(
async def api_all_alerts(request):
"""Get all active alerts across all hosts."""
user, err = _require_auth(request)
if err:
return err
all_alerts = []
for hostname, host in hbdclass.Host.hosts.items():
if not _can_view_host(user, host):
continue
if threshold_checker:
active_alerts = threshold_checker.get_active_alerts(host.alert_states)
else:
@@ -326,6 +446,9 @@ async def start(
async def api_acknowledge_alert(request):
"""Acknowledge an alert to stop reminder notifications."""
user, err = _require_auth(request)
if err:
return err
try:
data = await request.json()
except Exception:
@@ -350,7 +473,9 @@ async def start(
)
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:
return web.json_response(
{"error": f"Alert '{metric_path}' not found for host '{hostname}'"},
@@ -373,50 +498,338 @@ async def start(
async def plugins_page(request):
"""Render the plugin metrics visualization page."""
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))
# Collect all hosts with plugin data
# Collect all hosts with plugin data (filtered by visibility)
hosts_with_plugins = []
for hostname in sorted(hbdclass.Host.hosts.keys()):
host = hbdclass.Host.hosts[hostname]
if not _can_view_host(current_user, host):
continue
if host.plugin_data:
hosts_with_plugins.append({
"name": hostname,
"plugins": list(host.plugin_data.keys()),
})
tmpl = env.get_template("plugins.html")
body = tmpl.render(
title="Plugin Metrics - Heartbeat",
header="Plugin Metrics",
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")
async def alerts_page(request):
"""Render the alerts dashboard page."""
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))
tmpl = env.get_template("alerts.html")
body = tmpl.render(
title="Alerts Dashboard - Heartbeat",
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")
app = web.Application()
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/messages", api_messages),
web.get("/api/0/hosts/{hostname}/plugins", api_host_plugins),
web.get("/api/0/hosts/{hostname}/plugins/{plugin_name}", api_host_plugin_detail),
web.get("/api/0/hosts/{hostname}/alerts", api_host_alerts),
web.get("/api/0/hosts/{hostname}/access", api_host_access_get),
web.put("/api/0/hosts/{hostname}/access", api_host_access_put),
web.get("/api/0/alerts", api_all_alerts),
web.post("/api/0/alerts/acknowledge", api_acknowledge_alert),
web.get("/c", cmd),
@@ -426,6 +839,8 @@ async def start(
web.get("/live", live),
web.get("/plugins", plugins_page),
web.get("/alerts", alerts_page),
web.get("/profile", profile_page),
web.get("/settings", settings_page),
web.get("/static/{path:.*}", static),
web.get("/favicon.ico", favicon),
]
+85 -19
View File
@@ -14,7 +14,8 @@ from . import hbdclass
from . import ws as ws_mod
from . import notify as notify_mod
from . import data
from . import data
from . import users as users_mod
logger = logging.getLogger(__name__)
msg_to_websockets = ws_mod.broadcast
@@ -22,12 +23,12 @@ eventlog = notify_mod.eventlog
# shared runtime collections and helpers
def cleanup_function(config, hbdclass):
"""This function will be executed upon program exit."""
logger.info("Running cleanup function...")
def save_state(config, hbdclass):
"""Save current state to pickle file. Safe to call at any time."""
import pickle
import os
# 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 conn_type, conn in host.connections.items():
if hasattr(conn, 'cancel_overdue_timer'):
@@ -40,13 +41,26 @@ def cleanup_function(config, hbdclass):
conn.timeout_duration = None
pickfile = config.get("pickfile", "hbd.pickle")
tmpfile = pickfile + ".tmp"
pickf = open(pickfile, "wb")
pick = pickle.Pickler(pickf)
pick.dump(hbdclass.Host.hosts)
pick.dump(data.msgs)
pickf.close()
try:
with open(tmpfile, "wb") as pickf:
pick = pickle.Pickler(pickf)
pick.dump(hbdclass.Host.hosts)
pick.dump(data.msgs)
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.")
@@ -71,7 +85,16 @@ async def reload_configuration(config_obj, config_path, components):
# Update notify module
notify_mod.reload_config(new_config)
# Reload users
users_mod.load_users(new_config)
# Re-apply host access from updated config to all known hosts
from . import config as config_mod
for hostname, host in hbdclass.Host.hosts.items():
access = config_mod.get_host_access(new_config, hostname)
host.apply_access(access["owner"], access["managers"], access["monitors"])
# Reload threshold checker
if 'threshold_checker' in components:
components['threshold_checker'].reload(new_config)
@@ -160,14 +183,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}"
)
# 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))
sock.bind(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_TIMESTAMPNS).
# 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_TIMESTAMPNS enabled: using kernel receive timestamps for RTT")
else:
logger.info("SO_TIMESTAMPNS not available: using time.time() for RTT")
def udp_handler(msg, addr, transport, recv_ts=None):
ctx = dict(
config=config,
hbdclass=hbdclass,
@@ -177,13 +206,32 @@ async def _run_async(config, config_path=None):
threshold_checker=threshold_checker,
DEBUG=config.get("debug", 0),
verbose=config.get("verbose", False),
recv_ts=recv_ts,
)
udp.handle_datagram(msg, addr, transport, ctx)
transport, protocol = await loop.create_datagram_endpoint(
lambda: udp.EchoServerProtocol(config=config, handler=udp_handler),
sock=sock,
if use_kernel_ts:
# recvmsg path: manage the socket ourselves with loop.add_reader()
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)
try:
@@ -257,6 +305,19 @@ async def _run_async(config, config_path=None):
except Exception as 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
try:
while True:
@@ -304,7 +365,7 @@ async def _run_async(config, config_path=None):
except Exception as 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:
if task:
try:
@@ -385,6 +446,10 @@ def load_pickled_hosts(config, hbdclass):
hbdclass.Host.hosts[h].dyn = h in dyndnshosts
hbdclass.Host.hosts[h].watched = h in watchhosts
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:
if h in hbdclass.Host.hosts:
del hbdclass.Host.hosts[h]
@@ -412,6 +477,7 @@ def run(config, config_path=None):
load_pickled_hosts(config, hbdclass)
notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
users_mod.load_users(config)
eventlog(None, "INFO", f"hbd version {__version__} starting up")
if config_path:
+330
View File
@@ -0,0 +1,330 @@
"""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."),
field("logfmt", "Log format", "select",
"Format for event log entries: text, msg, or json."),
],
},
{
"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;
float: left;
}
+1 -29
View File
@@ -8,30 +8,6 @@
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 {
max-width: 1400px;
margin: 0 auto;
@@ -327,11 +303,7 @@
</style>
<body>
<div class="nav">
<a href="/live">Live Dashboard</a>
<a href="/plugins">Plugin Metrics</a>
<a href="/alerts" class="active">Alerts</a>
</div>
{% include 'nav.html' %}
<div class="container">
<h1>{{ header }}</h1>
+55 -1
View File
@@ -3,5 +3,59 @@
<link rel="stylesheet" href="/static/style.css" type="text/css" />
<link rel="icon" href="/static/images/favicon.ico" sizes="32x32" />
<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>
+1 -30
View File
@@ -9,31 +9,6 @@
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 {
max-width: 1600px;
margin: 0 auto;
@@ -419,11 +394,7 @@
WS_Connect();
</script>
<body>
<div class="nav">
<a href="/live" class="active">Live Dashboard</a>
<a href="/plugins">Plugin Metrics</a>
<a href="/alerts">Alerts</a>
</div>
{% include 'nav.html' %}
{% include 'menu.html' %}
-1
View File
@@ -1,3 +1,2 @@
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
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;
}
.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 {
max-width: 1400px;
margin: 0 auto;
@@ -357,11 +332,7 @@
</style>
<body>
<div class="nav">
<a href="/live">Live Dashboard</a>
<a href="/plugins" class="active">Plugin Metrics</a>
<a href="/alerts">Alerts</a>
</div>
{% include 'nav.html' %}
<div class="container">
<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) {
// Check if this should be rendered as a simple table
const pluginName = getCurrentPluginName();
const simplePlugins = ['os_info', 'cpu_monitor', 'memory_monitor', 'nagios_runner'];
if (simplePlugins.includes(pluginName) && isSimpleKeyValueData(data)) {
return renderSimpleDataTable(data, timestamp);
}
let html = '<div class="metric-grid">';
for (const [key, value] of Object.entries(data)) {
if (SKIP_FIELDS.has(key)) continue;
// Skip nested objects for now, handle them separately
if (typeof value === 'object' && value !== null) {
continue;
}
html += renderMetric(key, value);
}
@@ -572,10 +547,11 @@
// Table body
html += '<tbody>';
for (const [key, value] of Object.entries(data)) {
if (SKIP_FIELDS.has(key)) continue;
const label = formatLabel(key);
const formattedValue = formatValue(key, value);
const unit = getUnit(key);
html += '<tr>';
html += `<td class="name">${label}</td>`;
html += `<td class="value">${formattedValue}${unit ? ' ' + unit : ''}</td>`;
@@ -1012,12 +988,17 @@
}
function formatLabel(key) {
if (key === 'time') return 'Collected At';
return key
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
}
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') {
// Format percentages
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>
+429
View File
@@ -0,0 +1,429 @@
<!DOCTYPE html>
<html>
{% include 'head.html' %}
<style>
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>
+200 -49
View File
@@ -1,6 +1,9 @@
"""UDP listener and datagram processing."""
import asyncio
import socket
import struct
import time
import zlib
import logging
@@ -11,6 +14,99 @@ from . import notify as notify_mod
logger = logging.getLogger(__name__)
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, so fall back to the Linux value (29)
# when absent.
_SO_TIMESTAMP = getattr(socket, 'SO_TIMESTAMP', 29)
# 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):
def __init__(self, config=None, handler=None):
@@ -61,6 +157,100 @@ def dicttos(ID, d):
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
remaining = max(1.0, (interval + grace) - elapsed)
conn.reset_overdue_timer(remaining, on_overdue)
logger.debug(
"Restored UP timer %s/%s: %.0fs remaining (elapsed %.0fs)",
uname, afam, remaining, elapsed,
)
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):
"""Handle a parsed datagram message.
@@ -74,7 +264,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
"""
if not msg:
return
now = __import__("time").time()
now = ctx.get("recv_ts") or time.time()
# Log message to journal
msg_journal = ctx.get("msg_journal")
@@ -107,6 +297,9 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Use new config function to check dyndns
dyndnshosts = config_mod.get_dyndnshosts(cfg)
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:
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
newh = True
@@ -126,7 +319,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if msg.get("ID") == "HTB":
host.doesack = msg.get("acks", -1)
# send ACK back
rmsg = {"time": __import__("time").time()}
rmsg = {"time": time.time()}
opkt = dicttos("ACK", rmsg)
try:
transport.sendto(opkt, addr)
@@ -138,8 +331,9 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Handle plugin data message
plugin_name = msg.get("plugin")
if plugin_name:
# Extract all fields except ID and plugin name
plugin_data = {k: v for k, v in msg.items() if k not in ["ID", "plugin"]}
# Extract plugin fields, dropping protocol metadata fields
plugin_data = {k: v for k, v in msg.items()
if k not in ("ID", "plugin", "id", "name")}
# Store plugin data with timestamp
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
if DEBUG > 1:
@@ -229,51 +423,8 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Reset overdue timer on every heartbeat
if interval > 0 and conn.getstate() != hbdcls.Connection.DOWN:
grace = cfg.get("grace", 2)
timeout_seconds = (interval + grace) if interval > 0 else 30
# 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
timeout_seconds = interval + grace
on_overdue, _ = _make_timer_callbacks(uname, host, watchhosts, ctx)
conn.reset_overdue_timer(timeout_seconds, on_overdue)
# Check RTT thresholds using the threshold checker
+228
View File
@@ -0,0 +1,228 @@
"""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]
+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)
finally:
logger.debug("Removing WebSocket connection from %s", remote_address)
try:
_connections.remove(websocket)
except KeyError:
pass
await websocket.wait_closed()
_connections.discard(websocket)
async def start(
@@ -93,33 +89,40 @@ async def start(
_verbose = config.get("verbose", False),
_debug = config.get("debug", 0),
servers = []
# plain WebSocket
websockets_logger = logging.getLogger("websockets.server")
#if _debug > 2:
# 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)
# Start servers and keep the server objects for clean shutdown
running_servers = []
ws_server = await websockets.serve(_handler, host, ws_port)
running_servers.append(ws_server)
if wss_port and ssl_context:
wss_server = websockets.serve(
_handler, host, wss_port, ssl=ssl_context
) # , subprotocols=["hbd"])
servers.append(wss_server)
# await starting of all servers
for srv in servers:
await srv
wss_server = await websockets.serve(_handler, host, wss_port, ssl=ssl_context)
running_servers.append(wss_server)
logger.info(
"WebSocket server(s) started on port %s (wss %s)", ws_port, wss_port
)
# block forever (until loop is stopped or cancelled)
await asyncio.Future()
try:
# 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:
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hbd"
version = "5.0.6"
version = "5.0.12"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md"
requires-python = ">=3.11"
+3 -1
View File
@@ -3,7 +3,7 @@
set -e
uv version --bump patch
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
git commit -m "version $VER" pyproject.toml hbd/__init__.py
@@ -11,3 +11,5 @@ git push
# tag version
git tag -a v$VER -m "Version $VER"
git push --tags
rm hbd/__init__.py.bak
+11 -1
View File
@@ -1,6 +1,16 @@
#!/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
if [ ! -d ~/venvs/hbd ]; then