Compare commits

...

66 Commits

Author SHA1 Message Date
andreas 58c2b9d996 version 5.2.6
Release / release (push) Successful in 5s
2026-05-09 06:56:00 -04:00
andreas 2e8bcb630d fix: show human-readable duration in re-notification messages
Replace raw seconds with d h m s format in "ongoing for ..." strings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 06:53:41 -04:00
andreas 338711181b feat: alerts host-filter field with URL query param and notify URL
- Add regex filter input to the Alerts dashboard that filters displayed
  hosts on every keystroke; invalid regex turns the border red
- Initialise the filter from ?filter= in the URL query string
- Change _build_url() to produce /alerts?filter=<hostname> so
  notification links (Pushover, email, Matrix, etc.) land on the
  alerts page pre-filtered to the alerting host

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 06:46:13 -04:00
andreas 43487f17e7 feat: optional logo on Gitea OAuth login button
Reads oauth.gitea.logo from config and, when set, renders an <img>
inside the button with flex alignment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-09 06:24:27 -04:00
andreas 40205bf5c7 version 5.2.5
Release / release (push) Successful in 5s
2026-05-08 17:25:50 -04:00
andreas b95f1a5bb7 fix: agree: zpool ONLINE=OK, DEGRADED=WARNING, all else is CRITICAL 2026-05-08 17:18:41 -04:00
andreas 12f7eb722b fix: typo 2026-05-08 17:03:32 -04:00
andreas 217bba1b76 fix: change health_ok to status 2026-05-08 16:57:45 -04:00
andreas 967e05ed74 threshold: synthesize health_ok server-side for older ZFS clients
Older hbd clients send zfs_monitor data with a `health` string but no
`health_ok` numeric field (added in a recent plugin update). Without
health_ok in the data, the wildcard threshold check found nothing and
no CRITICAL alert was raised for DEGRADED/SUSPENDED pools.

Synthesize health_ok from the health string in the server's nested-
metric loop so alerts fire regardless of client version.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 16:39:16 -04:00
andreas c20245b0ab docs: document ZFS pool health alerting; fix pushover sound+url_title 2026-05-08 16:25:55 -04:00
andreas b9db0c552e feat: alert CRITICAL on degraded or suspended ZFS pools 2026-05-08 16:23:49 -04:00
andreas 05045bafa2 fix: use base_url config for OAuth redirect URI to handle reverse proxy 2026-05-08 14:11:09 -04:00
andreas 39f1b5de30 docs: add Gitea OAuth2 implementation plan 2026-05-08 13:56:00 -04:00
andreas b06de6fdd3 fix: remove dead helper, add state logging, add integration-style oauth tests
- Remove unused `_gitea_cfg_url` module-level helper from http.py
- Add logger.warning on invalid/expired state in oauth_gitea_callback
- Add test_callback_invalid_state_rejects and test_full_oauth_flow_chain to tests/test_oauth.py (21 tests total, all passing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:53:57 -04:00
andreas 940d0af35e fix: use error variable in login page template instead of hardcoded string 2026-05-08 13:50:02 -04:00
andreas d6d31aa2e3 feat: add Sign in with Gitea button to login page
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:48:28 -04:00
andreas 76edfe7577 feat: add Gitea OAuth2 redirect and callback routes 2026-05-08 13:44:12 -04:00
andreas d190029728 fix: guard unconfigured oauth calls; add missing test coverage; clean imports
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:42:21 -04:00
andreas b8307e7a9d feat: add authorization_url, exchange_code, fetch_user to oauth module
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:37:21 -04:00
andreas a2fdf091f5 fix: preserve OAuth users across config reload; fix test isolation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:34:57 -04:00
andreas 1914e6f28e feat: add provision_oauth_user() to users module
Creates or updates a user from an OAuth2 provider: new users are
inserted with an empty password_hash (OAuth-only login); existing users
have their display name and avatar refreshed while all other attributes
(admin flag, password_hash, notification_channels) are preserved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:32:08 -04:00
andreas 82cbce9615 test: fix shared state leak and fragile expiry assertion in oauth tests 2026-05-08 13:30:16 -04:00
andreas dbb779b013 feat: add OAuth2 CSRF state management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:28:18 -04:00
andreas ca908ee967 fix: remove unused imports from oauth module and tests 2026-05-08 13:26:51 -04:00
andreas 73c697b6c5 feat: add oauth module skeleton and is_enabled()
Add hbd/server/oauth.py with OAuthError, _gitea_cfg(), and is_enabled()
to detect when all three required Gitea OAuth2 config keys are present.
Add "oauth": {} default to SERVER_DEFAULTS in config.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:24:27 -04:00
andreas 3e2357380b docs: add Gitea OAuth2 design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:11:50 -04:00
andreas cc4a103bae scripts/c: add .gitignore for build outputs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:24:29 -04:00
andreas 53fb10fdf5 scripts/c: remove committed binary
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:24:05 -04:00
andreas 2df2ad18c9 scripts/c: add single-file C port of hbc_mini
hbc_mini.c is a full port of scripts/hbc_mini.py requiring only zlib,
pthreads, and a C11 compiler. Supports Linux, FreeBSD, NetBSD, and
DragonFly BSD with platform-specific plugin backends:

  - cpu_monitor:    /proc/stat (Linux) or kern.cp_time sysctl (BSD)
  - memory_monitor: /proc/meminfo (Linux), vm.stats.vm.* (FreeBSD),
                    struct uvmexp (NetBSD)
  - network_monitor:/proc/net/dev (Linux) or getifaddrs()+if_data (BSD)
  - disk_monitor:   df -P (all platforms)
  - ping_monitor:   ping subprocess (all platforms)
  - nagios_runner:  shell commands with perfdata parsing (all platforms)
  - os_info:        uname() + /etc/os-release (Linux) or kern.osrelease (BSD)

Build: cc -O2 -o hbc_mini hbc_mini.c -lz -lpthread -lm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 12:23:45 -04:00
andreas b81a0d2a6c plugins: persist owner chip in glance strip across JS updates
Store owner in data-owner attribute; updateHostHeader always prepends it
so it survives innerHTML replacement. Render it immediately on page load
before JS fetches plugin data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:57:58 -04:00
andreas 1a19088cfe udp: resolve host owner from config, default_owner, or os_info on each PLG
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:50:42 -04:00
andreas 172f6e950f plugins: show host owner in glance strip for admin users
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 09:12:02 -04:00
andreas 4349ae217a version 5.2.4
Release / release (push) Successful in 5s
2026-05-08 08:50:06 -04:00
andreas b3aa7b585f udp/config: fall back to default_owner when os_info has no owner; log debug
- When os_info arrives with no owner field, apply default_owner from server config
- Stop applying default_owner unconditionally in get_host_access (now deferred to os_info handling)
- os_info plugin logs debug message when injecting owner from client config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 08:49:42 -04:00
andreas 88a3c09b51 hbc/server: request InfoPlugin refresh when host has no plugin data; update docs
- Server sets request_update=1 in ACK when host.plugin_data is empty
- hbc: AsyncConnection.request_info_event; handle_ack sets it on request_update
- hbc: _info_plugin_refresh_loop clears InfoPlugin caches and resends on demand
- hbc_mini: same via _request_info event and _info_refresh_loop
- docs/USERS.md: document client-declared owner config key
- docs/PLUGIN_DEVELOPMENT.md: document server-initiated InfoPlugin refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 07:37:41 -04:00
andreas 0504402a8a hbc/hbc_mini: add owner config; include in os_info; server applies to host
- owner: optional top-level config key in ~/.hbc.yaml / ~/.hbc.json
- Propagated into plugin configs at load time so os_info can include it
- os_info PLG data carries owner field when set
- udp: sets host.owner from os_info if not already configured server-side
- live.html: format event log timestamps as YYYY-MM-DD HH:MM:SS (24-hour)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 07:25:47 -04:00
andreas ca58c18802 eventlog: store structured dicts; filter by user; clock: fix minute hand step
- eventlog() now stores {ts, host, level, service, message} dicts instead of strings
- WebSocket sends/broadcasts filter event log messages by the user's managed hosts
- live.html renders structured log entries with level-coloured spans
- Swiss railway clock minute hand now holds until second hand reaches 12, then steps

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 07:00:17 -04:00
andreas 1ddc4b8132 threshold/alerts: strip _status_code suffix from displayed metric names
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 06:19:16 -04:00
andreas 5e1720ed32 notify: use plain URL in Mattermost plugin metrics link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:43:18 -04:00
andreas 77f127fe60 hbc/hbc_mini: consolidate startup log into single line
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:33:31 -04:00
andreas 54fbd8d73d version 5.2.3
Release / release (push) Successful in 5s
2026-05-07 10:15:11 -04:00
andreas 7ab17e26e2 hbc/hbc_mini: log name and version at startup; ui: bump alert-metric font size
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 10:15:03 -04:00
andreas 28f5fa951c ui: show metric name inline with hostname in alerts and notifications
Alerts page: move metric name into the header row alongside hostname.
Notifications: include metric name in title (hostname  metric) and
strip the metric prefix from the body so it contains only value/detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 06:26:27 -04:00
andreas 37f1c58969 docs: remove dead warning/critical keys from ping_monitor config example
These fields were never read by the plugin; thresholds are configured
server-side. Also document the -b flag in README.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-07 06:12:15 -04:00
andreas f006077a71 send shutdown msg only if we sent a boot msg. Don't send eithe when restarting. 2026-05-06 11:57:43 -04:00
andreas d9fc8d632f send shutdown msg only if we sent a boot msg. Don't send eithe when restarting. 2026-05-06 11:54:09 -04:00
andreas f640574e4f version 5.2.2
Release / release (push) Successful in 5s
2026-05-06 09:57:43 -04:00
andreas 9a19424279 fix: retry connection on network error instead of permanently dropping it
error_received() no longer sets _dead=True; it just closes the transport
so the existing retry loop in heartbeat_sender (hbc) and sendto (hbc_mini)
reopens the connection on the next interval. This allows hbc to recover
when it starts before network connectivity is established.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 09:57:32 -04:00
andreas ca8ba84e65 fix: silence aiohttp.access log and strip plugin prefix in alerts UI
- main: disable aiohttp.access propagation unless --debug is active
- alerts.html: strip plugin-name prefix from metric_path display
  (nagios_runner.check_disk_root_status_code → check_disk_root_status_code)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:39:55 -04:00
andreas f3d08d1c9e version 5.2.1
Release / release (push) Successful in 5s
2026-05-06 07:07:01 -04:00
andreas 1e4263b793 fix: threshold and logging improvements
- threshold: fix crash when display is None (_format_display now falls
  back to default format string instead of calling None.format())
- threshold: shorten notification messages by stripping plugin-name prefix
  from metric_path (cpu_percent instead of cpu_monitor.cpu_percent)
- main: demote aiohttp.access log records from INFO to DEBUG
- udp: replace debug print with proper logger.info for new host sign-on

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 07:06:56 -04:00
andreas e931acb9f5 version 5.2.0
Release / release (push) Successful in 5s
2026-05-05 13:47:46 -04:00
andreas 018409e71d docs: correct README inaccuracies found during code audit
- Add ping_monitor to built-in plugins list
- Update cpu_monitor (uptime) and memory_monitor (ZFS ARC) descriptions
- Replace "aggregated status" bullet with accurate per-check reporting note
- Fix RTT hysteresis default: 0.1 → 0.02
- Fix client YAML config: remove non-existent server:/port: keys, use hb_port:
- Fix nagios_runner commands format: plain strings → {name:, command:} dicts
- Fix Supported Metrics: exit_code → actual <name>_status_code/<name>_status/<name>_output fields

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:45:43 -04:00
andreas 1824f637b4 fix: always show THRESHOLD_DEFAULTS in Settings threshold config
Seed threshold_configs["default"] from THRESHOLD_DEFAULTS at the start
of _parse_config() so the Settings page displays built-in defaults
regardless of whether the server config uses the multi-config format,
the legacy thresholds: format, or has no threshold config at all.
_parse_multi_config() overwrites the seed with the fully-merged
effective defaults when a threshold_configs section is present.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 13:02:28 -04:00
andreas a534c06b26 feat: nagios operator for direct exit-code severity mapping
Add ComparisonOperator.NAGIOS ("nagios") that maps Nagios exit codes
directly to alert levels (0=OK 1=WARNING 2=CRITICAL 3=UNKNOWN) without
requiring numeric warning/critical thresholds. Hysteresis is bypassed for
discrete codes. Display template defaults to "{check_name}: {output}".
_format_display() handles None threshold_value gracefully.

Add nagios_runner.status_code as a built-in default threshold config so
nagios checks alert out of the box.

Also: fix alerts.html scrolling (override html,body), make hostname a link
to /plugins#<hostname>, remove overall_status/overall_status_code/plugin_count
from nagios_runner and hbc_mini, replace with computed worst-status in
plugins.html via nagiosWorstStatus() helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 12:26:56 -04:00
andreas d7b5c97a4e version 5.1.21
Release / release (push) Successful in 6s
2026-05-05 11:05:48 -04:00
andreas ae447ac4a6 feat: nagios_runner improvements and alerts page fixes
- nagios_runner: remove overall_status/overall_status_code/plugin_count fields;
  each command still reports its own <name>_status and <name>_status_code
- threshold: expose {output} and {status} aliases in display templates for
  nagios_runner generic matches (mapped from <check_name>_output/status)
- alerts.html: fix scrolling by overriding html,body height/overflow (style.css
  sets both); make hostname a link to /plugins/<hostname>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:05:45 -04:00
andreas d44ce3d124 version 5.1.20
Release / release (push) Successful in 6s
2026-05-05 10:48:24 -04:00
andreas b1985d0eb2 feat: generic threshold matching for nagios_runner with {check_name} display support
_find_threshold() now returns the stripped prefix ("check_name") alongside
the ThresholdConfig, enabling a single generic entry (e.g. nagios_runner.status_code)
to cover all per-command metrics (check_disk_root_status_code, check_load_status_code,
…). The prefix is threaded through to _format_display() as {check_name}, with
{metric_name} also available in display templates. purge_stale_alerts() updated
to use generic matching so it does not incorrectly drop alerts on generic-matched
metrics. README updated with Display Format Templates and Generic Threshold
Matching sections.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 10:48:17 -04:00
andreas de778f680f fix: reduce default hysteresis 10%→2%; show recovery threshold in alerts UI
The 10% default hysteresis created an unreasonably wide recovery band:
a 95% threshold would only clear once the value dropped below 85.5%,
causing alerts to linger long after the metric was well below the
trigger level.

Change default hysteresis to 2% across all threshold parsers (plugin
metrics, partitions, RTT). For a 95% threshold, recovery is now at
93.1% instead of 85.5%.

Add AlertState.hysteresis field (set on every check, cleared on OK) and
expose recovery_threshold in to_dict() so the Alerts dashboard can
display "recovers < 93.1" alongside the trigger threshold, making the
hysteresis band visible to the user. Pickle backward-compatible via
__setstate__.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 14:47:50 -04:00
andreas d7b368c7c6 version 5.1.19
Release / release (push) Successful in 5s
2026-05-04 12:10:01 -04:00
andreas e790663f9f feat: exclude ZFS ARC from memory_percent; add uptime_seconds to cpu_monitor
memory_monitor / hbc_mini: ZFS ARC is reclaimable but not reflected in
MemAvailable by the Linux kernel (not in SReclaimable). Read ARC size
from /proc/spl/kstat/zfs/arcstats and add it to available memory before
computing memory_percent and memory_used. No-op on systems without ZFS.

cpu_monitor: report uptime_seconds via psutil.boot_time() (full client)
and /proc/uptime (hbc_mini).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 12:09:58 -04:00
andreas 475319e248 fix: send boot/shutdown on first open connection, not blindly first in list
Replace break-after-first-iteration with next(c for c in connections if
c.transport) so the message goes to the first connection that actually
has an open transport. Falls back to connections[0] if none are open
yet (sendto will attempt reopen), avoiding silent message loss when the
leading connection is still connecting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:59:30 -04:00
andreas ca5ef384a8 version 5.1.18
Release / release (push) Successful in 5s
2026-05-04 09:13:18 -04:00
andreas c93dbdc0f4 fix: settings thresholds show correct per-config metrics; misc hbc fixes
Settings page: pass threshold_checker to http.start so the Threshold
Configurations section has data. Use threshold_checker's already-parsed
ThresholdConfig objects instead of re-parsing the raw nested YAML.
Named (non-default) configs now display only their explicit overrides
via threshold_raw_configs, not the full merged set with defaults.

hbc/hbc_mini: send boot and shutdown messages on first connection only
to avoid duplicate packets when multiple servers are configured.
Replace print("Daemonizing...") with logging.info so output goes to
syslog in daemon mode.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 09:12:39 -04:00
andreas 3a546a1e5c feat: fetch-based Update/Delete buttons with toast notification on Host Overview
Replace href navigation with fetch() so the server response is captured
and displayed in a slide-up toast at the bottom of the page. Delete also
removes the host card from the DOM on success without a page reload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 08:16:54 -04:00
39 changed files with 3964 additions and 338 deletions
+72 -12
View File
@@ -58,10 +58,11 @@ Heartbeat includes a comprehensive plugin architecture that extends monitoring b
### Built-in Plugins
- `os_info`: Collects OS, kernel, distribution, and architecture information
- `cpu_monitor`: Monitors CPU usage, load average, frequency, and process counts
- `memory_monitor`: Monitors RAM and swap usage, available memory
- `cpu_monitor`: Monitors CPU usage, load average, frequency, process counts, and uptime
- `memory_monitor`: Monitors RAM and swap usage, available memory (ZFS ARC-aware)
- `disk_monitor`: Monitors disk usage, I/O statistics, and filesystem metrics
- `network_monitor`: Monitors network interface statistics, bandwidth, and connections
- `ping_monitor`: Measures round-trip latency to configured hosts
- `filesystem_info`: Collects mounted filesystem information (physical filesystems only by default)
- `nagios_runner`: Executes Nagios monitoring plugins (check_disk, check_load, check_http, etc.)
- `zfs_monitor`: Monitors ZFS pool health, capacity, fragmentation, dedup ratio, and cumulative I/O via `zpool(8)`
@@ -76,7 +77,7 @@ The `nagios_runner` plugin provides seamless integration with the vast Nagios pl
- Validates absolute command paths at startup and warns on missing or non-executable files
- Parses exit codes (OK/WARNING/CRITICAL/UNKNOWN)
- Extracts performance data with thresholds
- Reports aggregated status across all configured checks
- Reports per-check status, exit code, and output; no aggregate rollup field
See [docs/NAGIOS_INTEGRATION.md](docs/NAGIOS_INTEGRATION.md) for complete integration guide including configuration examples and custom plugin development.
@@ -181,7 +182,8 @@ thresholds:
warning: 80.0 # Warn when CPU > 80%
critical: 90.0 # Critical when CPU > 90%
operator: ">"
hysteresis: 0.1 # 10% hysteresis to prevent flapping
hysteresis: 0.02 # 2% hysteresis to prevent flapping
display: "(threshold: {op_symbol} {threshold_value}%)" # optional
memory_monitor:
percent:
@@ -223,7 +225,7 @@ thresholds:
<hostname>:
warning: <milliseconds> # Warn when RTT > this value
critical: <milliseconds> # Critical when RTT > this value
hysteresis: 0.1 # Optional: 10% hysteresis (default)
hysteresis: 0.02 # Optional: 2% hysteresis (default)
```
**Example alerts:**
@@ -274,7 +276,59 @@ All plugin metrics can be thresholded:
- **Memory**: percent, available_mb, swap_percent
- **Disk**: Per-partition percent, free_gb, free_mb
- **Network**: errors_total, dropped packets, connection counts
- **Nagios**: exit_code mapping (0=OK, 1=WARNING, 2=CRITICAL)
- **Nagios**: Any field emitted by `nagios_runner` (`<name>_status_code`, `<name>_status`, `<name>_output`, performance data fields)
### Display Format Templates
Each threshold entry accepts an optional `display` field — a Python format string shown in notifications and on the Alerts dashboard:
```yaml
nagios_runner:
status_code:
warning: 1
critical: 2
operator: ">="
display: "{check_name}: exit {value} (expected < {threshold_value})"
```
Available variables:
| Variable | Description |
|---|---|
| `{value}` | Current metric value |
| `{threshold_value}` | Threshold that was crossed |
| `{op_symbol}` | Comparison operator (`>`, `<`, `>=`, …); `"nagios"` for the nagios operator |
| `{check_name}` | Prefix stripped by generic matching (see below) |
| `{metric_name}` | Full field name within the plugin data |
| `{output}` | For `nagios_runner` generic matches: the matched check's status text (alias for `{check_name}_output`) |
| `{status}` | For `nagios_runner` generic matches: the matched check's status name — OK/WARNING/CRITICAL/UNKNOWN (alias for `{check_name}_status`) |
| any plugin field | Any other field present in the plugin's data |
### Generic Threshold Matching
When a metric name has no exact threshold entry, the server progressively strips leading underscore-separated segments and re-tries the lookup. This lets a single generic entry cover an entire family of metrics.
The classic use case is `nagios_runner`, which names each metric after the command that produced it:
```
nagios_runner.check_disk_root_status_code → no exact match
nagios_runner.disk_root_status_code → no match
nagios_runner.root_status_code → no match
nagios_runner.status_code → matched ✓
```
Configure the generic threshold once using the `nagios` operator, which maps exit codes directly to alert severity without requiring numeric warning/critical values:
```yaml
nagios_runner:
status_code:
operator: "nagios" # 0=OK 1=WARNING 2=CRITICAL 3=UNKNOWN
display: "{check_name}: {output}"
```
The stripped prefix (`check_disk_root` in the example above) is available as `{check_name}` in the display template, so you can identify which check triggered the alert without writing a separate threshold entry per command.
Exact matches always take priority. A generic entry only applies when no specific one is defined.
### Per-Host Threshold Profiles
@@ -453,6 +507,9 @@ hbc --boot your-server.example.com
# Verbose output
hbc -v your-server.example.com
# Send 'boot' and 'shutdown' messages on start and exit
hbc -b your-server.example.com
```
You can also run it via the module entrypoint:
@@ -461,12 +518,11 @@ You can also run it via the module entrypoint:
python -m hbd.client.main your-server.example.com
```
Client configuration can also be specified in YAML:
Client configuration can also be specified in YAML (`~/.hbc.yaml`):
```yaml
server: hbd.example.com
port: 50003
interval: 30
hb_port: 50003 # Server port (default: 50003)
interval: 30 # Heartbeat interval in seconds
plugins:
cpu_monitor:
interval: 300 # Check every 5 minutes (default)
@@ -480,10 +536,14 @@ plugins:
nagios_runner:
interval: 300 # Check every 5 minutes (default)
commands:
- /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
- /usr/lib/nagios/plugins/check_disk -w 20% -c 10% -p /
- name: check_load
command: /usr/lib/nagios/plugins/check_load -w 5,4,3 -c 10,8,6
- name: check_disk
command: /usr/lib/nagios/plugins/check_disk -w 20% -c 10% -p /
```
The server hostname is always passed as a positional command-line argument; there is no `server:` config key.
All monitoring plugins default to 5-minute (300 second) intervals, but can be customized as needed.
**Connection retry:** If a server is temporarily unreachable, `hbc` retries `open()` indefinitely on every heartbeat interval. IPv6 connections that never succeeded during early startup are dropped after 3 consecutive failures (to handle hosts without IPv6 routing), while IPv4 connections always retry.
-5
View File
@@ -104,11 +104,6 @@ The `nagios_runner` plugin collects:
- `{name}_{metric}_min` - Minimum value (if present)
- `{name}_{metric}_max` - Maximum value (if present)
**Overall:**
- `overall_status` - Worst status from all commands
- `overall_status_code` - Worst status code
- `plugin_count` - Number of Nagios plugins executed
## Configuration Options
```yaml
+23
View File
@@ -8,6 +8,7 @@ This guide explains how to create custom plugins for the Heartbeat monitoring sy
- [Plugin Types](#plugin-types)
- [Creating a Plugin](#creating-a-plugin)
- [Plugin Lifecycle](#plugin-lifecycle)
- [Server-initiated InfoPlugin refresh](#server-initiated-infoplugin-refresh)
- [Configuration](#configuration)
- [Best Practices](#best-practices)
- [Examples](#examples)
@@ -250,6 +251,28 @@ Understanding the plugin lifecycle helps you implement plugins correctly:
└─> Plugin releases resources, closes connections
```
## Server-initiated InfoPlugin refresh
When a heartbeat packet arrives from a host the server has no plugin data for (e.g. after a server restart), the server sets `request_update = 1` in the ACK reply. The client detects this flag and immediately re-runs all InfoPlugins — clearing their cached results first — then resends the data as PLG messages.
This means InfoPlugin data will always reach the server as soon as possible without requiring a client restart. No action is needed from plugin authors: the framework handles cache invalidation and re-collection automatically.
The lifecycle for this case looks like:
```
Server restarts, host reconnects
└─> hbd receives HTB with no existing plugin_data for host
└─> hbd sets request_update=1 in ACK
Client receives ACK
└─> Detects request_update flag
└─> Clears _cache on every registered InfoPlugin
└─> Calls collect() on each InfoPlugin
└─> Sends fresh PLG messages to server
```
If you write an `InfoPlugin` with side effects in `_collect_info()` (opening connections, writing files, etc.), be aware it may be called more than once per client session when this mechanism triggers.
## Configuration
### Plugin-Specific Configuration
+50 -27
View File
@@ -256,6 +256,56 @@ disk_monitor:
operator: "<"
```
### ZFS Monitor
ZFS pool health is checked automatically for every pool. A pool in any state
other than `ONLINE` (e.g. `DEGRADED`, `SUSPENDED`, `FAULTED`, `UNAVAIL`) raises
a **CRITICAL** alert by default — no configuration required.
The default threshold is equivalent to:
```yaml
zfs_monitor:
pools:
'*':
status:
warning: 1
critical: 2
operator: ">"
hysteresis: 0.0
display: "ZFS pool {pool_name} is {health}"
```
`'*'` matches every pool on the host. The notification message includes the pool
name and its current health string, e.g. `ZFS pool tank is DEGRADED`.
**Override for specific pools** — named pool entries take priority over `'*'`:
```yaml
zfs_monitor:
pools:
# Suppress health alerts for a scratch pool (not mission-critical)
scratch:
status:
enabled: false
# Capacity threshold for a specific pool
tank:
capacity:
warning: 75.0
critical: 90.0
operator: ">"
hysteresis: 0.05
```
**Alert state paths** follow the pattern `zfs_monitor.<pool_name>.status`,
so acknowledgements and silences target individual pools:
```
zfs_monitor.tank.status
zfs_monitor.backup.status
```
### Network Monitor
```yaml
@@ -1110,33 +1160,6 @@ hosts:
db-02:
threshold_config: [tight_memory, db_disk]
```
### Backward Compatibility
The legacy single threshold configuration is fully supported:
```yaml
# Old format - still works
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
```
This is equivalent to:
```yaml
# New format
threshold_configs:
default:
thresholds:
cpu_monitor:
cpu_percent:
warning: 80.0
critical: 90.0
```
### Configuration Priority
1. **Host `threshold_config` (list)**: Layer each named config's overrides left-to-right on top of the defaults
+18
View File
@@ -46,6 +46,24 @@ default_owner: andreas # owns hosts with no explicit owner
# falls back to the first admin user if omitted
```
### Client-declared host ownership
A host can declare its own owner directly in the hbc or hbc_mini client configuration. This is useful for hosts that are not listed in the server config, or during initial setup before a server-side config entry has been created.
**`~/.hbc.yaml`** (hbc):
```yaml
owner: andreas
```
**`~/.hbc.json`** (hbc_mini):
```json
{ "owner": "andreas" }
```
When set, the value is included in the `os_info` plugin data sent to the server. The server applies it as `host.owner` the first time `os_info` arrives, provided no owner has been configured server-side for that host. Server-configured ownership always takes precedence.
---
### Assigning roles to hosts
```yaml
@@ -0,0 +1,781 @@
# Gitea OAuth2 Authentication Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add Gitea as an OAuth2 login provider that coexists with password auth, auto-provisioning new users on first login.
**Architecture:** A new `oauth.py` module owns all Gitea-specific logic (CSRF state, URL building, token exchange, user-info fetch). `users.py` gains one function to upsert an OAuth-sourced user. `http.py` gets two new route handlers and a small login-page change. No new dependencies — `aiohttp.ClientSession` is already used in the codebase.
**Tech Stack:** Python 3.12, aiohttp 3.x, pytest, pytest-asyncio
---
## File Map
| Action | Path | Responsibility |
|--------|------|----------------|
| Modify | `hbd/server/config.py` | Add `"oauth": {}` default |
| Create | `hbd/server/oauth.py` | CSRF state, URL builder, token exchange, user-info fetch |
| Modify | `hbd/server/users.py` | Add `provision_oauth_user()` |
| Modify | `hbd/server/http.py` | Import oauth, two new routes, login page button |
| Create | `tests/test_oauth.py` | All new unit tests |
---
## Task 1: Add config default and `is_enabled()`
**Files:**
- Modify: `hbd/server/config.py:34` (after the `"users"` line)
- Create: `hbd/server/oauth.py`
- Create: `tests/test_oauth.py`
- [ ] **Step 1: Write the failing test**
Create `tests/test_oauth.py`:
```python
import pytest
from hbd.server import oauth
CFG_OFF = {}
CFG_ON = {
"oauth": {
"gitea": {
"url": "https://git.example.com",
"client_id": "cid",
"client_secret": "csec",
}
}
}
CFG_PARTIAL = {"oauth": {"gitea": {"url": "https://git.example.com"}}}
def test_is_enabled_when_all_keys_present():
assert oauth.is_enabled(CFG_ON) is True
def test_is_enabled_false_when_no_oauth_key():
assert oauth.is_enabled(CFG_OFF) is False
def test_is_enabled_false_when_partial_config():
assert oauth.is_enabled(CFG_PARTIAL) is False
```
- [ ] **Step 2: Run to confirm failure**
```
pytest tests/test_oauth.py -v
```
Expected: `ModuleNotFoundError: No module named 'hbd.server.oauth'`
- [ ] **Step 3: Add config default**
In `hbd/server/config.py`, add after the `"default_owner"` line (currently line 35):
```python
# OAuth2 providers
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
```
- [ ] **Step 4: Create `hbd/server/oauth.py` with `is_enabled`**
```python
"""Gitea OAuth2 support.
Config shape (in ~/.hb.yaml):
oauth:
gitea:
url: https://git.example.com
client_id: <client-id>
client_secret: <client-secret>
Register a Gitea OAuth2 application at:
Gitea → Settings → Applications → OAuth2
Set the redirect URI to:
https://<hbd-host>/login/oauth/gitea/callback
"""
import logging
import secrets
import time
import aiohttp
logger = logging.getLogger(__name__)
STATE_TTL = 600 # 10 minutes
# state_token -> expiry timestamp
_states: dict[str, float] = {}
class OAuthError(Exception):
"""Raised when the OAuth2 flow fails for any reason."""
def _gitea_cfg(config: dict) -> dict:
"""Return the gitea sub-dict or {} if absent/incomplete."""
return config.get("oauth", {}).get("gitea", {})
def is_enabled(config: dict) -> bool:
"""Return True when all three required Gitea OAuth keys are present."""
g = _gitea_cfg(config)
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
```
- [ ] **Step 5: Run to confirm tests pass**
```
pytest tests/test_oauth.py -v
```
Expected: 3 passed
- [ ] **Step 6: Commit**
```bash
git add hbd/server/config.py hbd/server/oauth.py tests/test_oauth.py
git commit -m "feat: add oauth module skeleton and is_enabled()"
```
---
## Task 2: CSRF state management
**Files:**
- Modify: `hbd/server/oauth.py` (add `make_state`, `validate_state`)
- Modify: `tests/test_oauth.py` (add state tests)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_oauth.py`:
```python
import time as time_mod
def test_make_state_returns_unique_tokens():
s1 = oauth.make_state()
s2 = oauth.make_state()
assert s1 != s2
assert len(s1) == 64 # 32 bytes hex
def test_validate_state_valid():
state = oauth.make_state()
assert oauth.validate_state(state) is True
def test_validate_state_consumed_on_use():
state = oauth.make_state()
oauth.validate_state(state)
assert oauth.validate_state(state) is False # replay rejected
def test_validate_state_unknown():
assert oauth.validate_state("notastate") is False
def test_validate_state_expired(monkeypatch):
state = oauth.make_state()
# Wind expiry into the past
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1)
assert oauth.validate_state(state) is False
```
- [ ] **Step 2: Run to confirm failure**
```
pytest tests/test_oauth.py -v -k "state"
```
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'make_state'`
- [ ] **Step 3: Implement state functions**
Add to `hbd/server/oauth.py` after the `_states` dict definition:
```python
def make_state() -> str:
"""Generate a CSRF state token, store it with TTL, and return it."""
_purge_states()
token = secrets.token_hex(32)
_states[token] = time.time() + STATE_TTL
return token
def validate_state(state: str) -> bool:
"""Return True if *state* is known and unexpired; always removes it."""
expiry = _states.pop(state, None)
if expiry is None:
return False
return time.time() < expiry
def _purge_states() -> None:
now = time.time()
expired = [k for k, exp in list(_states.items()) if exp < now]
for k in expired:
del _states[k]
```
- [ ] **Step 4: Run to confirm tests pass**
```
pytest tests/test_oauth.py -v
```
Expected: 8 passed
- [ ] **Step 5: Commit**
```bash
git add hbd/server/oauth.py tests/test_oauth.py
git commit -m "feat: add OAuth2 CSRF state management"
```
---
## Task 3: `provision_oauth_user` in users.py
**Files:**
- Modify: `hbd/server/users.py` (add `provision_oauth_user`)
- Modify: `tests/test_oauth.py` (add provisioning tests)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_oauth.py`:
```python
from hbd.server import users as users_mod
from hbd.server.users import User
def _reset_users(entries=None):
users_mod.users = entries or {}
def test_provision_oauth_user_new():
_reset_users()
user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png")
assert user.username == "gituser"
assert user.full_name == "Git User"
assert user.avatar == "https://example.com/avatar.png"
assert user.admin is False
assert user.password_hash == ""
assert "gituser" in users_mod.users
def test_provision_oauth_user_no_password_login():
_reset_users()
user = users_mod.provision_oauth_user("gituser", "Git User", "")
assert user.check_password("anything") is False
def test_provision_oauth_user_existing_updates_profile():
existing = User(
username="alice",
full_name="Old Name",
avatar="old.png",
password_hash="pbkdf2:sha256:1:salt:abc",
admin=True,
notification_channels=["chan1"],
)
_reset_users({"alice": existing})
user = users_mod.provision_oauth_user("alice", "New Name", "new.png")
assert user.full_name == "New Name"
assert user.avatar == "new.png"
# Preserved
assert user.admin is True
assert user.password_hash == "pbkdf2:sha256:1:salt:abc"
assert user.notification_channels == ["chan1"]
def test_provision_oauth_user_does_not_overwrite_with_empty():
existing = User(username="bob", full_name="Bob", avatar="bob.png")
_reset_users({"bob": existing})
user = users_mod.provision_oauth_user("bob", "", "")
assert user.full_name == "Bob"
assert user.avatar == "bob.png"
```
- [ ] **Step 2: Run to confirm failure**
```
pytest tests/test_oauth.py -v -k "provision"
```
Expected: `AttributeError: module 'hbd.server.users' has no attribute 'provision_oauth_user'`
- [ ] **Step 3: Implement `provision_oauth_user`**
Add to `hbd/server/users.py` after the `authenticate()` function (after line 187):
```python
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
"""Create or update a user sourced from an OAuth2 provider.
New users are inserted with no password_hash — they can only authenticate
via OAuth. Existing users (e.g. defined in config with a password) have
their display name and avatar refreshed; all other attributes are preserved.
"""
user = users.get(username)
if user is None:
user = User(username=username, full_name=full_name, avatar=avatar)
users[username] = user
logger.info("Provisioned OAuth user %r", username)
else:
if full_name:
user.full_name = full_name
if avatar:
user.avatar = avatar
return user
```
- [ ] **Step 4: Run to confirm tests pass**
```
pytest tests/test_oauth.py -v
```
Expected: 12 passed
- [ ] **Step 5: Commit**
```bash
git add hbd/server/users.py tests/test_oauth.py
git commit -m "feat: add provision_oauth_user() to users module"
```
---
## Task 4: URL builder, token exchange, and user-info fetch
**Files:**
- Modify: `hbd/server/oauth.py` (add `authorization_url`, `exchange_code`, `fetch_user`)
- Modify: `tests/test_oauth.py` (add async tests with mocked HTTP)
- [ ] **Step 1: Write the failing tests**
Append to `tests/test_oauth.py`:
```python
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import urlparse, parse_qs
def test_authorization_url_shape():
state = "teststate"
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
url = oauth.authorization_url(CFG_ON, state, redirect_uri)
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.scheme == "https"
assert parsed.netloc == "git.example.com"
assert parsed.path == "/login/oauth/authorize"
assert qs["client_id"] == ["cid"]
assert qs["state"] == ["teststate"]
assert qs["redirect_uri"] == [redirect_uri]
assert qs["scope"] == ["user:email"]
assert qs["response_type"] == ["code"]
@pytest.mark.asyncio
async def test_exchange_code_returns_token():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
token = await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
assert token == "tok123"
@pytest.mark.asyncio
async def test_exchange_code_raises_on_error_status():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(CFG_ON, "badcode", redirect_uri)
@pytest.mark.asyncio
async def test_fetch_user_returns_profile():
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
profile = await oauth.fetch_user(CFG_ON, "tok123")
assert profile == {
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
}
```
- [ ] **Step 2: Run to confirm failure**
```
pytest tests/test_oauth.py -v -k "url or exchange or fetch"
```
Expected: `AttributeError: module 'hbd.server.oauth' has no attribute 'authorization_url'`
- [ ] **Step 3: Implement the three functions**
Add to `hbd/server/oauth.py`:
```python
import urllib.parse
def authorization_url(config: dict, state: str, redirect_uri: str) -> str:
"""Return the Gitea OAuth2 authorization URL to redirect the browser to."""
g = _gitea_cfg(config)
params = urllib.parse.urlencode({
"client_id": g["client_id"],
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "user:email",
"state": state,
})
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}"
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
"""Exchange an authorization *code* for a Gitea access token.
Returns the access token string. Raises OAuthError on any failure.
"""
g = _gitea_cfg(config)
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
payload = {
"client_id": g["client_id"],
"client_secret": g["client_secret"],
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
}
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp:
if resp.status != 200:
text = await resp.text()
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
data = await resp.json()
except aiohttp.ClientError as exc:
raise OAuthError(f"Token exchange network error: {exc}") from exc
token = data.get("access_token")
if not token:
raise OAuthError(f"No access_token in response: {data}")
return token
async def fetch_user(config: dict, token: str) -> dict:
"""Fetch the authenticated user's profile from Gitea.
Returns a dict with keys: login, full_name, avatar_url.
Raises OAuthError on any failure.
"""
g = _gitea_cfg(config)
url = f"{g['url'].rstrip('/')}/api/v1/user"
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp:
if resp.status != 200:
text = await resp.text()
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
data = await resp.json()
except aiohttp.ClientError as exc:
raise OAuthError(f"User fetch network error: {exc}") from exc
return {
"login": data.get("login", ""),
"full_name": data.get("full_name", ""),
"avatar_url": data.get("avatar_url", ""),
}
```
Also add `import urllib.parse` at the top of `oauth.py` (alongside the existing imports).
- [ ] **Step 4: Run to confirm tests pass**
```
pytest tests/test_oauth.py -v
```
Expected: 17 passed
- [ ] **Step 5: Commit**
```bash
git add hbd/server/oauth.py tests/test_oauth.py
git commit -m "feat: add authorization_url, exchange_code, fetch_user to oauth module"
```
---
## Task 5: HTTP routes — redirect and callback
**Files:**
- Modify: `hbd/server/http.py`
`http.py` defines all handlers inside `async def start(...)`. The two new handlers go in the same block, just before the `app = web.Application()` line (~line 900). The import goes at the top of the file.
- [ ] **Step 1: Add the import**
In `hbd/server/http.py`, add after the existing local imports (after `from . import users as users_mod`):
```python
from . import oauth as oauth_mod
```
- [ ] **Step 2: Add the two route handlers**
In `hbd/server/http.py`, add the two handlers immediately before the `app = web.Application()` line:
```python
async def oauth_gitea_redirect(request):
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
if not oauth_mod.is_enabled(config):
return web.Response(status=404, text="OAuth not configured")
state = oauth_mod.make_state()
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
raise web.HTTPFound(oauth_mod.authorization_url(config, state, redirect_uri))
async def oauth_gitea_callback(request):
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
if not oauth_mod.is_enabled(config):
return web.Response(status=404, text="OAuth not configured")
code = request.rel_url.query.get("code", "")
state = request.rel_url.query.get("state", "")
if not code or not state:
return web.Response(status=400, text="Missing code or state")
if not oauth_mod.validate_state(state):
raise web.HTTPFound("/login?error=1")
redirect_uri = f"{request.url.origin()}/login/oauth/gitea/callback"
try:
token = await oauth_mod.exchange_code(config, code, redirect_uri)
profile = await oauth_mod.fetch_user(config, token)
except oauth_mod.OAuthError as exc:
logger.warning("OAuth error: %s", exc)
raise web.HTTPFound("/login?error=1")
user = users_mod.provision_oauth_user(
profile["login"],
profile["full_name"],
profile["avatar_url"],
)
session_token = users_mod.create_session(user.username)
resp = web.HTTPFound("/")
resp.set_cookie(
SESSION_COOKIE,
session_token,
max_age=users_mod.SESSION_TTL,
httponly=True,
samesite="Lax",
)
raise resp
```
- [ ] **Step 3: Register the routes**
In `hbd/server/http.py`, add to the route list after the existing auth routes (after `web.post("/api/0/auth/logout", api_logout)`):
```python
web.get("/login/oauth/gitea", oauth_gitea_redirect),
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
```
- [ ] **Step 4: Manual smoke test**
Start the server locally with OAuth configured in `~/.hb.yaml`:
```yaml
oauth:
gitea:
url: https://your-gitea-instance.example.com
client_id: your-client-id
client_secret: your-client-secret
```
Visit `http://localhost:50004/login/oauth/gitea` — confirm you are redirected to Gitea's authorization page.
- [ ] **Step 5: Commit**
```bash
git add hbd/server/http.py
git commit -m "feat: add Gitea OAuth2 redirect and callback routes"
```
---
## Task 6: Login page — "Sign in with Gitea" button
**Files:**
- Modify: `hbd/server/http.py` (update `login_page` handler, ~line 625)
- [ ] **Step 1: Replace the login page HTML**
In `hbd/server/http.py`, find the `html = f"""` block inside `login_page` and replace it with:
```python
gitea_button = ""
if oauth_mod.is_enabled(config):
gitea_url = _gitea_cfg_url(config)
gitea_button = f"""
<div class="divider">or</div>
<a href="/login/oauth/gitea" class="gitea-btn">
Sign in with Gitea
</a>"""
html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Heartbeat — Login</title>
<style>
body {{ font-family: sans-serif; background: #f5f5f5; display: flex;
justify-content: center; align-items: center; height: 100vh; margin: 0; }}
.box {{ background: #fff; padding: 2em 2.5em; border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,.15); min-width: 300px; }}
h2 {{ margin: 0 0 1.2em; color: #333; font-size: 1.4em; }}
label {{ display: block; margin-bottom: .3em; font-size: .9em; color: #555; }}
input {{ width: 100%; padding: .5em .7em; border: 1px solid #ccc;
border-radius: 4px; font-size: 1em; box-sizing: border-box; }}
button {{ margin-top: 1.2em; width: 100%; padding: .6em; background: #0066cc;
color: #fff; border: none; border-radius: 4px; font-size: 1em; cursor: pointer; }}
button:hover {{ background: #0055aa; }}
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
.field {{ margin-bottom: .9em; }}
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
.gitea-btn {{ display: block; width: 100%; padding: .6em; background: #609926;
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
text-decoration: none; box-sizing: border-box; }}
.gitea-btn:hover {{ background: #4e7d1e; }}
</style>
</head>
<body>
<div class="box">
<h2>Heartbeat</h2>
{'<p class="error">Invalid username, password, or OAuth error.</p>' if error else ''}
<form method="post">
<div class="field"><label>Username</label><input name="username" autofocus></div>
<div class="field"><label>Password</label><input name="password" type="password"></div>
<button type="submit">Sign in</button>
</form>{gitea_button}
</div>
</body>
</html>"""
```
- [ ] **Step 2: Add the `_gitea_cfg_url` helper**
Add this small helper in `hbd/server/http.py` just before the `login_page` handler (around line 600) so the template can read the Gitea display URL without importing internal oauth details:
```python
def _gitea_cfg_url(config: dict) -> str:
return config.get("oauth", {}).get("gitea", {}).get("url", "")
```
Also update the `login_page` handler's `error` logic to show the error when the `?error=1` query param is present (set by the callback on OAuth failure):
```python
async def login_page(request):
"""GET /login — show login form; POST /login — process and redirect."""
if not users_mod.users_enabled():
raise web.HTTPFound("/")
error = ""
if request.method == "POST":
form = await request.post()
username = form.get("username", "")
password = form.get("password", "")
user = users_mod.authenticate(username, password)
if user:
token = users_mod.create_session(username)
redirect_to = request.rel_url.query.get("next", "/")
resp = web.HTTPFound(redirect_to)
resp.set_cookie(
SESSION_COOKIE,
token,
max_age=users_mod.SESSION_TTL,
httponly=True,
samesite="Lax",
)
raise resp
error = "Invalid username or password."
elif request.rel_url.query.get("error"):
error = "Sign-in failed. Please try again."
```
- [ ] **Step 3: Manual verification**
Start the server with OAuth configured. Visit `/login`. Confirm:
- The "Sign in with Gitea" button appears (green, below a divider)
- Clicking it redirects to Gitea
- After authorising on Gitea, you are redirected back and land on `/` with a valid session cookie
Without OAuth configured, confirm the button does not appear.
- [ ] **Step 4: Commit**
```bash
git add hbd/server/http.py
git commit -m "feat: add Sign in with Gitea button to login page"
```
---
## Self-Review Notes
- All 5 spec requirements covered: coexist ✓, auto-provision ✓, regular user ✓, any Gitea user ✓, config-driven ✓
- `exchange_code` signature in Task 4 matches usage in Task 5 (`config, code, redirect_uri`) ✓
- `fetch_user` returns `{login, full_name, avatar_url}` — matched in callback handler ✓
- `validate_state` removes state on use (replay protection) ✓
- `provision_oauth_user` skips empty strings so existing avatar/name aren't erased ✓
- `_gitea_cfg_url` is a plain `def`, not `async` — safe to call in template prep ✓
@@ -0,0 +1,184 @@
# Gitea OAuth2 Authentication — Design Spec
Date: 2026-05-08
## Overview
Add Gitea as an OAuth2 login provider alongside the existing username/password
authentication. Any user on the configured Gitea instance can sign in; their
local account is auto-provisioned on first login as a regular (non-admin) user.
Password login continues to work unchanged.
---
## Config
A new optional `oauth.gitea` block in `~/.hb.yaml`. OAuth is disabled when the
block is absent or any of the three required keys is missing.
```yaml
oauth:
gitea:
url: https://git.example.com # Gitea base URL, no trailing slash
client_id: <gitea-app-client-id>
client_secret: <gitea-app-client-secret>
```
**Gitea setup:** Create an OAuth2 application in Gitea under
*Settings → Applications → OAuth2*. Set the redirect URI to
`https://<hbd-host>/login/oauth/gitea/callback`.
`config.py` default:
```python
"oauth": {},
```
---
## New module: `hbd/server/oauth.py`
Owns all OAuth2 logic. No new dependencies — uses `aiohttp.ClientSession`
already present in the codebase.
### CSRF state store
```python
# state -> expires (float)
_states: dict[str, float] = {}
STATE_TTL = 600 # 10 minutes
```
`_states` is an in-memory dict. Entries are created on redirect and deleted on
use or expiry. A purge runs on every new state generation.
### Public API
| Function | Description |
|---|---|
| `is_enabled(config)` | Returns `True` when url, client_id, and client_secret are all set |
| `make_state()` | Generates a random state token, stores it with TTL, returns it |
| `validate_state(state)` | Returns `True` and removes the state if valid and unexpired |
| `authorization_url(config, state, redirect_uri)` | Builds the Gitea `/login/oauth/authorize` redirect URL with `client_id`, `redirect_uri`, `scope=user:email`, `state` |
| `exchange_code(config, code, redirect_uri)` async | POSTs to Gitea `/login/oauth/access_token` with code and redirect_uri, returns the access token string or raises `OAuthError` |
| `fetch_user(config, token)` async | GETs Gitea `/api/v1/user` with Bearer token, returns `{"login", "full_name", "avatar_url"}` or raises `OAuthError` |
### Error handling
`OAuthError(message)` is a module-level exception. The callback route catches it
and renders the login page with an error message — identical to an invalid
password error in UX terms.
Network timeouts use a 10-second `aiohttp` timeout. Any non-2xx response from
Gitea raises `OAuthError`.
---
## Change: `hbd/server/users.py`
One new function added to the public API:
```python
def provision_oauth_user(username: str, full_name: str, avatar: str) -> User:
```
- If the username does not exist in the live `users` dict, creates a `User`
with no `password_hash` (so password login is impossible for this account)
and inserts it.
- If the username already exists (e.g. was defined in config with a password),
updates `full_name` and `avatar` from the OAuth profile and returns the
existing user unchanged in all other respects (preserving admin flag,
notification channels, etc.).
- Logs a one-line INFO message on first provision.
---
## Changes: `hbd/server/http.py`
### Two new route handlers
**`GET /login/oauth/gitea`**
1. Checks `oauth.is_enabled(config)` — returns 404 if not.
2. Calls `oauth.make_state()`.
3. Constructs `redirect_uri` as `{request.url.origin()}/login/oauth/gitea/callback` using aiohttp's `request.url.origin()`.
4. Redirects the browser to `oauth.authorization_url(config, state, redirect_uri)`.
**`GET /login/oauth/gitea/callback`**
1. Reads `code` and `state` query params; returns 400 if either is missing.
2. Calls `oauth.validate_state(state)` — redirects to `/login` with error if
invalid (CSRF or replay protection).
3. Reconstructs the same `redirect_uri` as the redirect handler (required by OAuth2 spec for token exchange).
4. Calls `await oauth.exchange_code(config, code, redirect_uri)` to get the access token.
4. Calls `await oauth.fetch_user(config, token)` to get the Gitea user profile.
5. Calls `users_mod.provision_oauth_user(login, full_name, avatar_url)`.
6. Calls `users_mod.create_session(username)` to get a session token.
7. Sets `hbd_session` cookie (same flags as password login: httponly, Lax,
24h TTL).
8. Redirects to `/`.
9. Any `OAuthError` re-renders the login page with a generic error message.
### Login page change
When `oauth.is_enabled(config)` is `True`, the existing login form gains a
separator and a "Sign in with Gitea" link button pointing to
`/login/oauth/gitea`. The password form is always rendered regardless.
### Route registration
```python
web.get("/login/oauth/gitea", oauth_redirect),
web.get("/login/oauth/gitea/callback", oauth_callback),
```
Added alongside the existing `/login` and `/logout` routes.
---
## Data flow
```
Browser hbd Gitea
| | |
|-- GET /login ----------->| |
|<- login page (+ button) -| |
| | |
|-- GET /login/oauth/gitea>| |
|<- 302 Gitea /authorize --| |
| | |
|-- GET /login/oauth/authorize ----------------------->|
|<- 302 /login/oauth/gitea/callback?code=..&state=.. --|
| | |
|-- GET /callback -------->| |
| |-- POST /access_token ---->|
| |<- {access_token} ---------|
| |-- GET /api/v1/user ------>|
| |<- {login, name, avatar} --|
| | provision_oauth_user() |
| | create_session() |
|<- 302 / (set cookie) ----| |
```
---
## Testing
- `test_oauth_state`: `make_state` + `validate_state` happy path; expired state
returns False; replay (double-use) returns False.
- `test_provision_oauth_user_new`: new username creates User with no password.
- `test_provision_oauth_user_existing`: existing config user updates name/avatar,
preserves admin flag and notification_channels.
- `test_oauth_callback_invalid_state`: callback with bad state redirects to login.
- Integration: mock Gitea endpoints with `aiohttp_client` fixture; full
redirect → callback → session cookie flow.
---
## Out of scope
- Restricting login to specific Gitea organisations or teams.
- Making OAuth users admin automatically.
- Multiple OAuth providers.
- Token refresh (Gitea access tokens are long-lived; the hbd session TTL governs
re-authentication).
+1 -1
View File
@@ -14,4 +14,4 @@ Install options:
"""
__all__ = ["__version__"]
__version__ = "5.1.17"
__version__ = "5.2.6"
+3
View File
@@ -16,6 +16,9 @@ CLIENT_DEFAULTS = {
"hb_port": 50003, # Port where hbd servers listen
"interval": 10, # Heartbeat interval in seconds
# Host identity
"owner": None, # Optional username to set as this host's owner on the server
# Runtime flags
"foreground": False,
"verbose": False,
+43 -23
View File
@@ -21,6 +21,7 @@ from typing import Dict, List, Optional
# Import protocol and config
from .config import load_config
from ..common.proto import dicttos, stodict
from .. import __version__
# Import plugin system
from .plugin import PluginRegistry, PluginLoader, InfoPlugin, MonitorPlugin
@@ -58,6 +59,7 @@ class AsyncConnection:
self._dead = False
self._ever_opened = False
self._open_fail_count = 0 # consecutive failures before first success
self.request_info_event: asyncio.Event = asyncio.Event()
self.logger = logging.getLogger(f"hbc.conn.{addr}")
@@ -137,6 +139,9 @@ class AsyncConnection:
self.ackcount += 1
self.logger.debug(f"ACK received, RTT: {rtt:.1f}ms")
if msg.get("request_update"):
self.logger.info("server requested plugin info refresh")
self.request_info_event.set()
class HeartbeatProtocol(asyncio.DatagramProtocol):
@@ -172,9 +177,8 @@ class HeartbeatProtocol(asyncio.DatagramProtocol):
self.logger.error(f"Error processing datagram: {e}", exc_info=True)
def error_received(self, exc):
"""Handle protocol errors."""
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc}dropping connection")
self.connection._dead = True
"""Handle protocol errors — close transport so the heartbeat sender retries."""
self.logger.warning(f"Protocol error on {self.connection.addr}: {exc}will retry")
self.connection.close()
@@ -338,6 +342,26 @@ async def heartbeat_sender(conn: AsyncConnection, interval: int):
raise
async def _info_plugin_refresh_loop(conn: AsyncConnection, info_plugins: List):
"""Wait for server requests to re-send InfoPlugin data."""
logger = logging.getLogger("hbc.plugins")
while running:
await conn.request_info_event.wait()
if not running:
break
conn.request_info_event.clear()
logger.info("refreshing InfoPlugins on server request")
for plugin in info_plugins:
plugin._cache = None
try:
data = await plugin.collect()
if data:
await conn.sendto({"plugin": plugin.name, **data}, "PLG")
logger.info(f"Resent {plugin.name} data")
except Exception as e:
logger.error(f"Error re-collecting {plugin.name}: {e}", exc_info=True)
async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
"""Collect and send plugin data.
@@ -369,16 +393,13 @@ async def plugin_collector(conn: AsyncConnection, registry: PluginRegistry):
for plugin in monitor_plugins:
by_interval[plugin.interval].append(plugin)
# Create tasks for each interval
tasks = []
# Create tasks for each interval; always include the info-refresh watcher
tasks = [asyncio.create_task(_info_plugin_refresh_loop(conn, info_plugins))]
for interval, plugins in by_interval.items():
task = asyncio.create_task(
tasks.append(asyncio.create_task(
plugin_collector_interval(conn, plugins, interval)
)
tasks.append(task)
))
# Wait for all tasks
if tasks:
try:
await asyncio.gather(*tasks, return_exceptions=True)
except asyncio.CancelledError:
@@ -463,16 +484,13 @@ async def cleanup(connections: List[AsyncConnection]):
logger = logging.getLogger("hbc.cleanup")
logger.info("Cleaning up connections")
for conn in connections:
target = next((c for c in connections if c.transport), connections[0] if connections else None)
if target and send_shutdown:
try:
msg = {
"shutdown": 1,
"acks": conn.ackcount
}
await conn.sendto(msg)
await target.sendto({"shutdown": 1, "acks": target.ackcount})
except Exception as e:
logger.error(f"Error sending shutdown: {e}")
for conn in connections:
conn.close()
# Give messages time to send
@@ -481,7 +499,7 @@ async def cleanup(connections: List[AsyncConnection]):
async def async_main(args, config):
"""Async main function."""
global running, shutdown_event, active_tasks
global running, shutdown_event, active_tasks, send_shutdown
# Create shutdown event
shutdown_event = asyncio.Event()
@@ -498,8 +516,7 @@ async def async_main(args, config):
hb_port = config.get("hb_port", PORT)
interval = config.get("interval", INTERVAL)
logger.info(f"Starting hbc for {iam} -> {hb_hosts}")
logger.info(f"Port: {hb_port}, Interval: {interval}s")
logger.info(f"hbc {__version__} on {iam} -> {hb_hosts} port={hb_port}, interval={interval}s")
# Create connections
connections = []
@@ -529,17 +546,20 @@ async def async_main(args, config):
logger.info(f"Created {len(connections)} connections")
# Send boot/message if requested
send_shutdown = False
if args.boot or args.message:
boot_msg = {}
if args.boot:
boot_msg["boot"] = 1
args.boot = False # Clear boot flag so we don't send it again in main loop
send_shutdown = True
if args.message:
boot_msg["service"] = "service"
boot_msg["msg"] = args.message
boot_msg["acks"] = 0
for conn in connections:
await conn.sendto(boot_msg)
target = next((c for c in connections if c.transport), connections[0])
await target.sendto(boot_msg)
if args.message and not args.daemon:
# Message-only mode
@@ -739,7 +759,7 @@ def main(argv=None):
# Daemonize if requested
if args.daemon:
print("Daemonizing...")
logging.info("Daemonizing...")
daemonize()
_reconfigure_logging_for_daemon(log_level)
logging.info(f"hbc starting, sending heartbeat to {', '.join(args.hosts)}")
+4 -1
View File
@@ -364,7 +364,10 @@ class PluginLoader:
# Instantiate plugin with config — check plugins subdict first,
# then top-level keys (e.g. nagios_runner: ... at root of config).
plugin_instance_config = plugins_subconfig.get(obj.name) or raw_config.get(obj.name, {})
plugin_instance_config = dict(plugins_subconfig.get(obj.name) or raw_config.get(obj.name) or {})
# Propagate top-level owner so os_info (and any future plugin) can report it.
if "owner" in raw_config and "owner" not in plugin_instance_config:
plugin_instance_config["owner"] = raw_config["owner"]
plugin = obj(config=plugin_instance_config)
# Initialize plugin
+7
View File
@@ -119,6 +119,13 @@ class CPUMonitorPlugin(MonitorPlugin):
except Exception as e:
self.logger.debug(f"Could not get CPU times: {e}")
# Uptime in seconds
try:
import time
data["uptime_seconds"] = int(time.time() - self.psutil.boot_time())
except Exception as e:
self.logger.debug(f"Could not get uptime: {e}")
self.logger.debug(
f"Collected CPU metrics: {data.get('cpu_percent', 'N/A')}% usage"
)
+31 -3
View File
@@ -14,6 +14,24 @@ except ImportError:
from hbd.client.plugin import MonitorPlugin
def _zfs_arc_bytes() -> int:
"""Return current ZFS ARC size in bytes, or 0 if ZFS is not present.
ZFS ARC is reclaimable but is not included in MemAvailable by the Linux
kernel (it is not in SReclaimable), so it would otherwise be counted as
used memory.
"""
try:
with open("/proc/spl/kstat/zfs/arcstats") as fh:
for line in fh:
parts = line.split()
if len(parts) >= 3 and parts[0] == "size":
return int(parts[2])
except (OSError, ValueError):
pass
return 0
logger = logging.getLogger(__name__)
@@ -101,11 +119,21 @@ class MemoryMonitorPlugin(MonitorPlugin):
# Virtual (physical) memory statistics
vmem = psutil.virtual_memory()
# psutil's available already excludes page cache / file buffers
# (uses MemAvailable on Linux). Add ZFS ARC on top because the kernel
# does not include it in SReclaimable / MemAvailable even though it is
# reclaimable.
arc_bytes = _zfs_arc_bytes()
available = min(vmem.available + arc_bytes, vmem.total)
used = vmem.total - available
percent = round(used / vmem.total * 100, 1) if vmem.total else 0.0
metrics['memory_total'] = vmem.total
metrics['memory_available'] = vmem.available
metrics['memory_used'] = vmem.used
metrics['memory_available'] = available
metrics['memory_used'] = used
metrics['memory_free'] = vmem.free
metrics['memory_percent'] = vmem.percent
metrics['memory_percent'] = percent
# Platform-specific memory details
if hasattr(vmem, 'active'):
+4 -20
View File
@@ -31,16 +31,13 @@ from hbd.client.plugin import MonitorPlugin
# Nagios exit codes
NAGIOS_OK = 0
NAGIOS_WARNING = 1
NAGIOS_CRITICAL = 2
NAGIOS_UNKNOWN = 3
STATUS_NAMES = {
NAGIOS_OK: "OK",
NAGIOS_WARNING: "WARNING",
NAGIOS_CRITICAL: "CRITICAL",
NAGIOS_UNKNOWN: "UNKNOWN"
0: "OK",
1: "WARNING",
2: "CRITICAL",
3: "UNKNOWN",
}
@@ -129,9 +126,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
"""
results = {}
# Track overall status (worst status wins)
worst_status = NAGIOS_OK
for cmd_config in self.commands:
name = cmd_config.get("name")
command = cmd_config.get("command")
@@ -149,10 +143,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
results[f"{name}_status_code"] = status_code
results[f"{name}_output"] = output
# Track worst status
if status_code > worst_status:
worst_status = status_code
# Parse and add performance data
if perfdata:
for metric_name, metric_value in perfdata.items():
@@ -167,12 +157,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
results[f"{name}_status"] = "ERROR"
results[f"{name}_status_code"] = NAGIOS_UNKNOWN
results[f"{name}_output"] = str(e)
worst_status = NAGIOS_UNKNOWN
# Add overall status
results["overall_status"] = STATUS_NAMES.get(worst_status, "UNKNOWN")
results["overall_status_code"] = worst_status
results["plugin_count"] = len(self.commands)
return results
+3
View File
@@ -62,6 +62,9 @@ class OSInfoPlugin(InfoPlugin):
"hbc_version": hbc_version,
"hbc_type": "full",
}
if self.config.get("owner"):
self.logger.debug(f"Adding owner from config: {self.config['owner']}")
data["owner"] = self.config["owner"]
# Add Linux-specific distribution info
if platform.system() == "Linux":
+2 -6
View File
@@ -13,12 +13,8 @@ plugins:
count: 3 # ICMP packets per ping run (default 3)
timeout: 5 # seconds before a host is considered unreachable (default 5)
hosts:
8.8.8.8:
warning: 20.0 # ms
critical: 100.0 # ms
192.168.1.1:
warning: 5.0
critical: 20.0
- 8.8.8.8
- 192.168.1.1
```
Reported metrics per host (metric key uses the hostname with dots/colons replaced
+11 -1
View File
@@ -89,8 +89,18 @@ class ZFSMonitorPlugin(MonitorPlugin):
name = parts[0].strip()
if self._pools_filter and name not in self._pools_filter:
continue
health = parts[1].strip()
if health == "ONLINE":
status = 0
elif health in ("DEGRADED", "ONLINE with errors"):
status = 1
elif health in ("FAULTED", "OFFLINE", "UNAVAIL"):
status = 2
else:
status = 3 # unknown status
pools[name] = {
"health": parts[1].strip(),
"health": health,
"status": status,
"size": _int(parts[2]),
"alloc": _int(parts[3]),
"free": _int(parts[4]),
+24
View File
@@ -134,6 +134,30 @@ thresholds:
hysteresis: 0.1
enabled: true
# ----------------------------------------------------------------------------
# ZFS Monitor Thresholds
# ----------------------------------------------------------------------------
zfs_monitor:
# Pool health check — built-in default; shown here for reference/override.
# status is 0 (ONLINE) or 1 (DEGRADED) or 2 (SUSPENDED, FAULTED, UNAVAIL…).
# Use '*' to apply the same rule to every pool, or name a specific pool.
pools:
'*':
status:
warning: 1 # Alert WARNING when pool is DEGRADED
critical: 2 # Alert CRITICAL when pool is SUSPENDED/FAULTED/UNAVAIL
operator: ">"
hysteresis: 0.0 # No hysteresis — a degraded pool is always critical
display: "ZFS pool {pool_name} is {health}"
# Per-pool capacity thresholds (optional; add pools you care about)
# tank:
# capacity:
# warning: 75.0 # Warn at 75% used
# critical: 90.0 # Critical at 90% used
# operator: ">"
# hysteresis: 0.05
# ----------------------------------------------------------------------------
# Network Monitor Thresholds
# ----------------------------------------------------------------------------
+23 -1
View File
@@ -34,6 +34,9 @@ SERVER_DEFAULTS = {
"users": {}, # username -> {full_name, avatar, password, admin, notification_channels}
"default_owner": None, # Username that owns hosts with no explicit owner
# OAuth2 providers
"oauth": {}, # oauth.gitea.{url,client_id,client_secret}
# Host management
"hosts": {}, # Unified host definitions
"dyndnshosts": [], # Hosts with dynamic DNS (legacy)
@@ -95,7 +98,26 @@ THRESHOLD_DEFAULTS = {
'warning': 200,
'critical': 250.0,
'count': 3 # Optional: number of consecutive breaches before alerting
},
'nagios_runner': {
'status_code': {
'display': '{check_name} {output}',
'operator': "nagios"
}
},
'zfs_monitor': {
'pools': {
'*': {
'status': {
'warning': 1,
'critical': 2,
'operator': '>',
'hysteresis': 0.0,
'display': 'ZFS pool {pool_name} is {health}'
}
}
}
},
}
}
@@ -303,7 +325,7 @@ def get_host_access(config, hostname) -> dict:
"""
host_cfg = get_host_config(config, hostname)
owner = host_cfg.get("owner") or get_default_owner(config)
owner = host_cfg.get("owner") # or get_default_owner(config)
managers = host_cfg.get("managers", [])
if isinstance(managers, str):
+70 -2
View File
@@ -16,6 +16,7 @@ from . import data
from . import notify as notify_mod
from . import settings as settings_mod
from . import users as users_mod
from . import oauth as oauth_mod
from . import ws as ws_mod
logger = logging.getLogger(__name__)
@@ -538,6 +539,7 @@ async def start(
"name": hostname,
"plugins": list(host.plugin_data.keys()),
"is_owner": _can_own_host(current_user, host),
"owner": host.owner,
})
tmpl = env.get_template("plugins.html")
@@ -620,6 +622,18 @@ async def start(
)
raise resp
error = "Invalid username or password."
elif request.rel_url.query.get("error"):
error = "Sign-in failed. Please try again."
gitea_button = ""
if oauth_mod.is_enabled(config):
logo_url = config.get("oauth", {}).get("gitea", {}).get("logo", "")
logo_img = f'<img src="{logo_url}" alt="" class="gitea-logo">' if logo_url else ""
gitea_button = f"""
<div class="divider">or</div>
<a href="/login/oauth/gitea" class="gitea-btn">
{logo_img}Sign in with Gitea
</a>"""
html = f"""<!DOCTYPE html>
<html>
@@ -640,6 +654,14 @@ async def start(
button:hover {{ background: #0055aa; }}
.error {{ color: #c00; font-size: .9em; margin-bottom: .8em; }}
.field {{ margin-bottom: .9em; }}
.divider {{ text-align: center; margin: 1.2em 0 .8em; color: #999;
font-size: .85em; border-top: 1px solid #eee; padding-top: .8em; }}
.gitea-btn {{ display: flex; align-items: center; justify-content: center;
gap: .5em; width: 100%; padding: .6em; background: #16191d;
color: #fff; border-radius: 4px; font-size: 1em; text-align: center;
text-decoration: none; box-sizing: border-box; }}
.gitea-btn:hover {{ background: #4e7d1e; }}
.gitea-logo {{ height: 1.2em; width: auto; vertical-align: middle; }}
</style>
</head>
<body>
@@ -650,7 +672,7 @@ async def start(
<div class="field"><label>Username</label><input name="username" autofocus></div>
<div class="field"><label>Password</label><input name="password" type="password"></div>
<button type="submit">Sign in</button>
</form>
</form>{gitea_button}
</div>
</body>
</html>"""
@@ -890,12 +912,56 @@ async def start(
tmpl = env.get_template("settings.html")
body = tmpl.render(
title="Settings - Heartbeat",
sections=settings_mod.get_settings_sections(config),
sections=settings_mod.get_settings_sections(config, threshold_checker=threshold_checker),
current_user=current_user.to_dict() if current_user else None,
active_page="settings",
)
return web.Response(text=body, content_type="text/html")
def _oauth_redirect_uri(request) -> str:
base = config.get("base_url", "").rstrip("/") or str(request.url.origin())
return f"{base}/login/oauth/gitea/callback"
async def oauth_gitea_redirect(request):
"""GET /login/oauth/gitea — kick off the Gitea OAuth2 flow."""
if not oauth_mod.is_enabled(config):
return web.Response(status=404, text="OAuth not configured")
state = oauth_mod.make_state()
raise web.HTTPFound(oauth_mod.authorization_url(config, state, _oauth_redirect_uri(request)))
async def oauth_gitea_callback(request):
"""GET /login/oauth/gitea/callback — handle Gitea's redirect back."""
if not oauth_mod.is_enabled(config):
return web.Response(status=404, text="OAuth not configured")
code = request.rel_url.query.get("code", "")
state = request.rel_url.query.get("state", "")
if not code or not state:
return web.Response(status=400, text="Missing code or state")
if not oauth_mod.validate_state(state):
logger.warning("OAuth: invalid or expired state token from %s", request.remote)
raise web.HTTPFound("/login?error=1")
try:
token = await oauth_mod.exchange_code(config, code, _oauth_redirect_uri(request))
profile = await oauth_mod.fetch_user(config, token)
except oauth_mod.OAuthError as exc:
logger.warning("OAuth error: %s", exc)
raise web.HTTPFound("/login?error=1")
user = users_mod.provision_oauth_user(
profile["login"],
profile["full_name"],
profile["avatar_url"],
)
session_token = users_mod.create_session(user.username)
resp = web.HTTPFound("/")
resp.set_cookie(
SESSION_COOKIE,
session_token,
max_age=users_mod.SESSION_TTL,
httponly=True,
samesite="Lax",
)
raise resp
app = web.Application()
app.add_routes(
[
@@ -907,6 +973,8 @@ async def start(
web.get("/logout", web_logout),
web.post("/api/0/auth/login", api_login),
web.post("/api/0/auth/logout", api_logout),
web.get("/login/oauth/gitea", oauth_gitea_redirect),
web.get("/login/oauth/gitea/callback", oauth_gitea_callback),
# Users
web.get("/api/0/users", api_users),
web.get("/api/0/users/me", api_user_self),
+3
View File
@@ -255,6 +255,7 @@ async def _run_async(config, config_path=None):
config=config,
hbdclass=hbdclass,
tcss=None,
threshold_checker=threshold_checker,
verbose=config.get("verbose", False),
get_now=lambda: time.time(),
VER="",
@@ -474,6 +475,8 @@ def run(config, config_path=None):
if config.get("debug", 0) > 0:
log_level = logging.DEBUG
logging.basicConfig(level=log_level)
if not config.get("debug", 0):
logging.getLogger("aiohttp.access").propagate = False
load_pickled_hosts(config, hbdclass)
notify_mod.initlog(logfile=config.get("logfile", "messages.log"))
+14 -5
View File
@@ -106,11 +106,18 @@ def closelog():
def eventlog(host, lvl, m, service=None):
ts = time.time()
msg = {
"ts": ts,
"host": host or None,
"level": lvl,
"service": service,
"message": m,
}
data.msgs.append(msg)
s = f"{time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))} {lvl} "
if host:
s += f"{host} "
s += m
data.msgs.append(s)
logger.info(s)
if logf:
try:
@@ -118,7 +125,7 @@ def eventlog(host, lvl, m, service=None):
logf.flush()
except Exception as e:
logger.warning("failed to write to logfile: %s", e)
msg_to_websockets("message", s)
msg_to_websockets("message", msg)
# ---------------------------------------------------------------------------
@@ -134,9 +141,11 @@ def _send_pushover(channel_cfg: dict, notif: Notification) -> bool:
logger.warning("pushover: missing token or user")
return False
params: dict = {"token": token, "user": user, "title": notif.title, "message": notif.body}
if channel_cfg.get("sound"):
params["sound"] = channel_cfg["sound"]
if notif.url:
params["url"] = notif.url
params["url_title"] = "Plugin metrics"
params["url_title"] = "Heartbeat"
conn = http.client.HTTPSConnection("api.pushover.net:443")
try:
conn.request(
@@ -209,7 +218,7 @@ def _send_mattermost(channel_cfg: dict, notif: Notification) -> bool:
return False
text = f"**{notif.title}**\n{notif.body}"
if notif.url:
text += f"\n[Plugin metrics]({notif.url})"
text += f"\n[Plugin metrics] {notif.url}"
ses = {"url": host, "scheme": "http", "basepath": "/api/v4", "port": 8065}
mm = Driver(ses)
payload: dict = {"text": text, "channel": channel, "username": channel_cfg.get("username", "hbd")}
@@ -392,7 +401,7 @@ def _build_url(host_name: str) -> str:
base_url = _config.get("base_url", "").rstrip("/")
if not base_url:
return ""
return f"{base_url}/plugins#{host_name}"
return f"{base_url}/alerts?filter={host_name}"
async def send_notification(host_name: str, notif: Notification) -> dict:
+142
View File
@@ -0,0 +1,142 @@
"""Gitea OAuth2 support.
Config shape (in ~/.hb.yaml):
oauth:
gitea:
url: https://git.example.com
client_id: <client-id>
client_secret: <client-secret>
Register a Gitea OAuth2 application at:
Gitea → Settings → Applications → OAuth2
Set the redirect URI to:
https://<hbd-host>/login/oauth/gitea/callback
"""
import logging
import secrets
import time
import urllib.parse
import aiohttp
logger = logging.getLogger(__name__)
STATE_TTL = 600 # 10 minutes
# state_token -> expiry timestamp
_states: dict[str, float] = {}
def make_state() -> str:
"""Generate a CSRF state token, store it with TTL, and return it."""
_purge_states()
token = secrets.token_hex(32)
_states[token] = time.time() + STATE_TTL
return token
def validate_state(state: str) -> bool:
"""Return True if *state* is known and unexpired; always removes it."""
expiry = _states.pop(state, None)
if expiry is None:
return False
return time.time() < expiry
def _purge_states() -> None:
"""Remove all expired CSRF state tokens from the in-memory store."""
now = time.time()
expired = [k for k, exp in list(_states.items()) if exp < now]
for k in expired:
del _states[k]
class OAuthError(Exception):
"""Raised when the OAuth2 flow fails for any reason."""
def _gitea_cfg(config: dict) -> dict:
"""Return the gitea sub-dict or {} if absent/incomplete."""
return config.get("oauth", {}).get("gitea", {})
def is_enabled(config: dict) -> bool:
"""Return True when all three required Gitea OAuth keys are present."""
g = _gitea_cfg(config)
return bool(g.get("url") and g.get("client_id") and g.get("client_secret"))
def authorization_url(config: dict, state: str, redirect_uri: str) -> str:
"""Return the Gitea OAuth2 authorization URL to redirect the browser to."""
g = _gitea_cfg(config)
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
raise OAuthError("Gitea OAuth2 is not configured")
params = urllib.parse.urlencode({
"client_id": g["client_id"],
"redirect_uri": redirect_uri,
"response_type": "code",
"scope": "user:email",
"state": state,
})
return f"{g['url'].rstrip('/')}/login/oauth/authorize?{params}"
async def exchange_code(config: dict, code: str, redirect_uri: str) -> str:
"""Exchange an authorization *code* for a Gitea access token.
Returns the access token string. Raises OAuthError on any failure.
"""
g = _gitea_cfg(config)
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
raise OAuthError("Gitea OAuth2 is not configured")
url = f"{g['url'].rstrip('/')}/login/oauth/access_token"
payload = {
"client_id": g["client_id"],
"client_secret": g["client_secret"],
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
}
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, json=payload, headers={"Accept": "application/json"}) as resp:
if resp.status != 200:
text = await resp.text()
raise OAuthError(f"Token exchange failed ({resp.status}): {text}")
data = await resp.json()
token = data.get("access_token")
if not token:
raise OAuthError(f"No access_token in response: {data}")
except aiohttp.ClientError as exc:
raise OAuthError(f"Token exchange network error: {exc}") from exc
return token
async def fetch_user(config: dict, token: str) -> dict:
"""Fetch the authenticated user's profile from Gitea.
Returns a dict with keys: login, full_name, avatar_url.
Raises OAuthError on any failure.
"""
g = _gitea_cfg(config)
if not (g.get("url") and g.get("client_id") and g.get("client_secret")):
raise OAuthError("Gitea OAuth2 is not configured")
url = f"{g['url'].rstrip('/')}/api/v1/user"
timeout = aiohttp.ClientTimeout(total=10)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(url, headers={"Authorization": f"token {token}"}) as resp:
if resp.status != 200:
text = await resp.text()
raise OAuthError(f"User fetch failed ({resp.status}): {text}")
data = await resp.json()
except aiohttp.ClientError as exc:
raise OAuthError(f"User fetch network error: {exc}") from exc
return {
"login": data.get("login", ""),
"full_name": data.get("full_name", ""),
"avatar_url": data.get("avatar_url", ""),
}
+30 -37
View File
@@ -88,7 +88,7 @@ def _sanitize_channel(name, cfg):
# Public API
# ---------------------------------------------------------------------------
def get_settings_sections(config: dict) -> list:
def get_settings_sections(config: dict, threshold_checker=None) -> list:
"""Return ordered list of setting sections for the settings page.
Each section:
@@ -182,46 +182,39 @@ def get_settings_sections(config: dict) -> list:
})
# ---- Threshold configurations -----------------------------------------
def _parse_metric_row(metric_path, metric_cfg):
if not isinstance(metric_cfg, dict):
return None
def _tc_to_row(tc):
return {
"metric": metric_path,
"operator": metric_cfg.get("operator", ">"),
"warning": metric_cfg.get("warning"),
"critical": metric_cfg.get("critical"),
"hysteresis": metric_cfg.get("hysteresis"),
"count": metric_cfg.get("count", 1),
"enabled": metric_cfg.get("enabled", True),
"metric": tc.metric_path,
"operator": tc.operator.value,
"warning": tc.warning,
"critical": tc.critical,
"hysteresis": tc.hysteresis,
"count": tc.count,
"enabled": tc.enabled,
}
threshold_config_list = []
raw_tconfigs = config.get("threshold_configs") or {}
if raw_tconfigs:
for cfg_name, cfg_data in sorted(raw_tconfigs.items()):
if not isinstance(cfg_data, dict):
continue
metrics = [
r for r in (
_parse_metric_row(mp, mc)
for mp, mc in (cfg_data.get("thresholds") or {}).items()
) if r
]
threshold_config_list.append({
"name": cfg_name,
"metrics": sorted(metrics, key=lambda m: m["metric"]),
})
elif config.get("thresholds"):
metrics = [
r for r in (
_parse_metric_row(mp, mc)
for mp, mc in config["thresholds"].items()
) if r
]
threshold_config_list.append({
"name": "default",
"metrics": sorted(metrics, key=lambda m: m["metric"]),
})
if threshold_checker is not None:
if threshold_checker.threshold_configs:
for cfg_name, cfg_metrics in sorted(threshold_checker.threshold_configs.items()):
# For the default config use the merged effective set;
# for named overrides use only the explicitly defined metrics
# (threshold_raw_configs) so inherited defaults are not repeated.
if cfg_name == "default":
display_metrics = cfg_metrics
else:
display_metrics = threshold_checker.threshold_raw_configs.get(cfg_name, cfg_metrics)
metrics = sorted(
[_tc_to_row(tc) for tc in display_metrics.values()],
key=lambda m: m["metric"],
)
threshold_config_list.append({"name": cfg_name, "metrics": metrics})
elif threshold_checker.thresholds:
metrics = sorted(
[_tc_to_row(tc) for tc in threshold_checker.thresholds.values()],
key=lambda m: m["metric"],
)
threshold_config_list.append({"name": "default", "metrics": metrics})
# ---- Hosts summary ----------------------------------------------------
hosts_list = []
+66 -8
View File
@@ -4,7 +4,7 @@
<style>
body {
html, body {
height: auto;
overflow-y: auto;
}
@@ -94,6 +94,24 @@
border-color: #2196f3;
}
.filter-input {
padding: 7px 12px;
border: 2px solid #ddd;
border-radius: 20px;
font-size: 0.9em;
outline: none;
width: 200px;
transition: border-color 0.2s;
}
.filter-input:focus {
border-color: #2196f3;
}
.filter-input.invalid {
border-color: #f44336;
}
.alerts-container {
background: white;
border-radius: 8px;
@@ -175,14 +193,18 @@
.alert-hostname {
font-weight: bold;
color: #333;
color: #0066cc;
font-size: 1.1em;
text-decoration: none;
}
.alert-hostname:hover {
text-decoration: underline;
}
.alert-metric {
color: #666;
font-family: 'Courier New', monospace;
font-size: 0.9em;
color: #0066cc;
font-size: 1.1em;
font-weight: normal;
}
.alert-details {
@@ -312,6 +334,7 @@
<button class="filter-button active" onclick="filterAlerts('all')">All</button>
<button class="filter-button" onclick="filterAlerts('critical')">Critical Only</button>
<button class="filter-button" onclick="filterAlerts('warning')">Warning Only</button>
<input id="host-filter" class="filter-input" type="text" placeholder="host filter (regex)" oninput="onHostFilterInput(this)">
</div>
<div class="alerts-container">
@@ -328,6 +351,7 @@
<script>
let currentFilter = 'all';
let allAlerts = [];
let hostFilterRe = null;
async function loadAlerts() {
try {
@@ -362,10 +386,13 @@
// Filter alerts based on current filter
let filteredAlerts = alerts;
if (currentFilter !== 'all') {
filteredAlerts = alerts.filter(alert =>
filteredAlerts = filteredAlerts.filter(alert =>
alert.level.toLowerCase() === currentFilter
);
}
if (hostFilterRe) {
filteredAlerts = filteredAlerts.filter(alert => hostFilterRe.test(alert.hostname));
}
if (filteredAlerts.length === 0) {
if (currentFilter === 'all' && alerts.length === 0) {
@@ -405,6 +432,10 @@
} else if (alert.threshold_value !== undefined && alert.threshold_value !== null && alert.operator) {
valueText += ` <span class="threshold-info">(threshold: ${alert.operator} ${formatValue(alert.threshold_value)})</span>`;
}
if (alert.recovery_threshold !== undefined && alert.recovery_threshold !== null) {
const recOp = (alert.operator === '>' || alert.operator === '>=') ? '<' : '>';
valueText += ` <span class="threshold-info" style="color:#888">(recovers ${recOp} ${formatValue(alert.recovery_threshold)})</span>`;
}
// Build actions section
let actionsHtml = '';
@@ -429,9 +460,9 @@
<div class="alert-main">
<div class="alert-header">
<span class="alert-level ${level}">${alert.level}</span>
<span class="alert-hostname">${alert.hostname}</span>
<a class="alert-hostname" href="/plugins#${alert.hostname}">${alert.hostname}</a>
<span class="alert-metric">${(alert.metric_path.includes('.') ? alert.metric_path.slice(alert.metric_path.indexOf('.') + 1) : alert.metric_path).replace(/_status_code$/, '')}</span>
</div>
<div class="alert-metric">${alert.metric_path}</div>
<div class="alert-details">
<span>${valueText}</span>
<span class="alert-duration">Active for ${duration}</span>
@@ -530,9 +561,36 @@
}
}
function onHostFilterInput(input) {
const val = input.value.trim();
if (!val) {
hostFilterRe = null;
input.classList.remove('invalid');
} else {
try {
hostFilterRe = new RegExp(val, 'i');
input.classList.remove('invalid');
} catch (_) {
hostFilterRe = null;
input.classList.add('invalid');
}
}
renderAlerts(allAlerts);
}
// Auto-refresh every 15 seconds
setInterval(loadAlerts, 15000);
// Initialise filter from URL query string (?filter=...)
(function () {
const param = new URLSearchParams(window.location.search).get('filter');
if (param) {
const input = document.getElementById('host-filter');
input.value = param;
onHostFilterInput(input);
}
})();
// Initial load
loadAlerts();
</script>
+1 -1
View File
@@ -214,7 +214,7 @@
ctx.restore();
}
hand((m + s / 60) / 60 * Math.PI * 2 - Math.PI / 2,
hand((sFrac >= 58.5 ? m + 1 : m) / 60 * Math.PI * 2 - Math.PI / 2,
R * 0.88, -R * 0.12, SIZE * 0.027, '#222'); /* minute */
hand((h + m / 60) / 12 * Math.PI * 2 - Math.PI / 2,
R * 0.58, -R * 0.12, SIZE * 0.039, '#222'); /* hour */
+28 -2
View File
@@ -183,11 +183,24 @@
line-height: 1.0;
}
#messages div {
#messages .log-entry {
padding: 5px 0;
border-bottom: 1px solid #f0f0f0;
display: flex;
gap: 0.5em;
align-items: baseline;
}
.log-ts { color: #888; white-space: nowrap; }
.log-level { font-weight: bold; min-width: 6em; }
.log-host { font-weight: 600; }
.log-service { color: #888; }
.log-warning .log-level { color: #b8860b; }
.log-critical .log-level { color: #c00; }
.log-recover .log-level { color: #2a7a2a; }
.log-info .log-level { color: #555; }
/* Modal for connection status messages */
.connection-modal {
display: none;
@@ -460,7 +473,20 @@
update_table(state.data);
} else if (state.type == "message") {
var msgs = document.getElementById("messages");
msgs.insertAdjacentHTML("afterbegin", "<div>" + state.data + "</div>");
var msg = state.data;
var _d = new Date(msg.ts * 1000);
function _p(n) { return n < 10 ? '0' + n : '' + n; }
var ts_str = _d.getFullYear() + '-' + _p(_d.getMonth()+1) + '-' + _p(_d.getDate())
+ ' ' + _p(_d.getHours()) + ':' + _p(_d.getMinutes()) + ':' + _p(_d.getSeconds());
var lvl = (msg.level || "INFO").toLowerCase();
var html = '<div class="log-entry log-' + lvl + '">';
html += '<span class="log-ts">' + ts_str + '</span>';
html += '<span class="log-level">' + (msg.level || "") + '</span>';
if (msg.host) html += '<span class="log-host">' + msg.host + '</span>';
if (msg.service) html += '<span class="log-service">' + msg.service + '</span>';
html += '<span class="log-msg">' + msg.message + '</span>';
html += '</div>';
msgs.insertAdjacentHTML("afterbegin", html);
}
cnt++;
};
+101 -15
View File
@@ -152,6 +152,31 @@
}
.host-action-btn.delete-btn:hover { background: #ffcdd2; }
/* ── Action result toast ───────────────────────────────────── */
#action-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: #323232;
color: #fff;
padding: 12px 22px;
border-radius: 6px;
font-size: 0.9em;
max-width: 480px;
text-align: center;
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
z-index: 9000;
white-space: pre-wrap;
}
#action-toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
#action-toast.error { background: #c62828; }
/* ── Host body ──────────────────────────────────────────────── */
.host-body {
@@ -391,7 +416,8 @@
<span class="host-name">{{ host.name }}</span>
</div>
<div class="glance-strip" id="glance-{{ host.name }}">
<div class="glance-strip" id="glance-{{ host.name }}" data-owner="{{ host.owner or '' }}">
{% if current_user and current_user.admin and host.owner %}<span class="glance-chip neutral">{{ host.owner }}</span>{% endif %}
<span class="glance-loading"></span>
</div>
@@ -401,12 +427,10 @@
{% endif %}
<span class="os-label" id="os-label-{{ host.name }}"></span>
{% if host.is_owner %}
<a class="host-action-btn update-btn"
href="/u?h={{ host.name }}"
onclick="event.stopPropagation()">Update</a>
<a class="host-action-btn delete-btn"
href="/d?h={{ host.name }}"
onclick="event.stopPropagation(); return confirm('Delete host {{ host.name }}?')">Delete</a>
<button class="host-action-btn update-btn"
onclick="event.stopPropagation(); hostAction(this, '/u?h={{ host.name }}')">Update</button>
<button class="host-action-btn delete-btn"
onclick="event.stopPropagation(); hostDelete(this, '{{ host.name }}')">Delete</button>
{% endif %}
</div>
</div>
@@ -457,6 +481,7 @@
const GLANCE_PLUGINS = ['cpu_monitor','memory_monitor','disk_monitor',
'network_monitor','nagios_runner','os_info'];
const SKIP_FIELDS = new Set(['id','name']);
const CURRENT_USER_ADMIN = {{ 'true' if current_user and current_user.admin else 'false' }};
// ── Cache ───────────────────────────────────────────────────────────────
@@ -476,6 +501,17 @@
return pluginCache[hostname]?.[pluginName] ?? null;
}
// Return worst nagios exit code (0-3) found in a nagios_runner data object.
function nagiosWorstStatus(data) {
let worst = 0;
for (const [k, v] of Object.entries(data || {})) {
if (k.endsWith('_status_code') && typeof v === 'number' && v > worst) {
worst = v;
}
}
return worst;
}
// ── Fetch helpers ───────────────────────────────────────────────────────
async function fetchPlugin(hostname, pluginName) {
@@ -524,6 +560,12 @@
const chips = [];
// Owner (admin only, static from server)
const owner = strip.dataset.owner;
if (CURRENT_USER_ADMIN && owner) {
chips.push(`<span class="glance-chip neutral">${owner}</span>`);
}
// CPU
const cpu = getCache(hostname, 'cpu_monitor');
if (cpu) {
@@ -577,13 +619,13 @@
? chips.join('')
: '<span class="glance-loading">—</span>';
// Nagios badge
// Nagios badge — derive worst status from individual check codes
const nagios = getCache(hostname, 'nagios_runner');
if (nagosBadge && nagios) {
const status = (nagios.data.overall_status || '—').toUpperCase();
const cls = status === 'OK' ? 'ok'
: status === 'WARNING' ? 'warning'
: status === 'CRITICAL' ? 'critical' : '';
const worst = nagiosWorstStatus(nagios.data);
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
const status = names[worst] || '—';
const cls = worst === 0 ? 'ok' : worst === 1 ? 'warning' : worst >= 2 ? 'critical' : '';
nagosBadge.className = `nagios-badge ${cls}`;
nagosBadge.textContent = status;
}
@@ -692,9 +734,10 @@
break;
}
case 'nagios_runner': {
const status = (d.overall_status || '?').toUpperCase();
const count = d.plugin_count;
text = status + (count != null ? `${count} checks` : '');
const worst = nagiosWorstStatus(d);
const names = {0:'OK', 1:'WARNING', 2:'CRITICAL', 3:'UNKNOWN'};
const codes = Object.keys(d).filter(k => k.endsWith('_status_code'));
text = (names[worst] || '?') + (codes.length ? `${codes.length} checks` : '');
break;
}
case 'filesystem_info': {
@@ -1204,6 +1247,49 @@
fetchHostGlance(first.dataset.hostname);
}
});
// ── Host action helpers ──────────────────────────────────────
let _toastTimer = null;
function showToast(msg, isError) {
const t = document.getElementById('action-toast');
t.textContent = msg;
t.classList.toggle('error', !!isError);
t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 4000);
}
async function hostAction(btn, url) {
btn.disabled = true;
try {
const res = await fetch(url);
const text = await res.text();
showToast(text, !res.ok);
} catch (e) {
showToast('Request failed: ' + e.message, true);
} finally {
btn.disabled = false;
}
}
async function hostDelete(btn, hostname) {
if (!confirm('Delete host ' + hostname + '?')) return;
btn.disabled = true;
try {
const res = await fetch('/d?h=' + encodeURIComponent(hostname));
const text = await res.text();
showToast(text, !res.ok);
if (res.ok) {
const card = document.querySelector(`.host-card[data-hostname="${hostname}"]`);
if (card) card.remove();
}
} catch (e) {
showToast('Request failed: ' + e.message, true);
btn.disabled = false;
}
}
</script>
<div id="action-toast"></div>
</body>
</html>
+278 -69
View File
@@ -36,6 +36,7 @@ class ComparisonOperator(Enum):
LTE = "<=" # Less than or equal
EQ = "==" # Equal to
NEQ = "!=" # Not equal to
NAGIOS = "nagios" # Nagios exit-code semantics: 0=OK 1=WARNING 2=CRITICAL 3=UNKNOWN
class AlertState:
@@ -57,6 +58,7 @@ class AlertState:
self.last_notification = None
self.threshold_value = None # The threshold value that triggered alert
self.operator = None # The comparison operator (>, <, >=, etc.)
self.hysteresis: Optional[float] = None # Hysteresis fraction used for recovery
self.formatted_message = None # Formatted display message for UI
self.acknowledged = False # Whether alert has been acknowledged
self.acknowledged_at = None # Timestamp when acknowledged
@@ -152,6 +154,15 @@ class AlertState:
if self.formatted_message is not None:
result["formatted_message"] = self.formatted_message
# Compute and expose the recovery threshold so the UI can display it
if (self.hysteresis and self.threshold_value is not None
and self.operator is not None):
ha = abs(self.threshold_value * self.hysteresis)
if self.operator in ('>', '>='):
result["recovery_threshold"] = round(self.threshold_value - ha, 4)
elif self.operator in ('<', '<='):
result["recovery_threshold"] = round(self.threshold_value + ha, 4)
return result
def __setstate__(self, state):
@@ -159,6 +170,8 @@ class AlertState:
self.__dict__.update(state)
if not hasattr(self, 'consecutive_count'):
self.consecutive_count = 0
if not hasattr(self, 'hysteresis'):
self.hysteresis = None
def acknowledge(self):
"""Acknowledge this alert to stop reminder notifications."""
@@ -227,6 +240,16 @@ class ThresholdConfig:
if not self.enabled:
return AlertLevel.OK
# Nagios exit-code semantics: value IS the severity
if self.operator == ComparisonOperator.NAGIOS:
try:
code = int(value)
except (TypeError, ValueError):
return AlertLevel.UNKNOWN
return {0: AlertLevel.OK, 1: AlertLevel.WARNING, 2: AlertLevel.CRITICAL}.get(
code, AlertLevel.UNKNOWN
)
try:
# Convert value to float for comparison
value = float(value)
@@ -263,6 +286,10 @@ class ThresholdConfig:
"""
new_level = self.evaluate(value)
# Nagios exit codes are discrete integers — hysteresis doesn't apply
if self.operator == ComparisonOperator.NAGIOS:
return new_level
# If no hysteresis, return new level
if self.hysteresis == 0.0:
return new_level
@@ -396,10 +423,24 @@ class ThresholdChecker:
Supports two formats:
1. Legacy format with direct 'thresholds' section
2. New format with 'threshold_configs' and 'host_threshold_mapping'
In all cases, THRESHOLD_DEFAULTS are seeded into threshold_configs["default"]
so the Settings page always shows the built-in defaults.
_parse_multi_config() overwrites this with the fully-merged effective defaults.
"""
# Always expose built-in defaults through threshold_configs["default"] so
# the Settings page has something to display even in legacy/no-config mode.
seed: Dict[str, ThresholdConfig] = {}
for plugin_name, plugin_thresholds in THRESHOLD_DEFAULTS.get("thresholds", {}).items():
if isinstance(plugin_thresholds, dict):
self._parse_plugin_thresholds(plugin_name, plugin_thresholds, target_dict=seed)
if seed:
self.threshold_configs["default"] = seed
self.threshold_raw_configs["default"] = {}
# Check for new multi-config format
if "threshold_configs" in config:
self._parse_multi_config(config)
self._parse_multi_config(config) # overwrites threshold_configs["default"]
elif "thresholds" in config:
# Legacy single threshold configuration
self._parse_legacy_config(config)
@@ -534,10 +575,13 @@ class ThresholdChecker:
if not isinstance(threshold_config, dict):
continue
# Handle nested metrics (e.g., partitions./.percent)
# Handle nested metrics (e.g., partitions./.percent or pools.*.status)
if metric_name == "partitions":
self._parse_partition_thresholds(plugin_name, threshold_config, target_dict)
continue
if metric_name == "pools":
self._parse_pool_thresholds(plugin_name, threshold_config, target_dict)
continue
metric_path = f"{plugin_name}.{metric_name}"
@@ -545,11 +589,14 @@ class ThresholdChecker:
warning = threshold_config.get("warning")
critical = threshold_config.get("critical")
operator = threshold_config.get("operator", ">")
display = threshold_config.get("display", "(threshold: {op_symbol} {threshold_value})")
hysteresis = threshold_config.get("hysteresis", 0.1) # 10% default
# Nagios operator maps exit codes directly; no numeric thresholds needed
is_nagios_op = (operator == "nagios")
default_display = "{check_name}: {output}" if is_nagios_op else "(threshold: {op_symbol} {threshold_value})"
display = threshold_config.get("display", default_display)
hysteresis = threshold_config.get("hysteresis", 0.0 if is_nagios_op else 0.02)
enabled = threshold_config.get("enabled", True)
if warning is None and critical is None:
if warning is None and critical is None and not is_nagios_op:
logger.warning("No thresholds defined for %s, skipping", metric_path)
continue
@@ -620,6 +667,56 @@ class ThresholdChecker:
target_dict[metric_path] = threshold
def _parse_pool_thresholds(
self,
plugin_name: str,
pools: Dict[str, Any],
target_dict: Optional[Dict[str, ThresholdConfig]] = None,
):
"""Parse ZFS pool thresholds. Pool names may be literal or '*' (all pools).
Config shape::
zfs_monitor:
pools:
'*':
status:
warning: 1
critical: 2
operator: '>'
tank:
capacity:
warning: 80
critical: 90
"""
if target_dict is None:
target_dict = self.thresholds
for pool_name, metrics in pools.items():
if not isinstance(metrics, dict):
continue
for metric_name, threshold_config in metrics.items():
if not isinstance(threshold_config, dict):
continue
metric_path = f"{plugin_name}.{pool_name}.{metric_name}"
warning = threshold_config.get("warning")
critical = threshold_config.get("critical")
operator = threshold_config.get("operator", ">")
hysteresis = threshold_config.get("hysteresis", 0.02)
enabled = threshold_config.get("enabled", True)
display = threshold_config.get("display")
if warning is None and critical is None:
continue
target_dict[metric_path] = ThresholdConfig(
metric_path=metric_path,
warning=warning,
critical=critical,
operator=operator,
hysteresis=hysteresis,
enabled=enabled,
display=display,
)
def _parse_rtt_thresholds(
self,
rtt_thresholds: Dict[str, Any],
@@ -649,7 +746,7 @@ class ThresholdChecker:
warning = rtt_thresholds.get("warning")
critical = rtt_thresholds.get("critical")
operator = rtt_thresholds.get("operator", ">")
hysteresis = rtt_thresholds.get("hysteresis", 0.1) # 10% default
hysteresis = rtt_thresholds.get("hysteresis", 0.02) # 2% default
enabled = rtt_thresholds.get("enabled", True)
display = rtt_thresholds.get("display")
count = rtt_thresholds.get("count", 1)
@@ -794,6 +891,12 @@ class ThresholdChecker:
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning
# Keep hysteresis on the state so the UI can show the recovery threshold
if new_level != AlertLevel.OK:
alert_state.hysteresis = threshold.hysteresis
else:
alert_state.hysteresis = None
# Update state and check for changes
old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
@@ -805,26 +908,33 @@ class ThresholdChecker:
return None
def _find_threshold(
self, thresholds: Dict[str, "ThresholdConfig"], metric_path: str
) -> Optional["ThresholdConfig"]:
"""Return the threshold for *metric_path*, falling back to suffix matches.
) -> Tuple[Optional["ThresholdConfig"], Optional[str]]:
"""Return (threshold, check_name) for *metric_path*, falling back to suffix matches.
Allows generic thresholds like ``ping_monitor.rtt_avg`` to match
fully-qualified paths like ``ping_monitor.8_8_8_8_rtt_avg``.
Allows generic thresholds like ``nagios_runner.status_code`` to match
fully-qualified paths like ``nagios_runner.check_disk_root_status_code``.
The exact match is always tried first; then successive leading
underscore-delimited segments are stripped from the field name until
a match is found or no segments remain.
Returns:
(ThresholdConfig, None) for an exact match.
(ThresholdConfig, "check_disk_root") for a suffix match — the second
element is the stripped prefix, available as ``{check_name}`` in
display format templates.
(None, None) when no threshold is found.
"""
if metric_path in thresholds:
return thresholds[metric_path]
return thresholds[metric_path], None
plugin, sep, field = metric_path.partition(".")
if not sep:
return None
return None, None
parts = field.split("_")
for i in range(1, len(parts)):
candidate = plugin + "." + "_".join(parts[i:])
if candidate in thresholds:
return thresholds[candidate]
return None
return thresholds[candidate], "_".join(parts[:i])
return None, None
def check_plugin_data(
self,
@@ -854,7 +964,7 @@ class ThresholdChecker:
for metric_name, value in data.items():
metric_path = f"{plugin_name}.{metric_name}"
threshold = self._find_threshold(thresholds, metric_path)
threshold, check_name = self._find_threshold(thresholds, metric_path)
if threshold is None:
continue
@@ -877,13 +987,15 @@ class ThresholdChecker:
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
# Update state and check for changes
old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
state_changes.append((metric_path, old_level, new_level, value))
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data)
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, data, check_name=check_name, metric_name=metric_name)
elif new_level != AlertLevel.OK:
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data)
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, data, check_name=check_name, metric_name=metric_name)
# Check nested metrics (e.g., partition data in disk_monitor)
self._check_nested_metrics(
@@ -908,6 +1020,44 @@ class ThresholdChecker:
# Get host-specific thresholds
thresholds = self.get_thresholds_for_host(host_name)
# ZFS pool health checks
if plugin_name == "zfs_monitor" and "pools" in data:
pools = data["pools"]
if isinstance(pools, dict):
for pool_name, pool_metrics in pools.items():
if not isinstance(pool_metrics, dict):
continue
# Synthesize status from health string for older clients
# that predate the status field.
pool_metrics_effective = dict(pool_metrics)
if "health" in pool_metrics and "status" not in pool_metrics:
pool_metrics_effective["status"] = 0 if pool_metrics["health"] == "ONLINE" else 1
for metric_name, value in pool_metrics_effective.items():
# Try specific pool name first, then wildcard '*'
metric_path = f"{plugin_name}.{pool_name}.{metric_name}"
wildcard_path = f"{plugin_name}.*.{metric_name}"
threshold = thresholds.get(metric_path) or thresholds.get(wildcard_path)
if threshold is None:
continue
if metric_path not in alert_states:
alert_states[metric_path] = AlertState(metric_path)
alert_state = alert_states[metric_path]
new_level = threshold.evaluate_with_hysteresis(value, alert_state.level)
threshold_value = None
if new_level == AlertLevel.CRITICAL and threshold.critical is not None:
threshold_value = threshold.critical
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
pool_context = dict(pool_metrics_effective)
pool_context["pool_name"] = pool_name
old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
state_changes.append((metric_path, old_level, new_level, value))
self._apply_grace(host_name, alert_state, metric_path, old_level, new_level, value, threshold, pool_context, metric_name=pool_name)
elif new_level != AlertLevel.OK:
self._check_pending_or_renotify(host_name, alert_state, metric_path, value, threshold, pool_context, metric_name=pool_name)
# Look for partition data in disk_monitor
if plugin_name == "disk_monitor" and "partitions" in data:
partitions = data["partitions"]
@@ -943,6 +1093,8 @@ class ThresholdChecker:
elif new_level == AlertLevel.WARNING and threshold.warning is not None:
threshold_value = threshold.warning
alert_state.hysteresis = threshold.hysteresis if new_level != AlertLevel.OK else None
old_level = alert_state.level
if alert_state.update(new_level, value, threshold_value, threshold.operator.value):
state_changes.append((metric_path, old_level, new_level, value))
@@ -959,6 +1111,8 @@ class ThresholdChecker:
value: Any,
threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]] = None,
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
):
"""Trigger a notification for an alert state change.
@@ -981,54 +1135,52 @@ class ThresholdChecker:
# Format operator symbol
op_symbol = threshold.operator.value
# Short metric label: strip the plugin-name prefix and _status_code suffix
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
# Use a display-friendly value (inf is the sentinel for "overdue")
import math
display_value = "overdue" if isinstance(value, float) and math.isinf(value) else value
# Format message
# Format message — for the nagios operator there is no numeric threshold_value;
# render the display template whenever one is available.
has_display = threshold_value is not None or threshold.operator == ComparisonOperator.NAGIOS
def _fmt():
return self._format_display(
threshold.display,
value=display_value,
threshold_value=threshold_value,
op_symbol=op_symbol,
plugin_data=plugin_data,
check_name=check_name,
metric_name=metric_name,
)
if new_level == AlertLevel.OK:
lvl = "RECOVER"
message = f"{metric_path} = {display_value} ({old_level.name} -> OK)"
message = f"{short_path} = {display_value} ({old_level.name} -> OK)"
elif new_level == AlertLevel.WARNING:
lvl = "WARNING"
if threshold_value is not None:
threshold_info = self._format_display(
threshold.display,
value=display_value,
threshold_value=threshold_value,
op_symbol=op_symbol,
plugin_data=plugin_data
)
message = f"{metric_path} = {display_value} {threshold_info}"
if has_display:
message = f"{short_path} = {display_value} {_fmt()}"
else:
message = f"{metric_path} = {display_value}"
message = f"{short_path} = {display_value}"
elif new_level == AlertLevel.CRITICAL:
lvl = "CRITICAL"
if threshold_value is not None:
threshold_info = self._format_display(
threshold.display,
value=display_value,
threshold_value=threshold_value,
op_symbol=op_symbol,
plugin_data=plugin_data
)
message = f"{metric_path} = {display_value} {threshold_info}"
if has_display:
message = f"{short_path} = {display_value} {_fmt()}"
else:
message = f"{metric_path} = {display_value}"
message = f"{short_path} = {display_value}"
else:
lvl = "UNKNOWN"
message = f"{metric_path} = {display_value}"
if has_display:
message = f"{short_path} = {display_value} {_fmt()}"
else:
message = f"{short_path} = {display_value}"
# Return the formatted threshold info for storing in AlertState
formatted_threshold_msg = None
if threshold_value is not None and new_level != AlertLevel.OK:
formatted_threshold_msg = self._format_display(
threshold.display,
value=display_value,
threshold_value=threshold_value,
op_symbol=op_symbol,
plugin_data=plugin_data
)
# Formatted threshold info stored on AlertState for the UI
formatted_threshold_msg = _fmt() if has_display and new_level != AlertLevel.OK else None
return lvl, message, formatted_threshold_msg
@@ -1048,11 +1200,16 @@ class ThresholdChecker:
if host is not None and not host.watched:
eventlog(host_name, lvl, message, service="threshold")
return
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
title = f"[{lvl}] {host_name} {short_path}"
# Strip the "metric = " prefix from message so body is just the value/detail
prefix = short_path + " = "
body = message[len(prefix):] if message.startswith(prefix) else message
asyncio.get_event_loop().create_task(notify_mod.send_notification(
host_name,
notify_mod.Notification(
title=f"[{lvl}] {host_name}",
body=message,
title=title,
body=body,
level=lvl,
),
))
@@ -1077,33 +1234,62 @@ class ThresholdChecker:
self,
display_format: str,
value: Any,
threshold_value: float,
threshold_value: Optional[float],
op_symbol: str,
plugin_data: Optional[Dict[str, Any]] = None,
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
) -> str:
"""Format the display string using available data.
Args:
display_format: Format string from threshold config
value: Current metric value
threshold_value: Threshold value that was exceeded
op_symbol: Comparison operator symbol
plugin_data: Optional dictionary of plugin data fields
Available template variables:
{value} - current metric value
{threshold_value} - threshold that was exceeded
{op_symbol} - comparison operator (>, <, >=, <=, ==, !=)
{check_name} - prefix stripped for generic threshold match
(e.g. "check_disk_root" when metric
"check_disk_root_status_code" matched generic
threshold "status_code")
{metric_name} - field name within the plugin data dict
Any key from plugin_data is also available.
Returns:
Formatted display string
"""
if not display_format:
display_format = "(threshold: {op_symbol} {threshold_value})" if threshold_value is not None else ""
# Build format context with standard variables
format_context = {
'value': value,
'threshold_value': threshold_value,
'op_symbol': op_symbol,
}
if threshold_value is not None:
format_context['threshold_value'] = threshold_value
# Add generic-match context variables when available
if check_name is not None:
format_context['check_name'] = check_name
if metric_name is not None:
format_context['metric_name'] = metric_name
# Add all plugin data fields if available
if plugin_data:
format_context.update(plugin_data)
# For nagios_runner generic matches, expose the matched check's output
# and status as short aliases {output} and {status} so display templates
# don't need to use the full {check_disk_root_output} form.
if check_name and plugin_data:
if 'output' not in format_context:
output = plugin_data.get(f"{check_name}_output")
if output is not None:
format_context['output'] = output
if 'status' not in format_context:
status = plugin_data.get(f"{check_name}_status")
if status is not None:
format_context['status'] = status
try:
# Format the display string
return display_format.format(**format_context)
@@ -1133,6 +1319,8 @@ class ThresholdChecker:
value: Any,
threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]],
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
) -> None:
"""Handle a state-change transition with grace-period logic.
@@ -1145,7 +1333,8 @@ class ThresholdChecker:
- Past grace: fires the RECOVER notification normally.
"""
lvl, message, formatted_msg = self._trigger_notification(
host_name, metric_path, old_level, new_level, value, threshold, plugin_data
host_name, metric_path, old_level, new_level, value, threshold, plugin_data,
check_name=check_name, metric_name=metric_name,
)
alert_state.formatted_message = formatted_msg
@@ -1181,6 +1370,8 @@ class ThresholdChecker:
value: Any,
threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]],
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
) -> None:
"""Called when alert level is unchanged and non-OK.
@@ -1190,7 +1381,8 @@ class ThresholdChecker:
if alert_state.pending_since is not None:
if time.time() - alert_state.pending_since >= self.grace_seconds:
lvl, message, formatted_msg = self._trigger_notification(
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data
host_name, metric_path, AlertLevel.OK, alert_state.level, value, threshold, plugin_data,
check_name=check_name, metric_name=metric_name,
)
alert_state.formatted_message = formatted_msg
self._send_notification(
@@ -1199,7 +1391,18 @@ class ThresholdChecker:
alert_state.pending_since = None
# else: still within grace window, do nothing
else:
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data)
self._check_renotify(host_name, alert_state, metric_path, value, threshold, plugin_data, check_name=check_name, metric_name=metric_name)
@staticmethod
def _human_duration(seconds: float) -> str:
s = int(seconds)
if s < 120:
return f"{s}s"
if s < 3600:
return f"{s // 60}m {s % 60}s"
h, rem = divmod(s, 3600)
m = rem // 60
return f"{h}h {m}m" if m else f"{h}h"
def _check_renotify(
self,
@@ -1209,6 +1412,8 @@ class ThresholdChecker:
value: Any,
threshold: ThresholdConfig,
plugin_data: Optional[Dict[str, Any]] = None,
check_name: Optional[str] = None,
metric_name: Optional[str] = None,
):
"""Check if we should send a repeat notification.
@@ -1246,6 +1451,7 @@ class ThresholdChecker:
# Format operator symbol
op_symbol = threshold.operator.value
short_path = (metric_path.partition(".")[2] or metric_path).removesuffix("_status_code")
# Time to re-notify
if threshold_value is not None:
@@ -1255,11 +1461,14 @@ class ThresholdChecker:
value=value,
threshold_value=threshold_value,
op_symbol=op_symbol,
plugin_data=plugin_data
plugin_data=plugin_data,
check_name=check_name,
metric_name=metric_name,
)
message = f"REMINDER ({alert_state.level.name}): {host_name} - {metric_path} = {value} {threshold_info}, ongoing for {int(now - alert_state.since)}s"
body = f"{value} {threshold_info}, ongoing for {self._human_duration(now - alert_state.since)}"
else:
message = f"REMINDER ({alert_state.level.name}): {host_name} - {metric_path} = {value} (ongoing for {int(now - alert_state.since)}s)"
body = f"{value} (ongoing for {self._human_duration(now - alert_state.since)})"
message = f"REMINDER ({alert_state.level.name}): {host_name} - {short_path} = {body}"
from . import hbdclass
host = hbdclass.Host.hosts.get(host_name)
@@ -1267,8 +1476,8 @@ class ThresholdChecker:
asyncio.get_event_loop().create_task(notify_mod.send_notification(
host_name,
notify_mod.Notification(
title=f"[REMINDER/{alert_state.level.name}] {host_name}",
body=message,
title=f"[REMINDER/{alert_state.level.name}] {host_name} {short_path}",
body=body,
level=alert_state.level.name,
),
))
@@ -1288,7 +1497,7 @@ class ThresholdChecker:
if not host.alert_states:
continue
configured = self.get_thresholds_for_host(hostname)
stale = [mp for mp in host.alert_states if mp not in configured]
stale = [mp for mp in host.alert_states if self._find_threshold(configured, mp)[0] is None]
for mp in stale:
logger.info(
"Purging stale alert state for %s / %s (no threshold configured)",
+12 -3
View File
@@ -336,8 +336,7 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
# Apply user-access settings from config
access = config_mod.get_host_access(cfg, uname)
host.apply_access(access["owner"], access["managers"], access["monitors"])
if verbose:
print(("XX: New host, num now %s" % (len(hbdcls.Host.hosts))))
logger.info("New host signed on: %s (dyn=%s, access=%s)", uname, host.dyn, access)
newh = True
else:
host = hbdcls.Host.hosts[uname]
@@ -351,8 +350,10 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if msg.get("ID") == "HTB":
host.doesack = msg.get("acks", -1)
# send ACK back
# send ACK back; ask client to resend plugin info when we have none yet
rmsg = {"time": time.time()}
if not host.plugin_data:
rmsg["request_update"] = 1
opkt = dicttos("ACK", rmsg)
try:
transport.sendto(opkt, addr)
@@ -369,6 +370,14 @@ def handle_datagram(msg: dict, addr, transport, ctx: dict):
if k not in ("ID", "plugin", "id", "name")}
# Store plugin data with timestamp
host.add_plugin_data(plugin_name, plugin_data, timestamp=now)
# If os_info reports an owner and none is configured server-side, apply it
if plugin_name == "os_info":
config_owner = config_mod.get_host_access(cfg, uname).get("owner")
default_owner = config_mod.get_default_owner(cfg)
inferred_owner = plugin_data.get("owner", config_owner or default_owner)
host.owner = inferred_owner
logger.info(f"owner for {uname} is '{host.owner}")
if DEBUG > 1:
print(f"Stored plugin data for {uname}: {plugin_name}")
+29
View File
@@ -146,9 +146,14 @@ def load_users(config: dict) -> dict:
Returns the new ``users`` dict.
"""
global users
old_users = dict(users) # snapshot before rebuild
users_cfg = config.get("users", {})
if not isinstance(users_cfg, dict):
users = {}
# Preserve OAuth-provisioned users (password_hash == "") that aren't in config.
for username, existing_user in old_users.items():
if not existing_user.password_hash and username not in users:
users[username] = existing_user
return users
result: dict = {}
@@ -166,6 +171,10 @@ def load_users(config: dict) -> dict:
)
users = result
# Preserve OAuth-provisioned users (password_hash == "") that aren't in config.
for username, existing_user in old_users.items():
if not existing_user.password_hash and username not in users:
users[username] = existing_user
logger.info("Loaded %d user(s) from config", len(users))
return users
@@ -187,6 +196,26 @@ def authenticate(username: str, password: str) -> "User | None":
return None
def provision_oauth_user(username: str, full_name: str, avatar: str) -> "User":
"""Create or update a user sourced from an OAuth2 provider.
New users are inserted with no password_hash — they can only authenticate
via OAuth. Existing users (e.g. defined in config with a password) have
their display name and avatar refreshed; all other attributes are preserved.
"""
user = users.get(username)
if user is None:
user = User(username=username, full_name=full_name, avatar=avatar)
users[username] = user
logger.info("Provisioned OAuth user %r", username)
else:
if full_name:
user.full_name = full_name
if avatar:
user.avatar = avatar
return user
# ---------------------------------------------------------------------------
# Session management
# ---------------------------------------------------------------------------
+5 -1
View File
@@ -85,10 +85,12 @@ async def handler(request):
except Exception as e:
logger.error("Error sending initial hosts: %s", e)
# Send recent messages
# Send recent messages, filtered to hosts this user may see
if data.msgs:
try:
for m in data.msgs:
host_name = m.get("host") if isinstance(m, dict) else None
if not host_name or _user_can_see_host(user, host_name):
await ws.send_str(json.dumps({"type": "message", "data": m}))
except Exception as e:
logger.error("Error sending initial messages: %s", e)
@@ -128,6 +130,8 @@ def broadcast(typ: str, payload) -> bool:
host_name: Optional[str] = None
if typ in ("host", "plugin"):
host_name = payload.get("raw_name") or payload.get("host") or payload.get("name")
elif typ == "message" and isinstance(payload, dict):
host_name = payload.get("host")
jmsg = json.dumps({"type": typ, "data": payload})
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hbd"
version = "5.1.17"
version = "5.2.6"
description = "Heartbeat monitoring system — client (hbc) and server (hbd)"
readme = "README.md"
requires-python = ">=3.11"
+2
View File
@@ -0,0 +1,2 @@
hbc_mini
hbc_mini_dbg
+21
View File
@@ -0,0 +1,21 @@
CC ?= cc
CFLAGS = -O2 -Wall -Wextra -std=c11
LDFLAGS = -lz -lpthread -lm
TARGET = hbc_mini
SRC = hbc_mini.c
# FreeBSD/NetBSD keep zlib in base; no extra flags needed.
# On some NetBSD installs pthreads may need -lpthread from pkgsrc.
.PHONY: all clean debug
all: $(TARGET)
$(TARGET): $(SRC)
$(CC) $(CFLAGS) -o $@ $< $(LDFLAGS)
debug: $(SRC)
$(CC) -g -fsanitize=address,undefined -o $(TARGET)_dbg $< $(LDFLAGS)
clean:
rm -f $(TARGET) $(TARGET)_dbg
+1422
View File
File diff suppressed because it is too large Load Diff
+61 -23
View File
@@ -41,7 +41,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
# updated by scripts/bumpminor.sh
__version__ = "5.1.17"
__version__ = "5.2.6"
# ---------------------------------------------------------------------------
# Protocol (mirrors hbd/common/proto.py)
@@ -114,6 +114,7 @@ def _stodict(data: bytes) -> Dict[str, Any]:
_DEFAULTS: Dict[str, Any] = {
"hb_port": 50003,
"interval": 10,
"owner": None,
"plugins": {},
}
@@ -239,6 +240,8 @@ class OSInfoPlugin(InfoPlugin):
"hbc_version": __version__,
"hbc_type": "mini",
}
if self.config.get("owner"):
data["owner"] = self.config["owner"]
if platform.system() == "Linux":
data.update(_linux_distro())
elif platform.system() == "Darwin":
@@ -388,7 +391,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
async def _collect_metrics(self) -> Dict[str, Any]:
results: Dict[str, Any] = {}
worst = 0
for cmd_cfg in self.commands:
name = cmd_cfg.get("name")
command = cmd_cfg.get("command")
@@ -399,10 +401,6 @@ class NagiosRunnerPlugin(MonitorPlugin):
results[f"{name}_status_code"] = rc
results[f"{name}_output"] = msg
results.update({f"{name}_{k}": v for k, v in perf.items()})
worst = max(worst, rc)
results["overall_status"] = _NAGIOS_STATUS.get(worst, "UNKNOWN")
results["overall_status_code"] = worst
results["plugin_count"] = len(self.commands)
return results
@@ -487,6 +485,12 @@ class CPUMonitorPlugin(MonitorPlugin):
except Exception:
pass
try:
with open("/proc/uptime") as fh:
data["uptime_seconds"] = int(float(fh.read().split()[0]))
except Exception:
pass
return data
@@ -535,6 +539,20 @@ class MemoryMonitorPlugin(MonitorPlugin):
total = mi.get("MemTotal", 0)
avail = mi.get("MemAvailable", mi.get("MemFree", 0))
free = mi.get("MemFree", 0)
# ZFS ARC is reclaimable but not included in MemAvailable; add it.
arc_kb = 0
try:
with open("/proc/spl/kstat/zfs/arcstats") as _f:
for _line in _f:
_p = _line.split()
if len(_p) >= 3 and _p[0] == "size":
arc_kb = int(_p[2]) // 1024
break
except (OSError, ValueError):
pass
avail = min(avail + arc_kb, total)
used = total - avail
data: Dict[str, Any] = {
"memory_total": total * 1024,
@@ -701,7 +719,9 @@ async def _load_plugins(cfg: Dict[str, Any]) -> List[Plugin]:
plugins_cfg: Dict[str, Any] = cfg.get("plugins", {})
loaded: List[Plugin] = []
for cls in _ALL_PLUGIN_CLASSES:
plugin_cfg = plugins_cfg.get(cls.name) or cfg.get(cls.name, {})
plugin_cfg = dict(plugins_cfg.get(cls.name) or cfg.get(cls.name) or {})
if "owner" in cfg and "owner" not in plugin_cfg:
plugin_cfg["owner"] = cfg["owner"]
plugin: Plugin = cls(config=plugin_cfg)
try:
ok = await plugin.initialize()
@@ -771,7 +791,7 @@ class _HeartbeatProtocol(asyncio.DatagramProtocol):
msg_id = msg.get("ID")
now = time.time()
if msg_id == "ACK":
self._conn._handle_ack(now)
self._conn._handle_ack(msg, now)
elif msg_id == "CMD":
asyncio.create_task(_handle_command(self._conn, msg))
elif msg_id == "UPD":
@@ -782,8 +802,7 @@ class _HeartbeatProtocol(asyncio.DatagramProtocol):
self._log.error("datagram error: %s", e)
def error_received(self, exc):
self._log.warning("protocol error on %s: %sdropping connection", self._conn.addr, exc)
self._conn._dead = True
self._log.warning("protocol error on %s: %swill retry", self._conn.addr, exc)
self._conn.close()
@@ -799,6 +818,7 @@ class AsyncConnection:
self.rtts: List[float] = [0.0]
self._transport: Optional[asyncio.DatagramTransport] = None
self._dead = False
self._request_info: asyncio.Event = asyncio.Event()
self._log = logging.getLogger(f"hbc.conn.{addr}")
async def open(self) -> bool:
@@ -817,12 +837,14 @@ class AsyncConnection:
self._transport.close()
self._transport = None
def _handle_ack(self, now: float):
def _handle_ack(self, msg: Dict[str, Any], now: float):
rtt = (now - self.lastsend) * 1000.0
self.rtts.append(rtt)
if len(self.rtts) > 10:
self.rtts.pop(0)
self.ackcount += 1
if msg.get("request_update"):
self._request_info.set()
async def sendto(self, msg: Dict[str, Any], msg_id: str = "HTB"):
if self._dead:
@@ -955,6 +977,19 @@ async def _run_monitor_group(conn: AsyncConnection, plugins: List[Plugin], inter
await _sleep(interval)
async def _info_refresh_loop(conn: AsyncConnection, info: List[Plugin]):
log = logging.getLogger("hbc.plugins")
while _running:
await conn._request_info.wait()
if not _running:
break
conn._request_info.clear()
log.info("refreshing InfoPlugins on server request")
for plugin in info:
plugin._cache = None
await _run_info_plugins(conn, info)
async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]):
info = [p for p in plugins if isinstance(p, InfoPlugin)]
monitor = [p for p in plugins if isinstance(p, MonitorPlugin)]
@@ -965,12 +1000,10 @@ async def _plugin_collector(conn: AsyncConnection, plugins: List[Plugin]):
for p in monitor:
by_interval[p.interval].append(p)
if by_interval:
await asyncio.gather(
*[asyncio.create_task(_run_monitor_group(conn, grp, iv))
for iv, grp in by_interval.items()],
return_exceptions=True,
)
tasks = [asyncio.create_task(_info_refresh_loop(conn, info))]
tasks += [asyncio.create_task(_run_monitor_group(conn, grp, iv))
for iv, grp in by_interval.items()]
await asyncio.gather(*tasks, return_exceptions=True)
# ---------------------------------------------------------------------------
@@ -1014,7 +1047,7 @@ def _reconfigure_syslog(level: int):
# ---------------------------------------------------------------------------
async def _async_main(args, cfg: Dict[str, Any]) -> int:
global _running, _shutdown_event, _active_tasks
global _running, _shutdown_event, _active_tasks, send_shutdown
_running = True
_shutdown_event = asyncio.Event()
_active_tasks = []
@@ -1024,7 +1057,7 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
port = cfg.get("hb_port", PORT)
interval = cfg.get("interval", INTERVAL)
log.info("starting: %s -> %s port=%d interval=%ds", iam, args.hosts, port, interval)
log.info("hbc_mini %s on %s -> %s port=%d interval=%ds",__version__, iam, args.hosts, port, interval)
connections: List[AsyncConnection] = []
conn_id = 1
@@ -1045,15 +1078,18 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
return 1
# Boot / one-shot message
send_shutdown = False
if args.boot or args.message:
bmsg: Dict[str, Any] = {"acks": 0}
if args.boot:
bmsg["boot"] = 1
args.boot = False # don't repeat on restart
send_shutdown = True
if args.message:
bmsg["service"] = "service"
bmsg["msg"] = args.message
for c in connections:
await c.sendto(bmsg)
target = next((c for c in connections if c._transport), connections[0])
await target.sendto(bmsg)
if args.message and not args.daemon:
await asyncio.sleep(0.3)
for c in connections:
@@ -1085,11 +1121,13 @@ async def _async_main(args, cfg: Dict[str, Any]) -> int:
pass
log.info("shutting down")
for conn in connections:
target = next((c for c in connections if c._transport), connections[0] if connections else None)
if target and send_shutdown:
try:
await conn.sendto({"shutdown": 1, "acks": conn.ackcount})
await target.sendto({"shutdown": 1, "acks": target.ackcount})
except Exception:
pass
for conn in connections:
conn.close()
await asyncio.sleep(0.3)
for plugin in plugins:
+1 -2
View File
@@ -68,8 +68,7 @@ async def test_nagios_runner():
print(f" ✓ Collected {len(data)} data points")
print(f"\n4. Results:")
print(f" Overall Status: {data.get('overall_status')} (code: {data.get('overall_status_code')})")
print(f" Plugins Executed: {data.get('plugin_count')}")
print(f" Data points collected: {len(data)}")
# Show individual plugin results
print(f"\n5. Individual Plugin Results:")
+324
View File
@@ -0,0 +1,324 @@
import time as time_mod
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import urlparse, parse_qs
import pytest
from hbd.server import oauth
from hbd.server import users as users_mod
from hbd.server.users import User
CFG_OFF = {}
CFG_ON = {
"oauth": {
"gitea": {
"url": "https://git.example.com",
"client_id": "cid",
"client_secret": "csec",
}
}
}
CFG_PARTIAL = {"oauth": {"gitea": {"url": "https://git.example.com"}}}
@pytest.fixture(autouse=True)
def clear_oauth_states():
oauth._states.clear()
yield
oauth._states.clear()
@pytest.fixture(autouse=True)
def reset_users_dict():
original = dict(users_mod.users)
yield
users_mod.users = original
def test_is_enabled_when_all_keys_present():
assert oauth.is_enabled(CFG_ON) is True
def test_is_enabled_false_when_no_oauth_key():
assert oauth.is_enabled(CFG_OFF) is False
def test_is_enabled_false_when_partial_config():
assert oauth.is_enabled(CFG_PARTIAL) is False
def test_make_state_returns_unique_tokens():
s1 = oauth.make_state()
s2 = oauth.make_state()
assert s1 != s2
assert len(s1) == 64 # 32 bytes hex
def test_validate_state_valid():
state = oauth.make_state()
assert oauth.validate_state(state) is True
def test_validate_state_consumed_on_use():
state = oauth.make_state()
oauth.validate_state(state)
assert oauth.validate_state(state) is False # replay rejected
def test_validate_state_unknown():
assert oauth.validate_state("notastate") is False
def test_validate_state_expired(monkeypatch):
state = oauth.make_state()
# Wind expiry into the past
monkeypatch.setitem(oauth._states, state, time_mod.time() - 1000)
assert oauth.validate_state(state) is False
def _reset_users(entries=None):
users_mod.users = entries or {}
def test_provision_oauth_user_new():
_reset_users()
user = users_mod.provision_oauth_user("gituser", "Git User", "https://example.com/avatar.png")
assert user.username == "gituser"
assert user.full_name == "Git User"
assert user.avatar == "https://example.com/avatar.png"
assert user.admin is False
assert user.password_hash == ""
assert "gituser" in users_mod.users
def test_provision_oauth_user_no_password_login():
_reset_users()
user = users_mod.provision_oauth_user("gituser", "Git User", "")
assert user.check_password("anything") is False
def test_provision_oauth_user_existing_updates_profile():
existing = User(
username="alice",
full_name="Old Name",
avatar="old.png",
password_hash="pbkdf2:sha256:1:salt:abc",
admin=True,
notification_channels=["chan1"],
)
_reset_users({"alice": existing})
user = users_mod.provision_oauth_user("alice", "New Name", "new.png")
assert user.full_name == "New Name"
assert user.avatar == "new.png"
# Preserved
assert user.admin is True
assert user.password_hash == "pbkdf2:sha256:1:salt:abc"
assert user.notification_channels == ["chan1"]
def test_provision_oauth_user_does_not_overwrite_with_empty():
existing = User(username="bob", full_name="Bob", avatar="bob.png")
_reset_users({"bob": existing})
user = users_mod.provision_oauth_user("bob", "", "")
assert user.full_name == "Bob"
assert user.avatar == "bob.png"
def test_provision_oauth_user_survives_config_reload():
_reset_users()
users_mod.provision_oauth_user("oauthonly", "OAuth Only", "https://example.com/a.png")
assert "oauthonly" in users_mod.users
# Reload with empty config — OAuth user should survive
users_mod.load_users({})
assert "oauthonly" in users_mod.users
def test_authorization_url_shape():
state = "teststate"
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
url = oauth.authorization_url(CFG_ON, state, redirect_uri)
parsed = urlparse(url)
qs = parse_qs(parsed.query)
assert parsed.scheme == "https"
assert parsed.netloc == "git.example.com"
assert parsed.path == "/login/oauth/authorize"
assert qs["client_id"] == ["cid"]
assert qs["state"] == ["teststate"]
assert qs["redirect_uri"] == [redirect_uri]
assert qs["scope"] == ["user:email"]
assert qs["response_type"] == ["code"]
@pytest.mark.asyncio
async def test_exchange_code_returns_token():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"access_token": "tok123"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
token = await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
assert token == "tok123"
@pytest.mark.asyncio
async def test_exchange_code_raises_on_error_status():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(CFG_ON, "badcode", redirect_uri)
@pytest.mark.asyncio
async def test_fetch_user_returns_profile():
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
})
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
profile = await oauth.fetch_user(CFG_ON, "tok123")
assert profile == {
"login": "alice",
"full_name": "Alice Smith",
"avatar_url": "https://git.example.com/avatars/alice.png",
}
@pytest.mark.asyncio
async def test_exchange_code_raises_when_no_access_token():
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
mock_response = AsyncMock()
mock_response.status = 200
mock_response.json = AsyncMock(return_value={"error": "bad_request"})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.exchange_code(CFG_ON, "mycode", redirect_uri)
@pytest.mark.asyncio
async def test_fetch_user_raises_on_error_status():
mock_response = AsyncMock()
mock_response.status = 401
mock_response.text = AsyncMock(return_value="unauthorized")
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
with pytest.raises(oauth.OAuthError):
await oauth.fetch_user(CFG_ON, "tok123")
# ---------------------------------------------------------------------------
# Integration-style tests: callback logic chain
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_callback_invalid_state_rejects():
"""Verify validate_state returns False for unknown state tokens."""
fake_state = "this-is-not-a-real-state"
assert oauth.validate_state(fake_state) is False
@pytest.mark.asyncio
async def test_full_oauth_flow_chain():
"""Integration-style test: state → exchange → fetch → provision chain."""
redirect_uri = "https://hbd.example.com/login/oauth/gitea/callback"
# Step 1: create a state token
state = oauth.make_state()
assert oauth.validate_state(state) is True # consumed; replay would return False
# Step 2: exchange code → token (mocked)
mock_token_response = AsyncMock()
mock_token_response.status = 200
mock_token_response.json = AsyncMock(return_value={"access_token": "flow_token"})
mock_user_response = AsyncMock()
mock_user_response.status = 200
mock_user_response.json = AsyncMock(return_value={
"login": "flowuser",
"full_name": "Flow User",
"avatar_url": "https://git.example.com/avatars/flow.png",
})
mock_session = MagicMock()
mock_session.post = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_token_response),
__aexit__=AsyncMock(return_value=False),
))
mock_session.get = MagicMock(return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_user_response),
__aexit__=AsyncMock(return_value=False),
))
with patch("hbd.server.oauth.aiohttp.ClientSession", return_value=AsyncMock(
__aenter__=AsyncMock(return_value=mock_session),
__aexit__=AsyncMock(return_value=False),
)):
token = await oauth.exchange_code(CFG_ON, "authcode", redirect_uri)
profile = await oauth.fetch_user(CFG_ON, token)
assert token == "flow_token"
assert profile["login"] == "flowuser"
# Step 3: provision user
_reset_users()
user = users_mod.provision_oauth_user(
profile["login"], profile["full_name"], profile["avatar_url"]
)
assert user.username == "flowuser"
assert user.check_password("anything") is False