Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 79bf00abfd | |||
| d77277857f | |||
| 3232239a85 | |||
| 014781de5e | |||
| 68b1c65384 | |||
| e8bb553349 | |||
| e4ecb8723f | |||
| 5edbaacf81 | |||
| 8421f472f2 | |||
| 51f9bdc2b5 | |||
| 02bc42fbf0 | |||
| 832a8b0bda |
@@ -25,7 +25,7 @@ jobs:
|
||||
- name: Install build tools
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# pip install build twine
|
||||
pip install build twine
|
||||
|
||||
- name: Build package
|
||||
run: python -m build
|
||||
@@ -34,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
Vendored
+1
-1
@@ -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}"
|
||||
|
||||
@@ -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,20 +271,63 @@ 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
|
||||
|
||||
- **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
|
||||
@@ -287,56 +335,29 @@ Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST AP
|
||||
### 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
@@ -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
@@ -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
@@ -14,4 +14,4 @@ Install options:
|
||||
"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
__version__ = "5.0.8"
|
||||
__version__ = "5.0.12"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""HeartBeat Client (hbc) - System monitoring client."""
|
||||
|
||||
__version__ = "5.0.5"
|
||||
from hbd import __version__
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Common utilities shared between hbc and hbd."""
|
||||
|
||||
__version__ = "5.0.5"
|
||||
from hbd import __version__
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""HeartBeat Daemon (hbd) - Server/daemon component."""
|
||||
|
||||
__version__ = "5.0.5"
|
||||
from hbd import __version__
|
||||
|
||||
+60
-10
@@ -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
|
||||
|
||||
@@ -31,6 +31,10 @@ SERVER_DEFAULTS = {
|
||||
"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)
|
||||
@@ -338,3 +342,52 @@ def get_notification_channels_config(config, hostname):
|
||||
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),
|
||||
}
|
||||
|
||||
+43
-1
@@ -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 = {}
|
||||
@@ -413,6 +417,11 @@ class Host:
|
||||
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
|
||||
|
||||
@@ -517,6 +533,32 @@ class Host:
|
||||
"""
|
||||
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",
|
||||
|
||||
+439
-24
@@ -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,15 +294,17 @@ 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 = {}
|
||||
@@ -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,6 +473,8 @@ 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(
|
||||
@@ -373,14 +498,17 @@ 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,
|
||||
@@ -392,11 +520,14 @@ async def start(
|
||||
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))
|
||||
@@ -405,18 +536,300 @@ async def start(
|
||||
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),
|
||||
]
|
||||
|
||||
+83
-17
@@ -15,6 +15,7 @@ from . import hbdclass
|
||||
from . import ws as ws_mod
|
||||
from . import notify as notify_mod
|
||||
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.")
|
||||
|
||||
|
||||
@@ -72,6 +86,15 @@ 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:
|
||||
|
||||
@@ -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 |
@@ -140,3 +140,4 @@
|
||||
float: left;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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,3 +1,2 @@
|
||||
<!-- <label for="drawer-toggle" id="drawer-toggle-label"></label>
|
||||
s<header>{{ header }}</header> -->
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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,6 +430,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
@@ -471,6 +445,7 @@
|
||||
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;
|
||||
@@ -572,6 +547,7 @@
|
||||
// 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);
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hbd"
|
||||
version = "5.0.8"
|
||||
version = "5.0.12"
|
||||
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user