From d77277857fdc9ce275d6c4419fb50d610964c127 Mon Sep 17 00:00:00 2001 From: Andreas Wrede Date: Wed, 8 Apr 2026 16:21:46 -0400 Subject: [PATCH] Add user management and a settings page --- .hb.yaml | 24 ++ .vscode/launch.json | 2 +- README.md | 115 ++++--- docs/HTTP_API.md | 109 +++++- docs/USERS.md | 242 +++++++++++++ hbd/server/cli.py | 70 +++- hbd/server/config.py | 71 +++- hbd/server/hbdclass.py | 46 ++- hbd/server/http.py | 485 +++++++++++++++++++++++++-- hbd/server/main.py | 19 +- hbd/server/settings.py | 330 ++++++++++++++++++ hbd/server/static/images/favicon.ico | Bin 5390 -> 185730 bytes hbd/server/static/style.css | 3 +- hbd/server/templates/alerts.html | 30 +- hbd/server/templates/head.html | 56 +++- hbd/server/templates/live.html | 31 +- hbd/server/templates/menu.html | 1 - hbd/server/templates/nav.html | 19 ++ hbd/server/templates/plugins.html | 31 +- hbd/server/templates/profile.html | 334 ++++++++++++++++++ hbd/server/templates/settings.html | 429 +++++++++++++++++++++++ hbd/server/udp.py | 3 + hbd/server/users.py | 228 +++++++++++++ 23 files changed, 2477 insertions(+), 201 deletions(-) create mode 100644 docs/USERS.md create mode 100644 hbd/server/settings.py create mode 100644 hbd/server/templates/nav.html create mode 100644 hbd/server/templates/profile.html create mode 100644 hbd/server/templates/settings.html create mode 100644 hbd/server/users.py diff --git a/.hb.yaml b/.hb.yaml index 35c4272..ef34db0 100644 --- a/.hb.yaml +++ b/.hb.yaml @@ -9,6 +9,30 @@ 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 notification_channels: diff --git a/.vscode/launch.json b/.vscode/launch.json index 9229db8..96fe3b2 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -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}" diff --git a/README.md b/README.md index 55cc86b..847fe09 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,13 @@ A lightweight daemon that listens for UDP heartbeat messages and acts on them: k - Queue DNS updates via `nsupdate` and run them in a background thread ✅ - WebSocket API for live updates (hosts & messages) ✅ - Notification pipeline (email, Pushover, Mattermost, Signal) ✅ +- **User management & access control** ✅ + - Optional user accounts with bcrypt-style password hashing (stdlib only) + - Per-host roles: owner, manager, monitor + - Session-based auth with cookie support (browser login page included) + - Backwards compatible: no auth required when no users are configured - **HTTP API & Web UI** ✅ - - REST API for plugin data, alerts, and host information + - REST API for plugin data, alerts, host information, and user management - Live dashboard with WebSocket updates - Interactive plugin metrics visualization - Alerts dashboard with filtering and summaries @@ -266,77 +271,93 @@ See [docs/THRESHOLD_ALERTING.md](docs/THRESHOLD_ALERTING.md) for comprehensive d --- +## 👥 User Management + +Heartbeat supports optional user accounts with role-based access control per host. + +### Roles + +- **monitor** — view status, plugin data, alerts +- **manager** — monitor + queue commands, trigger DNS, queue upgrades +- **owner** — manager + drop host, transfer ownership, update access +- **admin** (user flag) — owner-level access on every host + +When no users are configured the server runs in **unauthenticated mode** — all existing behaviour is unchanged. + +### Quick setup + +```yaml +users: + alice: + full_name: Alice Smith + password: pbkdf2:sha256:... # hbd passwd alice + admin: true + +default_owner: alice + +hosts: + webserver01: + owner: alice + managers: [bob] + monitors: [carol] +``` + +```bash +# Generate a password hash +hbd passwd alice +``` + +Browser users are redirected to `/login` automatically. The session cookie is set on login, so `fetch()` calls from dashboards work without any JavaScript changes. + +See [docs/USERS.md](docs/USERS.md) for complete user management documentation. + +--- + ## 🌐 HTTP API & Web UI Heartbeat includes a built-in HTTP/WebSocket server that provides both a REST API and web-based dashboards for monitoring and visualization. ### Features -- **REST API**: JSON endpoints for accessing plugin data, alerts, and host information +- **User auth**: Optional session-based authentication with per-host role enforcement +- **REST API**: JSON endpoints for accessing plugin data, alerts, host information, and user management - **Live Dashboard**: Real-time WebSocket-powered host status view - **Plugin Metrics**: Interactive visualization of all plugin data with auto-refresh - **Alerts Dashboard**: Comprehensive alert monitoring with filtering and summaries -- **CORS Support**: Configurable for integration with external applications ### Web Dashboards -- **Live View** (`/live`): Real-time host connectivity, latency, and messages -- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins -- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering +- **Login** (`/login`): Browser login form (shown automatically when auth is configured) +- **Live View** (`/live`): Real-time host connectivity, latency, and messages +- **Plugin Metrics** (`/plugins`): Browse and visualize metrics from all plugins +- **Alerts Dashboard** (`/alerts`): Monitor active alerts with severity filtering ### API Endpoints ```bash +# Log in (when auth is configured) +TOKEN=$(curl -s -X POST http://localhost:50004/api/0/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"alice","password":"secret"}' | jq -r .token) +AUTH="-H \"Authorization: Bearer $TOKEN\"" + # List all monitored hosts -curl http://localhost:50004/api/0/hosts +curl $AUTH http://localhost:50004/api/0/hosts # Get all plugin data for a host -curl http://localhost:50004/api/0/hosts/webserver01/plugins +curl $AUTH http://localhost:50004/api/0/hosts/webserver01/plugins # Get detailed plugin history (last 50 samples) -curl http://localhost:50004/api/0/hosts/webserver01/plugins/cpu_monitor?limit=50 +curl $AUTH "http://localhost:50004/api/0/hosts/webserver01/plugins/cpu_monitor?limit=50" # Get alert states for a specific host -curl http://localhost:50004/api/0/hosts/webserver01/alerts +curl $AUTH http://localhost:50004/api/0/hosts/webserver01/alerts # Get all active alerts across all hosts -curl http://localhost:50004/api/0/alerts -``` +curl $AUTH http://localhost:50004/api/0/alerts -### Integration Examples - -**Python Client:** -```python -import requests - -# Monitor for critical alerts -response = requests.get('http://localhost:50004/api/0/alerts') -alerts = response.json() - -if alerts['summary']['critical'] > 0: - print(f"⚠️ {alerts['summary']['critical']} CRITICAL alerts!") - for alert in alerts['alerts']: - if alert['level'] == 'CRITICAL': - print(f" {alert['hostname']}: {alert['metric_path']} = {alert['last_value']}") -``` - -**Bash Monitoring Script:** -```bash -#!/bin/bash -# Check for critical alerts -CRITICAL=$(curl -s http://localhost:50004/api/0/alerts | jq '.summary.critical') -if [ "$CRITICAL" -gt 0 ]; then - echo "CRITICAL: $CRITICAL critical alerts detected!" - # Send notification -fi -``` - -### Demo & Testing - -Run the API demo script to test all endpoints: - -```bash -python3 scripts/demo_http_api.py +# View/update host access roles +curl $AUTH http://localhost:50004/api/0/hosts/webserver01/access ``` See [docs/HTTP_API.md](docs/HTTP_API.md) for complete API documentation including response formats, error handling, and integration examples. @@ -452,6 +473,8 @@ Set breakpoints in modules such as `hbd/udp.py`, `hbd/dns.py`, or `hbd/server.py - `cert_path`: directory where TLS certificate and key are looked up (default: /usr/local/etc/ssl/) - `wss_pem`: filename for the certificate chain (default: fullchain.pem) - `wss_key`: filename for the private key (default: privkey.pem) +- `users`: mapping of username → user attributes (full_name, avatar, password, admin, notification_channels) +- `default_owner`: username that owns hosts with no explicit owner (falls back to first admin user) Example `.hb.yaml` (minimal): diff --git a/docs/HTTP_API.md b/docs/HTTP_API.md index 8579efb..ca600ac 100644 --- a/docs/HTTP_API.md +++ b/docs/HTTP_API.md @@ -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 ` 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 ` 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) diff --git a/docs/USERS.md b/docs/USERS.md new file mode 100644 index 0000000..af34350 --- /dev/null +++ b/docs/USERS.md @@ -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": "", "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 ` 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` diff --git a/hbd/server/cli.py b/hbd/server/cli.py index 8f8ccad..e954602 100644 --- a/hbd/server/cli.py +++ b/hbd/server/cli.py @@ -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 diff --git a/hbd/server/config.py b/hbd/server/config.py index 4f91b0b..22f736a 100644 --- a/hbd/server/config.py +++ b/hbd/server/config.py @@ -14,23 +14,27 @@ SERVER_DEFAULTS = { "hb_port": 50003, # Port to listen for heartbeats "hbd_port": 50004, # HTTP API port "hbd_host": "", # Bind address (empty = all interfaces) - + # Persistence "pickfile": "/tmp/hb.pick", - + # Logging "logfile": "/var/log/heartbeat.log", "logfmt": "text", # text or msg or json - + # Notification channels "notification_channels": {}, # Named channels with type and credentials "default_notification_channels": [], # Default channels if host doesn't specify - + # Monitoring settings "interval": 20, # Expected heartbeat interval (for server checks) "grace": 2, # Grace multiplier (interval * grace = timeout) "threshold_renotify_interval": 3600, # Seconds between threshold re-notifications - + + # User management + "users": {}, # username -> {full_name, avatar, password, admin, notification_channels} + "default_owner": None, # Username that owns hosts with no explicit owner + # Host management "hosts": {}, # New unified host definitions (optional) "watchhosts": [], # Hosts to monitor and notify about (legacy) @@ -321,20 +325,69 @@ def get_channel_config(config, channel_name): def get_notification_channels_config(config, hostname): """Get list of notification channel configurations for a host. - + Args: config: Configuration dictionary hostname: Host name - + Returns: List of (channel_name, channel_config) tuples """ channel_names = get_notification_channels_for_host(config, hostname) - + channels = [] for channel_name in channel_names: channel_config = get_channel_config(config, channel_name) if channel_config and channel_config.get("type"): channels.append((channel_name, channel_config)) - + return channels + + +# --------------------------------------------------------------------------- +# User / host-access helpers +# --------------------------------------------------------------------------- + +def get_default_owner(config) -> str | None: + """Return the configured default_owner username, or the first admin user, or None.""" + explicit = config.get("default_owner") + if explicit: + return explicit + # Fall back to first admin user found in config + users_cfg = config.get("users", {}) + if isinstance(users_cfg, dict): + for username, attrs in users_cfg.items(): + if isinstance(attrs, dict) and attrs.get("admin", False): + return username + return None + + +def get_host_access(config, hostname) -> dict: + """Return the access dict for *hostname*: owner, managers, monitors. + + Falls back to default_owner for hosts without an explicit owner. + + Returns: + { + "owner": str | None, + "managers": list[str], + "monitors": list[str], + } + """ + host_cfg = get_host_config(config, hostname) + + owner = host_cfg.get("owner") or get_default_owner(config) + + managers = host_cfg.get("managers", []) + if isinstance(managers, str): + managers = [managers] + + monitors = host_cfg.get("monitors", []) + if isinstance(monitors, str): + monitors = [monitors] + + return { + "owner": owner, + "managers": list(managers), + "monitors": list(monitors), + } diff --git a/hbd/server/hbdclass.py b/hbd/server/hbdclass.py index 5eba378..a696b27 100644 --- a/hbd/server/hbdclass.py +++ b/hbd/server/hbdclass.py @@ -297,6 +297,10 @@ class Host: self.plugin_retention = 100 # Keep last N samples per plugin # Alert state tracking: {metric_path: AlertState} self.alert_states = {} + # User access control + self.owner: str | None = None # username of owner + self.managers: list = [] # usernames with manager role + self.monitors: list = [] # usernames with monitor role def statedict(self): d = {} @@ -412,7 +416,12 @@ class Host: ddict["alert_warning_acked"] = warning_acked ddict["alert_critical_unacked"] = critical_unacked ddict["alert_critical_acked"] = critical_acked - + + # User access + ddict["owner"] = getattr(self, "owner", None) + ddict["managers"] = list(getattr(self, "managers", [])) + ddict["monitors"] = list(getattr(self, "monitors", [])) + return ddict def jsons(self): @@ -458,6 +467,13 @@ class Host: self.plugin_retention = 100 if not hasattr(self, "alert_states"): self.alert_states = {} + # User access fields (added in user-management feature) + if not hasattr(self, "owner"): + self.owner = None + if not hasattr(self, "managers"): + self.managers = [] + if not hasattr(self, "monitors"): + self.monitors = [] pass @@ -511,12 +527,38 @@ class Host: def get_all_plugin_data(self): """Get all plugin data for this host. - + Returns: Dict of {plugin_name: [(timestamp, data), ...]} """ return self.plugin_data + # ------------------------------------------------------------------ + # User-role helpers + # ------------------------------------------------------------------ + + def apply_access(self, owner, managers, monitors): + """Set owner/managers/monitors on this host (called from config load).""" + self.owner = owner + self.managers = list(managers) + self.monitors = list(monitors) + + def is_owner(self, username: str) -> bool: + return self.owner == username + + def is_manager(self, username: str) -> bool: + return username in self.managers or self.is_owner(username) + + def is_monitor(self, username: str) -> bool: + return username in self.monitors or self.is_manager(username) + + def access_dict(self) -> dict: + return { + "owner": self.owner, + "managers": list(self.managers), + "monitors": list(self.monitors), + } + hostfields_long = [ "name", "IPv4.addr", diff --git a/hbd/server/http.py b/hbd/server/http.py index 5728e9b..f5ca967 100644 --- a/hbd/server/http.py +++ b/hbd/server/http.py @@ -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('') res.append("") @@ -62,7 +137,15 @@ async def start( return web.Response(text=body, content_type="text/html") async def api_hosts(request): - lst = [hbdclass.Host.hosts[h].jsons() for h in hbdclass.Host.hosts] + user, err = _require_auth(request) + if err: + return err + hosts = [ + hbdclass.Host.hosts[h] + for h in hbdclass.Host.hosts + if _can_view_host(user, hbdclass.Host.hosts[h]) + ] + lst = [h.jsons() for h in hosts] return web.json_response(json.loads("[" + ",".join(lst) + "]")) async def api_messages(request): @@ -70,6 +153,9 @@ async def start( return web.json_response(lst) async def cmd(request): + user, err = _require_auth(request) + if err: + return err qa = request.rel_url.query uname = qa.get("h") ucmd = qa.get("c") @@ -77,34 +163,50 @@ async def start( return web.Response(status=400, text="need h= and c= arguments") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") - hbdclass.Host.hosts[uname].cmds.append( - ("CMD", {"cmd": urllib.parse.unquote(ucmd)}) - ) + host = hbdclass.Host.hosts[uname] + if not _can_operate_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) + host.cmds.append(("CMD", {"cmd": urllib.parse.unquote(ucmd)})) return web.Response(text=f"cmd {uname} queued") async def drop(request): + user, err = _require_auth(request) + if err: + return err qa = request.rel_url.query uname = qa.get("h") if not uname: return web.Response(status=400, text="need h= argument") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") + host = hbdclass.Host.hosts[uname] + if not _can_own_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) eventlog(uname, "INFO", "dropped") del hbdclass.Host.hosts[uname] return web.Response(text="Done") async def register(request): + user, err = _require_auth(request) + if err: + return err qa = request.rel_url.query uname = qa.get("h") if not uname: return web.Response(status=400, text="need h= argument") if uname not in hbdclass.Host.hosts: return web.Response(status=400, text=f"h={uname} not found") - ll = hbdclass.Host.hosts[uname].registerDns() + host = hbdclass.Host.hosts[uname] + if not _can_operate_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) + ll = host.registerDns() eventlog(uname, "INFO", ll) return web.Response(text=str(ll)) async def update(request): + user, err = _require_auth(request) + if err: + return err qa = request.rel_url.query uname = urllib.parse.unquote(qa.get("h", "")) ucode = qa.get("c") @@ -118,16 +220,21 @@ async def start( names = [n for n in hbdclass.Host.hosts] out = [] for n in names: - err = None + host = hbdclass.Host.hosts[n] + if not _can_operate_host(user, host): + out.append(f"update skipped for {n}: Forbidden") + continue + op_err = None try: r = {"csum": None, "code": ucode} - hbdclass.Host.hosts[n].cmds.append(("UPD", r)) + host.cmds.append(("UPD", r)) except Exception as e: - err = str(e) - out.append(f"update started for {n}: {err if err else 'OK'}") + op_err = str(e) + out.append(f"update started for {n}: {op_err if op_err else 'OK'}") return web.Response(text="\n".join(out)) async def live(request): + current_user, _ = _require_auth_redirect(request) # render template from hbd/templates/live.html using Jinja2 # Resolve templates directory relative to the hbd package pkg_dir = os.path.dirname(__file__) @@ -151,6 +258,8 @@ async def start( hbdclass.Host.hosts[h].stateinfo() for h in sorted(hbdclass.Host.hosts) ], messages=data.msgs[-30:], + current_user=current_user.to_dict() if current_user else None, + active_page="live", ) return web.Response(text=body, content_type="text/html") @@ -185,16 +294,18 @@ async def start( async def api_host_plugins(request): """Get all plugin data for a specific host.""" + user, err = _require_auth(request) + if err: + return err hostname = request.match_info.get("hostname") if hostname not in hbdclass.Host.hosts: - return web.json_response( - {"error": f"Host '{hostname}' not found"}, - status=404 - ) - + return web.json_response({"error": f"Host '{hostname}' not found"}, status=404) + host = hbdclass.Host.hosts[hostname] - + if not _can_view_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) + # Get plugin data with most recent sample for each plugin plugins_summary = {} for plugin_name, samples in host.plugin_data.items(): @@ -214,16 +325,18 @@ async def start( async def api_host_plugin_detail(request): """Get detailed data for a specific plugin on a host.""" + user, err = _require_auth(request) + if err: + return err hostname = request.match_info.get("hostname") plugin_name = request.match_info.get("plugin_name") - + if hostname not in hbdclass.Host.hosts: - return web.json_response( - {"error": f"Host '{hostname}' not found"}, - status=404 - ) - + return web.json_response({"error": f"Host '{hostname}' not found"}, status=404) + host = hbdclass.Host.hosts[hostname] + if not _can_view_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) # Get limit from query parameter limit = request.rel_url.query.get("limit", "10") @@ -259,15 +372,17 @@ async def start( async def api_host_alerts(request): """Get alert states for a specific host.""" + user, err = _require_auth(request) + if err: + return err hostname = request.match_info.get("hostname") - + if hostname not in hbdclass.Host.hosts: - return web.json_response( - {"error": f"Host '{hostname}' not found"}, - status=404 - ) - + return web.json_response({"error": f"Host '{hostname}' not found"}, status=404) + host = hbdclass.Host.hosts[hostname] + if not _can_view_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) # Get alert states alerts = [] @@ -287,9 +402,14 @@ async def start( async def api_all_alerts(request): """Get all active alerts across all hosts.""" + user, err = _require_auth(request) + if err: + return err all_alerts = [] - + for hostname, host in hbdclass.Host.hosts.items(): + if not _can_view_host(user, host): + continue if threshold_checker: active_alerts = threshold_checker.get_active_alerts(host.alert_states) else: @@ -326,6 +446,9 @@ async def start( async def api_acknowledge_alert(request): """Acknowledge an alert to stop reminder notifications.""" + user, err = _require_auth(request) + if err: + return err try: data = await request.json() except Exception: @@ -350,7 +473,9 @@ async def start( ) host = hbdclass.Host.hosts[hostname] - + if not _can_view_host(user, host): + return web.json_response({"error": "Forbidden"}, status=403) + if metric_path not in host.alert_states: return web.json_response( {"error": f"Alert '{metric_path}' not found for host '{hostname}'"}, @@ -373,50 +498,338 @@ async def start( async def plugins_page(request): """Render the plugin metrics visualization page.""" + current_user, _ = _require_auth_redirect(request) pkg_dir = os.path.dirname(__file__) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) - - # Collect all hosts with plugin data + + # Collect all hosts with plugin data (filtered by visibility) hosts_with_plugins = [] for hostname in sorted(hbdclass.Host.hosts.keys()): host = hbdclass.Host.hosts[hostname] + if not _can_view_host(current_user, host): + continue if host.plugin_data: hosts_with_plugins.append({ "name": hostname, "plugins": list(host.plugin_data.keys()), }) - + tmpl = env.get_template("plugins.html") body = tmpl.render( title="Plugin Metrics - Heartbeat", header="Plugin Metrics", hosts=hosts_with_plugins, + current_user=current_user.to_dict() if current_user else None, + active_page="plugins", ) return web.Response(text=body, content_type="text/html") async def alerts_page(request): """Render the alerts dashboard page.""" + current_user, _ = _require_auth_redirect(request) pkg_dir = os.path.dirname(__file__) templates_dir = config.get("templates_dir", os.path.join(pkg_dir, "templates")) env = jinja2.Environment(loader=jinja2.FileSystemLoader(templates_dir)) - + tmpl = env.get_template("alerts.html") body = tmpl.render( title="Alerts Dashboard - Heartbeat", header="Alerts Dashboard", + current_user=current_user.to_dict() if current_user else None, + active_page="alerts", + ) + return web.Response(text=body, content_type="text/html") + + # ------------------------------------------------------------------------- + # Auth endpoints + # ------------------------------------------------------------------------- + + async def api_login(request): + """POST /api/0/auth/login {username, password} -> {token} + Also sets an hbd_session cookie for browser clients. + """ + if not users_mod.users_enabled(): + return web.json_response({"error": "Auth not configured"}, status=404) + try: + body = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON"}, status=400) + username = body.get("username", "") + password = body.get("password", "") + user = users_mod.authenticate(username, password) + if user is None: + return web.json_response({"error": "Invalid credentials"}, status=401) + token = users_mod.create_session(username) + resp = web.json_response({"token": token, "username": username}) + resp.set_cookie( + SESSION_COOKIE, + token, + max_age=users_mod.SESSION_TTL, + httponly=True, + samesite="Lax", + ) + return resp + + async def login_page(request): + """GET /login — show login form; POST /login — process and redirect.""" + if not users_mod.users_enabled(): + raise web.HTTPFound("/") + + error = "" + if request.method == "POST": + form = await request.post() + username = form.get("username", "") + password = form.get("password", "") + user = users_mod.authenticate(username, password) + if user: + token = users_mod.create_session(username) + redirect_to = request.rel_url.query.get("next", "/") + resp = web.HTTPFound(redirect_to) + resp.set_cookie( + SESSION_COOKIE, + token, + max_age=users_mod.SESSION_TTL, + httponly=True, + samesite="Lax", + ) + raise resp + error = "Invalid username or password." + + html = f""" + + + + Heartbeat — Login + + + +
+

Heartbeat

+ {'

' + error + '

' if error else ''} +
+
+
+ +
+
+ +""" + 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), ] diff --git a/hbd/server/main.py b/hbd/server/main.py index e18bb48..4d4e83b 100644 --- a/hbd/server/main.py +++ b/hbd/server/main.py @@ -14,7 +14,8 @@ from . import hbdclass from . import ws as ws_mod from . import notify as notify_mod -from . import data +from . import data +from . import users as users_mod logger = logging.getLogger(__name__) msg_to_websockets = ws_mod.broadcast @@ -84,7 +85,16 @@ async def reload_configuration(config_obj, config_path, components): # Update notify module notify_mod.reload_config(new_config) - + + # Reload users + users_mod.load_users(new_config) + + # Re-apply host access from updated config to all known hosts + from . import config as config_mod + for hostname, host in hbdclass.Host.hosts.items(): + access = config_mod.get_host_access(new_config, hostname) + host.apply_access(access["owner"], access["managers"], access["monitors"]) + # Reload threshold checker if 'threshold_checker' in components: components['threshold_checker'].reload(new_config) @@ -436,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] @@ -463,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: diff --git a/hbd/server/settings.py b/hbd/server/settings.py new file mode 100644 index 0000000..754066a --- /dev/null +++ b/hbd/server/settings.py @@ -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 ````/``